Run requests from a Postman Local View workspace, including unary gRPC, from the terminal.
preman is a small alternative runner for Postman's filesystem format. It is not a replacement
for the full Postman CLI.
Local View and Native Git keep collections, environments, specs, and tests in the same Git repository as the application code. Postman Desktop remains a good place to edit them.
The free Postman CLI can run HTTP collections, but gRPC collection runs require a paid plan:
$ postman collection run postman/collections/payment
Error: This collection contains gRPC requests. Please upgrade your plan to run collections with gRPC protocol support.
Visit https://www.postman.com/pricing/ for more details.
preman reads the checked-in .postman/ and postman/ files directly, then runs those requests
locally without the paid Postman runner.
Requires Node.js 20+ to install and Bun plus Node.js 20.19+ to build.
git clone https://github.com/tranphucbol/preman-cli.git
cd preman-cli
bun install
bun run build
cd packages/cli && npm linkThe built packages/cli/dist/preman.js resolves runtime dependencies from node_modules; it is not
a standalone file. Keep it with the installed package, or install through npm so those dependencies
are present.
From a repository connected to Postman Local View:
preman list
preman run "payment/Long Chau"payment/Long Chau → pe.aev2.ExchangeService.Exchange
target localhost:9095 [plaintext] (request url) · schema proto-file
✓ OK / OK 563ms
{
"return_code": "OK",
"transaction": { ... }
}
✓ Transaction status is TRANS_PROCESSING
1 test · 1 passed
preman searches upward from the current directory for .postman/resources.yaml or a
postman/collections directory. Use -d <path> to point it at another repository.
preman list
preman lint [--strict] report authored fields preman will not honour
preman run [<collection/request>] run one request
preman run <collection|folder> run every request in order
preman env show
preman env set <key> <value>
preman protos list the declared .proto files, grouped by shared link
preman protos link <name> <dir> point a shared link at a checkout on this machine
preman import --into <collection> turn a pasted curl or grpcurl into a request
preman import --into <collection> -- <pasted command>
preman copy <collection/request> print that request as a curl or grpcurl command
preman migrate --list list the Postman cloud workspaces in reach
preman migrate --workspace <id|name> --out <dir>
Common options:
| Option | Purpose |
|---|---|
-d, --dir <path> |
Select a workspace |
-e, --env <name> |
Select an environment |
--url <target> |
Override the gRPC target or HTTP origin |
--var <key=value> |
Override a variable; repeatable |
--tls / --plaintext |
Force TLS or cleartext |
--ssl-extra-ca-certs <path> |
Trust an extra CA on top of the public roots |
--ssl-client-cert <path> |
Client certificate for mutual TLS |
--ssl-client-key <path> |
Private key for that certificate |
--ssl-client-passphrase <text> |
Passphrase for an encrypted key |
-k, --insecure |
Skip server certificate verification |
--working-dir <path> |
Resolve request file paths from this directory; defaults to the workspace root |
--insecure-file-read |
Allow request files outside the working directory |
-n, --iteration-count <n> |
Run a collection or folder multiple times |
--iteration-data <path> |
Load iteration rows from a JSON or CSV file |
--delay-request <ms> |
Wait between collection requests |
--timeout <ms> |
Set the whole-run budget when used with --timeout-request; 0 is unbounded |
--timeout-request <ms> |
Set each request deadline; defaults to 600000 |
--timeout-script <ms> |
Set each script deadline; defaults to 5000 |
--no-save |
Do not write script changes back to the environment |
--bail |
Stop a collection run after the first failure |
-r, --reporter <name> |
Select cli, json, or junit; repeat or comma-separate |
--reporter-json-export <path> |
Write the JSON report to a file |
--reporter-junit-export <path> |
Write the JUnit report to a file |
--json |
Alias for --reporter json |
--repoint |
protos link only: move a link that already points elsewhere |
--into <group> |
import only: which collection or folder to write the request into |
--name <name> |
import only: name the request instead of taking the proposed one |
--from <path> |
import only: read the pasted command from a file instead of the fence |
-v, --verbose |
Show request, response, script, and transport details |
Run preman --help for every option.
- Unary gRPC using the request's
.protofile or embedded descriptor - HTTP requests, including multipart form-data uploads, structured
urlencoded, binary, and GraphQL bodies - Server-sent event streams, shown event by event as they arrive in the app and buffered by the CLI
- Collections and folders in Postman order
- Repeated collection runs with JSON or CSV iteration data
- Postman environments, globals, collection variables, and the dynamic variable set
- Pre-request, post-response, and gRPC
onMessagescripts - Collection- and folder-level scripts and authentication, inherited by descendant requests
bearer,basic, andapikeyauthentication for both HTTP and gRPCpm.test,pm.expect, cookies,pm.sendRequest, mutablepm.request, and sandboxrequire()- Postman's common script libraries, including Lodash, CryptoJS, Moment, Cheerio, XML2JS, and UUID
- Private certificate authorities and mutual TLS, from flags or
.postman/preman.yaml - Declaring
.protofiles from a file browser, through a shared link that resolves on any machine - Importing a pasted
curlorgrpcurlcommand as a request, with every dropped flag named - Migrating a Postman cloud workspace onto disk, gRPC included
- Linting a workspace for fields that parse but are never read, without sending a request
- Environment writeback and JSON output for CI
- JUnit reports for GitLab, Jenkins, and other CI test-report consumers
Print the normal report while writing JUnit XML for the CI test tab:
preman run payment -r cli,junit --reporter-junit-export junit.xmlStreaming gRPC and request kinds other than gRPC and HTTP are not supported. Unsupported items in a collection run are reported instead of being executed.
See the reference for selection rules, variable precedence, protocol behavior, scripts, assertions, exit codes, and schema resolution.
A Postman request file can be valid and still be wrong in a way nothing tells you about. Write
multipart parts under body.content instead of body.formdata and the file parses, the app paints
an empty grid, and a run sends no body and no Content-Type — silently, because preman only raises
that class of warning while a request is going out.
preman lint reads every request and reports those fields without sending anything:
preman lint # what will be ignored, and what cannot run
preman lint --strict -v # warnings are fatal too, with the rule id on each line
preman lint --json # the same findings, machine-readable Paparazzi/Instances/Create Instance postman/collections/Paparazzi/Instances/Create Instance.request.yaml
error body.formdata body.content is not read by type formdata, and body.formdata is absent: no body is sent
rename body.content to body.formdata
An error means the request cannot do what it says — no body is sent, a named file is not on
disk, an auth type throws before the socket opens — and exits 1. A warning means it runs, but
something you wrote is being dropped, and exits 0 unless you pass --strict, which is what a CI
job wants.
It says nothing about {{tokens}}. A variable a script sets on one request and reads on the next
is unresolved at rest for good reason, and a linter that flagged it would be wrong far more often
than right. See ADR 057.
A gRPC request finds its method through the .proto files the workspace declares in
.postman/resources.yaml. In the app, Protos in the command palette — or the link button beside
a gRPC request's method picker — browses for them.
Every declared path runs through a symlink farm at /Users/Shared/postman-protos, one link per
repository, named after it:
localResources:
specs:
- /Users/Shared/postman-protos/zas-spec/api/zas/admin/admin.proto
- /Users/Shared/postman-protos/acquiring-core/api/proto/admin.protoThat path is the same string on every machine, which an absolute path to your home directory is not. What differs per machine is one symlink per repository, and both front ends tell you which ones are missing by name. On a fresh checkout:
preman protos # what is declared, and which links are absent
preman protos link zas-spec ~/repos/zas-spec # one link covers every proto in that repositoryIn the app the same repair is a Locate… button beside each missing link. Adding a proto stages a
plan first: it names the link it would create, shows the path it would write, and loads each .proto
so an unresolvable import surfaces before anything is written. Nothing reaches disk until you
apply it.
The link points at the checkout's root, so imports resolve exactly as they do inside the repository. A workspace whose specs are still plain relative or absolute paths keeps working; Move onto links… converts them in one reviewed step.
A workspace that lives inside the repository whose protos it declares needs no link at all. If the
missing link is named after the checkout the workspace itself is in — the directory holding the
.git above .postman/ — those specs are read straight out of that checkout, so a fresh clone
resolves its own protos with nothing set up. The link name stays on screen, labelled as coming from
the workspace's own checkout, because anyone whose workspace is not in that repository still needs
it — the label appears only where it is news, so a machine whose link already points at that same
checkout reads exactly as it did before. Every other case is unchanged: a spec from another repository, or one absent from this checkout,
still comes through the link, and a clone in a differently-named directory still has to be located
by hand — with the path pre-filled rather than guessed.
Set PREMAN_SHARED_PROTO_ROOT, or the field in the app's settings, if /Users/Shared is not
writable on your machine. Only where this machine looks moves — what the workspace records is
always the default, so the file stays portable.
Copy as cURL in a browser's devtools, or a grpcurl line from a runbook, becomes a request file:
pbpaste | preman import --into adminImported POST Create order -> postman/collections/admin/Create order.request.yaml
Not imported
--compressed preman already negotiates response encoding
Every flag is accounted for. The ones preman can represent become fields; the ones it cannot are
listed with the reason, so nothing is dropped quietly. --dry-run prints the document it would
write and writes nothing.
Pasting inline needs a -- fence. preman owns -d, -e, -k, -n, -r and -v, and curl
spells all six differently — unfenced, curl -d '{"sku":"A-1"}' https://x hands the body to
--dir and nothing reports it. So everything after -- is the paste, verbatim:
preman import --into admin -- curl -X POST -H 'content-type: application/json' \
--data-raw '{"sku":"A-1","qty":2}' https://api.example.test/v1/ordersPiping from the clipboard or passing --from <file> needs no fence. --into picks the collection
or folder, and is required as soon as the workspace has more than one collection; --name overrides
the name taken from the URL's last path segment. Chrome's macOS copy (\-continued, with $'…'
quoting) and its Windows copy (^-continued, with "…") are both accepted and produce the same
file.
A grpcurl paste is imported the same way. A -proto that exists on disk is declared through the
shared link described above, so the request runs on the first send:
preman import --into payment -- grpcurl -plaintext \
-proto ~/src/refund-core/api/refund/v1/refund.proto \
-d '{"id":"r-1"}' localhost:9090 refund.v1.RefundService/GetA paste with no -proto relies on gRPC server reflection, which preman does not do. It is still
imported — the target, metadata, and message are worth keeping — and it says so at import time and
again when the run reaches schema resolution.
The desktop app does the same from the sidebar header's import button, File ▸ Import from cURL or grpcurl…, a group's context menu, or the command palette. The dialog pre-fills itself from the clipboard when it holds a command, and shows the exact document before anything is written.
The other direction. preman copy prints one request as the command that would send it — curl
for an HTTP request, grpcurl for a gRPC one, decided by the request's kind rather than by a flag:
preman copy admin/Profile --env QC | pbcopycurl -H 'Authorization: Bearer eyJhbGci…' -L 'https://api.example.test/profile?tab=main'
Not in this command
collection admin http:beforeRequest not run; a script that sets a header is not in this command
pm.test assertions a command has no test result
cookie jar populated by earlier responses in a run
In cleartext
auth the request's own auth block
http_url environment
token environment
Nothing is sent and no script runs, so the only exit codes are 0 and 1. Everything the command
cannot carry is named — the scripts that would have run, the pm.test assertions that have no
result, the cookie jar, the timeout — and every {{token}} that was substituted is listed with the
scope it came from. Nothing is redacted: preman has no way to tell a credential from a variable,
so it reports what it resolved and leaves the judgement to you.
A gRPC request comes out as a grpcurl with the -proto and -import-path flags it needs. Those
are paths on this machine, which is warned; a request resolved from an embedded descriptor has no
.proto to name at all, so the command is printed with the schema listed as missing and a warning
that it will not run as written.
The app shows the same thing in a panel beside the request, opened with the </> button in the
request bar, a request's context menu, File ▸ Copy as cURL or grpcurl, or the command palette.
It follows your unsaved edits rather than what is on disk, so editing the url rewrites the command
without saving first — a text box lands when you leave it, an editor when you pause. The clipboard
is written when you press Copy and never on open.
If the collections are still in a Postman cloud workspace rather than in Git, preman migrate copies
one onto disk in the same format Local View writes:
preman migrate --list
preman migrate --workspace "Payment Core" --out ./payment-coreMigrated 2 collections, 1 folder, 1 environment into ./payment-core
1 gRPC request
2 HTTP requests
1 skipped (websocket-request)
Adapter/Legacy/Legacy Socket
Postman Desktop must be running and signed in. preman borrows that window's own session, so
there is no API key to create and no password to type. --workspace takes an id or a name; two
workspaces sharing a name are reported with their ids rather than guessed between. --dry-run prints
every file that would be written and writes none. The destination must be empty — an existing
workspace is never merged into.
A large workspace takes the better part of a minute, so it draws a bar while it works:
reading collections █████░░░░░░░░░░░░░░░ 29% 12/41 327 reads
It is on standard error, so the report and --json still pipe cleanly, and it comes back down before
anything is printed. The proportion counts collections rather than requests, because Postman reveals
what is inside a collection only as it is read — the total number of calls is genuinely unknown until
the end, and a bar that revised its own denominator would slide backwards.
This reads Postman's private API, which is the only one that can see gRPC requests; the documented one returns schema v2.1 and omits them entirely. It is undocumented, so a Postman update can break it without notice.
A migrated gRPC request needs its .proto on disk. Postman's cloud copy of the embedded
descriptor is truncated — 184 of 188 requests in the workspace this was measured against came back cut
to 300 characters, and a truncated descriptor does not decode. Where Postman recorded the path to the
.proto it was built from, the migration keeps that path exactly as recorded and lists it in
.postman/resources.yaml, so on the machine that authored the request it runs against the live file,
which is the better source anyway. Where it did not, or where the file is not on this machine, the
request is still written and says which .proto it wants when you run it. Request kinds preman
cannot run, websockets among them, are skipped and named one line each, so it is clear what stayed
behind.
The desktop app does the same thing from File ▸ Migrate from Postman…, or the command palette: it lists the workspaces, asks where to put the one you pick, and opens it when it is written.
@preman/desktop puts a window in front of the same engine: it opens a workspace, edits requests
and environments, and runs them. The CLI and the app read and write the same files, so a request
created in the window runs from the terminal, and a file changed by either one appears in the other
on its next read.
Every release carries a macOS arm64 DMG on
the releases page. It is ad-hoc signed rather
than notarized, because a Developer ID belongs to whoever ships the build and not to this repository
(ADR 018). macOS quarantines anything a
browser downloaded, and Gatekeeper reports a quarantined ad-hoc-signed app as
"preman" is damaged and can't be opened rather than as merely unsigned — so drag preman.app to
/Applications, then clear the attribute the download added:
xattr -dr com.apple.quarantine /Applications/preman.appNothing inside the app changes; only that attribute is removed. There is no Intel or universal build, so an Intel Mac has nothing to run yet (ADR 030).
That is the first install only. From then on the app checks for new versions itself — the app menu's Check for Updates, or Settings → Diagnostics → Updates, which also carries the switch that turns the automatic check off. What it downloads is verified against an Ed25519 signature the app ships the public half of, rather than by Squirrel.Mac, which cannot update an ad-hoc-signed app at all (ADR 054). Nothing installs itself: the check is automatic, the download and the restart are two separate presses. macOS may ask for Local Network access again after an update, because the new build is a different signature to it.
It can also create an empty workspace, named from the workspace dropdown, the File menu or the
command palette, always under ~/.local/share/preman/workspace; Open workspace… remains the way
to a workspace that already exists anywhere else.
A new request comes from either end of the window. The sidebar's context menu on a collection or a
folder offers New HTTP request, New gRPC request and New folder, which put one inside it;
the + in the tab row, beside the environment picker, asks the same three as one form — the
protocol, a name, and which collection or folder it goes in, with New folder beside its
Create. That form's destination list starts at the workspace root, which is where a collection
lives, so the first one can be made from there too. The context menu on a request itself offers
Duplicate, which copies the file —
comments, scripts and examples included — to Foo copy directly below the original and opens it.
Duplicate copies what is on disk, so save an edited tab first.
An environment is made from the last row of the environment picker itself — New environment…,
below the values and a rule — or from the command palette. It is created empty and becomes the
active one, so the next {{token}} you write has somewhere to be set. A name another environment
already holds is refused rather than resolved to Foo (2): an environment is reached by name, by
the picker and by -e alike, so two files answering to one name would be one of them silently
ignored. The picker is present in every workspace, including one with no environments yet.
The Auth sub-tab picks a type — No Auth, Bearer Token, Basic Auth or API Key —
and then asks for exactly the credentials that type sends, so a {{token}} goes in a field named
Token rather than a row you had to know to add. Its first option, Inherit from parent, removes
the request's own auth: block, which is how the format spells inheritance; the pane then names
the collection or folder your token is coming from and which type it is, and that name is a button
that opens it. A type preman cannot send says so in the pane instead of at the send. Credentials
the file carries that the chosen type does not read are kept and shown below, because nothing here
deletes something you wrote without saying so.
The block also wins over a header you wrote by hand, the way Postman's does. If a request declares
bearer auth and also carries an Authorization header, the block is what goes on the wire — so
the Headers sub-tab says which header is being replaced and what it said, above the list, before
you send. This is the direction Postman takes; the header that used to quietly beat a correct token
is the reason it changed.
A collection or a folder is a document too — Edit collection or Edit folder on its context
menu opens its .resources/definition.yaml as a tab, with an Auth tab of its own and a
YAML one. There is no Send: a group is not an exchange, and running one is still Run…. A
group that has no definition file yet opens empty, and saving writes it.
A raw HTTP body and a gRPC message can be re-indented from the Beautify glyph at the right of
their toolbar. It rewrites whitespace and nothing else, so a bare {{token}}, 1e3, 1.0 and a
twenty-digit id all survive byte for byte — a body is bytes that go on the wire, and a formatter that
reserialises them sends a different request. Cmd+Z reverts it, and a body it cannot read says so
rather than being quietly changed. The response pane's pretty-print toggle is the same gesture on the
other half of the app; that one does reserialise, because a response has already been sent.
The response header counts up from the moment Send is clicked, so the wait is visible rather
than inferred, and settles on the transport duration once the exchange finishes — the number the
Timeline tab, the console and --report all agree on. The Timeline shows the wall-clock wait beside
it, which is where the difference between the two goes.
A text/event-stream response is read as it arrives. The Body tab becomes a list of events, newest
first, each with the time it landed and expandable to its full data; the header states how many have
arrived and how many bytes, and says whether the stream is still open. Nothing about it waits for the
stream to close, which is the point — a completion stream is worth watching, not worth waiting for.
Cancel closes the socket and keeps every event that had already arrived; a stream the server or
the network cut short instead says so above the list.
preman run does not stream: it buffers, so --timeout-request stays a hard ceiling in CI
(ADR 052).
bun run desktop # build the app and launch it
bun run desktop:inspect # the same, with the main process's inspector on 127.0.0.1:9229
bun run desktop:package # build, then wrap it into packages/desktop/releaseThe app writes one log — preman.log in Electron's logs directory, which Settings ▸
Diagnostics will reveal for you — holding process lifecycle, host spawns, exits and crash reasons,
every failure the engine turned into an error you saw, and everything the engine wrote to its own
output. Each line is timestamped and carries one of INFO, WARN, ERROR or FATAL:
2026-08-31T20:36:35.613Z INFO preman starting: 43.4.1 (electron 43.4.1, chrome 150.0.7871.224, node 24.18.1)
2026-08-31T20:36:35.644Z INFO preman-engine-acquiring-core: engine host started for /Users/you/repos/acquiring-core
2026-08-31T20:36:45.283Z WARN preman-engine-acquiring-core: proto not loaded: cannot read /Users/you/repos/acquiring-core/api/proto/refund.proto: ENOENT
There is nothing to turn on and nothing to filter, and it holds no traffic at all: no URL, no
header, no body, no variable. The engine resolves {{token}} before it sends, so a log that
recorded a request would be a credential file with a different name; the console drawer is where you
look at a request. It does name files inside your workspace, which means your home directory is in
it — worth a glance before you attach one to a bug report. See
ADR 035 and
ADR 036.
To debug the engine, set PREMAN_INSPECT=1 (or brk, to stop before it reads the workspace) and
the host forks with an inspector on an ephemeral port — one host per open workspace, so the port
cannot be fixed. The Debugger listening on ws://… line it prints is forwarded to the terminal
along with everything else the host says; .vscode/launch.json's engine configuration asks for
that URL, and preman attaches to both processes at once.
The console drawer logs every call the app makes, not only what the scripts said: each request
appears where it happened, with the logs and pm.sendRequest calls it caused indented under it, and
expands to show what was sent and what came back.
Window bounds, open tabs, and unsaved drafts live in Electron's userData, never in the workspace,
so git status stays clean while the app is open. Packaging targets are configured for macOS in
packages/desktop/electron-builder.yml.
The window holds no engine: it talks to a separate process over a message port. See the engine protocol for that contract.
bun run typecheck
bun run typecheck:core
bun run test
bun run test:watch
bun run build
bun run lint
bun run lint:fix
bun run format
bun run format:checkThe performance budgets in test/perf.test.ts run with the normal suite. The ones that need a real
window launch Electron five times, so they are gated and want a built app:
bun run build && PREMAN_PERF=1 bunx vitest run test/renderer/perf.app.test.tsdocs/performance.md is the budget itself, and what each number counts.
docs/decisions/ is why the app is shaped the way it is — three
processes, a synchronous core, an editor that never reformats your files.