diff --git a/extensions/memory-wiki/src/cli.test.ts b/extensions/memory-wiki/src/cli.test.ts index 3514743a643..d9db994bd38 100644 --- a/extensions/memory-wiki/src/cli.test.ts +++ b/extensions/memory-wiki/src/cli.test.ts @@ -19,10 +19,12 @@ describe("memory-wiki cli", () => { vi.spyOn(process.stdout, "write").mockImplementation( (() => true) as typeof process.stdout.write, ); + process.exitCode = undefined; }); afterEach(() => { vi.restoreAllMocks(); + process.exitCode = undefined; }); it("registers apply synthesis and writes a synthesis page", async () => { @@ -123,4 +125,24 @@ cli note expect(parsed.frontmatter).not.toHaveProperty("confidence"); expect(parsed.body).toContain("cli note"); }); + + it("runs wiki doctor and sets a non-zero exit code when warnings exist", async () => { + const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-cli-")); + tempDirs.push(rootDir); + const config = resolveMemoryWikiConfig( + { + vault: { path: rootDir }, + obsidian: { enabled: true, useOfficialCli: true }, + }, + { homedir: "/Users/tester" }, + ); + const program = new Command(); + program.name("test"); + registerWikiCli(program, config); + await fs.rm(rootDir, { recursive: true, force: true }); + + await program.parseAsync(["wiki", "doctor", "--json"], { from: "user" }); + + expect(process.exitCode).toBe(1); + }); }); diff --git a/extensions/memory-wiki/src/cli.ts b/extensions/memory-wiki/src/cli.ts index 6b88b7e6c7b..b0002c24fdc 100644 --- a/extensions/memory-wiki/src/cli.ts +++ b/extensions/memory-wiki/src/cli.ts @@ -16,13 +16,22 @@ import { } from "./obsidian.js"; import { getMemoryWikiPage, searchMemoryWiki } from "./query.js"; import { syncMemoryWikiImportedSources } from "./source-sync.js"; -import { renderMemoryWikiStatus, resolveMemoryWikiStatus } from "./status.js"; +import { + buildMemoryWikiDoctorReport, + renderMemoryWikiDoctor, + renderMemoryWikiStatus, + resolveMemoryWikiStatus, +} from "./status.js"; import { initializeMemoryWikiVault } from "./vault.js"; type WikiStatusCommandOptions = { json?: boolean; }; +type WikiDoctorCommandOptions = { + json?: boolean; +}; + type WikiInitCommandOptions = { json?: boolean; }; @@ -149,6 +158,24 @@ export async function runWikiStatus(params: { return status; } +export async function runWikiDoctor(params: { + config: ResolvedMemoryWikiConfig; + appConfig?: OpenClawConfig; + json?: boolean; + stdout?: Pick; +}) { + await syncMemoryWikiImportedSources({ config: params.config, appConfig: params.appConfig }); + const report = buildMemoryWikiDoctorReport(await resolveMemoryWikiStatus(params.config)); + if (!report.healthy) { + process.exitCode = 1; + } + writeOutput( + params.json ? JSON.stringify(report, null, 2) : renderMemoryWikiDoctor(report), + params.stdout, + ); + return report; +} + export async function runWikiInit(params: { config: ResolvedMemoryWikiConfig; json?: boolean; @@ -469,6 +496,14 @@ export function registerWikiCli( await runWikiStatus({ config, appConfig, json: opts.json }); }); + wiki + .command("doctor") + .description("Audit wiki vault setup and report actionable fixes") + .option("--json", "Print JSON") + .action(async (opts: WikiDoctorCommandOptions) => { + await runWikiDoctor({ config, appConfig, json: opts.json }); + }); + wiki .command("init") .description("Initialize the wiki vault layout") diff --git a/extensions/memory-wiki/src/status.test.ts b/extensions/memory-wiki/src/status.test.ts index 81a1f68a13d..43c40eb7bca 100644 --- a/extensions/memory-wiki/src/status.test.ts +++ b/extensions/memory-wiki/src/status.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; import { resolveMemoryWikiConfig } from "./config.js"; -import { renderMemoryWikiStatus, resolveMemoryWikiStatus } from "./status.js"; +import { + buildMemoryWikiDoctorReport, + renderMemoryWikiDoctor, + renderMemoryWikiStatus, + resolveMemoryWikiStatus, +} from "./status.js"; describe("resolveMemoryWikiStatus", () => { it("reports missing vault and missing requested obsidian cli", async () => { @@ -83,3 +88,28 @@ describe("renderMemoryWikiStatus", () => { expect(rendered).toContain("Wiki vault has not been initialized yet."); }); }); + +describe("memory wiki doctor", () => { + it("builds actionable fixes from status warnings", async () => { + const config = resolveMemoryWikiConfig( + { + vault: { path: "/tmp/wiki" }, + obsidian: { enabled: true, useOfficialCli: true }, + }, + { homedir: "/Users/tester" }, + ); + + const status = await resolveMemoryWikiStatus(config, { + pathExists: async () => false, + resolveCommand: async () => null, + }); + const report = buildMemoryWikiDoctorReport(status); + const rendered = renderMemoryWikiDoctor(report); + + expect(report.healthy).toBe(false); + expect(report.warningCount).toBe(2); + expect(report.fixes.map((fix) => fix.code)).toEqual(["vault-missing", "obsidian-cli-missing"]); + expect(rendered).toContain("Suggested fixes:"); + expect(rendered).toContain("openclaw wiki init"); + }); +}); diff --git a/extensions/memory-wiki/src/status.ts b/extensions/memory-wiki/src/status.ts index 6714fad4dae..700ee554653 100644 --- a/extensions/memory-wiki/src/status.ts +++ b/extensions/memory-wiki/src/status.ts @@ -35,6 +35,18 @@ export type MemoryWikiStatus = { warnings: MemoryWikiStatusWarning[]; }; +export type MemoryWikiDoctorFix = { + code: MemoryWikiStatusWarning["code"]; + message: string; +}; + +export type MemoryWikiDoctorReport = { + healthy: boolean; + warningCount: number; + status: MemoryWikiStatus; + fixes: MemoryWikiDoctorFix[]; +}; + type ResolveMemoryWikiStatusDeps = { pathExists?: (inputPath: string) => Promise; resolveCommand?: (command: string) => Promise; @@ -172,6 +184,30 @@ export async function resolveMemoryWikiStatus( }; } +export function buildMemoryWikiDoctorReport(status: MemoryWikiStatus): MemoryWikiDoctorReport { + const fixes = status.warnings.map((warning) => ({ + code: warning.code, + message: + warning.code === "vault-missing" + ? "Run `openclaw wiki init` to create the vault layout." + : warning.code === "obsidian-cli-missing" + ? "Install the official Obsidian CLI or disable `obsidian.useOfficialCli`." + : warning.code === "bridge-disabled" + ? "Enable `plugins.entries.memory-wiki.config.bridge.enabled` or switch vaultMode away from `bridge`." + : warning.code === "unsafe-local-disabled" + ? "Enable `unsafeLocal.allowPrivateMemoryCoreAccess` or switch vaultMode away from `unsafe-local`." + : warning.code === "unsafe-local-paths-missing" + ? "Add explicit `unsafeLocal.paths` entries before running unsafe-local imports." + : "Disable private memory-core access unless you explicitly want unsafe-local mode.", + })); + return { + healthy: status.warnings.length === 0, + warningCount: status.warnings.length, + status, + fixes, + }; +} + export function renderMemoryWikiStatus(status: MemoryWikiStatus): string { const lines = [ `Wiki vault mode: ${status.vaultMode}`, @@ -192,3 +228,20 @@ export function renderMemoryWikiStatus(status: MemoryWikiStatus): string { return lines.join("\n"); } + +export function renderMemoryWikiDoctor(report: MemoryWikiDoctorReport): string { + const lines = [ + report.healthy ? "Wiki doctor: healthy" : `Wiki doctor: ${report.warningCount} issue(s) found`, + "", + renderMemoryWikiStatus(report.status), + ]; + + if (report.fixes.length > 0) { + lines.push("", "Suggested fixes:"); + for (const fix of report.fixes) { + lines.push(`- ${fix.message}`); + } + } + + return lines.join("\n"); +}