Part 4 of 12 · Build a Cursor-style AI coding agent in your terminal

12 min read

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 modes of an agent CLI and when the user finds out
FailureSurfacesCost of finding out late
Node older than the code requiresAt import time, as a syntax error deep in a moduleConfusing stack trace naming a file they have never opened
Data directory not writableOn the first checkpoint writeThe turn has already been billed; the work is lost
No provider keyOn the first HTTP requestA confusing 401 rather than 'you have not set a key'
Provider key present but wrongSame 401, one layer further downLooks identical to 'no key', sends them down the wrong path
Wrong working directoryThe agent confidently summarises an empty directoryThe worst case: a plausible, confident, wrong answer
Not a git repoOn any tool that shells out to gitTraceback from inside a child process
Too little memory for a large repoTwenty minutes in, as an OOM killThe whole turn is lost with nothing to show
Tool layer silently not gatingNever. This one does not announce itselfA 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

src/agent/doctor.js
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:

src/agent/doctor.js
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.

src/agent/doctor.js
/**
 * 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.

src/agent/doctor.js
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.

src/agent/doctor.js
/** 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.

src/agent/doctor.js
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.

src/agent/doctor.js
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

bin/owl.js
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:

ci
- 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 want

The output, and what it looks like on a real machine

terminal
$ 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 $?
1

And once a key is exported:

terminal
$ 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 $?
0

Testing 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.

__tests__/doctor.test.js
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.