Skip to content

Commit a2cf3c5

Browse files
committed
fix: document and harden doctor previews
1 parent d1584bc commit a2cf3c5

6 files changed

Lines changed: 173 additions & 31 deletions

File tree

docs/cli/doctor.md

Lines changed: 117 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,12 @@ wrong.
2424

2525
Doctor has three postures:
2626

27-
| Posture | Command | Behavior |
28-
| ------- | ------------------------ | ------------------------------------------------------------------------------- |
29-
| Inspect | `openclaw doctor` | Human-oriented checks and guided prompts. |
30-
| Repair | `openclaw doctor --fix` | Applies supported repairs, using prompts unless non-interactive repair is safe. |
31-
| Lint | `openclaw doctor --lint` | Read-only structured findings for CI, preflight, and review gates. |
27+
| Posture | Command | Behavior |
28+
| ------- | --------------------------------- | ------------------------------------------------------------------------------- |
29+
| Inspect | `openclaw doctor` | Human-oriented checks and guided prompts. |
30+
| Repair | `openclaw doctor --fix` | Applies supported repairs, using prompts unless non-interactive repair is safe. |
31+
| Preview | `openclaw doctor --fix --dry-run` | Plans supported repairs without mutating config or state. |
32+
| Lint | `openclaw doctor --lint` | Read-only structured findings for CI, preflight, and review gates. |
3233

