rq 0.1: plain files, honest conversions, and what is still missing
A terminal API client whose requests are Markdown files, which runs the collection you already have, and which tells you what it could not carry across. Here is what it does, the handful of rules it was built under, and the parts that are not built yet.
The shape of it
A request is one Markdown file. Frontmatter for the parts a machine reads, sections for the parts a person writes.
---
url: '{{api}}/repos/{{owner}}/{{repo}}/issues'
query:
state: open
capture:
first: response[0].number
---
-- description --
Open issues, newest first.
-- view --
{% for i in response %}
- [**#{{ i.number }}**]({{ i.html_url }}) {{ i.title }} — {{ i.comments }} comments
{% endfor %}
Three things follow from that choice, and they are most of what makes the tool feel
different. Your requests diff and review like code, because they are text in your repo.
They are editable by anything — an editor, a script, sed — because nothing is
hidden in a database. And a request can carry a view: a template that renders the
response into something legible, so the thing you look at fifty times a day is the thing you
shaped rather than a wall of JSON.
Six rules
1. Never rename someone's code
Every converter in this space is tempted to string-replace pm. with its own
prefix. That imports clean and throws at run time, which is the worst possible order to
discover a problem in. So a converted script is carried verbatim, with the
dialect it was written in recorded next to it, and the runtime reconciles it at execution —
every run, against a parser rather than a regex.
The practical payoff is that your file keeps the script its author wrote, so exporting back to Postman gives back Postman; and improving the translation improves every project that already exists, without re-importing anything.
2. Report what you could not carry
Conversions lose things. A tool that pretends otherwise is worse than one that loses less and says so. Every conversion produces diagnostics with the file they came from, and the fidelity tests assert more than "it parsed": every request in the source has to survive into the model, and round-trip loss has to stay inside a documented allowlist. When something new starts being dropped, a test fails.
3. stdout is the result, stderr is the narration
The step tree, the captured values, the test lines and the notes go to stderr. The result —
the rendered view, or --json, or the raw body — goes to stdout. So the payload
is byte-identical whether you are watching or piping, and the only things that change when
nothing is attached to the terminal are where the narration lands and whether the console
opens.
This one arrived by being wrong first. An earlier version changed what you got depending on whether stdout was a terminal, which is exactly the kind of cleverness you have to remember.
4. The path is the interface
Two flags keep state between runs, and neither of them brought a subcommand with it:
There is no rq cookies list and no rq log clear, because a path
needs neither. Every command a tool does not add is one nobody has to discover. The log is
JSONL for the same reason: appending is a write rather than a rewrite, a torn line costs one
request instead of the whole history, and tail -f and jq already
know what to do with it.
Both are off by default. Session cookies are credentials, and a client that stored yours without being asked would be storing them without consent.
5. Loud failures, and no false greens
The failure this project is most organised against is the one that looks fine. A few of the shapes it takes, all of which happened here:
- A test corpus that had been quietly truncated to 7% of its files by a throttled download. Every assertion still passed — they compared the parse against whatever was on disk. The corpora now assert their pinned size, because a corpus is pinned by commit, so its size is a fact rather than a range.
- A missing script engine that silently skipped the script. It is now a reported non-execution: the request succeeded and only the script did not, and losing the response over it would be the worse answer.
- A cookie jar that could not be read. Starting empty in silence looks exactly like the server logging you out, and sends you debugging the wrong end.
The strongest version of that instinct is refusing to act at all. A request that still
carries {{TOKEN}} is not sent: a placeholder on the wire is
always a bug, and the symptom it produces — a 401 that reads like a credentials problem —
sends you to the API rather than to your file, after the request has gone out. Erring
toward strictness costs one command; the alternative costs an afternoon. Declaring a
variable is how you say it may legitimately be empty, and an empty credential is dropped
rather than sent.
The same instinct shows up in rq check, which reads every file the way a run
would and reports what a run would trip over — a parents: naming something that
was renamed, a capture: path that can never match, a view template that stopped
parsing.
6. Do not build an editor
A request is a Markdown file, so the editor you already use is better at editing it than
anything we would write. rq e <name> hands the file to
$EDITOR and gets out of the way. That is not a gap waiting to be filled; it is
the answer.
The engine question
Scripts run on a QuickJS runtime with the same API the Requestly app uses, so a collection behaves the same in both places. The interesting part is how that runtime got into a Rust binary.
It started as a Node sidecar: rq spawned node against a checked-out
copy of the JavaScript engine. That works on a development machine and nowhere else — a
downloaded binary has no npm install beside it — which would have made scripting
a feature only contributors had.
What made the fix cheap was noticing that the engine builds its guest environment out of source strings: the API surface, the shims, the assertion library, the require chain. So the package now generates those strings as data, and the binary evaluates them in an in-process QuickJS, in the package's own order. Nothing about the API is reimplemented in Rust. The semantics still have exactly one owner, and a change there reaches the CLI by regenerating a file rather than by porting anything.
Postman and Bruno then arrive by two different routes, and the difference is worth stating
because it is not arbitrary. Postman has to be rewritten: its v1 scripts say
tests['ok'] = responseCode.code === 200, an assignment to a subscript, which no
runtime object can intercept. Bruno needs no rewriting at all: bru,
req and res are objects, so the runtime simply provides them — and
a shim survives aliasing that a static rewrite would miss.
Where this actually is
Working today:
- Postman (v1.0, v2.0, v2.1), Bruno, cURL and rq projects in; rq, Postman v2.1, Bruno and two Requestly shapes out.
- Reading a foreign collection in place — point rq at a Postman export and run it as it is.
- Chaining and capture without code; variables, environments, secrets that stay redacted.
- Scripts, including
pm.*andbru.*, with no Node installed. - The console,
rq check,rq fmt,--jsonfor CI, persistent cookies and a network log.
Not built, in rough order of how much it matters:
- Packaged binaries. No tap, no apt repo, no release artifacts. Everything above is invisible to anyone unwilling to compile it, which makes this the next thing.
- Data-driven runs and a JUnit reporter — the shape you want when a collection lives in a pipeline.
- Saved response examples, which are also a fidelity gap today: the model carries them and rq drops them.
- More formats — OpenAPI first.
Everything is MIT licensed, has no telemetry and no account, and lives at github.com/amitu/rq.