PaperBoy
A Rust-native alternative to Postman. Collections are Hurl
.hurl files and environments are .vars files (KEY=value), so everything is
plain text you can commit, diff and review. No hosted service, no telemetry,
nothing leaves your machine.
One binary, three front-ends over the same core:
| Front-end | How | Notes |
|---|---|---|
| Terminal UI | paperboy |
The default. Full client. |
| Graphical UI | paperboy -g |
eframe/egui. Behind the gui Cargo feature. |
| Headless runner | paperboy -c collection.hurl |
For scripts and CI. Also runs reports (-r). Exits non-zero on failure. |
Collections, environments, themes and the git workflows behave identically in all three.
- Install
- Concepts
- Terminal UI
- Graphical UI
- Environments and secrets
- Git remotes
- Importing from Postman
- Headless runner
- Changelog
Install
--locked is recommended: it builds the dependency versions PaperBoy was
tested against rather than re-resolving to whatever is newest. (A yanked
arrayref release took plain cargo install down on 2026-08-20 while
--locked kept working — and it took the terminal-only build with it, since
Cargo resolves optional dependencies whether or not their feature is on.)
The gui feature is opt-in because eframe/winit/wgpu roughly double the
dependency tree. Both builds share the same state file, so you lose nothing by
switching. Running --gui without it prints the command to install one with it.
Build prerequisites
Five things Cargo can't fetch for you:
| Platform | Command |
|---|---|
| macOS | xcode-select --install then brew install pkg-config |
| Debian/Ubuntu | sudo apt install build-essential pkg-config libxml2-dev libclang-dev perl |
| Fedora/RHEL | sudo dnf install pkgconf-pkg-config gcc make perl libxml2-devel clang-devel |
| Arch | sudo pacman -S pkgconf base-devel perl libxml2 clang |
| Alpine | sudo apk add build-base pkgconfig perl libxml2-dev clang-dev |
| Windows (MSVC) | vcpkg install libxml2:x64-windows-static-md |
libxml2 + pkg-config because hurl/hurl_core depend unconditionally on
the libxml crate (Hurl's XPath asserts are libxml2's XPath engine), and that
crate is a binding to a system libxml2 rather than a vendored copy.
libclang because bindgen generates those bindings at build time. A C
compiler, perl and make because PaperBoy pulls curl in directly with
static-curl/static-ssl, so libcurl and OpenSSL are compiled from vendored
sources — which is why there is no libcurl-dev row above. The gui feature
adds no build-time requirement; its X11/Wayland libraries are dlopened at
runtime.
On macOS the Command Line Tools cover everything except pkg-config, which is
the failure most people hit. If your libxml2 came from Homebrew rather than the
SDK:
PaperBoy's build.rs checks for all five before the build gets going and fails
with the package-manager command your machine actually wants (it detects
Homebrew/MacPorts, apt, dnf, yum, zypper, pacman, apk). It fails rather than
warns because Cargo runs build scripts concurrently and doesn't replay their
warnings — a warning lands dozens of Compiling … lines above the real error.
It never installs anything, and it can't prompt: a build script has no terminal.
Checks that could be wrong (cross-compilation, target-suffixed PKG_CONFIG_*,
the libclang heuristic) only warn, and PAPERBOY_SKIP_DEP_CHECK=1 disables it
entirely.
If you have a libxml2 lying around and want to skip pkg-config and bindgen,
libxml's build script takes an explicit path — --config reaches transitive
build scripts:
Vendoring libxml2 instead isn't possible from here: the crate has no vendored
build, no libxml2-src exists, and it declares no links key, so there's no
DEP_* channel to reach into it with. It would have to be added upstream.
From a checkout:
Concepts
- Collection — a
.hurlfile: an ordered list of requests with method, URL, headers, cookies, body/form fields and optional[Captures]/[Asserts]. Postman.jsonexports open directly and are converted on the way in. - Environment — a
.varsfile ofKEY=valuelines supplying{{ VAR }}values. See Environments and secrets. - Workspace — a folder of collections, reports and environments, browsed through a single tab as a filesystem tree.
- Report — a
.trailfile: a PaperTrail script that runs requests from a collection, loops over environments or data, and writes CSV/JSON/HTML/XLSX. Editable as text or as blocks; runnable from the UI or headlessly. - Generated value — a
# [Gen]row: an expression evaluated just before a request is sent, supplying the nonces, timestamps and signatures a pre-request script used to. See Generated values. - Scratch Space — tab 0. A collection with no file behind it until you save it.
- Request names encode folders.
Auth/Tokens/Refreshbrowses as a folder path; Postman's folder structure imports into this automatically.
Terminal UI
Press ? or F1 for the full, current key list. The essentials:
| Key | Action |
|---|---|
Tab / Shift+Tab |
Move focus between panes |
↑/↓, j/k |
Move selection |
←/→, h/l |
Switch tabs / scroll list text horizontally |
Enter |
Edit the selected request (or descend into a folder row) |
Shift+R |
Edit as raw Hurl text — for anything the form doesn't expose |
F5, Ctrl+Enter |
Run the current request |
Alt+F5 |
Run the whole collection in one Hurl execution |
n / b |
New request / set the base URL |
f / s |
File menu / Settings menu |
Ctrl+S |
Save the open report, else the active collection |
[ / ], PageUp/PageDown |
Previous / next tab |
Ctrl+Shift+←/→ |
Reorder the active tab |
x / u |
Delete / undo — requests, tabs and environments each keep their own undo stack |
/ |
Find a request anywhere in the collection (whole tree on a Workspace tab) |
Alt+↑/↓ |
Reorder requests — the order Alt+F5 and the CLI follow |
m / c |
Move / copy a request to another collection in the workspace |
p (Requests) |
Link an environment to this collection |
a (Response) |
Build an assert or a capture from what came back |
a (Env pane) |
Make an environment active |
r (Env pane) |
Retry a failed secret lookup |
w (Workspace tab) |
Reopen the file-tree picker |
+/-, </> |
Resize the response pane / left column |
q, Ctrl+C |
Quit |
In the request wizard:
| Key | Action |
|---|---|
[/], PageUp/PageDown |
Switch section tab (All│Headers│Cookies│Queries│Options│Form│Body│Asserts│Captures│Reports│Generated). [/] only when focus isn't on a text field, so brackets stay typable |
Alt+1–9, Alt+0 |
Jump straight to a section (Alt because most terminals can't report Ctrl+digit) |
Ctrl+↑/↓ |
Previous / next section |
Ctrl+D / Ctrl+E |
Delete a row / toggle its enabled checkbox |
Ctrl+Z / Ctrl+Shift+Z |
Undo / redo within the focused text cell |
← from a Key cell |
Reach the enabled checkbox — it's the leftmost column |
Ctrl+F or Enter on a File value |
Open a file picker |
F2, Ctrl+Enter |
Save |
Esc |
Cancel (asks first if there are unsaved edits) |
Worth knowing:
[Form]/[Multipart],[Cookies],[Captures],[Asserts]and[BasicAuth]are all editable as tables in the wizard; the expected status is just an assert (status == 200). Saving picks the right Hurl section: all-text fields become[Form], any file field promotes it to[Multipart]. File paths are colour-coded by whether they resolve and are readable (relative to the collection's directory, matching where Hurl looks). ABase64 Filefield is encoded at send time behind a configurable prefix, sodata:image/png;base64,yields a ready-made data URI.- Asserts and captures can be built from a response. With a reply on
screen,
ain the Response pane opens a two-step palette: pick a value the server actually sent — status, duration, any header, any value in the JSON body, listed beside what it currently is — then pick what to say about it. The rows are the Hurl lines themselves (jsonpath "$.data.token" == "ey…"), so what you choose is what gets written. Typing narrows the list, and anything selected in the body pre-fills the filter. The last row on every value is keep it in a variable, which adds a[Captures]row under a name taken from the field itself — the fastest way to chain one request into the next. Choosing the status sets theHTTP <status>line rather than adding a competing assert. - The request preview substitutes
{{ VAR }}and colours each by status — green loaded, cyan literal, orange loading, red missing — while the editor keeps the original text. Secrets are masked as eight dots. - Sections start empty and dropdowns only auto-open on an empty cell, so arrowing through a populated table doesn't keep reopening them.
- Settings ▸ Preferences persists: confirm on exit/clear, confirm before
deleting an environment or a request, always-save-when-prompted, whether
Alt+F5runs the collection in batch mode (chaining cookies and captures), whether Esc discards request edits without asking (off by default), and the default Request view (JSON or Hurl). - Settings ▸ Theme ships three presets — Britannia, Parisian Purple,
Dannebrog — one per UI language (English/French/Danish), and follows the
language until you pick one by hand.
Ctrl+Nclones a preset into an editable custom theme;Enteron a colour opens an RGB picker that previews live and auto-saves.Ctrl+Ddeletes a custom theme. - Saving. Save overwrites the file the tab came from without confirmation; Save As… always prompts, and confirms an overwrite. Every File-menu item has a bracketed mnemonic that both selects and activates it.
Graphical UI
paperboy -g, from a build with the gui feature. Feature-for-feature
equivalent to the terminal UI — same tabs, folder tree, request editor,
response viewer, environments panel, reports, theme editor, git remotes, and
the same three languages. What differs:
- Panels and result columns are resized by dragging; double-click a column border to hand it back to the automatic fit. Hand-set widths persist as long as the report keeps producing the same columns.
Tab/Shift+Tabcycle panels in the terminal UI's order. The focused request list is arrow-driven:Home/End,Enterto run,F2to rename,Deleteto delete,Ctrl+Zto undo. A Workspace tree addsLeft/Rightto collapse and expand, andPageUp/PageDownfor ten rows.- Single-letter shortcuts don't carry over — in a desktop window those keys
are text. Globally:
F5/Ctrl+Enterrun,Ctrl+S/Ctrl+Shift+Ssave,Ctrl+Wcloses,Ctrl+Zundoes a delete,Alt+Fopens the File menu,F1shows every shortcut. - Asserts and captures are built by right-clicking the response. Click a
value in the body and choose Assert this… — the field under the caret is
worked out from the raw JSON, so it works on a minified body as well as a
pretty-printed one. Right-clicking a header row does the same for that
header, and the Assert… button beside Copy opens the same builder on the
whole list of values the reply carried. The list is filterable by name or by
value, and "keep it in a variable" adds the
[Captures]row. - The File menu is grouped by verb (New / Import / Open / Save). Open ▸ Collection and Load ▸ Environment take Postman exports too — they work out what the file holds. Every dialog reopens where you left it.
- Workspaces are editable in place: New adds a collection, report or environment; drag files and folders onto another folder to move them, or onto the empty space to move them back to the root. Nothing escapes the workspace root and nothing is silently overwritten.
- Reports bind to their collection by a relative path (
../apis/billing.hurlincluded), so a workspace survives being zipped up or handed over. Thecollectiondropdown offers the report's own workspace first and hides outside collections behind a toggle. - The window remembers itself — size, every splitter you dragged, the open view, the selected report/request and the Workspace node.
The PaperTrail block editor
Reports get a Blocks view alongside Source and Results: a
drag-and-drop editor where blocks are dragged from a palette, reordered, nested
inside FOR loops (which move as one, body included) and dropped on the trash
bar to delete. The drag outline and the drop marker are both drawn as the
block's own silhouette, at its real width and indent.
Editable on the blocks: the request a step runs, its alias, response format and
SHOW(…)/HIDE(…)/STATISTICS(…) lists; a FOR loop's binder, source, roles
and PARALLEL(n) concurrency; and the report's own settings — collection,
output, environment, root, baseline, columns — in a boxed panel at the
top of the flow. Those apply to the report rather than running as a step, so
they're deliberately not blocks. output names a format (csv, json,
html, xlsx), not a filename; only the CLI's -o takes a path. Everything has
hover help, and Source is highlighted with the terminal UI's colours,
underlining whatever the parser rejected.
Desktop icon on Linux
Wayland has no per-window icon protocol, so shells match the window's app id
against an installed .desktop file. The first GUI launch writes
$XDG_DATA_HOME/paperboy/paperboy_logo.png and
$XDG_DATA_HOME/applications/paperboy.desktop (with StartupWMClass=paperboy
for X11) if they aren't already there, and never touches them again — so you
can customise them. The shell may need a rescan (log out, or restart it) to
notice. Delete both and relaunch to regenerate, which is also how you refresh
Exec= after moving the binary.
Environments and secrets
A .vars file is one KEY=value per line. Values can be:
| Form | Example | Resolved by |
|---|---|---|
| Literal | USERNAME=demo |
— |
| Process env var | BASE_URL={{ env:DEMO_BASE_URL }} |
The process environment |
| 1Password | API_TOKEN={{ op://Vault/Item/field }} |
The local op CLI |
| AWS SSM | DB_PASSWORD={{ ssm:/path/to/param }} |
Local AWS auth |
Provider references resolve in the background at load time, and the resolved
values are never persisted — state.json keeps only the reference. Every
1Password reference across every open collection resolves in a single op inject call, so you get one authorization prompt rather than one per
collection. r in the Environment panel retries a single failed entry. Editing
a value into something that looks like a reference triggers a load attempt, and
a "still secret?" checkbox decides whether the new value stays masked.
Loading a .vars file substitutes nothing on its own. It only joins the
Global Environments list. It has to be either:
- active —
ain the Global Environments panel (GUI: the Active button). One at a time, shared by every tab; or - linked —
pin the Requests list pins one to the active collection (GUI: Linked).
Both at once merge, with the linked value winning. A collection still showing
raw {{ VAR }}, or a red "variables in this request are undefined" band, nearly
always means this step was missed.
A variable that is defined but empty is not undefined and warns about nothing
— it substitutes as an empty string. With Basic Auth that produces a
well-formed request that comes back 401.
Generated values
Some values can't be written down: a nonce, a timestamp, an HMAC over the two.
Postman uses a pre-request script; PaperBoy uses a # [Gen] block of named
expressions, evaluated immediately before the request is sent.
# [Gen] 3
# nonce = random_hex(16)
# ts = timestamp
# sig = hmac_sha256_b64(API_SECRET, concat(nonce, ts))
POST https://api.example.com/orders
X-Nonce: {{nonce}}
X-Timestamp: {{ts}}
Authorization: HMAC {{sig}}
The expressions stay in comments and the request refers to results as ordinary
{{name}} placeholders, so the file remains a plain .hurl file: stock hurl
parses it byte for byte and runs it given --variable nonce=… --variable sig=….
Nothing else could work — Hurl reads a placeholder only as far as the first
character outside A-Za-z0-9_- and discards the rest silently, so
{{ hmac_sha256(K, M) }} would be sent as the value of hmac_sha256. PaperBoy
now refuses to save such a placeholder rather than let it truncate.
The block may sit above the request line, as here, or immediately below it; both are read. PaperBoy writes it below when it saves, so a hand-written file in the other order moves its block down the first time it is saved and is otherwise unchanged.
A bare identifier is a variable reference — an environment variable, a request
parameter, or an earlier row in the same block. Calls nest. Rows are evaluated
in order and a row may only refer to one above it. Values are computed per run,
never previewed, and never written to state.json; a secret read through
{{ op://… }} is no more exposed by signing with it than by sending it.
What a block computes stays available to the rest of the session, exactly as a
[Captures] value does: sign a request, and the request after it can echo the
same {{nonce}} — including when you run it on its own. (Memory only, for the
reason above: a fresh PaperBoy computes fresh values.) A [Captures] row of
the same name is the later, more specific statement and wins.
One request per name, though. "Run All" and paperboy -c normally run one
request at a time, so each block is evaluated in its own window and two
requests may each have their own nonce. A batch run (the --batch flag,
or the Run All batch preference) is a single Hurl call over the whole file with
one variable set, so there the two share the first request's value — a
signature computed over another request's nonce. Both front-ends say so before
starting such a run, and --batch prints the warning too; the fix is usually
to not use batch.
The same applies to a name the environment already defines. Running one request at a time, a block's value overrides the environment's from that request onwards; a batch has one variable set for the whole file, so it cannot override from partway through without changing what the earlier requests send. Batch therefore leaves the environment's value in place and says which names it did that to.
counter counts within the process, not within a run: it starts at 1 the first
time it is evaluated and keeps going for as long as PaperBoy is open, so sending
the same request three times gives 1, 2, 3. It is a sequence, not a setting, and
is not saved — a restarted PaperBoy counts from 1 again.
Edit the block in the request wizard's Generated section (Alt+0), in the
GUI editor's Generated tab, or as text. Both editors offer the functions as
you type — with their arguments named — and the GUI's Function… menu lists
them all; either way the call is written at the caret, over any part-typed
name, with the caret left between the brackets. Both say what is wrong with a
row while
it is still a typo rather than leaving it to be a 401: an unknown function, the
wrong number of arguments, an expression that doesn't parse. Placeholders that a
generator will fill render in the theme's generated colour and keep their
braces, because the value doesn't exist yet.
| Time | timestamp, timestamp_ms, iso8601, date(fmt) (strftime, UTC) |
| Random | uuid, counter, random_int(lo, hi), random_hex(n), random_alnum(n), random_base64(n) |
| Encoding | base64, base64url, base64_decode, hex, urlencode, urldecode, json_string |
| Hashes | md5, sha1, sha256, sha512 |
| MACs | hmac_sha1(key, msg), hmac_sha256, hmac_sha512 |
| Text | concat(…), upper, lower, trim |
Every hash and MAC returns lowercase hex — matching sha256sum and CryptoJS's
.toString(), so a ported Postman script lands right — and each has a _b64
variant returning standard padded Base64. The encoding is in the name rather
than a default because a signature in the wrong one is the right length,
entirely plausible to look at, and rejected with the same 401 as a wrong
secret. Note that base64(sha256(m)) is not sha256_b64(m): the first
encodes 64 hex characters, the second the 32 bytes they spell.
Canonicalisation is yours. PaperBoy signs exactly the bytes you assemble; it will not build a canonical request from the live headers, so AWS SigV4 and friends are out of scope. Chaining a MAC into the key of the next one isn't expressible either, since every value here is text.
The block works headlessly too. paperboy -c … evaluates each request's rows
in its own window, so a generator can read a value an earlier request captured
and two requests each get their own nonce. --batch is a single Hurl call over
the whole file and has no such window: there every block is evaluated once
before the run, a name computed by two requests takes the first one's value for
both, and the run says so before it starts.
A row that fails — unknown function, wrong arity, a name nothing defines —
reports rather than blocks the send. It binds nothing, so {{sig}} goes out
literally and comes back a loud 401, which is easier to diagnose than a
refusal.
Importing from Postman maps the dynamic variables that have an exact equivalent:
$guid/$randomUUID and $isoTimestamp become Hurl's own {{newUuid}} and
{{newDate}}, while $timestamp, $randomInt and $randomAlphaNumeric become
[Gen] rows. The rest
are renamed and listed in CONVERSION-NOTES.md as values you must supply —
guessing at $randomFirstName would send a plausible wrong value, which is
harder to notice than a request that won't run.
Worked examples — collections to import, and the .hurl file they should become
— are in examples/postman/.
Git remotes
Load and save collections, environments and whole workspaces straight from a remote, with no local clone: PaperBoy lists refs, fetches just enough history to read the file tree, and checks out only the files you actually asked for. Nothing else in the repo touches your disk, however large it is.
Loading (File ▸ Load ▸ kind ▸ From Git…): give the URL — https://… or
git@…, with an optional access token used only for that fetch (GitHub-style
https://x-access-token:<token>@host/… is handled for you) — then pick a ref
and a file, both filterable as you type. ↓ on the URL field offers your
recent URLs. Loading a collection then offers to pair an environment from the
same listing, with no second round-trip. Anything loaded from git shows a ⎇ in
its tab title and remembers its origin.
Workspaces ask which files to fetch first — .hurl and .json (default),
.hurl only, .json only, or everything — and then whether to keep the
download temporarily or copy it somewhere permanent immediately.
A temporary workspace is never cleaned up. Its files live in a temp folder for as long as the tab exists — including across a close and undo, and across restarts. They accumulate. Choose "save to a permanent location" when asked, or later via File ▸ Save ▸ Workspace ▸ Save As…, which copies the folder and stops tracking it as temporary.
Saving (File ▸ Save ▸ Collection ▸ To Git…) pushes a commit directly to the remote. The URL is prefilled from where the collection came from, so you can redirect it to a fork. You choose the in-repo path, whether the attached environment goes in the same commit, and a branch or tag:
- A branch defaults to the one you loaded from, so
Enterjust appends a commit.↓lists the remote's branches. No merge or rebase is attempted — a non-fast-forward is reported as an error. - A tag must be new. The remote is re-fetched immediately before the check, and an existing tag is always rejected with no way to force it.
The message defaults to Update <name> via PaperBoy and is editable. The author
is your git identity, or PaperBoy <paperboy@localhost> if you have none. A
branch push updates the remembered origin and clears the modified markers; a tag
push clears the markers but leaves the origin on your working branch.
To Git… only works for something loaded from git. For anything else, Save As… into your own clone and use git normally.
Importing from Postman
Already have an export? Just open it — Open ▸ Collection and Load ▸ Environment both work out what the file holds, and File ▸ Import from Postman ▸ From an exported file says so explicitly. No API key, no account.
To pull from an account, File ▸ Import ▸ Postman account… (terminal) or File ▸ Import from Postman ▸ From my Postman account… (GUI). Give it an API key, pick a workspace, choose what to bring and where, and the result opens as a workspace. Paste a workspace id — or its Postman address — on the first step to skip the listing entirely.
Migrating off Postman altogether: Ctrl+A on the workspace list, or Import
all in the GUI. Everything the list is showing is imported (so the filter is
honoured), each workspace into its own folder, so two "Billing API" collections
from different workspaces both survive.
Postman rate-limits its API, so the wizard shows what it found and roughly how long the download will take before fetching anything, then reports the remaining time from the rate it is actually achieving and says when it is pausing to stay inside the limit. A Postman API key carries its owner's full access and can't be scoped, so a missing workspace is one your account isn't a member of.
The same import runs headlessly:
| Flag | Effect |
|---|---|
--postman-key |
The key, instead of $POSTMAN_API_KEY. Takes the same {{ … }} provider references as a .vars file — '{{ op://Private/Postman/credential }}' keeps it out of your shell history. Never written to disk, stripped from error messages. |
--postman-all |
Every visible workspace, each into its own folder under -o. Empty workspaces are skipped and inaccessible ones reported rather than fatal, so forty workspaces aren't stopped by one. Excludes --postman-workspace. |
--postman-what |
collections, environments or all. |
--postman-format |
postman (default) keeps the JSON byte for byte; hurl converts. |
--overwrite |
Replace a non-empty destination, which is otherwise refused. |
--postman-base-url |
Another tenant, e.g. https://api.eu.postman.com for EU Enterprise. |
The result is a folder of Collections/ and Environments/; open it with Open
▸ Workspace.
Converting to Hurl
--postman-format hurl brings across requests, folders (as Folder/Name
titles), headers, query parameters, raw bodies and form/multipart fields, plus:
- Auth, including inheritance. Collection- and folder-level auth is applied
to requests that don't set their own, and
noauthopts back out.basic,bearerandapikey(header or query) are mapped. - Collection variables, which have nowhere to live in a
.hurlfile, as<name> (collection variables).varsbeside the environments. pm.<store>.set("NAME", body.a.b)calls in test scripts, as[Captures].- Dynamic variables.
{{$guid}}and{{$isoTimestamp}}become Hurl's own{{newUuid}}/{{newDate}};{{$timestamp}},{{$randomInt}}and{{$randomAlphaNumeric}}become generated values. The rest are listed as values to supply. - Pre-request scripts, as far as they reduce to values PaperBoy can compute:
pm.environment.set("id", uuid.v4()),Date.now(),Math.floor(Date.now() / 1000),new Date().toISOString(),pm.variables.replaceIn("{{$guid}}")and literals become generated values. - Test scripts, as the status and assertions they always make:
pm.response.to.have.status(400)becomes the request's expected status, andpm.expect(...)checks on the body, headers and response time become[Asserts]. A deep equality against a literal document (.to.eql({ id: 7, name: "Ada" })) is written out one leaf at a time, since Hurl has no predicate that takes a document — which also makes a failure name the field that differed. Only checks that run unconditionally are taken — anything inside anif, a loop or a helper function is left for you, since an assertion that was meant for one branch fails every run. setNextRequest, as a note saying which of the four things it was doing: polling (which Hurl writes as[Options] retry), an order you can write down in the file or asREQUESTlines in a PaperTrail flow, a run that stopped early, or a request name built as the script ran. PaperBoy runs a collection in file order, so none of them convert — but they are four different problems with four different fixes.- Scripts on a folder or on the collection, which Postman runs for every request inside; they are converted for each request they cover, and reported once against the folder that holds them.
Hurl doesn't cover everything Postman does. Anything dropped — the rest of a
script, OAuth 2, GraphQL bodies — is listed per request in
CONVERSION-NOTES.md at the root of the import; no file means nothing was lost.
A collection this build can't read is written out as its original JSON, so
converting can't cost you data.
Headless runner
-c takes a .hurl file or a Postman export. -e supplies the environment.
Exit status is 0 only if every request passed.
By default each request's method, URL, status, asserts, captures and truncated
body print as it finishes, coloured unless the output isn't a terminal or
NO_COLOR is set. Streaming runs one request at a time through the same hurl
runner, so captures still chain — but it can't carry Hurl's automatic cookie jar
between requests, and says so at startup. An explicit [Cookies] section is
unaffected. -b/--batch runs the collection as a single Hurl call, trading
incremental output for cookie continuity.
Reports
-r report.trail runs a PaperTrail report and exits.
Without -c/-e the report's own # collection: / # environment: headers
apply, resolved relative to the report. -e is repeatable: each file is named
by its stem and becomes selectable in an ENVS loop, so -e prod.vars -e staging.vars satisfies FOR … IN ENVS BASELINE("prod"), COMPARISON("staging");
the first is also the base variable layer. -o's extension picks the format
(.csv, .json, .html, .xlsx), - writes CSV to stdout, and omitting it
derives the filename from the report's own headers.