Exit codes

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

CodeMeans
0The command did what it said
non-zeroIt 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:

KeyMeans
interrupted: truePresent on this line alone. It is what says workers is a truncated report rather than a whole one
failedThe database the run died on, as its binding and database
unreachedEvery 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.

ESC