Report toolchain state and update status, plus — inside a project — each Worker’s config, binding and migration health.
This is the first command to run when your model of the project and the project disagree. It writes nothing.
Synopsis
pithy doctor [--worker <name>] [--offline] [--enable-notifier | --disable-notifier] [--json]Flags
| Flag | Meaning |
|---|---|
--worker <name> | Check only this Worker. Default: every Worker under apps/ |
--offline | Use no ambient credentials and make no network call. PITHY_OFFLINE=1 says the same thing to every command |
--disable-notifier | Turn off the update notifier, persisted in the state file |
--enable-notifier | Turn it back on |
--json | Machine-readable output — one line, one object, the same exit code |
What it reports
$ pithy doctor
pithy 1.2.0 (installed via bun)
Update available: 1.3.0
Run: bun update -g @pithy-sh/cli
Shell: zsh (~/.zshrc)
Alias: installed (`p.` → `pithy`)
Config dir: ~/.config/pithy
State file: ~/.config/pithy/state.json
Dev login: ~/.config/pithy/acme/dev.json — none yet; sign-in stays magic-link only
Secrets: ~/.config/pithy/acme/secrets.jsonc (run `pithy secrets edit`)
Ports: ~/.config/pithy/dev-ports.json
8787–8806 main
8807–8826 feature/12-auth
8827–8846 ~/code/other-app — main
8947–8966 ~/code/old-thing — main ← not on disk
Notifier: enabled (PITHY_NO_UPDATE_NOTIFIER to disable)
Project: pithy.config.ts found
Project capabilities:
@pithy-sh/core 1.2.0 ✓
@pithy-sh/auth 1.1.8 (1.2.0 available — run `pithy upgrade`)
Project health:
api:
prereqs every composed capability has its peers ✓
config parses against every capability schema ✓
bindings MEDIA_BUCKET (r2) missing from wrangler.jsonc
env: staging, prod
migrations 2 pending — run: pithy migrate --env dev
entitlements no gated route without a provider ✓
collab: healthy ✓
Cloudflare: reachable (token active)
Project name: acme — every resource name matches
OS: macOS 14.5
Runtime: Bun 1.2.4 (Node 22.10.0 compat)Project health
This block is pithy upgrade’s manifest-versus-wiring comparison in read-only mode — one engine, two commands: doctor reports drift, upgrade fixes it.
It is reported per Worker, since each Worker under apps/ carries its own config and drifts independently. A healthy Worker collapses to one line, and the whole block is omitted when every Worker is healthy. Doctor exits non-zero when any Worker fails a check, so CI can gate on it — what fails an exit and what merely has not happened yet is the rule this command is the gate for. Nothing else in the CLI tells you a required binding is missing before deploy does. When something is already broken and you do not know what, diagnosing a broken project starts here.
The migrations line asks the question in both directions, and the second one is why it exists. A migration this project declares that the database has not applied is N pending, and pithy migrate is the remedy. A migration the database has applied that the project no longer declares is a different fault with a different remedy — and it is invisible to a pending count.
The bindings line covers both halves of a binding, not only the half that is config. A Durable Object needs a durable_objects.bindings entry and an export of its class from the Worker’s entry module, and wrangler refuses the deploy when the second is missing.
The locations block
Every line between Config dir: and Notifier: reports a location, not a complaint — and each one is a path nothing in your repository mentions.
Secrets: names your dev secret values at <config>/<project>/secrets.jsonc — the file pithy add mints into, pithy seed reads, and every Worker’s .dev.vars is generated from. It is not in your repository, and that is the point: nothing to gitignore, nothing git add -A can reach, nothing npm pack can carry, and nothing an rm -rf on the working copy destroys. Delete the whole clone and the secrets are still there.
The line names the command as well as the path. A path outside the checkout is one no editor’s file tree reaches and no ls in the project finds, so knowing it is not yet a way to open it — pithy secrets edit is.
Ports: names <config>/dev-ports.json, one registry for the whole machine, and lists what is in it. This checkout’s blocks come first and unqualified — which ports am I on, and on which branch. Everything under them is the machine-wide view, each block named by the checkout that holds it, which is what turns 8847, and I don’t know why into because other-app holds 8827–8846.
Ranges, never block indices: a block’s ports are base + index × size, so the index is the whole answer only while every entry is the same width, and a registry written before the width changed keeps its old entries verbatim.
← not on disk is the one line here you can act on. Pruning cannot tell a deleted checkout from a moved one — both are a missing directory — so a relocated repository has its blocks freed by the next allocation any project on the machine makes, and nothing anywhere reports that it happened. This row is taken before the sweep, and it means: you renamed that directory.
Dev login: names dev.json, the one config file that is per project rather than per machine — the opt-in that makes pithy seed mint a real session instead of leaving you a magic link. The path is reported whether or not the file exists, because where it would go is most of what anybody asking needs. No file is not a fault; a file that will not parse is, and so is one naming no user.
None of the locations block can fail the exit. A checkout that is gone from disk is information, not drift.
The Cloudflare: line
It answers can I reach the account — the bootstrap credential pair, verified against Cloudflare rather than merely read. A configured-but-broken credential fails the exit; an absent one does not, because a project that has not been provisioned yet is a legitimate state.
It prints on every run, and names the file it resolved. Which account am I about to deploy to must never require inspection, and once a machine holds cloudflare.leed.json beside cloudflare.other-co.json, the state alone does not answer it.
The line also says where the credentials came from — the file, or the environment. Naming the resolved file is not the same claim, and the difference is the whole of it: CI has no file at all and authenticates from environment variables, so a report reading from ~/.config/pithy/cloudflare.json was naming a path with nothing at it while a token from somewhere else did the work.
PITHY_OFFLINE=1 means no ambient credentials and no network call. Set it, or pass --offline, and Pithy stops reading the credential pair out of the environment and doctor reports not checked instead of probing.
It bites in the one function every command resolves credentials through, and it is inherited by a spawned pithy, wrangler or test runner — which a flag is not. --offline exists on this command because run the diagnostic without touching anything is a thing you say once, at a prompt.
Making PITHY_CONFIG_DIR imply it was considered and rejected: it conflates read config from here with do not use the environment, and CI legitimately means the first without the second. What it does not touch is the credentials file — a value written there was written deliberately; a value in the environment is the one nobody in the room remembers exporting.
Project name:
Every resource this project provisions leads with the root config’s name, and teardown recomputes those names rather than scanning for them — so one edit to name orphans everything while every command keeps exiting 0.
Doctor requires positive evidence before it says so, because <app>-<env>-<resource> is also the ordinary Cloudflare convention and a database you brought with you is not an orphan. Two states fail the exit:
- drifted — the wiring contradicts the config wholesale: every declared name leads with one and the same other project, and the configured name appears nowhere. That is the only shape a one-string rename can leave.
- orphaned — a database’s
pithy_migrations_ownerstamp proves Pithy created it under another project’s name.
Neither state ever advises deleting a resource. A single foreign name, a mix, an unset name and an unreadable wrangler.jsonc all pass — none of them establishes anything.
The settings check
Every other check asks whether something is present — the option key, the binding, the migration. None of them looks at the value, so a project stays green while fromAddress names a domain nobody onboarded and BASE_URL points at a host nothing serves.
A capability declares its own settings check on its Capability object. Two tiers, because they cost different things:
| Tier | Asks | Cost |
|---|---|---|
local | Does the value parse, is it the right shape, is it right for this environment — validated through the same Zod object the capability’s host Worker validates at boot | Free, offline, always run |
account | Is the domain a zone here, does the secret have a value, is the database there | One Cloudflare call, on credentials this report already resolved |
Both are faults. A local finding fails the exit. An account finding fails it too — mail that will not send is not a warning — but only when the account was actually reached. When it cannot be, the tier is reported as skipped with the reason and gates nothing.
A capability that declares nothing is skipped in silence, and that is not a fault.
--json
--json mirrors every block, because an agent cannot read aligned columns. One line, one object, and the same exit code.
Three rules hold across the payload.
Paths are absolute here, never tilde-abbreviated — this output is opened by a script, not recognized by a person.
A check with no project to run against is null, not an empty verdict. project, projectName, workerNames, environments, origins, workflows, devPreferences, devSecretsFile, devSecrets, secretBindings, devVarsLocal and devVars all take that shape, so nothing ever reports a name verdict for a directory that has no config.
A check that threw is neither of those. Every probe is guarded, so one that fails costs its own line rather than the report — and it says so on its own value rather than being filed under null. Several report "state":"could-not-check"; cloudflare reports "state":"probe_failed", distinct from not_checked, which is the caller having said not to look. None of these states fails the exit: a check that did not run established nothing.
And every finding carries its own detail sentence beside its fields, so an agent fixing one never has to reproduce the report’s wording from the parts.
| Key | Type | Meaning |
|---|---|---|
cli | object | The installed version, the latest known, the detected installer, the state, and the command that upgrades it |
shell | string or null | The detected shell |
alias | object | state (installed, not-installed, unknown), rcPath, and reason — the refusal’s own sentence, null on the two known states |
configDir | string | The config directory |
stateFile | string | The notifier state file |
notifier | string | enabled or disabled |
offline | boolean | Whether this run refused ambient credentials and the network |
project | object or null | present, then either loadError or the project’s capabilities and health. null outside a project |
cloudflare | object | state, missing, tokenStatus, credentialSplit, configPath, accountName, accountMismatch, credentialSource, detail |
projectName | object or null | state, project, misnamed, detail |
workerNames | object or null | state, and one mismatches entry per stamp that disagrees |
environments | object or null | state, declared, and one drift entry per Worker-and-environment that disagrees — kind is undeclared, missing or orphaned |
origins | object or null | state, and one drift entry per Worker-and-environment whose named and served origins disagree — fault is no-origin, unserved-origin or workers-dev-open |
workflows | object or null | state, and one drift entry per Worker-and-environment whose declaration and stanza are not the same table — declared and bound are the two sides of the comparison |
devPreferences | object or null | state, path, user, detail — the dev.json this project resolves |
devSecretsFile | object or null | path, present, orphans — reported whether or not the file exists |
devSecrets | object or null | The dev-secrets findings. healthy is the one field to gate on — it is every fault in the block, decided by the same function the text report draws its lines from, so a fault class added later needs no consumer to be updated. mode is octal-formatted (600), because 384 is not a permission anybody recognizes |
secretBindings | object or null | state, one missing entry per Worker-and-environment-and-binding a declared environment does not bind. null when no Worker composes secrets |
settings | object or null | state, account (checked or skipped, with the reason), checked, one findings entry per setting that does not work, and unchecked |
localDelivery | object or null | live — whether a message sent from this machine leaves it — the capability that answers for delivery, and a detail. Never gates the exit: the simulator is a choice |
devVarsLocal | object or null | The root .dev.vars.local classification |
devVars | object or null | The root .dev.vars classification — names only, never a value |
os | string | The operating system and its version |
runtime | object | name, version and nodeCompat — the interpreter that ran |
node | string | The Node version it emulates. Equal to runtime.version on Node, different under Bun |
runtime names the interpreter actually executing. Under Bun, process.versions.node is the Node version being emulated, so reporting it alone would name a runtime that is not running — the one thing a diagnostic must not do.
Errors
Nothing here refuses except a contradiction between two flags. Doctor is a report, and what it finds is carried in the exit code rather than in a throw — a diagnostic has to work in the environment it diagnoses, so every read failure it meets is discarded into a line.
| Condition | Effect |
|---|---|
--disable-notifier and --enable-notifier together | Refused before anything is read |
A pithy.config.ts that will not load | Exit 1 |
| A Worker failing a config, binding, migration or entitlement check, or a manifest nothing can read | Exit 1 |
| A Worker whose plan could not be built, or a database whose ledger could not be read | Exit 1. A check that did not run established nothing, and a healthy verdict around a hole is the under-report this block exists to prevent |
| A Cloudflare credential that is configured and broken, or a pinned account the credentials do not match | Exit 1. not configured and not checked establish nothing, so neither gates |
| A project name that is invalid, drifted or orphaned | Exit 1 |
| A Worker whose three names disagree | Exit 1 |
A dev.json that will not parse, or that names no user | Exit 1. No file at all is the documented default and never gates |
| A capability setting that does not work, from either tier | Exit 1. The account tier contributes findings only when the account was reached |
| A settings check that could not be run, or an account that could not be reached | Never fails the exit. Reported as skipped or unchecked, never as a pass |
| Local delivery running on the simulator | Never fails the exit. It is a choice, and offline it is the only one |
| Toolchain state — the CLI version, the shell, the alias | Never fails the exit |
Examples
When everything is up to date, the output is correspondingly terser:
$ pithy doctor
pithy 1.3.0 (installed via brew)
Up to date.
Shell: zsh
Alias: installed
Secrets: ~/.config/pithy/acme/secrets.jsonc (run `pithy secrets edit`)
Project: pithy.config.ts found
Project capabilities: all up to date
Cloudflare: reachable (token active)
OS: macOS 14.5
Runtime: Bun 1.2.4 (Node 22.10.0 compat)The Secrets: line survives into the terse report, and it is the only one that does. Every other line reports a fault, and terse is the report saying there is none; this one reports a location, and where is my dev secrets file is a question rather than a complaint — asked most often by the developer whose project is working fine.
Outside a project, the Project: line states the one fact and every other project line is omitted. The exit stays 0 when the toolchain is clean: checking the CLI version, the shell or the alias from anywhere is legitimate, and somebody doing it is asking about their toolchain rather than their project.
$ cd /tmp && pithy doctor
pithy 1.3.0 (installed via brew)
Up to date.
Shell: zsh
Alias: installed
Project: no pithy.config.ts here — run `pithy init`, or change to a project directory
Cloudflare: reachable (token active)
OS: macOS 14.5
Runtime: Bun 1.2.4 (Node 22.10.0 compat)The Cloudflare: check still runs there, and it is the one check that never needed a project: the credentials are account-scoped, so are my credentials right has the same answer in every directory on the machine.