Part 4 of 12 · Build a Cursor-style AI coding agent in your terminal
Writing the doctor command your users will actually run
How to write a doctor command for an AI CLI: check the runtime, prove the data directory is writable, confirm credentials without printing them, and exit non-zero on real failures only.
- Tutorial
- Developer Experience
- Diagnostics
What actually breaks, and when you find out
Here is the honest list of failures, ordered by how long they take to surface. The right-hand column is the whole argument for writing this command.
| Failure | Surfaces | Cost of finding out late |
|---|---|---|
| Node older than the code requires | At import time, as a syntax error deep in a module | Confusing stack trace naming a file they have never opened |
| Data directory not writable | On the first checkpoint write | The turn has already been billed; the work is lost |
| No provider key | On the first HTTP request | A confusing 401 rather than 'you have not set a key' |
| Provider key present but wrong | Same 401, one layer further down | Looks identical to 'no key', sends them down the wrong path |
| Wrong working directory | The agent confidently summarises an empty directory | The worst case: a plausible, confident, wrong answer |
| Not a git repo | On any tool that shells out to git | Traceback from inside a child process |
| Too little memory for a large repo | Twenty minutes in, as an OOM kill | The whole turn is lost with nothing to show |
| Tool layer silently not gating | Never. This one does not announce itself | A safety control you believed in was decorative |
That last row is why doctor includes a self-test of the guard rails rather than only the environment. A control that fails open is worse than a control that is absent, because you stop looking for it.
Severity, and the one exit-code rule
export const LEVELS = Object.freeze(['pass', 'warn', 'fail', 'skip']);pass: verified working.warn: real, but not a blocker. Low memory. A PATH note on Windows. An empty directory.fail: the tool cannot work.skip: not checked, and the output says so and says why.
The rule I got wrong the first time
I initially made warn fail the run, on the theory that anything worth reporting is worth blocking on. That made the command exit 1 on every Windows machine, because of the PATH note. The correct rule is narrower and I would have reached it faster by asking what the exit code is for:
return {
cwd: resolve(cwd),
checks,
// Only `fail` blocks. A `warn` is information for a human, the Windows PATH
// note, low memory, an unrecognised directory, and exiting non-zero on those
// would make the command useless, so nobody would run it.
ok: checks.every((c) => c.level !== 'fail'),
counts: LEVELS.reduce((acc, lvl) => {
acc[lvl] = checks.filter((c) => c.level === lvl).length;
return acc;
}, {}),
};The exit code is for scripts. If a check ever fires spuriously, every owl doctor && owl ask ... in a developer’s shell profile stops working, and they will delete it rather than debug it.
Check by doing
The data-directory check is the template for the rest. Do not fs.access(W_OK) — that asks the operating system about permissions, and permissions are not capability. Write a file.
/**
* The project data directory. Checked by writing, not by stat: a directory can
* exist, be owned by you, and still be read-only.
*/
export function checkDataDir(cwd = process.cwd()) {
const dir = join(cwd, '.owl');
const probe = join(dir, `.doctor-${process.pid}-${Date.now()}.tmp`);
try {
mkdirSync(dir, { recursive: true });
} catch (e) {
return check(
'data-dir',
'Project data directory',
'fail',
`cannot create ${dir}: ${e.message}`,
'Check the directory permissions, or run the agent outside a read-only checkout.',
);
}
try {
writeFileSync(probe, 'ok');
rmSync(probe, { force: true }); // leave nothing behind
} catch (e) {
return check(
'data-dir',
'Project data directory',
'fail',
`${dir} exists but is not writable: ${e.message}`,
'Sessions, checkpoints and the risk ledger all live here, so nothing will persist until this is fixed.',
);
}
return check('data-dir', 'Project data directory', 'pass', `writable · ${dir}`);
}Two details worth copying. The probe filename includes the pid and a timestamp, so two concurrent doctors — which happens the moment you wire this into CI — cannot collide on the same path. And the file is removed, which is why the test asserts the directory is empty afterwards: a health check that litters is a health check people stop running.
Credentials: presence, never value
Check the environment only. Deliberately do not read the config file here — a pre-flight that silently merges a stale config into its report is harder to reason about than one that tells you exactly which process environment will be used. And show the variable name.
export const PROVIDER_ENV = Object.freeze({
openai: 'OPENAI_API_KEY',
anthropic: 'ANTHROPIC_API_KEY',
groq: 'GROQ_API_KEY',
// ...one per provider, so the mapping is auditable in one place
});
export function checkProviders(env = process.env) {
const present = Object.entries(PROVIDER_ENV)
.filter(([, varName]) => {
const v = env[varName];
return typeof v === 'string' && v.trim().length > 0;
})
.map(([provider, varName]) => ({ provider, varName }));
if (present.length) {
const list = present.map((p) => `${p.provider} (${p.varName})`).join(', ');
return check(
'providers',
'Provider credentials',
'pass',
`${present.length} set · ${list}`,
'Keys are never read back or printed; only the variable names are shown.',
);
}
return check(
'providers',
'Provider credentials',
'fail',
'no provider API key found in the environment',
'Export one, e.g. `export GROQ_API_KEY=gsk_...`, or run Ollama locally which needs no key.',
);
}Two consequences of naming rather than showing. The report is safe to paste into a bug, and a test can assert it — Sentinel’s suite runs the full report with a canary key and fails if the value appears anywhere in the output. And a whitespace-only key is treated as absent, because API_KEY=" " set by a broken CI step is a real failure mode that a truthiness check would pass.
Test the control, not just the environment
The most valuable check here has nothing to do with your machine. It asserts the shell classifier still refuses the catastrophic commands, because a guard rail that has quietly stopped matching is invisible until the day it matters.
/** The tool layer's two non-negotiables: classification runs, and the red gate works. */
export function checkTooling() {
const probes = [
'git status',
'ls -la',
'npm test',
'git commit -m "x"',
'rm -rf /',
'echo hi > out.txt',
];
const classified = probes.filter((c) => classifyBashCommand(c).intent !== undefined);
if (classified.length !== probes.length) {
return check('tooling', 'Tool layer', 'fail',
'bash command classification is not classifying',
'Every shell tool call depends on this; without it nothing can be gated.');
}
// The destructive classifier is the control that keeps an agent out of trouble.
const rmrf = classifyBashCommand('rm -rf /');
if (!rmrf.destructive) {
return check('tooling', 'Tool layer', 'fail',
'destructive pattern table is not matching',
'`rm -rf /` must classify as destructive for the red gate to work.');
}
return check('tooling', 'Tool layer', 'pass',
`classified ${probes.length} probe command(s); destructive patterns armed`);
}The check that catches the worst bug
Not enough is wrong, but the most expensive failure is a confident answer about the wrong directory. Look for markers and say what you found.
export function checkWorkdir(cwd = process.cwd()) {
const dir = resolve(cwd);
if (!existsSync(dir)) {
return check('workdir', 'Working directory', 'fail',
`${dir} does not exist`, 'cd to a real directory first.');
}
const markers = ['package.json', 'pyproject.toml', 'go.mod', 'Cargo.toml', '.git', 'src'];
const found = markers.filter((m) => existsSync(join(dir, m)));
if (!found.length) {
// warn, not fail: an empty scratch directory is a legitimate place to work.
return check('workdir', 'Working directory', 'warn',
`${dir} has no project markers`,
'It may be empty or not a repo root. Tools still work; expect fewer useful results.');
}
return check('workdir', 'Working directory', 'pass',
`${dir} · ${found.slice(0, 3).join(', ')}`);
}The network probe is opt-in
Local model servers need a round trip to check. That makes them the one check you cannot do offline, so they are skipped by default and say so in the output.
export async function checkLocalModels(entries) {
const reachable = [];
const unreachable = [];
for (const [name, host] of entries) {
try {
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), 1200);
const res = await fetch(host, { signal: ac.signal });
clearTimeout(timer);
// Any HTTP answer means something is listening and speaking HTTP.
(res.ok || res.status ? reachable : unreachable).push(`${name} (${host})`);
} catch {
unreachable.push(`${name} (${host})`);
}
}
if (reachable.length) return check('local-models', 'Local model servers', 'pass', `up · ${reachable.join(', ')}`);
if (!unreachable.length) return check('local-models', 'Local model servers', 'skip', 'not probed');
// warn: a hosted provider is a perfectly good reason for Ollama to be down.
return check('local-models', 'Local model servers', 'warn',
`not reachable · ${unreachable.join(', ')}`,
'Expected if you use a hosted provider. To run fully offline, start `ollama serve`.');
}The abort timer matters more than the fetch. A server that accepts the connection and then hangs would otherwise turn a one-second pre-flight into a thirty-second one, which is the fastest way to make someone stop running the command.
Wiring it, and the flag that makes it scriptable
program
.command('doctor')
.description('Check the runtime, data directory, provider keys and tool layer')
.option('-d, --dir <path>', 'Project to check (default: cwd)')
.option('--network', 'Also probe local model servers over HTTP (Ollama, LM Studio)')
.option('--json', 'Print the report as JSON')
.action(async (options) => {
const { runDoctor, renderDoctor } = await import('../src/agent/doctor.js');
const cwd = path.resolve(options.dir || process.cwd());
const report = await runDoctor({ cwd, probeNetwork: !!options.network });
if (options.json) process.stdout.write(JSON.stringify(report, null, 2) + '\n');
else console.log(renderDoctor(report));
process.exit(report.ok ? 0 : 1);
});--json is what lets you use this in CI rather than only by hand:
- run: node bin/owl.js doctor --json
id: agent-preflight
continue-on-error: false
- run: node bin/owl.js doctor --network --json > doctor.json
if: always()
continue-on-error: true # upload the report either way; it is the artifact you wantThe output, and what it looks like on a real machine
$ node bin/owl.js doctor
owl doctor · /home/you/projects/myapp
✓ Node runtime, v22.23.2
✓ Working directory, /home/you/projects/myapp · package.json, .git, src
✓ Project data directory, writable · /home/you/projects/myapp/.owl
✗ Provider credentials, no provider API key found in the environment
Export one, e.g. `export GROQ_API_KEY=gsk_...`, or run Ollama locally which needs no key.
✓ Host resources, linux 6.8.0 · 32 GB RAM (24 GB free)
✓ Tool layer, classified 6 probe command(s); destructive patterns armed
! Shell PATH. PATH separator ":" on linux
· Local model servers, not probed (--network)
5 passed · 1 warning(s) · 1 failed · 1 skipped
Not ready. Fix the failures above before starting a turn.
$ echo $?
1And once a key is exported:
$ export GROQ_API_KEY=gsk_...
$ node bin/owl.js doctor
...
6 passed · 1 warning(s) · 0 failed · 1 skipped
Ready. Try `owl ask "what is this project?"`
$ echo $?
0Testing a diagnostic
A health check has an obvious failure mode: it only ever gets exercised on the machine of whoever wrote it, where everything passes. Four techniques fix that.
import { describe, it, test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, mkdir, rm, chmod } from 'node:fs/promises';
import { tmpdir, platform } from 'node:os';
import { join } from 'node:path';
// 1. A temp directory per test, so tests never touch the real project and never
// collide with each other.
async function tempDir(prefix = 'owl-doctor-') {
return mkdtemp(join(tmpdir(), prefix));
}
describe('checkDataDir', () => {
test('passes and creates .owl on demand', async () => {
const dir = await tempDir();
try {
const c = checkDataDir(dir);
assert.equal(c.level, 'pass');
// 2. Assert the *absence* of litter, not just the return value.
const entries = await readdir(join(dir, '.owl'));
assert.deepEqual(entries, []);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test('fails when .owl exists but is a file', async () => {
const dir = await tempDir();
try {
await writeFile(join(dir, '.owl'), 'not a directory');
const c = checkDataDir(dir);
assert.equal(c.level, 'fail');
assert.match(c.detail, /cannot create/);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test('fails when the directory is read-only', async (t) => {
// 3. Skip when the precondition does not hold, rather than asserting
// something the platform will not deliver.
if (platform() === 'win32') return t.skip('POSIX mode bits only');
if (typeof process.getuid === 'function' && process.getuid() === 0) {
return t.skip('root bypasses permission bits');
}
const dir = await tempDir();
try {
const data = join(dir, '.owl');
await mkdir(data);
await chmod(data, 0o500);
assert.equal(checkDataDir(dir).level, 'fail');
} finally {
await chmod(join(dir, '.owl'), 0o700).catch(() => {});
await rm(dir, { recursive: true, force: true });
}
});
});
// 4. Assert the exit code of the real binary, because "it printed something" and
// "it exits correctly" are different contracts and scripts depend on the second.
it('exits 0 when healthy and 1 when a check fails', async () => {
const dir = await tempDir();
const ok = await run(process.execPath, [bin, 'doctor', '--json', '-d', dir], {
env: { ...process.env, GROQ_API_KEY: 'gsk_x' },
});
assert.equal(JSON.parse(ok.stdout).ok, true);
await assert.rejects(
() => run(process.execPath, [bin, 'doctor', '--json'], {
env: stripKeys(process.env),
}),
(e) => e.code === 1,
);
});And one test for the thing that matters most
The suite also runs the whole report with a canary key and asserts the value appears nowhere in the output. It is the cheapest test in the file and the one that would have caught the worst possible regression: a diagnostic that helpfully prints your API key into a CI log.
What part 5 adds
The tool can now tell you it is broken, and it still cannot do anything. Part 5 is the turn itself: a streaming client over raw fetch that normalises three different provider wire formats into one set of events, and a loop that feeds tool results back until the model stops asking. That is where the course stops being a CLI tutorial and starts being an agent.
Frequently asked questions
Why does a coding agent need a doctor command when a normal CLI does not?
Because of how many independent things can be wrong, and how late they surface. A broken CLI fails on its first line, where the stack trace is useful. An agent CLI can pass its startup, take a key, build a prompt, and then fail when a tool tries to write to a directory that is read-only, after you have spent money and two minutes. A pre-flight collapses all of that into one second and one list.
Should doctor contact the model provider?
Not by default. A health check that needs the internet to tell you the internet is broken is useless, and it burns an API call and can rate-limit you. Check that a key is present by default; probe the endpoint behind an opt-in flag. The presence of a credential and the validity of that credential are genuinely different questions and belong in different runs.
How do I check a directory is writable portably?
Write to it. `fs.access(path, fs.constants.W_OK)` is a permissions check, and permissions are not the same as capability, a mounted filesystem, a container with a read-only mount, an ACL, or a full disk all pass a permissions check and fail an actual write. Write a uniquely named temp file, then remove it. Sentinel does exactly this, and the test asserts the probe file is not left behind.
Should warnings make the command exit non-zero?
No. Only failures should. Warnings exist for conditions that are information for a human but not blockers: the PATH separator note on Windows, low memory, an unrecognised directory. If a warning exits non-zero, then on any platform with a routine warning the command always fails, people wrap it in `|| true`, and the check silently stops running. Which is worse than not shipping it.
The command in this post ships in Sentinel. Run it against the real thing with node bin/sentinel.js doctor.
Written by Kunj Shah
Sentinel is an open source AI coding agent for the terminal, MIT licensed, no servers, no telemetry. Read the source or install it.