A script cannot read a sentence. Everything below is the part of the CLI a pipeline, a Makefile or an agent is allowed to depend on.
Two codes
| Code | Means |
|---|---|
0 | The command did what it said |
| non-zero | It did not |
There is no taxonomy beyond that, deliberately. A numbered error space is a contract that has to be maintained forever and read correctly by everyone, and the failures a caller can actually branch on are better carried in the --json payload — which names them, in words, with the remedy attached.
pithy --pithiest exits 0. It is a refusal, not an error.
Two streams
stdout carries the answer. Under --json it is exactly one line, one object. Nothing else is ever written there.
stderr carries everything about the run. Progress arrows, warnings, the update notifier, and the error line. A pipeline that captures stdout and lets stderr through gets clean, parseable output and a readable log at the same time.
The failure line
A failed command writes one object to stderr and exits non-zero:
{"error":{"code":"cli/no_project","message":"No pithy.config.ts here.","action":"Run pithy init, or cd into a project."}}code is domain/reason and is stable. message states the problem; action states what to do about it, and it is there because an error that tells you what broke and not what to do costs a search. Both are English, permanently — see Errors for why an error payload does not translate and what a client renders instead.
Without --json the same two strings are the same two lines, the first in red:
No pithy.config.ts here.
Run pithy init, or cd into a project.Partial success is reported, not hidden
Some commands touch several things, and there is no transaction across them. Those report what happened on the way rather than collapsing to a single verdict.
pithy deploy attempts every Worker in apps/, even after one fails. Each gets an entry in workers[] with its own ok, and the command exits non-zero if any of them failed. One Worker’s typo does not stop the other four from shipping.
pithy migrate fans out over databases, and a fan-out has no transaction either — the third database throws and the first two are already ahead of it. So the error line still goes to stderr and the exit is still non-zero, and stdout still carries what the run changed on the way. That line adds three keys you will not see on a clean run:
| Key | Means |
|---|---|
interrupted: true | Present on this line alone. It is what says workers is a truncated report rather than a whole one |
failed | The database the run died on, as its binding and database |
unreached | Every database in scope the run never opened, in fan-out order |
An empty unreached means the failure was on the last database — never nothing was scanned.
pithy doctor is the gate
Doctor exits non-zero when any Worker fails a check, which is what makes it usable as a CI step. What fails it is worth knowing, because the rule is consistent:
A finding fails the exit. A state that has not been reached yet does not.
- A contradiction between the project’s own config and its own wiring fails — a Worker declaring a Workflow its stanza does not bind, a domain nothing routes, a capability composed without its prerequisites.
- A step you have not taken yet passes — a project with no domain, an environment not provisioned, a secret registry never provisioned. Every project is in those states on day one, and turning the report red for everyone on day one would teach people to ignore it.
- A check that could not run gates nothing and says so. Offline, no credentials, an account that did not answer: reported as skipped with the reason, never rendered as a pass and never as a failure. A check nobody ran established nothing.
A declined binding is a configuration rather than a defect, so it prints — with the reason you wrote — and does not fail the exit.
In CI
The three commands a deploy pipeline runs, and nothing around them — the whole GitHub Actions workflow has the token, the caching, and what to run on a pull request.
- run: pithy doctor --json
- run: pithy migrate --env prod --json
- run: pithy deploy --env prod --json
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}Nothing there needs a TTY, and nothing prompts. Under --json every prompt in the CLI is suppressed rather than defaulted — a command that would have asked you something refuses instead, and names the flag that answers it. That refusal is a feature: a scaffold that quietly picked an answer nobody gave is worse than one that stopped.