# PaperBoy
PaperBoy is a Rust-native alternative to Postman. Collections and environments
are plain, git-friendly text files — [Hurl](https://hurl.dev) `.hurl` files
for requests, `.vars` files for environments — so everything can be committed,
diffed, and shared through a normal git repository. There is no hosted
service and nothing runs outside your machine.
It ships as a single binary with two front-ends:
- **Terminal UI** (default) — a full interactive client for building,
editing, running and organizing requests.
- **Headless CLI** (`-c`/`--collection`) — runs a collection end-to-end and
prints the results, for scripts and CI.
## Contents
- [Installing & running](#installing--running)
- [Core concepts](#core-concepts)
- [Features](#features)
- [Working with collections & environments](#working-with-collections--environments)
- [Loading & saving via git](#loading--saving-via-git)
- [Secrets & environment variables](#secrets--environment-variables)
- [Keyboard shortcuts](#keyboard-shortcuts)
- [Headless CLI mode](#headless-cli-mode)
## Installing & running
From the `paperboy` directory:
```sh
cargo run # launch the terminal UI
cargo build --release
```
## Core concepts
- **Collection** — a `.hurl` file: an ordered list of requests, each with a
method, URL, headers, cookies, body/form fields, and optional
`[Captures]`/`[Asserts]` sections. Postman collection exports (`.json`)
can also be opened directly — they are imported and converted to Hurl
automatically.
- **Environment** — a `.vars` file: `KEY=value` lines supplying the
`{{ VAR }}` values a collection references. A value can be a literal, or a
reference to a secret provider (see [Secrets](#secrets--environment-variables)).
- **Scratch Space** — the first, always-present tab. It behaves like any
other collection (add/run/edit requests) but isn't tied to a file until you
explicitly save it — a place to try things out without committing to a
collection first.
## Features
- Multiple collection tabs, each with its own environment.
- Add, edit, reorder, run, and delete requests within a collection.
- **Edit Request wizard**: pressing `Enter` on a request opens the same
form used for "New Request", prefilled with its current method, URL,
headers, cookies, body, form fields, `[Captures]`, and `[Asserts]` — no
manual JSON or Hurl syntax required. `Shift+R` opens **Raw Mode** instead,
a direct editor for the request's real Hurl text (for anything the form
doesn't expose, such as query params, Basic Auth, or the expected
status); saving reparses the text and applies it back to the request,
keeping invalid text open for correction rather than discarding it.
- **`[Form]`/`[Multipart]` and `[Cookies]` sections** in the request wizard:
add form fields (enabled checkbox, Key, a Kind dropdown defaulting to
`Text` — press `Enter` to open it and pick `File` instead, Value, a
separate Content-Type column, and a free-text Description column — Kind
comes before Value so filling a row naturally picks `Text`/`File` before
typing the value) and cookies (checkbox, Key, Value), using the same
table and tabbing conventions as `[Headers]`. Saving picks the correct Hurl
section automatically — all-Text fields become `[Form]`, and any File
field promotes the section to `[Multipart]`. File fields are colored to
show whether the referenced path exists and is readable — relative paths
resolve against the collection's own directory, matching where Hurl
actually looks for them when sending the request. With focus on a
`File`-kind Value cell, `Ctrl+F` (or `Enter`) opens a file picker to
choose the path instead of typing it (without stealing focus to the
Content-Type column afterwards); the Content-Type cell is a dropdown
offering "Auto" plus the fixed MIME types from
[hurl.dev](https://hurl.dev/docs/request.html) (still editable as free
text for anything else), and is auto-set from the picked file's
extension. An empty/auto-detected Content-Type cell shows a dimmed
"Auto" placeholder rather than looking blank. Editing a request into an
impossible Body + Form/Multipart combination shows a clear status-bar
error instead of sending it.
- **Headers, Cookies, Form, Asserts, and Captures all start empty** — just
a "+ Add …" button for each, with no default blank row to delete for
requests that don't need one (a lingering blank row used to intercept
the `↓` key meant for scrolling past that section). To remove a row you
no longer need, focus any cell in it and press `Ctrl+D`; the wizard's
hint bar shows `^D delete row` as a reminder whenever focus is on a
deletable row.
- **Enabled checkbox is reachable with `←` from a row's Key cell** — it's
the leftmost visual column of every Headers/Cookies/Form row, so arrow
navigation now matches that layout instead of requiring a trip through
every other column to reach it. A brand new row still starts focus on
Key (not the checkbox), matching how you naturally fill a row in. `Ctrl+E`
toggles the focused row's enabled state and jumps focus to the checkbox,
from anywhere in that row.
- **Dropdowns only auto-open when their cell is empty.** The Key suggestion
and Content-Type dropdowns pop open automatically the first time you land
on an empty cell, but once a cell has a value, navigating onto it again
leaves the dropdown closed — press `Enter` to bring it back up without
disturbing the current value. The Form Kind dropdown follows the same
"populated cell" rule: since it defaults to `Text` on a fresh row, it
never auto-opens — `Enter` always reveals it to switch to `File`. This
keeps `↑`/`↓` navigation through a populated section from getting stuck
reopening a dropdown on every row.
- **Scrollable, navigable tables**: when a Headers/Cookies/Form/Asserts/
Captures section has more rows than fit on screen, it grows to show up
to 5 rows before a scrollbar appears on the **left** edge, right next to
the data; `↑`/`↓` scroll the focused row into view, and `Ctrl+↑`/`Ctrl+↓`
jump straight to the first row of the previous/next section. The "+ Add
…" row always stays reachable even when the list is scrolled, and a long
`Body` also grows a left-hand scrollbar once it overflows.
- **Per-section wizard tabs**: the request wizard has a tab bar (`All │
Headers │ Cookies │ Form │ Body │ Asserts │ Captures`), switched with
`PageUp`/`PageDown` and reordered with `Ctrl+Shift+←`/`→` (just like
collection tabs). Picking a section tab gives that section almost the
whole dialog to itself — handy for editing long Headers or Form-field
lists — while `All` keeps the combined, everything-at-once layout. Every
tab is just a different view of the same request, so edits made on one
tab show up immediately on every other. Switching to a section tab jumps
focus straight to its first entry (Body's tab drops you right into
editing), and `Tab`/`Shift+Tab`/`↑`/`↓`/`Enter` are confined to the
active section so navigation never jumps to a section you can't see.
- **Request chaining**: capture a value from a response (Hurl `[Captures]`)
and reference it as `{{ name }}` in any later request in the same
collection.
- **Basic Auth** support (Hurl's native `[BasicAuth]` section).
- Colored, substituted request preview: `{{ VAR }}` placeholders in the
Request JSON panel are shown replaced with their real value, colored by
status (green = loaded, cyan = literal, orange = still loading, red =
missing) — while the editor itself always keeps the original `{{ VAR }}`
text, so you always know exactly what will be sent versus what's stored.
Secrets substituted into the preview are masked as 8 dots.
- Editing an environment entry to look like a secret reference (e.g. typing
`{{ op://vault/item/field }}` or `{{ ssm:/path }}` into a value) triggers an
automatic load attempt, the same as if it had been in the file originally.
- Load/save collections and environments from local files, or straight from a
git remote (see below) — no local clone required. A collection or
environment loaded from git shows a small ⎇ icon in its tab title /
Environment heading, and can be pushed back to a new branch or tag with
**Save Collection to Git…**.
- A **recently-used git URLs** dropdown in the "Load from Git" wizard, most
recent first.
- Resizable panes: the left column width and the response pane height are
both adjustable and persisted across restarts.
- Tab management: close (`Ctrl+W`/`x`), reopen the last-closed tab (`u`),
cycle tabs (`[`/`]` or `PageUp`/`PageDown`), and reorder tabs
(`Ctrl+Shift+←`/`→`).
- **Deleted requests can be restored too.** Pressing `x` on the Requests
list deletes the selected request; `u` on that same pane brings back the
most recently deleted one (last-deleted-first, one collection's stack
per collection) — the exact parallel of reopening a closed tab, just
scoped to individual requests instead of whole collections.
- The app remembers your window layout, active tab/request, language, and
recently-used git URLs across restarts.
- English, French and Danish UI languages (Options → Language).
- **Nested folders.** A request's name can encode a folder path with `/`
separators (e.g. `Auth/Tokens/Refresh`); the Requests list browses these
as folders — `Enter` on a folder row descends into it, `Enter` on the
"‹ .." row (or `Backspace`) goes back up — instead of an ever-more-indented
tree, and the panel title shows a breadcrumb of the folder you're in.
Importing a Postman collection preserves its nested folder structure this
way automatically; native Hurl collections get the same behavior for free
simply by naming requests with `/` in them. Creating a new request while
browsing a folder prefills the Name field with that folder's path.
## Working with collections & environments
Open the **File menu** with `f`. Load and save actions are grouped together:
| Load Request | Load a single saved request-JSON snippet into the Scratch Space |
| Open Collection | Open a `.hurl` or Postman `.json` file from disk as a new tab |
| Load from Git… | Open a collection file (optionally with its environment) from a remote git repo (see below) |
| Load Environment | Open a `.vars` file from disk into the active tab |
| Load Environment from Git… | Open an environment file from a remote git repo |
| Save Request | Save the current request's JSON to disk |
| Save Collection | Write the active collection back to its original file |
| Save Collection As… | Write the active collection to a new/chosen file |
| Save Collection to Git… | Push the active collection (and, optionally, its environment) back to the git repo it was loaded from (see below) — only offered for a collection that was itself loaded from git |
| Save Environment | Write the active environment back to its original file |
| Save Environment As… | Write the active environment to a new/chosen file |
| Save Response | Save the last response body to disk |
Notes on saving:
- **Save Collection** / **Save Environment** write back to the file the tab
was originally loaded from. If there are no new or modified entries, saving
is a no-op and no confirmation is shown (there's nothing to warn about).
If there *are* unsaved changes, you'll get a confirmation naming how many
new/modified requests (for a collection) or variables (for an environment)
will be written — the two counts are tracked and reported independently, so
saving a collection only ever mentions collection changes, and saving an
environment only ever mentions environment changes.
- **Save As…** always prompts for a filename. If the chosen path already
exists you'll be asked to confirm the overwrite (Yes/No) before anything is
written.
- The Scratch Space (tab 0) can be saved like any other collection — pick
**Save Collection As…** to give it a file for the first time.
- A modified request shows a pencil icon next to it in the Requests list
until it's saved.
## Loading & saving via git
This is the one workflow that isn't just "open a file", so here's the full
step-by-step. **Loading** fetches a single file out of a remote repository
without cloning it: it lists the repo's branches/tags, fetches just enough
git history to read the file tree for the ref you pick, and checks out only
the one file you select. No other file in the repo ever touches your disk.
1. Press `f` to open the File menu, then choose **Load from Git…** (for a
collection) or **Load Environment from Git…**.
2. **Connect** — type the repository URL (`https://…` or `git@…`). If the
repo needs an access token, Tab to the token field and enter it (only used
for this fetch — the wizard supports GitHub-style
`https://x-access-token:<token>@host/...` URLs and injects the token for
you). Press `↓` on the URL field to open a dropdown of your recently-used
URLs and pick one instead of retyping it; press `Enter` to connect.
3. **Pick a ref** — choose a branch or tag from the list (type to filter).
4. **Pick a file** — choose the `.hurl`/`.json` (or `.vars`) file from the
ref's file tree (type to filter).
5. The file is checked out and opened as a new tab. Its URL is remembered at
the top of the "recently used" list for next time, and a small ⎇ icon is
shown in the tab title (or, for an environment, in the Environment panel
heading) to mark it as loaded from git.
6. When loading a **collection**, you're then asked whether to also load its
environment — answering Yes reuses the same file listing already fetched
in step 4 (no second network round-trip) and lets you pick the `.vars`
file to pair with it. Answering No (or loading an environment on its own
via **Load Environment from Git…**) leaves it as-is.
Loading a directory of files, or importing a whole repo, is not supported
this way — for that, see **Loading a Workspace from git** below.
### Loading a Workspace from git
A **Workspace** is a folder containing many `.hurl`/`.json` collection
files, browsed and chosen from through a single tab (press `w` on a
Workspace tab to reopen its file-tree picker at any time). Workspaces can
also be loaded straight from a remote repository, instead of only from a
local folder:
1. Press `f` to open the File menu, then choose **Work(s)pace from Git…**.
2. **Connect** and **pick a ref**, exactly as for a single collection/
environment above.
3. **Choose which files to download** — a repo can hold plenty of large,
unrelated files that have nothing to do with your collections, so before
anything is fetched you pick one of: `.hurl` and `.json` files only
(the default), `.hurl` files only, `.json` files only, or every file.
Only files matching this choice are ever downloaded — anything else in
the repo never touches your disk.
4. The matching files are checked out into a throwaway local folder. Before
the new tab is created you're asked whether to **keep it temporarily**
(the folder above) or **save it to a permanent location now** — picking
the latter opens a folder browser and a name prompt, then copies the
files there immediately and binds the new tab to that permanent folder
instead. Either way, the file-tree picker then opens immediately so you
can choose which collection to view.
**⚠️ A temporarily-kept Workspace's files are not cleaned up automatically.**
Unlike the single-file collection/environment load above, if you choose to
keep it temporary its files stay on disk in a temp folder for as long as its
tab exists — including across closing and reopening the tab within the same
session (`u` / `Ctrl+Shift+T` undoes a close, so the folder can't be safely
deleted the moment the tab closes). Restarting PaperBoy does not delete it
either. If you load the same Workspace from git repeatedly, or load many
different ones and keep them all temporary, these folders will accumulate
under your system's temp directory and you may want to clear them out
yourself from time to time — or avoid the problem entirely by choosing
**save it to a permanent location** when asked, or later via **File → Save
→ (W)orkspace…** (available for any Workspace tab, local or from git; it
copies the whole folder to a destination and name you choose, and stops
tracking it as a temporary git download). Saving a Workspace-loaded
collection back to git is also not currently supported — use **Save
Collection As…** into your own local clone instead.
### Saving to git
A collection that was itself loaded from git (shown by the ⎇ icon in its tab
title) gains a **Save Collection to Git…** File menu item, which pushes a new
commit straight to the remote — no local clone, staging area, or manual `git`
commands needed. Like loading, this never performs a full checkout: only the
file(s) you're actually writing are ever fetched or touched, no matter how
large the repository is.
1. **Connect** — the repository URL is pre-filled with the one this
collection was loaded from (editable, e.g. to push to a fork), plus an
optional access token field.
2. **Choose what to save** — the collection's in-repo path (defaults to
where it was loaded from), and, if an environment is attached, whether to
also save it in the same commit (and its in-repo path).
3. **Choose a target** — Tab switches between **Branch** and **Tag**:
- A **branch** name defaults to the one you loaded from (so pressing
Enter here just appends a new commit to it); typing a different name
targets another existing branch (also a plain append) or creates a
brand-new one. `↓` opens a dropdown of the remote's existing branches
to pick from instead of typing. No merge or rebase is ever attempted —
if the branch has moved in a way that makes the push a non-fast-forward,
it's simply reported as an error.
- A **tag** name must be brand new. PaperBoy re-`fetch`es the remote
immediately before checking, so even a tag another user created seconds
ago is caught — **an existing tag name is always rejected and never
overwritten**, with no way to force it.
4. **Commit message** — auto-generated (`Update <name> via PaperBoy`) and
editable before pushing. The commit author is your system git identity
(`git config user.name`/`user.email`), or a generic `PaperBoy
<paperboy@localhost>` identity if that isn't configured.
5. Once pushed, a branch-target save updates the collection's (and, if
included, the environment's) remembered git origin to that branch and
clears their "new"/"modified" markers, exactly like a local Save. A
tag-target save clears the markers too but leaves the remembered branch
origin untouched, so the *next* "Save to Git" still defaults back to your
working branch.
For a collection that *wasn't* loaded from git (or when you'd rather manage
git yourself), the old workflow still works exactly as before: use **Save
Collection As…** / **Save Environment As…** and choose a path inside your own
local clone of the repository, then commit and push with your normal git
tooling outside the app.
## Secrets & environment variables
An environment `.vars` file has one `KEY=value` entry per line. The value can
be:
| Literal | `USERNAME=demo` | Used as-is |
| Process env var | `BASE_URL={{ env:DEMO_BASE_URL }}` | Read from the process environment |
| 1Password (`op` CLI) | `API_TOKEN={{ op://Vault/Item/field }}` | Resolved via the local `op` CLI |
| AWS SSM parameter | `DB_PASSWORD={{ ssm:/path/to/param }}` | Resolved via local AWS auth |
Provider-backed values (`op://…`, `ssm:…`) are resolved in the background
when the environment loads; the Environment panel shows their status while
this happens. Every 1Password reference across every open collection's
environment is resolved with a single `op inject` call (one authorization
prompt), instead of one prompt per collection. If an entry fails to load
(missing tool, declined auth, unreachable parameter store), select it in
the Environment panel and press `r` to retry just that one entry — works
for process env vars, 1Password, and SSM references alike. When editing
such an entry, a **"still secret?"** checkbox lets you keep it masked or,
if you're confident the new value isn't sensitive, save it as a plain
value in the state. Rewriting a value to look like a provider reference
(typing `{{ op://... }}` or `{{ ssm:... }}` into any entry) automatically
triggers a load attempt for it, same as on initial file load.
## Keyboard shortcuts
Open the in-app help overlay any time with `?` or `F1` for the full,
up-to-date list. Highlights:
| `Tab` / `Shift+Tab` | Move focus between panes |
| `↑`/`↓`, `j`/`k` | Move selection |
| `←`/`→`, `h`/`l` | Switch tabs / scroll list-panel text horizontally |
| `Enter` | Open the Edit Request wizard for the selected request (or, on a folder row in the Requests list, descend into that folder; on the "‹ .." row, go back up) |
| `Backspace` (Requests list) | Go up a folder, from anywhere in the current folder |
| `Shift+R` | Edit the selected request in Raw Mode (Hurl text) |
| `F5`, `Ctrl+Enter` | Run the current request |
| `Alt+F5` | Run every request in the collection in order, in one Hurl execution (like the CLI's batch mode) — pass/fail markers appear in the Requests list and a Passed/Failed/Total summary on the status bar |
| `n` | New request (List/Response/Tabs panes) — or add environment variable (Env pane) |
| `b` | Set the base URL |
| `f` / `o` | File menu / Options menu |
| `[` / `]`, `Ctrl+←`/`→` | Previous / next tab |
| `PageUp`/`PageDown` | Previous / next tab (same as `[`/`]`) |
| `F2`, `Enter` (on tab bar) | Rename the active collection tab |
| `x` | Delete request / close collection tab |
| `r` (Env pane) | Reload the selected environment entry if it failed to load |
| `Ctrl+W` / `u` | Close / reopen a collection tab |
| `u` (Requests list) | Restore the most recently deleted request in the active collection |
| `Ctrl+Shift+←`/`→` | Reorder the active tab |
| `+` / `-` | Grow / shrink the response pane |
| `<` / `>` | Grow / shrink the left column |
| `Ctrl+↑`/`↓` (in wizard) | Jump to the previous/next section (Headers/Cookies/Form/Body/Asserts/Captures) |
| `Alt+1`-`6` (in wizard) | Jump directly to a section (1=Headers, 2=Cookies, 3=Form, 4=Body, 5=Asserts, 6=Captures) — `Alt`, not `Ctrl`, since most terminals can't report `Ctrl`+digit without special keyboard-protocol support |
| `PageUp`/`PageDown` (in wizard) | Switch the wizard's section-view tab (`All`/Headers/Cookies/Form/Body/Asserts/Captures); switching to a section tab focuses its first entry |
| `Ctrl+Shift+←`/`→` (in wizard) | Reorder the wizard's section-view tabs |
| `Ctrl+D` (on a Header/Cookie/Form/Assert/Capture row) | Delete that row |
| `Ctrl+E` (on a Header/Cookie/Form row) | Toggle that row's enabled/disabled state and focus its checkbox |
| `←` (from a row's Key cell) | Reach the Enabled checkbox — it's the leftmost visual column, so arrowing left from Key goes straight to it |
| `Ctrl+F`, `Enter` (on a `File`-kind Form Value cell) | Open a file picker to choose the path |
| `F2`, `Ctrl+Enter` | Save the open editor / wizard |
| `Esc` | Cancel |
| `q`, `Ctrl+C` | Quit |
## Headless CLI mode
Run a collection end-to-end from the command line, with no UI:
```sh
paperboy -c collection.hurl
paperboy -c collection.hurl -e environment.vars
```
- `-c`/`--collection` accepts a Hurl `.hurl` file or a Postman collection
export (`.json`), imported automatically.
- `-e`/`--env` supplies a `.vars` environment file for any `{{ VAR }}`
references in the collection (required if the collection uses
`{{ BASE_URL }}` or similar).
- `-b`/`--batch` runs every request as a single Hurl call instead of the
default streaming mode (see below).
By default, each request's method, URL, status, asserts, captures, and body
(truncated) are printed as soon as that request finishes, instead of
waiting for the whole collection — output is color-formatted (skipped
automatically when not writing to a terminal, or when `NO_COLOR` is set) to
make passes, failures, and sections easier to tell apart at a glance.
Streaming reuses the same `hurl` crate runner one request at a time, so
captures still chain correctly between requests — but it can't carry
Hurl's automatic cookie jar (cookies remembered from `Set-Cookie` response
headers) across requests the way running the whole collection in one call
can; a warning to this effect is printed at startup. An explicit
`[Cookies]` section on a request is unaffected either way. If a collection
relies on cookie continuity between requests, pass `--batch` to run it as
a single call like before, trading incremental output for that guarantee.
The process exits `0` if every request passed, non-zero otherwise.