3334
Prefer `--lint` when automation needs a stable result. Prefer `--fix` when a
3435
human operator intentionally wants doctor to edit config or state.
@@ -42,6 +43,9 @@ openclaw doctor --lint --json
4243
openclaw doctor --lint --severity-min warning
4344
openclaw doctor --deep
4445
openclaw doctor --fix
46+
openclaw doctor --fix --dry-run
47+
openclaw doctor --fix --dry-run --diff
48+
openclaw doctor --fix --dry-run --json
4549
openclaw doctor --fix --non-interactive
4650
openclaw doctor --generate-gateway-token
4751
```
@@ -65,8 +69,10 @@ The targeted Discord capabilities probe reports the bot's effective channel perm
6569
- `--non-interactive`: run without prompts; safe migrations and non-service repairs only
6670
- `--generate-gateway-token`: generate and configure a gateway token
6771
- `--deep`: scan system services for extra gateway installs and report recent Gateway supervisor restart handoffs
72+
- `--dry-run`: with `--fix` or `--repair`, preview supported repair actions without mutating state
73+
- `--diff`: with repair preview, include concrete repair diffs when converted checks can describe file or config edits
6874
- `--lint`: run modernized health checks in read-only mode and emit diagnostic findings
69-
- `--json`: with `--lint`, emit JSON findings instead of human output
75+
- `--json`: with `--lint` or repair preview, emit JSON instead of human output
7076
- `--severity-min <level>`: with `--lint`, drop findings below `info`, `warning`, or `error`
7177
- `--skip <id>`: with `--lint`, skip a check id; repeat to skip more than one
7278
- `--only <id>`: with `--lint`, run only a check id; repeat to run a small selected set
@@ -78,7 +84,8 @@ It uses the structured health-check path, does not prompt, and does not repair
7884
or rewrite config/state. Use it in CI, preflight scripts, and review workflows
7985
when you want machine-readable findings instead of guided repair prompts.
8086
Lint-output options such as `--json`, `--severity-min`, `--only`, and `--skip`
81-
are only accepted with `--lint`.
87+
are only accepted with `--lint`, except that `--json` also works with repair
88+
preview.
8289

8390
```bash
8491
openclaw doctor --lint
@@ -114,6 +121,81 @@ JSON output is the scripting surface for lint runs:
114121
}
115122
```
116123

124+
## Repair preview
125+
126+
`openclaw doctor --fix --dry-run` uses the repair posture but stops before
127+
mutation. Converted checks can return the same repair plan they would use for a
128+
real fix: findings, planned changes, warnings, side effects, and optional diffs.
129+
Legacy repair slots that are not converted yet are reported as skipped instead
130+
of being treated as completed preview coverage.
131+
132+
```bash
133+
openclaw doctor --fix --dry-run
134+
openclaw doctor --fix --dry-run --diff
135+
openclaw doctor --fix --dry-run --json
136+
```
137+
138+
Human preview output shows the planned repair surface:
139+
140+
```text
141+
Doctor repair preview:
142+
[warning] core/doctor/gateway-config gateway.mode - gateway.mode is unset; gateway start will be blocked.
143+
change: Set gateway.mode to local.
144+
effect: config:set gateway.mode (dry-run safe)
145+
```
146+
147+
JSON preview output is the machine-readable repair plan:
148+
149+
```json
150+
{
151+
"ok": false,
152+
"mode": "dry-run",
153+
"diff": true,
154+
"checksRun": 8,
155+
"checksRepaired": 1,
156+
"checksValidated": 0,
157+
"findings": [
158+
{
159+
"checkId": "core/doctor/gateway-config",
160+
"severity": "warning",
161+
"message": "gateway.mode is unset; gateway start will be blocked.",
162+
"path": "gateway.mode"
163+
}
164+
],
165+
"changes": ["Set gateway.mode to local."],
166+
"warnings": [],
167+
"effects": [
168+
{
169+
"kind": "config",
170+
"action": "set",
171+
"target": "gateway.mode",
172+
"dryRunSafe": true
173+
}
174+
],
175+
"diffs": [
176+
{
177+
"kind": "config",
178+
"path": "gateway.mode",
179+
"before": "<unset>",
180+
"after": "local"
181+
}
182+
],
183+
"skipped": [
184+
{
185+
"id": "doctor:ui-freshness",
186+
"label": "UI freshness",
187+
"healthCheckIds": ["core/doctor/ui-freshness"],
188+
"targets": ["core/doctor/ui-freshness"],
189+
"reason": "legacy-preflight-repair-not-converted-to-structured-dry-run-diff"
190+
}
191+
]
192+
}
193+
```
194+
195+
Preview JSON suppresses presentation notes. If a repair check has an advisory
196+
note, the human preview can print it, but JSON consumers should use `findings`,
197+
`changes`, `warnings`, `effects`, `diffs`, and `skipped`.
198+
117199
Exit behavior:
118200

119201
- `0`: no findings at or above the selected severity threshold
@@ -124,35 +206,41 @@ Exit behavior:
124206
example, `openclaw doctor --lint --severity-min error` can print no findings and
125207
exit `0` even when lower-severity `info` or `warning` findings exist.
126208

127-
## Structured Health Checks
209+
## Structured health checks
128210

129211
Modern doctor checks use a small structured contract:
130212

131213
```ts
132-
detect(ctx, scope?) -> HealthFinding[]
133-
repair?(ctx, findings) -> HealthRepairResult
214+
run(ctx) -> HealthCheckRunResult
134215
```
135216

136-
`detect()` powers `doctor --lint`. `repair()` is optional and is only considered
137-
by `doctor --fix` / `doctor --repair`. Checks that have not migrated to this
138-
shape continue to use the legacy doctor contribution flow.
217+
The main branch in the contract is `ctx.repair`:
218+
219+
- `ctx.repair === false`: inspect state and return findings plus any repair
220+
plan that can be computed without mutation.
221+
- `ctx.repair === true`: inspect the same state, return the same structured
222+
plan, and apply the repair when possible.
223+
224+
Checks that are naturally analysis-first can still use the split
225+
`detect(ctx)`/`repair(ctx, findings)` adapter. The registry normalizes both
226+
styles to the same `run(ctx)` shape before the runner presents lint, preview,
227+
diff, JSON, or fix output.
139228

140-
The split is intentional: `detect()` owns diagnosis, while `repair()` owns
141-
reporting what it changed or would change. Repair contexts can carry
142-
`dryRun`/`diff` requests, and repair results can return structured `diffs` for
143-
config/file edits plus `effects` for service, process, package, state, or other
144-
side effects. That lets converted checks grow toward `doctor --fix --dry-run`
145-
and diff reporting without moving mutation planning into `detect()`.
229+
Repair results can return structured `diffs` for config/file edits plus
230+
`effects` for service, process, package, state, or other side effects. Lint
231+
reads findings from the non-mutating run, repair preview reads the returned
232+
plan, and fix applies only when `ctx.repair` is true.
146233

147-
`repair()` reports whether it attempted the requested repair with `status:
148-
"repaired" | "skipped" | "failed"`. Omitted status means `repaired`, so simple
149-
repair checks only need to return changes. When repair returns `skipped` or
150-
`failed`, doctor reports the reason and does not run validation for that check.
234+
A non-mutating repair plan returns `status: "repairable"`. Applied repair
235+
results use `"repaired"`, `"skipped"`, or `"failed"`. When a repair returns
236+
`skipped` or `failed`, doctor reports the reason and does not run validation for
237+
that check.
151238

152-
After a successful structured repair, doctor re-runs `detect()` with the
153-
repaired findings as scope. Checks can use selected findings, paths, or `ocPath`
154-
values for focused validation. If the finding is still present, doctor reports a
155-
repair warning instead of treating the change as silently complete.
239+
Detect-after validation is opt-in for the repair runner. When enabled after a
240+
successful structured repair, doctor re-runs the check with the repaired
241+
findings as scope. Checks can use selected findings, paths, or `ocPath` values
242+
for focused validation. If the finding is still present, doctor reports a repair
243+
warning instead of treating the change as silently complete.
156244

157245
A finding includes:
158246

@@ -190,9 +278,9 @@ Notes:
190278
- In Nix mode (`OPENCLAW_NIX_MODE=1`), read-only doctor checks still work, but `doctor --fix`, `doctor --repair`, `doctor --yes`, and `doctor --generate-gateway-token` are disabled because `openclaw.json` is immutable. Edit the Nix source for this install instead; for nix-openclaw, use the agent-first [Quick Start](https://github.com/openclaw/nix-openclaw#quick-start).
191279
- Interactive prompts (like keychain/OAuth fixes) only run when stdin is a TTY and `--non-interactive` is **not** set. Headless runs (cron, Telegram, no terminal) will skip prompts.
192280
- Performance: non-interactive `doctor` runs skip eager plugin loading so headless health checks stay fast. Interactive doctor sessions still load the plugin surfaces needed by the legacy health and repair flow.
193-
- `--lint` is stricter than `--non-interactive`: it is always read-only, never prompts, and never applies safe migrations. Run `doctor --fix` or `doctor --repair` when you want doctor to make changes.
281+
- `--lint` is stricter than `--non-interactive`: it is always read-only, never prompts, and never applies safe migrations. Run `doctor --fix --dry-run` when you want a repair plan, or `doctor --fix` / `doctor --repair` when you want doctor to make changes.
194282
- `--fix` (alias for `--repair`) writes a backup to `~/.openclaw/openclaw.json.bak` and drops unknown config keys, listing each removal.
195-
- Modernized health checks can expose a `repair()` path for `doctor --fix`; checks that do not expose one continue through the existing doctor repair flow.
283+
- Modernized health checks can expose repair plans through `run(ctx)` for `doctor --fix`, `--dry-run`, and `--diff`; checks that do not expose one continue through the existing doctor repair flow.
196284
- `doctor --fix --non-interactive` reports missing or stale gateway service definitions but does not install or rewrite them outside update repair mode. Run `openclaw gateway install` for a missing service, or `openclaw gateway install --force` when you intentionally want to replace the launcher.
197285
- State integrity checks now detect orphan transcript files in the sessions directory. Archiving them as `.deleted.<timestamp>` requires an interactive confirmation; `--fix`, `--yes`, and headless runs leave them in place.
198286
- Doctor also scans `~/.openclaw/cron/jobs.json` (or `cron.store`) for legacy cron job shapes and can rewrite them in place before the scheduler has to auto-normalize them at runtime.

src/commands/doctor-config-flow.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,16 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
8585
confirm: (p: { message: string; initialValue: boolean }) => Promise<boolean>;
8686
runtime?: RuntimeEnv;
8787
prompter?: DoctorPrompter;
88+
preflight?: {
89+
migrateState?: boolean;
90+
migrateLegacyConfig?: boolean;
91+
};
8892
}) {
8993
const shouldRepair = params.options.repair === true || params.options.yes === true;
90-
const preflight = await runDoctorConfigPreflight({ repairPrefixedConfig: shouldRepair });
94+
const preflight = await runDoctorConfigPreflight({
95+
...params.preflight,
96+
repairPrefixedConfig: shouldRepair,
97+
});
9198
let snapshot = preflight.snapshot;
9299
const baseCfg = preflight.baseConfig;
93100
let cfg: OpenClawConfig = baseCfg;

src/flows/doctor-core-checks.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,25 @@ describe("registerCoreHealthChecks", () => {
419419
target: "skills.entries.missing-tool.enabled",
420420
}),
421421
);
422+
423+
const preview = await check.repair?.(
424+
{
425+
mode: "fix",
426+
runtime,
427+
cfg,
428+
cwd: "/tmp/openclaw-test-workspace",
429+
dryRun: true,
430+
},
431+
findings,
432+
);
433+
expect(preview?.changes).toContain("Would disable unavailable skill missing-tool.");
434+
expect(preview?.effects).toContainEqual(
435+
expect.objectContaining({
436+
kind: "config",
437+
action: "would-disable-skill",
438+
target: "skills.entries.missing-tool.enabled",
439+
}),
440+
);
422441
});
423442

424443
it("converts security doctor warnings into health findings", async () => {

src/flows/doctor-core-checks.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1901,9 +1901,10 @@ function createSkillsReadinessCheck(deps: CoreHealthCheckDeps): RegisteredHealth
19011901
return { changes: [] };
19021902
}
19031903
const nextConfig = disableUnavailableSkillsInConfig(ctx.cfg, unavailable);
1904+
const changePrefix = ctx.dryRun === true ? "Would disable" : "Disabled";
19041905
return {
19051906
config: nextConfig,
1906-
changes: unavailable.map((skill) => `Disabled unavailable skill ${skill.name}.`),
1907+
changes: unavailable.map((skill) => `${changePrefix} unavailable skill ${skill.name}.`),
19071908
effects: unavailable.map((skill) => ({
19081909
kind: "config" as const,
19091910
action: ctx.dryRun === true ? "would-disable-skill" : "disable-skill",

src/flows/doctor-health.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,10 @@ describe("doctorCommand preview mode", () => {
139139
yes: false,
140140
generateGatewayToken: false,
141141
}),
142+
preflight: {
143+
migrateState: false,
144+
migrateLegacyConfig: false,
145+
},
142146
}),
143147
);
144148
expect(mocks.note).toHaveBeenCalledWith(
@@ -162,6 +166,10 @@ describe("doctorCommand preview mode", () => {
162166
expect(mocks.loadAndMaybeMigrateDoctorConfig).toHaveBeenCalledWith(
163167
expect.objectContaining({
164168
options: expect.objectContaining({ dryRun: true, diff: true, repair: false }),
169+
preflight: {
170+
migrateState: false,
171+
migrateLegacyConfig: false,
172+
},
165173
}),
166174
);
167175
expect(mocks.runDoctorHealthContributions).toHaveBeenCalledWith(
@@ -182,6 +190,14 @@ describe("doctorCommand preview mode", () => {
182190
},
183191
];
184192
await expect(confirm({ message: "Apply?", initialValue: true })).resolves.toBe(false);
193+
expect(mocks.loadAndMaybeMigrateDoctorConfig).toHaveBeenCalledWith(
194+
expect.objectContaining({
195+
preflight: {
196+
migrateState: false,
197+
migrateLegacyConfig: false,
198+
},
199+
}),
200+
);
185201
expect(mocks.runDoctorHealthContributions).toHaveBeenCalledWith(
186202
expect.objectContaining({
187203
options: expect.objectContaining({
@@ -202,6 +218,9 @@ describe("doctorCommand preview mode", () => {
202218
expect(mocks.assertConfigWriteAllowedInCurrentMode).toHaveBeenCalledTimes(1);
203219
expect(mocks.maybeRepairUiProtocolFreshness).toHaveBeenCalledTimes(1);
204220
expect(mocks.note).not.toHaveBeenCalledWith(expect.any(String), "Doctor preview");
221+
expect(mocks.loadAndMaybeMigrateDoctorConfig).toHaveBeenCalledWith(
222+
expect.not.objectContaining({ preflight: expect.anything() }),
223+
);
205224
expect(mocks.runDoctorHealthContributions).toHaveBeenCalledWith(
206225
expect.objectContaining({
207226
options: expect.objectContaining({ repair: true }),

src/flows/doctor-health.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,14 @@ async function runDoctorCommandBody(params: {
133133
confirm: previewOnly ? async () => false : (p) => prompter.confirm(p),
134134
runtime: effectiveRuntime,
135135
prompter,
136+
...(previewOnly
137+
? {
138+
preflight: {
139+
migrateState: false,
140+
migrateLegacyConfig: false,
141+
},
142+
}
143+
: {}),
136144
});
137145
if (previewReport !== undefined) {
138146
recordDoctorPreviewSkippedContribution({

0 commit comments

Comments
 (0)