Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/doctor-read-only.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": minor
---

Add a read-only `agent-bundle doctor` command for inspecting host availability, installed bundles, bundle drift, and runtime endpoint health without applying repairs.
2 changes: 2 additions & 0 deletions docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ gate a build, a validation, or a dev rebuild.
| `AB473x` | Migration nudges (informational; see below). |
| `AB474x`/`AB4750` | Prebuilt payloads and prebuilt entries (see below). |
| `AB5000` | General CLI and adapter failures. |
| `AB700x` | Host installation: bundle identity, host availability, scope, command failure, and collision checks. |
| `AB7xxx` | Project preparation and development rebuilds. |
| `AB7300`–`AB7315` | Read-only install Doctor: host probes, installed inventory, bundle comparison and registration proof, and runtime endpoint health. |
| `AB8xxx` | Development server configuration. |
| `AB9xxx` | Eval selection, harnesses, and persisted runs. |

Expand Down
67 changes: 66 additions & 1 deletion packages/agent-bundle/src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/env node
import { Command, CommanderError } from 'commander';
import { Command, CommanderError, InvalidArgumentError } from 'commander';
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';

Expand All @@ -24,6 +24,11 @@ import type {
InstallResult,
InstallScope,
} from './install/install.ts';
import type {
DoctorHost,
DoctorReport,
runDoctor,
} from './install/doctor.ts';
import { DiagnosticError, type Diagnostic } from './core/diagnostics.ts';
import { projectVersionLabel } from './core/project-context.ts';
import { stableJson } from './core/digest.ts';
Expand All @@ -47,6 +52,7 @@ interface CliSignalSource {

export interface CliDependencies {
readonly installBundle?: typeof installBundle;
readonly runDoctor?: typeof runDoctor;
/** Injectable only to make foreground shutdown behavior deterministic in tests. */
readonly signals?: CliSignalSource;
readonly startDevServer?: typeof startDevServer;
Expand Down Expand Up @@ -74,6 +80,12 @@ interface InstallCommandOptions {
readonly scope: string;
}

interface DoctorCommandOptions {
readonly from?: string;
readonly host: readonly DoctorHost[];
readonly json?: boolean;
}

interface EvalCommandOptions extends SourceCommandOptions {
readonly artifact?: string;
readonly case?: readonly string[];
Expand Down Expand Up @@ -141,6 +153,14 @@ const installScope = (value: string): InstallScope => {
throw new TypeError('Install scope must be user, project, or local.');
};

const doctorHost = (value: string): DoctorHost => {
if (value === 'claude' || value === 'codex' || value === 'cursor') return value;
throw new InvalidArgumentError('Doctor host must be claude, codex, or cursor.');
};

const collectDoctorHost = (value: string, previous: readonly DoctorHost[]): readonly DoctorHost[] =>
[...previous, doctorHost(value)];

const configureSourceOptions = (command: Command): Command => command
.option('--root <root>', 'Project root', process.cwd())
.option('--config <path>', 'Configuration file relative to --root')
Expand Down Expand Up @@ -238,6 +258,35 @@ const writeHumanInstall = (output: Output, result: InstallResult): void => {
);
};

const writeHumanDoctor = (output: Output, result: DoctorReport): void => {
for (const host of result.hosts) {
const detail = host.probe.version ?? host.probe.evidence;
output.write(`${host.host}: ${host.probe.status}${detail === undefined ? '' : ` (${detail})`}\n`);
output.write(
` inventory: ${host.inventory.status}` +
`${host.inventory.status === 'known' ? ` (${host.inventory.findings.length} finding(s))` : ''}\n`,
);
if (host.bundle !== undefined) {
const identity = host.bundle.name === undefined
? ''
: ` ${host.bundle.name}${host.bundle.version === undefined ? '' : `@${host.bundle.version}`}`;
output.write(` bundle:${identity} ${host.bundle.state}\n`);
}
}
output.write(
`runtime endpoints: ${result.endpoints.status}; ${result.endpoints.summary.live} live, ` +
`${result.endpoints.summary.staleSockets} stale socket(s), ` +
`${result.endpoints.summary.staleLocks} stale lock(s)\n`,
);
for (const entry of result.diagnostics) {
output.write(`${entry.code}: ${entry.message}\nRecovery: ${entry.recovery}\n`);
}
output.write(
`Doctor summary: ${result.summary.errors} error(s), ${result.summary.warnings} warning(s), ` +
`${result.summary.infos} info(s)\n`,
);
};

const writeHumanInspect = (output: Output, result: Awaited<ReturnType<typeof inspect>>): void => {
if (result.state === 'invalid') {
for (const diagnostic of result.diagnostics) {
Expand Down Expand Up @@ -418,6 +467,22 @@ export const runCli = async (
else writeHumanInstall(stdout, result);
});

const doctorCommand = program.command('doctor')
.description('Inspect host installs and runtime endpoints without changing them')
.option('--host <host>', 'Host to inspect (repeatable)', collectDoctorHost, [])
.option('--from <bundle-dir>', 'Target bundle directory or artifact root')
.option('--json', 'Write one machine-readable JSON document');
doctorCommand.action(async (options: DoctorCommandOptions) => {
const doctor = dependencies.runDoctor ?? (await import('./install/doctor.ts')).runDoctor;
const result = await doctor({
...(options.from === undefined ? {} : { from: options.from }),
...(options.host.length === 0 ? {} : { hosts: options.host }),
});
if (options.json === true) writeMachine(stdout, result);
else writeHumanDoctor(stdout, result);
if (result.diagnostics.some((entry) => entry.severity === 'error')) exitCode = 1;
});

const validateCommand = configureSourceOptions(
program.command('validate').description('Validate project source or one artifact'),
)
Expand Down
Loading
Loading