diff --git a/docs/cli/doctor.md b/docs/cli/doctor.md index 0f91b60bf..994f6a80d 100644 --- a/docs/cli/doctor.md +++ b/docs/cli/doctor.md @@ -23,6 +23,7 @@ clawdbot doctor --deep Notes: - 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. +- `--fix` (alias for `--repair`) writes a backup to `~/.clawdbot/clawdbot.json.bak` and drops unknown config keys, listing each removal. ## macOS: `launchctl` env overrides diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index df7a5a9ee..1f65879b7 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -34,4 +34,36 @@ describe("doctor config flow", () => { }); }); }); + + it("drops unknown keys on repair", async () => { + await withTempHome(async (home) => { + const configDir = path.join(home, ".clawdbot"); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile( + path.join(configDir, "clawdbot.json"), + JSON.stringify( + { + bridge: { bind: "auto" }, + gateway: { auth: { mode: "token", token: "ok", extra: true } }, + agents: { list: [{ id: "pi" }] }, + }, + null, + 2, + ), + "utf-8", + ); + + const result = await loadAndMaybeMigrateDoctorConfig({ + options: { nonInteractive: true, repair: true }, + confirm: async () => false, + }); + + const cfg = result.cfg as Record; + expect(cfg.bridge).toBeUndefined(); + expect((cfg.gateway as Record)?.auth).toEqual({ + mode: "token", + token: "ok", + }); + }); + }); }); diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index b9deff472..8c2ca9b40 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -1,5 +1,8 @@ +import type { ZodIssue } from "zod"; + import type { ClawdbotConfig } from "../config/config.js"; import { + ClawdbotSchema, CONFIG_PATH_CLAWDBOT, migrateLegacyConfig, readConfigFileSnapshot, @@ -13,6 +16,68 @@ function isRecord(value: unknown): value is Record { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } +type UnrecognizedKeysIssue = ZodIssue & { + code: "unrecognized_keys"; + keys: string[]; +}; + +function isUnrecognizedKeysIssue(issue: ZodIssue): issue is UnrecognizedKeysIssue { + return issue.code === "unrecognized_keys"; +} + +function formatPath(parts: Array): string { + if (parts.length === 0) return ""; + let out = ""; + for (const part of parts) { + if (typeof part === "number") { + out += `[${part}]`; + continue; + } + out = out ? `${out}.${part}` : part; + } + return out || ""; +} + +function resolvePathTarget(root: unknown, path: Array): unknown { + let current: unknown = root; + for (const part of path) { + if (typeof part === "number") { + if (!Array.isArray(current)) return null; + if (part < 0 || part >= current.length) return null; + current = current[part]; + continue; + } + if (!current || typeof current !== "object" || Array.isArray(current)) return null; + const record = current as Record; + if (!(part in record)) return null; + current = record[part]; + } + return current; +} + +function stripUnknownConfigKeys(config: ClawdbotConfig): { config: ClawdbotConfig; removed: string[] } { + const parsed = ClawdbotSchema.safeParse(config); + if (parsed.success) { + return { config, removed: [] }; + } + + const next = structuredClone(config) as ClawdbotConfig; + const removed: string[] = []; + for (const issue of parsed.error.issues) { + if (!isUnrecognizedKeysIssue(issue)) continue; + const target = resolvePathTarget(next, issue.path); + if (!target || typeof target !== "object" || Array.isArray(target)) continue; + const record = target as Record; + for (const key of issue.keys) { + if (!(key in record)) continue; + delete record[key]; + removed.push(formatPath([...issue.path, key])); + } + } + + return { config: next, removed }; +} + function noteOpencodeProviderOverrides(cfg: ClawdbotConfig) { const providers = cfg.models?.providers; if (!providers) return; @@ -89,6 +154,18 @@ export async function loadAndMaybeMigrateDoctorConfig(params: { } } + const unknown = stripUnknownConfigKeys(cfg); + if (unknown.removed.length > 0) { + const lines = unknown.removed.map((path) => `- ${path}`).join("\n"); + if (shouldRepair) { + cfg = unknown.config; + note(lines, "Doctor changes"); + } else { + note(lines, "Unknown config keys"); + note('Run "clawdbot doctor --fix" to remove these keys.', "Doctor"); + } + } + noteOpencodeProviderOverrides(cfg); return { cfg, path: snapshot.path ?? CONFIG_PATH_CLAWDBOT }; diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index c98c234e2..2519bcc0a 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -1,3 +1,5 @@ +import fs from "node:fs"; + import { intro as clackIntro, outro as clackOutro } from "@clack/prompts"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; @@ -251,6 +253,10 @@ export async function doctorCommand( cfg = applyWizardMetadata(cfg, { command: "doctor", mode: resolveMode(cfg) }); await writeConfigFile(cfg); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); + const backupPath = `${CONFIG_PATH_CLAWDBOT}.bak`; + if (fs.existsSync(backupPath)) { + runtime.log(`Backup: ${backupPath}`); + } } else { runtime.log('Run "clawdbot doctor --fix" to apply changes.'); }