fix(cli): make doctor --json imply read-only lint mode (#122662)

* fix(cli): make doctor json imply lint

* fix(cli): preserve doctor session selector errors
This commit is contained in:
Peter Steinberger
2026-08-12 09:04:51 -07:00
committed by GitHub
parent 44335ac7ce
commit 6093e3477d
5 changed files with 104 additions and 22 deletions
+7 -5
View File
@@ -32,17 +32,18 @@ Doctor has five postures:
| ------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------- |
| Inspect | `openclaw doctor` | Human-oriented checks and guided prompts. |
| Repair | `openclaw doctor --fix` | Applies supported repairs, using prompts unless non-interactive repair is safe. |
| Lint | `openclaw doctor --lint` | Read-only structured findings for CI, preflight, and review gates. |
| Lint | `openclaw doctor --json` | Read-only JSON findings for deployment preflight and CI gates. |
| Shared SQLite maintenance | `openclaw doctor --state-sqlite compact` | Explicitly checkpoints, compacts, and verifies the canonical shared state DB. |
| Session SQLite migration | `openclaw doctor --session-sqlite <mode>` | Inspects, imports, validates, compacts, recovers, or restores session state. |
Prefer `--lint` when automation needs a stable result. Prefer `--fix` when a human operator wants doctor to edit config or state.
Use `openclaw doctor --json` as the machine-readable deployment preflight. It runs the same read-only checks, JSON output, and exit codes as `openclaw doctor --lint --json`. Prefer `--fix` when a human operator wants doctor to edit config or state.
## Examples
```bash
openclaw doctor
openclaw doctor --lint
openclaw doctor --json
openclaw doctor --lint --json
openclaw doctor --lint --severity-min warning
openclaw doctor --lint --all
@@ -93,19 +94,20 @@ openclaw channels status --probe
| `--session-sqlite-agent <id>` | With `--session-sqlite`: select one configured agent. |
| `--session-sqlite-all-agents` | With `--session-sqlite`: select configured and discovered agent stores. |
| `--github-issue` | With `--session-sqlite recover`: prepare a sanitized openclaw/openclaw issue report; doctor creates it with `gh` after `--yes` or interactive confirmation. |
| `--json` | With `--lint`: JSON findings. With `--post-upgrade`: `{ probesRun, findings }`. With `--state-sqlite` or `--session-sqlite`: the maintenance report as JSON. |
| `--json` | Run lint checks in read-only mode and emit JSON. With another machine mode, emit that mode's existing JSON report. |
| `--severity-min <level>` | With `--lint`: drop findings below `info`, `warning`, or `error`. |
| `--all` | With `--lint`: run all registered checks, including opt-in checks excluded from the default set. |
| `--skip <id>` | With `--lint`: skip a check id. Repeatable. |
| `--only <id>` | With `--lint`: run only the given check id(s). Repeatable. |
`--severity-min`, `--all`, `--only`, and `--skip` are only accepted together with `--lint`; `--json` is accepted with `--lint`, `--post-upgrade`, `--state-sqlite`, and `--session-sqlite`.
`--severity-min`, `--all`, `--only`, and `--skip` are only accepted together with `--lint`. Bare `--json` implies lint mode. It cannot be combined with `--repair`, `--fix`, or `--force` unless another machine mode owns the command.
## Lint mode
`openclaw doctor --lint` is read-only: no prompts, no repair, no config/state rewrites.
`openclaw doctor --json` is the deployment-preflight form of lint mode. It is read-only and non-interactive: no prompts, repairs, or config/state rewrites. `openclaw doctor --lint --json` remains an equivalent explicit spelling.
```bash
openclaw doctor --json
openclaw doctor --lint
openclaw doctor --lint --severity-min warning
openclaw doctor --lint --json
+10 -5
View File
@@ -1,10 +1,15 @@
import type { MachineOutputResolverParams } from "./machine-output-argv.js";
import { hasMachineOutputOption } from "./machine-output-argv.js";
/** Doctor lint follows Unix convention and emits JSON when stdout is not a terminal. */
/** Bare doctor JSON and non-TTY lint runs own machine-readable stdout. */
export function isDoctorMachineOutput(params: MachineOutputResolverParams): boolean {
return (
hasMachineOutputOption(params.argv, "--lint") &&
(hasMachineOutputOption(params.argv, "--json") || !params.stdoutIsTTY)
);
const lint = hasMachineOutputOption(params.argv, "--lint");
if (lint) {
return hasMachineOutputOption(params.argv, "--json") || !params.stdoutIsTTY;
}
const existingMachineMode =
hasMachineOutputOption(params.argv, "--post-upgrade") ||
hasMachineOutputOption(params.argv, "--state-sqlite") ||
hasMachineOutputOption(params.argv, "--session-sqlite");
return hasMachineOutputOption(params.argv, "--json") && !existingMachineMode;
}
+17
View File
@@ -30,6 +30,23 @@ describe("built-in machine-output resolvers", () => {
expect(isDoctorMachineOutput({ argv, stdoutIsTTY: true })).toBe(false);
});
it("reserves doctor JSON output with or without explicit lint mode", () => {
for (const argv of [
["node", "openclaw", "doctor", "--json"],
["node", "openclaw", "doctor", "--lint", "--json"],
]) {
expect(isDoctorMachineOutput({ argv, stdoutIsTTY: true })).toBe(true);
}
});
it.each(["--post-upgrade", "--state-sqlite=compact", "--session-sqlite=dry-run"])(
"preserves registered-command JSON handling for doctor %s",
(mode) => {
const argv = ["node", "openclaw", "doctor", mode, "--json"];
expect(isDoctorMachineOutput({ argv, stdoutIsTTY: true })).toBe(false);
},
);
it.each(["blob", "coverage", "purge", "query", "sessions"])(
"detects proxy %s output",
(command) => {
+54 -2
View File
@@ -231,10 +231,14 @@ describe("registerMaintenanceCommands doctor action", () => {
expect(runtime.exit).toHaveBeenCalledWith(2);
});
it("rejects session sqlite selectors without session sqlite mode", async () => {
await runMaintenanceCli(["doctor", "--session-sqlite-agent", "main"]);
it.each([
["without JSON", ["--session-sqlite-agent", "main"]],
["with JSON", ["--json", "--session-sqlite-agent", "main"]],
])("rejects session sqlite selectors without session sqlite mode %s", async (_label, args) => {
await runMaintenanceCli(["doctor", ...args]);
expect(doctorCommand).not.toHaveBeenCalled();
expect(runDoctorLintCli).not.toHaveBeenCalled();
expect(runtime.error).toHaveBeenCalledWith(
"doctor session SQLite options require --session-sqlite. Use `openclaw doctor --session-sqlite dry-run ...`.",
);
@@ -271,6 +275,54 @@ describe("registerMaintenanceCommands doctor action", () => {
expect(runtime.exit).toHaveBeenCalledWith(1);
});
it("treats bare --json as lint mode and emits machine-readable output", async () => {
const output: string[] = [];
const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation((chunk) => {
output.push(String(chunk));
return true;
});
runDoctorLintCli.mockImplementationOnce(async () => {
process.stdout.write('{"ok":true,"checksRun":1,"checksSkipped":0,"findings":[]}\n');
return 0;
});
try {
await runMaintenanceCli(["doctor", "--json"]);
expect(doctorCommand).not.toHaveBeenCalled();
expect(runDoctorLintCli).toHaveBeenCalledWith(runtime, {
json: true,
severityMin: undefined,
includeAllChecks: false,
skipIds: [],
onlyIds: [],
allowExec: false,
deep: false,
});
expect(JSON.parse(output.join(""))).toEqual({
ok: true,
checksRun: 1,
checksSkipped: 0,
findings: [],
});
expect(runtime.exit).toHaveBeenCalledWith(0);
} finally {
writeSpy.mockRestore();
runDoctorLintCli.mockReset();
}
});
it("rejects JSON repair mode before running doctor", async () => {
await runMaintenanceCli(["doctor", "--json", "--repair"]);
expect(doctorCommand).not.toHaveBeenCalled();
expect(runDoctorLintCli).not.toHaveBeenCalled();
expect(runtime.error).toHaveBeenCalledWith(
"doctor --json runs read-only lint checks and cannot be combined with --repair, --fix, or --force.",
);
expect(runtime.exit).toHaveBeenCalledWith(2);
});
it("rejects lint selectors outside doctor lint mode", async () => {
await runMaintenanceCli(["doctor", "--fix", "--only", "policy/channels-denied-provider"]);
+16 -10
View File
@@ -79,7 +79,7 @@ export function registerMaintenanceCommands(program: Command) {
)
.option(
"--json",
"With --lint, --post-upgrade, --state-sqlite, or --session-sqlite: emit machine-readable JSON output",
"Run read-only lint checks as JSON (or emit JSON for another machine mode)",
false,
)
.option(
@@ -110,7 +110,21 @@ export function registerMaintenanceCommands(program: Command) {
defaultRuntime.exit(2);
return;
}
if (opts.lint === true) {
const jsonImpliesLint =
opts.json === true &&
opts.lint !== true &&
opts.postUpgrade !== true &&
typeof opts.stateSqlite !== "string" &&
typeof opts.sessionSqlite !== "string" &&
!hasSessionSqliteOnlyDoctorOptions(opts);
if (jsonImpliesLint && (opts.repair === true || opts.fix === true || opts.force === true)) {
defaultRuntime.error(
"doctor --json runs read-only lint checks and cannot be combined with --repair, --fix, or --force.",
);
defaultRuntime.exit(2);
return;
}
if (opts.lint === true || jsonImpliesLint) {
await runCommandWithRuntime(
defaultRuntime,
async () => {
@@ -258,20 +272,12 @@ export function registerMaintenanceCommands(program: Command) {
}
function hasLintOnlyDoctorOptions(opts: {
readonly json?: boolean;
readonly postUpgrade?: boolean;
readonly stateSqlite?: unknown;
readonly sessionSqlite?: unknown;
readonly severityMin?: unknown;
readonly all?: boolean;
readonly skip?: unknown;
readonly only?: unknown;
}): boolean {
return (
(opts.json === true &&
opts.postUpgrade !== true &&
typeof opts.stateSqlite !== "string" &&
typeof opts.sessionSqlite !== "string") ||
typeof opts.severityMin === "string" ||
opts.all === true ||
(Array.isArray(opts.skip) && opts.skip.length > 0) ||