rq

rq 0.1: plain files, honest conversions, and what is still missing

2026-08-20

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.

github/issues.md
---
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:

$ rq r login --cookies # .rq/cookies.json, or name a file $ rq r issues --log # .rq/log.jsonl, or name a file $ cat .rq/cookies.json # there is nothing to wrap $ rm .rq/cookies.json # and that is how you log out

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:

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:

Not built, in rough order of how much it matters:

0.1 means 0.1. The file format is documented and tested, and it is also new enough that it may still move. If you keep a collection in it, keep it in git — which is rather the point of the format.

Everything is MIT licensed, has no telemetry and no account, and lives at github.com/amitu/rq.