From b24092b912449f331bf88398f2300c9f31d77578 Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Sun, 1 Mar 2026 15:47:30 -0800 Subject: [PATCH 1/5] feat(cli): add doctor command for configuration validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `poe-code doctor [agent]` to validate the full configuration stack — system dirs, config integrity, API key, and per-agent binary, config probe, and model checks. All agent checks are derived from provider config with zero provider branching. - System checks: home dir exists, config.json valid, no corruption backups - Auth checks: API key present, API key valid (HTTP) - Agent checks: binary exists, config probe, model configured - Sequential execution with dependency skipping - SDK exports: collectChecks, runChecks, DoctorResult types - 42 unit tests across 6 test files --- src/cli/commands/doctor-command.test.ts | 142 +++++++++++++++++++ src/cli/commands/doctor.ts | 124 +++++++++++++++++ src/cli/program.ts | 7 + src/index.ts | 8 ++ src/sdk/doctor/checks/agent.test.ts | 175 ++++++++++++++++++++++++ src/sdk/doctor/checks/agent.ts | 123 +++++++++++++++++ src/sdk/doctor/checks/auth.test.ts | 162 ++++++++++++++++++++++ src/sdk/doctor/checks/auth.ts | 73 ++++++++++ src/sdk/doctor/checks/system.test.ts | 145 ++++++++++++++++++++ src/sdk/doctor/checks/system.ts | 92 +++++++++++++ src/sdk/doctor/collect-checks.test.ts | 146 ++++++++++++++++++++ src/sdk/doctor/collect-checks.ts | 40 ++++++ src/sdk/doctor/index.ts | 9 ++ src/sdk/doctor/run.test.ts | 95 +++++++++++++ src/sdk/doctor/run.ts | 27 ++++ src/sdk/doctor/types.ts | 40 ++++++ 16 files changed, 1408 insertions(+) create mode 100644 src/cli/commands/doctor-command.test.ts create mode 100644 src/cli/commands/doctor.ts create mode 100644 src/sdk/doctor/checks/agent.test.ts create mode 100644 src/sdk/doctor/checks/agent.ts create mode 100644 src/sdk/doctor/checks/auth.test.ts create mode 100644 src/sdk/doctor/checks/auth.ts create mode 100644 src/sdk/doctor/checks/system.test.ts create mode 100644 src/sdk/doctor/checks/system.ts create mode 100644 src/sdk/doctor/collect-checks.test.ts create mode 100644 src/sdk/doctor/collect-checks.ts create mode 100644 src/sdk/doctor/index.ts create mode 100644 src/sdk/doctor/run.test.ts create mode 100644 src/sdk/doctor/run.ts create mode 100644 src/sdk/doctor/types.ts diff --git a/src/cli/commands/doctor-command.test.ts b/src/cli/commands/doctor-command.test.ts new file mode 100644 index 00000000..bce429bf --- /dev/null +++ b/src/cli/commands/doctor-command.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createCliContainer } from "../container.js"; +import type { FileSystem } from "../../utils/file-system.js"; +import type { CommandRunner } from "../../utils/command-checks.js"; +import { createHomeFs, createTestProgram } from "../../../tests/test-helpers.js"; +import type { LoggerFn } from "../types.js"; +import { executeDoctor } from "./doctor.js"; + +const cwd = "/repo"; +const homeDir = "/home/test"; +const configPath = homeDir + "/.poe-code/config.json"; + +describe("doctor command", () => { + let fs: FileSystem; + + beforeEach(() => { + fs = createHomeFs(homeDir); + }); + + function createContainer( + overrides: { + commandRunner?: CommandRunner; + logger?: LoggerFn; + } = {} + ) { + const prompts = vi.fn().mockResolvedValue({}); + const commandRunner: CommandRunner = + overrides.commandRunner ?? + vi.fn(async () => ({ stdout: "", stderr: "", exitCode: 0 })); + const logger = overrides.logger ?? (() => {}); + const httpClient = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ current_point_balance: 1000 }) + })); + const container = createCliContainer({ + fs, + prompts, + env: { cwd, homeDir }, + logger, + commandRunner, + httpClient + }); + return { container, prompts, commandRunner, httpClient }; + } + + it("runs system and auth checks on empty config", async () => { + const messages: string[] = []; + const { container } = createContainer({ + logger: (msg) => messages.push(msg) + }); + vi.spyOn(container, "readApiKey").mockResolvedValue("sk-test"); + + const program = createTestProgram(); + await executeDoctor(program, container, undefined, {}); + + expect(messages.some((m) => m.includes("home-dir"))).toBe(false); + // Should have intro + expect(messages[0]).toBe("doctor"); + }); + + it("reports pass when all system checks pass", async () => { + const messages: string[] = []; + await fs.mkdir(homeDir + "/.poe-code", { recursive: true }); + await fs.writeFile(configPath, JSON.stringify({ apiKey: "sk-test" })); + const { container } = createContainer({ + logger: (msg) => messages.push(msg) + }); + vi.spyOn(container, "readApiKey").mockResolvedValue("sk-test"); + + const program = createTestProgram(); + await executeDoctor(program, container, undefined, {}); + + // Should have summary + const summaryLine = messages.find((m) => m.includes("pass")); + expect(summaryLine).toBeDefined(); + }); + + it("runs agent-specific checks when agent argument is provided", async () => { + const messages: string[] = []; + await fs.mkdir(homeDir + "/.poe-code", { recursive: true }); + await fs.writeFile( + configPath, + JSON.stringify({ + apiKey: "sk-test", + configured_services: { + codex: { files: ["~/.codex/config.toml"] } + } + }) + ); + const commandRunner = vi.fn(async (command: string) => { + if (command === "which") { + return { stdout: "/usr/local/bin/codex\n", stderr: "", exitCode: 0 }; + } + return { stdout: "", stderr: "", exitCode: 0 }; + }); + const { container } = createContainer({ + commandRunner, + logger: (msg) => messages.push(msg) + }); + vi.spyOn(container, "readApiKey").mockResolvedValue("sk-test"); + + const program = createTestProgram(); + await executeDoctor(program, container, "codex", {}); + + // Should include codex-specific checks + const hasCodexCheck = messages.some( + (m) => m.includes("codex") + ); + expect(hasCodexCheck).toBe(true); + }); + + it("exits with failure summary when checks fail", async () => { + const messages: string[] = []; + // No .poe-code dir => system.home-dir fails + const { container } = createContainer({ + logger: (msg) => messages.push(msg) + }); + vi.spyOn(container, "readApiKey").mockResolvedValue(null); + + const program = createTestProgram(); + const result = await executeDoctor(program, container, undefined, {}); + + expect(result.summary.fail).toBeGreaterThan(0); + }); + + it("respects dry-run mode", async () => { + const messages: string[] = []; + await fs.mkdir(homeDir + "/.poe-code", { recursive: true }); + await fs.writeFile(configPath, JSON.stringify({ apiKey: "sk-test" })); + const { container, httpClient } = createContainer({ + logger: (msg) => messages.push(msg) + }); + vi.spyOn(container, "readApiKey").mockResolvedValue("sk-test"); + + const program = createTestProgram(["node", "cli", "--dry-run"]); + await executeDoctor(program, container, undefined, {}); + + // HTTP client should not be called in dry-run + expect(httpClient).not.toHaveBeenCalled(); + }); +}); diff --git a/src/cli/commands/doctor.ts b/src/cli/commands/doctor.ts new file mode 100644 index 00000000..b6962118 --- /dev/null +++ b/src/cli/commands/doctor.ts @@ -0,0 +1,124 @@ +import type { Command } from "commander"; +import type { CliContainer } from "../container.js"; +import { + createExecutionResources, + resolveCommandFlags, + formatServiceList +} from "./shared.js"; +import { loadConfiguredServices } from "../../services/config.js"; +import { collectChecks, runChecks } from "../../sdk/doctor/index.js"; +import type { DoctorResult, CheckResult } from "../../sdk/doctor/types.js"; + +export type DoctorCommandOptions = Record; + +export function registerDoctorCommand( + program: Command, + container: CliContainer +): Command { + const serviceNames = container.registry.list().map((s) => s.name); + return program + .command("doctor") + .description("Validate Poe configuration and connectivity.") + .argument("[agent]", `Agent to check${formatServiceList(serviceNames)}`) + .action( + async ( + agentArg: string | undefined, + options: DoctorCommandOptions + ) => { + const result = await executeDoctor( + program, + container, + agentArg, + options + ); + if (result.summary.fail > 0) { + process.exitCode = 1; + } + } + ); +} + +export async function executeDoctor( + program: Command, + container: CliContainer, + agentArg: string | undefined, + _options: DoctorCommandOptions +): Promise { + const flags = resolveCommandFlags(program); + const resources = createExecutionResources(container, flags, "doctor"); + + resources.logger.intro("doctor"); + + const configuredServices = await loadConfiguredServices({ + fs: container.fs, + filePath: container.env.configPath + }); + + const providers = container.registry.list(); + const checks = collectChecks(providers, configuredServices, agentArg); + + const result = await runChecks(checks, { + fs: container.fs, + env: container.env, + runCommand: resources.context.runCommand, + httpClient: container.httpClient, + readApiKey: container.readApiKey, + verbose: flags.verbose, + dryRun: flags.dryRun, + previousResults: new Map() + }); + + let currentCategory = ""; + for (const { check, result: checkResult } of result.checks) { + if (check.category !== currentCategory) { + currentCategory = check.category; + resources.logger.info(formatCategory(currentCategory)); + } + logCheckResult(resources.logger, check.description, checkResult); + if (flags.verbose && checkResult.detail) { + resources.logger.verbose(` ${checkResult.detail}`); + } + } + + const { summary } = result; + const parts: string[] = []; + if (summary.pass > 0) parts.push(`${summary.pass} passed`); + if (summary.warn > 0) parts.push(`${summary.warn} warnings`); + if (summary.fail > 0) parts.push(`${summary.fail} failed`); + if (summary.skip > 0) parts.push(`${summary.skip} skipped`); + resources.logger.resolved("Summary", parts.join(", ")); + + resources.context.finalize(); + return result; +} + +function formatCategory(category: string): string { + if (category === "system") return "System"; + if (category === "auth") return "Authentication"; + if (category.startsWith("agent:")) { + return `Agent: ${category.slice("agent:".length)}`; + } + return category; +} + +function logCheckResult( + logger: { success(msg: string): void; warn(msg: string): void; error(msg: string): void; info(msg: string): void }, + description: string, + result: CheckResult +): void { + const fixSuffix = result.fix ? ` — ${result.fix}` : ""; + if (result.status === "pass") { + logger.success(`${description}: ${result.message}`); + return; + } + if (result.status === "warn") { + logger.warn(`${description}: ${result.message}${fixSuffix}`); + return; + } + if (result.status === "fail") { + logger.error(`${description}: ${result.message}${fixSuffix}`); + return; + } + // skip + logger.info(`${description}: ${result.message}`); +} diff --git a/src/cli/program.ts b/src/cli/program.ts index 7cd494b8..7106b114 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -23,6 +23,7 @@ import { registerVersionOption } from "./commands/version.js"; import { registerRalphCommand } from "./commands/ralph.js"; import { registerUsageCommand } from "./commands/usage.js"; import { registerModelsCommand } from "./commands/models.js"; +import { registerDoctorCommand } from "./commands/doctor.js"; import packageJson from "../../package.json" with { type: "json" }; import { throwCommandNotFound } from "./command-not-found.js"; import { @@ -127,6 +128,11 @@ function formatHelpText(input: { name: "usage list", args: "", description: "Display usage history" + }, + { + name: "doctor", + args: "[agent]", + description: "Validate Poe configuration and connectivity" } ]; const nameWidth = Math.max(0, ...commandRows.map((row) => row.name.length)); @@ -320,6 +326,7 @@ function bootstrapProgram(container: CliContainer): Command { registerRalphCommand(program, container); registerUsageCommand(program, container); registerModelsCommand(program, container); + registerDoctorCommand(program, container); program.allowExcessArguments().action(function (this: Command) { const args = this.args; diff --git a/src/index.ts b/src/index.ts index 223695f5..8f7eeb16 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,14 @@ export type { GenerateResult, MediaGenerateResult } from "./sdk/types.js"; +export { collectChecks, runChecks } from "./sdk/doctor/index.js"; +export type { + DoctorCheck, + DoctorContext, + DoctorResult, + CheckResult, + DoctorOptions +} from "./sdk/doctor/types.js"; async function main(): Promise { const [{ createProgram }, { createCliMain }] = await Promise.all([ diff --git a/src/sdk/doctor/checks/agent.test.ts b/src/sdk/doctor/checks/agent.test.ts new file mode 100644 index 00000000..2446917f --- /dev/null +++ b/src/sdk/doctor/checks/agent.test.ts @@ -0,0 +1,175 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createHomeFs } from "../../../../tests/test-helpers.js"; +import type { FileSystem } from "../../../utils/file-system.js"; +import type { DoctorContext } from "../types.js"; +import type { ProviderService } from "../../../cli/service-registry.js"; +import { + binaryCheck, + configProbeCheck, + modelConfiguredCheck +} from "./agent.js"; + +const homeDir = "/home/test"; +const configPath = homeDir + "/.poe-code/config.json"; + +function createContext( + fs: FileSystem, + overrides: { + commandRunner?: (...args: any[]) => Promise; + } = {} +): DoctorContext { + return { + fs, + env: { + cwd: "/repo", + homeDir, + platform: "darwin", + configPath, + logDir: homeDir + "/.poe-code/logs", + poeApiBaseUrl: "https://api.poe.com/v1", + poeBaseUrl: "https://api.poe.com", + variables: {}, + resolveHomePath: (...segments: string[]) => + [homeDir, ...segments].join("/"), + getVariable: () => undefined + }, + runCommand: + overrides.commandRunner ?? + vi.fn(async () => ({ stdout: "", stderr: "", exitCode: 0 })), + httpClient: vi.fn(), + readApiKey: vi.fn(async () => null), + verbose: false, + dryRun: false, + previousResults: new Map() + }; +} + +describe("agent checks", () => { + let fs: FileSystem; + + beforeEach(() => { + fs = createHomeFs(homeDir); + }); + + describe("binaryCheck", () => { + it("passes when binary is found via which", async () => { + const commandRunner = vi.fn(async (command: string) => { + if (command === "which") { + return { + stdout: "/usr/local/bin/claude\n", + stderr: "", + exitCode: 0 + }; + } + return { stdout: "", stderr: "", exitCode: 1 }; + }); + const ctx = createContext(fs, { commandRunner }); + const check = binaryCheck("agent:claude-code", "claude-code", "claude"); + const result = await check.run(ctx); + expect(result.status).toBe("pass"); + }); + + it("fails when binary is not found", async () => { + const commandRunner = vi.fn( + async () => ({ stdout: "", stderr: "", exitCode: 1 }) + ); + const ctx = createContext(fs, { commandRunner }); + const check = binaryCheck("agent:codex", "codex", "codex"); + const result = await check.run(ctx); + expect(result.status).toBe("fail"); + expect(result.fix).toContain("poe-code install codex"); + }); + }); + + describe("configProbeCheck", () => { + it("passes when config probe file exists", async () => { + const probePath = + homeDir + "/.poe-code/codex/config.toml"; + await fs.mkdir(homeDir + "/.poe-code/codex", { recursive: true }); + await fs.writeFile(probePath, "content"); + const ctx = createContext(fs); + const provider = { + name: "codex", + isolatedEnv: { + agentBinary: "codex", + configProbe: { kind: "isolatedFile" as const, relativePath: "config.toml" }, + env: {} + } + } as unknown as ProviderService; + const check = configProbeCheck("agent:codex", provider); + const result = await check.run(ctx); + expect(result.status).toBe("pass"); + }); + + it("fails when config probe file is missing", async () => { + await fs.mkdir(homeDir + "/.poe-code/codex", { recursive: true }); + const ctx = createContext(fs); + const provider = { + name: "codex", + isolatedEnv: { + agentBinary: "codex", + configProbe: { kind: "isolatedFile" as const, relativePath: "config.toml" }, + env: {} + } + } as unknown as ProviderService; + const check = configProbeCheck("agent:codex", provider); + const result = await check.run(ctx); + expect(result.status).toBe("fail"); + expect(result.fix).toContain("poe-code configure codex"); + }); + + it("skips when binary check failed", async () => { + const ctx = createContext(fs); + ctx.previousResults.set("agent.codex.binary", { + status: "fail", + message: "Binary not found" + }); + const provider = { + name: "codex", + isolatedEnv: { + agentBinary: "codex", + configProbe: { kind: "isolatedFile" as const, relativePath: "config.toml" }, + env: {} + } + } as unknown as ProviderService; + const check = configProbeCheck("agent:codex", provider); + const result = await check.run(ctx); + expect(result.status).toBe("skip"); + }); + }); + + describe("modelConfiguredCheck", () => { + it("passes when model metadata exists in config", async () => { + await fs.mkdir(homeDir + "/.poe-code", { recursive: true }); + await fs.writeFile( + configPath, + JSON.stringify({ + configured_services: { + codex: { files: ["~/.codex/config.toml"] } + } + }) + ); + const ctx = createContext(fs); + const check = modelConfiguredCheck("agent:codex", "codex"); + const result = await check.run(ctx); + expect(result.status).toBe("pass"); + }); + + it("fails when agent is not in configured services", async () => { + await fs.mkdir(homeDir + "/.poe-code", { recursive: true }); + await fs.writeFile(configPath, JSON.stringify({})); + const ctx = createContext(fs); + const check = modelConfiguredCheck("agent:codex", "codex"); + const result = await check.run(ctx); + expect(result.status).toBe("fail"); + expect(result.fix).toContain("poe-code configure codex"); + }); + + it("skips when config file does not exist", async () => { + const ctx = createContext(fs); + const check = modelConfiguredCheck("agent:codex", "codex"); + const result = await check.run(ctx); + expect(result.status).toBe("skip"); + }); + }); +}); diff --git a/src/sdk/doctor/checks/agent.ts b/src/sdk/doctor/checks/agent.ts new file mode 100644 index 00000000..99125285 --- /dev/null +++ b/src/sdk/doctor/checks/agent.ts @@ -0,0 +1,123 @@ +import path from "node:path"; +import { isNotFound } from "@poe-code/config-mutations"; +import type { DoctorCheck, DoctorContext, CheckResult } from "../types.js"; +import type { ProviderService } from "../../../cli/service-registry.js"; + +export function binaryCheck( + category: string, + providerName: string, + binaryName: string +): DoctorCheck { + return { + id: `agent.${providerName}.binary`, + category, + description: `${binaryName} binary exists`, + async run(ctx: DoctorContext): Promise { + const result = await ctx.runCommand("which", [binaryName]); + if (result.exitCode === 0) { + return { + status: "pass", + message: `${binaryName} found`, + detail: result.stdout.trim() + }; + } + return { + status: "fail", + message: `${binaryName} not found on PATH`, + fix: `Run "poe-code install ${providerName}" to install it.` + }; + } + }; +} + +export function configProbeCheck( + category: string, + provider: ProviderService +): DoctorCheck { + const providerName = provider.name; + return { + id: `agent.${providerName}.config-probe`, + category, + description: `${providerName} config exists`, + async run(ctx: DoctorContext): Promise { + const binaryResult = ctx.previousResults.get( + `agent.${providerName}.binary` + ); + if (binaryResult && binaryResult.status === "fail") { + return { + status: "skip", + message: `Skipped (${providerName} binary not found)` + }; + } + + const isolated = provider.isolatedEnv!; + const baseDir = path.join(ctx.env.homeDir, ".poe-code", providerName); + const probe = isolated.configProbe!; + const probePath = + probe.kind === "isolatedFile" + ? path.join(baseDir, probe.relativePath) + : probe.relativePath + ? path.join(baseDir, probe.relativePath) + : baseDir; + + try { + await ctx.fs.stat(probePath); + return { + status: "pass", + message: `${providerName} config found`, + detail: probePath + }; + } catch (error) { + if (isNotFound(error)) { + return { + status: "fail", + message: `${providerName} config not found at ${probePath}`, + fix: `Run "poe-code configure ${providerName}" to create it.` + }; + } + throw error; + } + } + }; +} + +export function modelConfiguredCheck( + category: string, + providerName: string +): DoctorCheck { + return { + id: `agent.${providerName}.model`, + category, + description: `${providerName} model configured`, + async run(ctx: DoctorContext): Promise { + try { + const raw = await ctx.fs.readFile(ctx.env.configPath, "utf8"); + const config = JSON.parse(raw); + const services = config.configured_services; + if (services && providerName in services) { + return { + status: "pass", + message: `${providerName} is configured` + }; + } + return { + status: "fail", + message: `${providerName} not found in configured services`, + fix: `Run "poe-code configure ${providerName}" to set it up.` + }; + } catch (error) { + if (isNotFound(error)) { + return { + status: "skip", + message: "Config file not found" + }; + } + return { + status: "fail", + message: `Error reading config: ${(error as Error).message}`, + fix: `Run "poe-code configure ${providerName}" to recreate config.` + }; + } + } + }; +} diff --git a/src/sdk/doctor/checks/auth.test.ts b/src/sdk/doctor/checks/auth.test.ts new file mode 100644 index 00000000..8f29a5d9 --- /dev/null +++ b/src/sdk/doctor/checks/auth.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createHomeFs } from "../../../../tests/test-helpers.js"; +import type { FileSystem } from "../../../utils/file-system.js"; +import type { DoctorContext } from "../types.js"; +import type { HttpClient, HttpResponse } from "../../../cli/http.js"; +import { authChecks } from "./auth.js"; + +const homeDir = "/home/test"; +const configPath = homeDir + "/.poe-code/config.json"; + +function createContext( + fs: FileSystem, + overrides: { + readApiKey?: () => Promise; + httpClient?: HttpClient; + dryRun?: boolean; + } = {} +): DoctorContext { + return { + fs, + env: { + cwd: "/repo", + homeDir, + platform: "darwin", + configPath, + logDir: homeDir + "/.poe-code/logs", + poeApiBaseUrl: "https://api.poe.com/v1", + poeBaseUrl: "https://api.poe.com", + variables: {}, + resolveHomePath: (...segments: string[]) => + [homeDir, ...segments].join("/"), + getVariable: () => undefined + }, + runCommand: vi.fn(async () => ({ stdout: "", stderr: "", exitCode: 0 })), + httpClient: overrides.httpClient ?? vi.fn(), + readApiKey: overrides.readApiKey ?? vi.fn(async () => null), + verbose: false, + dryRun: overrides.dryRun ?? false, + previousResults: new Map() + }; +} + +function okResponse(): HttpResponse { + return { + ok: true, + status: 200, + json: async () => ({ current_point_balance: 1000 }) + }; +} + +function unauthorizedResponse(): HttpResponse { + return { + ok: false, + status: 401, + json: async () => ({}) + }; +} + +describe("auth checks", () => { + let fs: FileSystem; + + beforeEach(() => { + fs = createHomeFs(homeDir); + }); + + describe("auth.api-key-present", () => { + it("passes when API key is available", async () => { + const ctx = createContext(fs, { + readApiKey: async () => "sk-test-key" + }); + const checks = authChecks(); + const check = checks.find((c) => c.id === "auth.api-key-present")!; + const result = await check.run(ctx); + expect(result.status).toBe("pass"); + }); + + it("fails when no API key is available", async () => { + const ctx = createContext(fs, { + readApiKey: async () => null + }); + const checks = authChecks(); + const check = checks.find((c) => c.id === "auth.api-key-present")!; + const result = await check.run(ctx); + expect(result.status).toBe("fail"); + expect(result.fix).toContain("poe-code login"); + }); + }); + + describe("auth.api-key-valid", () => { + it("passes when API returns 200", async () => { + const httpClient = vi.fn(async () => okResponse()); + const ctx = createContext(fs, { + readApiKey: async () => "sk-valid", + httpClient + }); + const checks = authChecks(); + const check = checks.find((c) => c.id === "auth.api-key-valid")!; + const result = await check.run(ctx); + expect(result.status).toBe("pass"); + expect(httpClient).toHaveBeenCalledWith( + "https://api.poe.com/usage/current_balance", + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ + Authorization: "Bearer sk-valid" + }) + }) + ); + }); + + it("fails when API returns 401", async () => { + const ctx = createContext(fs, { + readApiKey: async () => "sk-invalid", + httpClient: vi.fn(async () => unauthorizedResponse()) + }); + const checks = authChecks(); + const check = checks.find((c) => c.id === "auth.api-key-valid")!; + const result = await check.run(ctx); + expect(result.status).toBe("fail"); + expect(result.fix).toContain("poe-code login"); + }); + + it("skips when api-key-present failed", async () => { + const ctx = createContext(fs, { + readApiKey: async () => null + }); + ctx.previousResults.set("auth.api-key-present", { + status: "fail", + message: "No API key" + }); + const checks = authChecks(); + const check = checks.find((c) => c.id === "auth.api-key-valid")!; + const result = await check.run(ctx); + expect(result.status).toBe("skip"); + }); + + it("skips during dry run", async () => { + const ctx = createContext(fs, { + readApiKey: async () => "sk-test", + dryRun: true + }); + const checks = authChecks(); + const check = checks.find((c) => c.id === "auth.api-key-valid")!; + const result = await check.run(ctx); + expect(result.status).toBe("skip"); + }); + + it("fails when network error occurs", async () => { + const ctx = createContext(fs, { + readApiKey: async () => "sk-test", + httpClient: vi.fn(async () => { + throw new Error("fetch failed"); + }) + }); + const checks = authChecks(); + const check = checks.find((c) => c.id === "auth.api-key-valid")!; + const result = await check.run(ctx); + expect(result.status).toBe("fail"); + expect(result.message).toContain("fetch failed"); + }); + }); +}); diff --git a/src/sdk/doctor/checks/auth.ts b/src/sdk/doctor/checks/auth.ts new file mode 100644 index 00000000..cbdad5f1 --- /dev/null +++ b/src/sdk/doctor/checks/auth.ts @@ -0,0 +1,73 @@ +import type { DoctorCheck, DoctorContext, CheckResult } from "../types.js"; + +function apiKeyPresentCheck(): DoctorCheck { + return { + id: "auth.api-key-present", + category: "auth", + description: "API key available", + async run(ctx: DoctorContext): Promise { + const key = await ctx.readApiKey(); + if (key) { + return { status: "pass", message: "API key found" }; + } + return { + status: "fail", + message: "No API key found", + fix: 'Run "poe-code login" to store your Poe API key.' + }; + } + }; +} + +function apiKeyValidCheck(): DoctorCheck { + return { + id: "auth.api-key-valid", + category: "auth", + description: "API key works", + async run(ctx: DoctorContext): Promise { + const prev = ctx.previousResults.get("auth.api-key-present"); + if (prev && prev.status === "fail") { + return { status: "skip", message: "Skipped (no API key)" }; + } + + if (ctx.dryRun) { + return { status: "skip", message: "Skipped (dry run)" }; + } + + const key = await ctx.readApiKey(); + if (!key) { + return { status: "skip", message: "Skipped (no API key)" }; + } + + try { + const response = await ctx.httpClient( + `${ctx.env.poeBaseUrl}/usage/current_balance`, + { + method: "GET", + headers: { Authorization: `Bearer ${key}` } + } + ); + + if (response.ok) { + return { status: "pass", message: "API key is valid" }; + } + + return { + status: "fail", + message: `API key rejected (HTTP ${response.status})`, + fix: 'Run "poe-code login" to update your API key.' + }; + } catch (error) { + return { + status: "fail", + message: `API request failed: ${(error as Error).message}`, + fix: "Check your internet connection." + }; + } + } + }; +} + +export function authChecks(): DoctorCheck[] { + return [apiKeyPresentCheck(), apiKeyValidCheck()]; +} diff --git a/src/sdk/doctor/checks/system.test.ts b/src/sdk/doctor/checks/system.test.ts new file mode 100644 index 00000000..d78aee27 --- /dev/null +++ b/src/sdk/doctor/checks/system.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createHomeFs } from "../../../../tests/test-helpers.js"; +import type { FileSystem } from "../../../utils/file-system.js"; +import type { DoctorContext } from "../types.js"; +import { systemChecks } from "./system.js"; + +const homeDir = "/home/test"; +const configPath = homeDir + "/.poe-code/config.json"; +const poeCodeDir = homeDir + "/.poe-code"; + +function createContext(fs: FileSystem): DoctorContext { + return { + fs, + env: { + cwd: "/repo", + homeDir, + platform: "darwin", + configPath, + logDir: homeDir + "/.poe-code/logs", + poeApiBaseUrl: "https://api.poe.com/v1", + poeBaseUrl: "https://api.poe.com", + variables: {}, + resolveHomePath: (...segments: string[]) => + [homeDir, ...segments].join("/"), + getVariable: () => undefined + }, + runCommand: vi.fn(async () => ({ stdout: "", stderr: "", exitCode: 0 })), + httpClient: vi.fn(), + readApiKey: vi.fn(async () => null), + verbose: false, + dryRun: false, + previousResults: new Map() + }; +} + +describe("system checks", () => { + let fs: FileSystem; + + beforeEach(() => { + fs = createHomeFs(homeDir); + }); + + describe("system.home-dir", () => { + it("passes when .poe-code directory exists and is writable", async () => { + await fs.mkdir(poeCodeDir, { recursive: true }); + const ctx = createContext(fs); + const checks = systemChecks(); + const check = checks.find((c) => c.id === "system.home-dir")!; + const result = await check.run(ctx); + expect(result.status).toBe("pass"); + }); + + it("fails when .poe-code directory does not exist", async () => { + // createHomeFs only creates homeDir, not .poe-code + // Remove the homeDir so .poe-code definitely doesn't exist + const freshFs = createHomeFs("/other"); + const ctx = createContext(freshFs); + ctx.env = { + ...ctx.env, + homeDir: "/other", + configPath: "/other/.poe-code/config.json", + resolveHomePath: (...segments: string[]) => + ["/other", ...segments].join("/") + }; + const checks = systemChecks(); + const check = checks.find((c) => c.id === "system.home-dir")!; + const result = await check.run(ctx); + expect(result.status).toBe("fail"); + expect(result.fix).toContain("poe-code configure"); + }); + }); + + describe("system.config-valid", () => { + it("passes when config.json is valid JSON", async () => { + await fs.mkdir(poeCodeDir, { recursive: true }); + await fs.writeFile(configPath, '{"apiKey":"sk-test"}'); + const ctx = createContext(fs); + const checks = systemChecks(); + const check = checks.find((c) => c.id === "system.config-valid")!; + const result = await check.run(ctx); + expect(result.status).toBe("pass"); + }); + + it("fails when config.json has invalid JSON", async () => { + await fs.mkdir(poeCodeDir, { recursive: true }); + await fs.writeFile(configPath, "{broken"); + const ctx = createContext(fs); + const checks = systemChecks(); + const check = checks.find((c) => c.id === "system.config-valid")!; + const result = await check.run(ctx); + expect(result.status).toBe("fail"); + expect(result.fix).toBeDefined(); + }); + + it("skips when config.json does not exist", async () => { + await fs.mkdir(poeCodeDir, { recursive: true }); + const ctx = createContext(fs); + const checks = systemChecks(); + const check = checks.find((c) => c.id === "system.config-valid")!; + const result = await check.run(ctx); + expect(result.status).toBe("skip"); + }); + }); + + describe("system.config-backups", () => { + it("passes when no backup files exist", async () => { + await fs.mkdir(poeCodeDir, { recursive: true }); + const ctx = createContext(fs); + const checks = systemChecks(); + const check = checks.find((c) => c.id === "system.config-backups")!; + const result = await check.run(ctx); + expect(result.status).toBe("pass"); + }); + + it("warns when invalid backup files exist", async () => { + await fs.mkdir(poeCodeDir, { recursive: true }); + await fs.writeFile( + poeCodeDir + "/config.json.invalid-2024-01-01.json", + "{}" + ); + const ctx = createContext(fs); + const checks = systemChecks(); + const check = checks.find((c) => c.id === "system.config-backups")!; + const result = await check.run(ctx); + expect(result.status).toBe("warn"); + expect(result.message).toContain("1"); + }); + + it("skips when .poe-code directory does not exist", async () => { + const freshFs = createHomeFs("/other"); + const ctx = createContext(freshFs); + ctx.env = { + ...ctx.env, + homeDir: "/other", + configPath: "/other/.poe-code/config.json", + resolveHomePath: (...segments: string[]) => + ["/other", ...segments].join("/") + }; + const checks = systemChecks(); + const check = checks.find((c) => c.id === "system.config-backups")!; + const result = await check.run(ctx); + expect(result.status).toBe("skip"); + }); + }); +}); diff --git a/src/sdk/doctor/checks/system.ts b/src/sdk/doctor/checks/system.ts new file mode 100644 index 00000000..f0b00744 --- /dev/null +++ b/src/sdk/doctor/checks/system.ts @@ -0,0 +1,92 @@ +import path from "node:path"; +import { isNotFound } from "@poe-code/config-mutations"; +import type { DoctorCheck, DoctorContext, CheckResult } from "../types.js"; + +function homeDirCheck(): DoctorCheck { + return { + id: "system.home-dir", + category: "system", + description: "Home directory writable", + async run(ctx: DoctorContext): Promise { + const poeCodeDir = path.join(ctx.env.homeDir, ".poe-code"); + try { + await ctx.fs.stat(poeCodeDir); + return { status: "pass", message: `${poeCodeDir} exists` }; + } catch (error) { + if (isNotFound(error)) { + return { + status: "fail", + message: `${poeCodeDir} does not exist`, + fix: 'Run "poe-code configure" to create it.' + }; + } + return { + status: "fail", + message: `Cannot access ${poeCodeDir}: ${(error as Error).message}`, + fix: "Check directory permissions." + }; + } + } + }; +} + +function configValidCheck(): DoctorCheck { + return { + id: "system.config-valid", + category: "system", + description: "Config file parseable", + async run(ctx: DoctorContext): Promise { + try { + const raw = await ctx.fs.readFile(ctx.env.configPath, "utf8"); + JSON.parse(raw); + return { status: "pass", message: "config.json is valid JSON" }; + } catch (error) { + if (isNotFound(error)) { + return { + status: "skip", + message: "config.json not found" + }; + } + return { + status: "fail", + message: "config.json contains invalid JSON", + fix: 'Delete the file and run "poe-code configure" to recreate it.', + detail: (error as Error).message + }; + } + } + }; +} + +function configBackupsCheck(): DoctorCheck { + return { + id: "system.config-backups", + category: "system", + description: "No corruption backups", + async run(ctx: DoctorContext): Promise { + const poeCodeDir = path.join(ctx.env.homeDir, ".poe-code"); + let entries: string[]; + try { + entries = await ctx.fs.readdir(poeCodeDir); + } catch (error) { + if (isNotFound(error)) { + return { status: "skip", message: ".poe-code directory not found" }; + } + throw error; + } + const backups = entries.filter((name) => name.includes(".invalid-")); + if (backups.length === 0) { + return { status: "pass", message: "No corruption backups found" }; + } + return { + status: "warn", + message: `Found ${backups.length} corruption backup(s)`, + detail: backups.join(", ") + }; + } + }; +} + +export function systemChecks(): DoctorCheck[] { + return [homeDirCheck(), configValidCheck(), configBackupsCheck()]; +} diff --git a/src/sdk/doctor/collect-checks.test.ts b/src/sdk/doctor/collect-checks.test.ts new file mode 100644 index 00000000..a0000769 --- /dev/null +++ b/src/sdk/doctor/collect-checks.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect } from "vitest"; +import { collectChecks } from "./collect-checks.js"; +import type { ProviderService } from "../../cli/service-registry.js"; + +function createProvider( + overrides: Partial = {} +): ProviderService { + return { + id: "test", + name: "test", + label: "Test", + summary: "Test provider", + configure: async () => {}, + unconfigure: async () => false, + ...overrides + }; +} + +describe("collectChecks", () => { + it("includes system and auth checks for empty registry", () => { + const checks = collectChecks([], {}); + const ids = checks.map((c) => c.id); + expect(ids).toContain("system.home-dir"); + expect(ids).toContain("system.config-valid"); + expect(ids).toContain("auth.api-key-present"); + expect(ids).toContain("auth.api-key-valid"); + }); + + it("adds binary check when provider has isolatedEnv with agentBinary", () => { + const provider = createProvider({ + name: "codex", + isolatedEnv: { + agentBinary: "codex", + configProbe: { kind: "isolatedFile", relativePath: "config.toml" }, + env: {} + } + }); + const checks = collectChecks([provider], { codex: { files: [] } }); + const ids = checks.map((c) => c.id); + expect(ids).toContain("agent.codex.binary"); + }); + + it("adds config-probe check when provider has configProbe and requiresConfig not false", () => { + const provider = createProvider({ + name: "codex", + isolatedEnv: { + agentBinary: "codex", + configProbe: { kind: "isolatedFile", relativePath: "config.toml" }, + env: {} + } + }); + const checks = collectChecks([provider], { codex: { files: [] } }); + const ids = checks.map((c) => c.id); + expect(ids).toContain("agent.codex.config-probe"); + }); + + it("skips config-probe when requiresConfig is false", () => { + const provider = createProvider({ + name: "claude-code", + isolatedEnv: { + agentBinary: "claude", + requiresConfig: false, + env: {} + } + }); + const checks = collectChecks([provider], { "claude-code": { files: [] } }); + const ids = checks.map((c) => c.id); + expect(ids).not.toContain("agent.claude-code.config-probe"); + }); + + it("adds model check when provider has configurePrompts.model", () => { + const provider = createProvider({ + name: "codex", + configurePrompts: { + model: { label: "Model", defaultValue: "m1", choices: [] } + }, + isolatedEnv: { + agentBinary: "codex", + env: {} + } + }); + const checks = collectChecks([provider], { codex: { files: [] } }); + const ids = checks.map((c) => c.id); + expect(ids).toContain("agent.codex.model"); + }); + + it("skips unconfigured providers", () => { + const provider = createProvider({ + name: "codex", + isolatedEnv: { + agentBinary: "codex", + configProbe: { kind: "isolatedFile", relativePath: "config.toml" }, + env: {} + } + }); + const checks = collectChecks([provider], {}); + const ids = checks.map((c) => c.id); + expect(ids).not.toContain("agent.codex.binary"); + }); + + it("skips disabled providers", () => { + const provider = createProvider({ + name: "codex", + disabled: true, + isolatedEnv: { + agentBinary: "codex", + env: {} + } + }); + const checks = collectChecks([provider], { codex: { files: [] } }); + const ids = checks.map((c) => c.id); + expect(ids).not.toContain("agent.codex.binary"); + }); + + it("does not generate agent checks for poe-agent (no isolatedEnv)", () => { + const provider = createProvider({ + name: "poe-agent" + }); + const checks = collectChecks([provider], { "poe-agent": { files: [] } }); + const ids = checks.map((c) => c.id); + const agentChecks = ids.filter((id) => id.startsWith("agent.")); + expect(agentChecks).toHaveLength(0); + }); + + it("filters to a single agent when agentFilter is provided", () => { + const codex = createProvider({ + name: "codex", + isolatedEnv: { agentBinary: "codex", env: {} } + }); + const claude = createProvider({ + name: "claude-code", + isolatedEnv: { agentBinary: "claude", requiresConfig: false, env: {} } + }); + const configured = { + codex: { files: [] }, + "claude-code": { files: [] } + }; + const checks = collectChecks([codex, claude], configured, "codex"); + const agentIds = checks + .filter((c) => c.id.startsWith("agent.")) + .map((c) => c.id); + expect(agentIds.every((id) => id.startsWith("agent.codex"))).toBe(true); + // System/auth checks still included + expect(checks.some((c) => c.id === "system.home-dir")).toBe(true); + }); +}); diff --git a/src/sdk/doctor/collect-checks.ts b/src/sdk/doctor/collect-checks.ts new file mode 100644 index 00000000..2008d57c --- /dev/null +++ b/src/sdk/doctor/collect-checks.ts @@ -0,0 +1,40 @@ +import type { DoctorCheck } from "./types.js"; +import type { ProviderService } from "../../cli/service-registry.js"; +import type { ConfiguredServiceMetadata } from "../../services/config.js"; +import { systemChecks } from "./checks/system.js"; +import { authChecks } from "./checks/auth.js"; +import { binaryCheck, configProbeCheck, modelConfiguredCheck } from "./checks/agent.js"; + +export function collectChecks( + providers: ProviderService[], + configuredServices: Record, + agentFilter?: string +): DoctorCheck[] { + const checks: DoctorCheck[] = [...systemChecks(), ...authChecks()]; + + for (const provider of providers) { + if (provider.disabled) continue; + if (!(provider.name in configuredServices)) continue; + if (agentFilter && provider.name !== agentFilter) continue; + if (!provider.isolatedEnv) continue; + + const category = `agent:${provider.name}`; + + checks.push( + binaryCheck(category, provider.name, provider.isolatedEnv.agentBinary) + ); + + if ( + provider.isolatedEnv.configProbe && + provider.isolatedEnv.requiresConfig !== false + ) { + checks.push(configProbeCheck(category, provider)); + } + + if (provider.configurePrompts?.model) { + checks.push(modelConfiguredCheck(category, provider.name)); + } + } + + return checks; +} diff --git a/src/sdk/doctor/index.ts b/src/sdk/doctor/index.ts new file mode 100644 index 00000000..803bf74a --- /dev/null +++ b/src/sdk/doctor/index.ts @@ -0,0 +1,9 @@ +export { collectChecks } from "./collect-checks.js"; +export { runChecks } from "./run.js"; +export type { + DoctorCheck, + DoctorContext, + DoctorResult, + CheckResult, + DoctorOptions +} from "./types.js"; diff --git a/src/sdk/doctor/run.test.ts b/src/sdk/doctor/run.test.ts new file mode 100644 index 00000000..e60bf37c --- /dev/null +++ b/src/sdk/doctor/run.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, vi } from "vitest"; +import { runChecks } from "./run.js"; +import type { DoctorCheck, DoctorContext, CheckResult } from "./types.js"; + +function createCheck( + id: string, + result: CheckResult, + category = "test" +): DoctorCheck { + return { + id, + category, + description: `Check ${id}`, + run: vi.fn(async () => result) + }; +} + +const stubContext = {} as DoctorContext; + +describe("runChecks", () => { + it("runs all checks and returns results", async () => { + const checks = [ + createCheck("a", { status: "pass", message: "ok" }), + createCheck("b", { status: "fail", message: "bad" }) + ]; + const result = await runChecks(checks, stubContext); + expect(result.checks).toHaveLength(2); + expect(result.checks[0].result.status).toBe("pass"); + expect(result.checks[1].result.status).toBe("fail"); + }); + + it("computes summary correctly", async () => { + const checks = [ + createCheck("a", { status: "pass", message: "ok" }), + createCheck("b", { status: "warn", message: "warn" }), + createCheck("c", { status: "fail", message: "bad" }), + createCheck("d", { status: "skip", message: "skipped" }) + ]; + const result = await runChecks(checks, stubContext); + expect(result.summary).toEqual({ + pass: 1, + warn: 1, + fail: 1, + skip: 1 + }); + }); + + it("populates previousResults for dependency skipping", async () => { + const dependentCheck: DoctorCheck = { + id: "b", + category: "test", + description: "Check b", + run: vi.fn(async (ctx: DoctorContext) => { + const prev = ctx.previousResults.get("a"); + if (prev?.status === "fail") { + return { status: "skip", message: "skipped due to a" }; + } + return { status: "pass", message: "ok" }; + }) + }; + + const checks = [ + createCheck("a", { status: "fail", message: "failed" }), + dependentCheck + ]; + const result = await runChecks(checks, stubContext); + expect(result.checks[1].result.status).toBe("skip"); + }); + + it("runs checks sequentially in order", async () => { + const order: string[] = []; + const makeSequentialCheck = (id: string): DoctorCheck => ({ + id, + category: "test", + description: `Check ${id}`, + async run() { + order.push(id); + return { status: "pass", message: "ok" }; + } + }); + const checks = [ + makeSequentialCheck("first"), + makeSequentialCheck("second"), + makeSequentialCheck("third") + ]; + await runChecks(checks, stubContext); + expect(order).toEqual(["first", "second", "third"]); + }); + + it("returns empty result for no checks", async () => { + const result = await runChecks([], stubContext); + expect(result.checks).toHaveLength(0); + expect(result.summary).toEqual({ pass: 0, warn: 0, fail: 0, skip: 0 }); + }); +}); diff --git a/src/sdk/doctor/run.ts b/src/sdk/doctor/run.ts new file mode 100644 index 00000000..6be5d4a7 --- /dev/null +++ b/src/sdk/doctor/run.ts @@ -0,0 +1,27 @@ +import type { + DoctorCheck, + DoctorContext, + DoctorResult, + CheckResult +} from "./types.js"; + +export async function runChecks( + checks: DoctorCheck[], + baseContext: DoctorContext +): Promise { + const previousResults = new Map( + baseContext.previousResults + ); + const results: Array<{ check: DoctorCheck; result: CheckResult }> = []; + const summary = { pass: 0, warn: 0, fail: 0, skip: 0 }; + + for (const check of checks) { + const ctx: DoctorContext = { ...baseContext, previousResults }; + const result = await check.run(ctx); + previousResults.set(check.id, result); + results.push({ check, result }); + summary[result.status] += 1; + } + + return { checks: results, summary }; +} diff --git a/src/sdk/doctor/types.ts b/src/sdk/doctor/types.ts new file mode 100644 index 00000000..ca56c6b3 --- /dev/null +++ b/src/sdk/doctor/types.ts @@ -0,0 +1,40 @@ +import type { FileSystem } from "../../utils/file-system.js"; +import type { CliEnvironment } from "../../cli/environment.js"; +import type { CommandRunner } from "../../utils/command-checks.js"; +import type { HttpClient } from "../../cli/http.js"; + +export interface CheckResult { + status: "pass" | "warn" | "fail" | "skip"; + message: string; + fix?: string; + detail?: string; +} + +export interface DoctorContext { + fs: FileSystem; + env: CliEnvironment; + runCommand: CommandRunner; + httpClient: HttpClient; + readApiKey: () => Promise; + verbose: boolean; + dryRun: boolean; + previousResults: Map; +} + +export interface DoctorCheck { + id: string; + category: string; + description: string; + run(ctx: DoctorContext): Promise; +} + +export interface DoctorResult { + checks: Array<{ check: DoctorCheck; result: CheckResult }>; + summary: { pass: number; warn: number; fail: number; skip: number }; +} + +export interface DoctorOptions { + agent?: string; + verbose?: boolean; + dryRun?: boolean; +} From d221058fabe0422cc49f3c5f0f3cb96285eae97d Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Sun, 1 Mar 2026 16:00:23 -0800 Subject: [PATCH 2/5] refactor(doctor): rename modelConfiguredCheck to serviceConfiguredCheck Address code review feedback: - Rename to accurately reflect what the check does (validates service existence in configured_services, not model value) - Use ScopedLogger type instead of inline shape in logCheckResult - Provide proper previousResults map in run.test.ts stub context --- src/cli/commands/doctor.ts | 3 ++- src/sdk/doctor/checks/agent.test.ts | 10 +++++----- src/sdk/doctor/checks/agent.ts | 6 +++--- src/sdk/doctor/collect-checks.test.ts | 4 ++-- src/sdk/doctor/collect-checks.ts | 4 ++-- src/sdk/doctor/run.test.ts | 2 +- 6 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/cli/commands/doctor.ts b/src/cli/commands/doctor.ts index b6962118..07039361 100644 --- a/src/cli/commands/doctor.ts +++ b/src/cli/commands/doctor.ts @@ -8,6 +8,7 @@ import { import { loadConfiguredServices } from "../../services/config.js"; import { collectChecks, runChecks } from "../../sdk/doctor/index.js"; import type { DoctorResult, CheckResult } from "../../sdk/doctor/types.js"; +import type { ScopedLogger } from "../logger.js"; export type DoctorCommandOptions = Record; @@ -102,7 +103,7 @@ function formatCategory(category: string): string { } function logCheckResult( - logger: { success(msg: string): void; warn(msg: string): void; error(msg: string): void; info(msg: string): void }, + logger: ScopedLogger, description: string, result: CheckResult ): void { diff --git a/src/sdk/doctor/checks/agent.test.ts b/src/sdk/doctor/checks/agent.test.ts index 2446917f..04a58dda 100644 --- a/src/sdk/doctor/checks/agent.test.ts +++ b/src/sdk/doctor/checks/agent.test.ts @@ -6,7 +6,7 @@ import type { ProviderService } from "../../../cli/service-registry.js"; import { binaryCheck, configProbeCheck, - modelConfiguredCheck + serviceConfiguredCheck } from "./agent.js"; const homeDir = "/home/test"; @@ -138,7 +138,7 @@ describe("agent checks", () => { }); }); - describe("modelConfiguredCheck", () => { + describe("serviceConfiguredCheck", () => { it("passes when model metadata exists in config", async () => { await fs.mkdir(homeDir + "/.poe-code", { recursive: true }); await fs.writeFile( @@ -150,7 +150,7 @@ describe("agent checks", () => { }) ); const ctx = createContext(fs); - const check = modelConfiguredCheck("agent:codex", "codex"); + const check = serviceConfiguredCheck("agent:codex", "codex"); const result = await check.run(ctx); expect(result.status).toBe("pass"); }); @@ -159,7 +159,7 @@ describe("agent checks", () => { await fs.mkdir(homeDir + "/.poe-code", { recursive: true }); await fs.writeFile(configPath, JSON.stringify({})); const ctx = createContext(fs); - const check = modelConfiguredCheck("agent:codex", "codex"); + const check = serviceConfiguredCheck("agent:codex", "codex"); const result = await check.run(ctx); expect(result.status).toBe("fail"); expect(result.fix).toContain("poe-code configure codex"); @@ -167,7 +167,7 @@ describe("agent checks", () => { it("skips when config file does not exist", async () => { const ctx = createContext(fs); - const check = modelConfiguredCheck("agent:codex", "codex"); + const check = serviceConfiguredCheck("agent:codex", "codex"); const result = await check.run(ctx); expect(result.status).toBe("skip"); }); diff --git a/src/sdk/doctor/checks/agent.ts b/src/sdk/doctor/checks/agent.ts index 99125285..dbd12cbc 100644 --- a/src/sdk/doctor/checks/agent.ts +++ b/src/sdk/doctor/checks/agent.ts @@ -81,14 +81,14 @@ export function configProbeCheck( }; } -export function modelConfiguredCheck( +export function serviceConfiguredCheck( category: string, providerName: string ): DoctorCheck { return { - id: `agent.${providerName}.model`, + id: `agent.${providerName}.configured`, category, - description: `${providerName} model configured`, + description: `${providerName} configured`, async run(ctx: DoctorContext): Promise { try { const raw = await ctx.fs.readFile(ctx.env.configPath, "utf8"); diff --git a/src/sdk/doctor/collect-checks.test.ts b/src/sdk/doctor/collect-checks.test.ts index a0000769..6f7c2817 100644 --- a/src/sdk/doctor/collect-checks.test.ts +++ b/src/sdk/doctor/collect-checks.test.ts @@ -68,7 +68,7 @@ describe("collectChecks", () => { expect(ids).not.toContain("agent.claude-code.config-probe"); }); - it("adds model check when provider has configurePrompts.model", () => { + it("adds configured check when provider has configurePrompts.model", () => { const provider = createProvider({ name: "codex", configurePrompts: { @@ -81,7 +81,7 @@ describe("collectChecks", () => { }); const checks = collectChecks([provider], { codex: { files: [] } }); const ids = checks.map((c) => c.id); - expect(ids).toContain("agent.codex.model"); + expect(ids).toContain("agent.codex.configured"); }); it("skips unconfigured providers", () => { diff --git a/src/sdk/doctor/collect-checks.ts b/src/sdk/doctor/collect-checks.ts index 2008d57c..043ebca1 100644 --- a/src/sdk/doctor/collect-checks.ts +++ b/src/sdk/doctor/collect-checks.ts @@ -3,7 +3,7 @@ import type { ProviderService } from "../../cli/service-registry.js"; import type { ConfiguredServiceMetadata } from "../../services/config.js"; import { systemChecks } from "./checks/system.js"; import { authChecks } from "./checks/auth.js"; -import { binaryCheck, configProbeCheck, modelConfiguredCheck } from "./checks/agent.js"; +import { binaryCheck, configProbeCheck, serviceConfiguredCheck } from "./checks/agent.js"; export function collectChecks( providers: ProviderService[], @@ -32,7 +32,7 @@ export function collectChecks( } if (provider.configurePrompts?.model) { - checks.push(modelConfiguredCheck(category, provider.name)); + checks.push(serviceConfiguredCheck(category, provider.name)); } } diff --git a/src/sdk/doctor/run.test.ts b/src/sdk/doctor/run.test.ts index e60bf37c..c6b9798f 100644 --- a/src/sdk/doctor/run.test.ts +++ b/src/sdk/doctor/run.test.ts @@ -15,7 +15,7 @@ function createCheck( }; } -const stubContext = {} as DoctorContext; +const stubContext = { previousResults: new Map() } as DoctorContext; describe("runChecks", () => { it("runs all checks and returns results", async () => { From ed392929f071b98dda1567c82316e22ea8e0819f Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Sun, 1 Mar 2026 16:05:17 -0800 Subject: [PATCH 3/5] feat(doctor): add MCP configuration checks Add MCP config validation derived from agent-mcp-config package: - mcpConfigValidCheck: validates MCP config file exists and parses - mcpCommandExistsCheck: verifies MCP server command binary on PATH - Automatically discovers MCP config paths per agent - Export AgentMcpConfig type and resolveConfigPath from agent-mcp-config --- packages/agent-mcp-config/src/index.ts | 4 +- src/cli/commands/doctor.ts | 8 +- src/sdk/doctor/checks/mcp.test.ts | 119 +++++++++++++++++++++++++ src/sdk/doctor/checks/mcp.ts | 90 +++++++++++++++++++ src/sdk/doctor/collect-checks.test.ts | 44 +++++++++ src/sdk/doctor/collect-checks.ts | 72 ++++++++++++--- src/sdk/doctor/index.ts | 2 +- 7 files changed, 323 insertions(+), 16 deletions(-) create mode 100644 src/sdk/doctor/checks/mcp.test.ts create mode 100644 src/sdk/doctor/checks/mcp.ts diff --git a/packages/agent-mcp-config/src/index.ts b/packages/agent-mcp-config/src/index.ts index 85f506a6..3697829f 100644 --- a/packages/agent-mcp-config/src/index.ts +++ b/packages/agent-mcp-config/src/index.ts @@ -6,10 +6,12 @@ export type { ApplyOptions } from "./types.js"; +export type { AgentMcpConfig } from "./configs.js"; export { supportedAgents, isSupported, - resolveAgentSupport + resolveAgentSupport, + resolveConfigPath } from "./configs.js"; export { diff --git a/src/cli/commands/doctor.ts b/src/cli/commands/doctor.ts index 07039361..67c006da 100644 --- a/src/cli/commands/doctor.ts +++ b/src/cli/commands/doctor.ts @@ -56,7 +56,10 @@ export async function executeDoctor( }); const providers = container.registry.list(); - const checks = collectChecks(providers, configuredServices, agentArg); + const checks = collectChecks(providers, configuredServices, agentArg, { + homeDir: container.env.homeDir, + platform: container.env.platform + }); const result = await runChecks(checks, { fs: container.fs, @@ -99,6 +102,9 @@ function formatCategory(category: string): string { if (category.startsWith("agent:")) { return `Agent: ${category.slice("agent:".length)}`; } + if (category.startsWith("mcp:")) { + return `MCP: ${category.slice("mcp:".length)}`; + } return category; } diff --git a/src/sdk/doctor/checks/mcp.test.ts b/src/sdk/doctor/checks/mcp.test.ts new file mode 100644 index 00000000..8f41f3ce --- /dev/null +++ b/src/sdk/doctor/checks/mcp.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createHomeFs } from "../../../../tests/test-helpers.js"; +import type { FileSystem } from "../../../utils/file-system.js"; +import type { DoctorContext } from "../types.js"; +import { mcpConfigValidCheck, mcpCommandExistsCheck } from "./mcp.js"; + +const homeDir = "/home/test"; + +function createContext( + fs: FileSystem, + overrides: { + commandRunner?: (...args: any[]) => Promise; + } = {} +): DoctorContext { + return { + fs, + env: { + cwd: "/repo", + homeDir, + platform: "darwin", + configPath: homeDir + "/.poe-code/config.json", + logDir: homeDir + "/.poe-code/logs", + poeApiBaseUrl: "https://api.poe.com/v1", + poeBaseUrl: "https://api.poe.com", + variables: {}, + resolveHomePath: (...segments: string[]) => + [homeDir, ...segments].join("/"), + getVariable: () => undefined + }, + runCommand: + overrides.commandRunner ?? + vi.fn(async () => ({ stdout: "", stderr: "", exitCode: 0 })), + httpClient: vi.fn(), + readApiKey: vi.fn(async () => null), + verbose: false, + dryRun: false, + previousResults: new Map() + }; +} + +describe("MCP checks", () => { + let fs: FileSystem; + + beforeEach(() => { + fs = createHomeFs(homeDir); + }); + + describe("mcpConfigValidCheck", () => { + it("passes when MCP config file exists and is valid JSON", async () => { + const configPath = homeDir + "/.claude.json"; + await fs.writeFile( + configPath, + JSON.stringify({ + mcpServers: { + "poe-code": { + command: "npx", + args: ["--yes", "poe-code", "mcp", "serve"] + } + } + }) + ); + const ctx = createContext(fs); + const check = mcpConfigValidCheck("claude-code", configPath, "json", "mcpServers"); + const result = await check.run(ctx); + expect(result.status).toBe("pass"); + }); + + it("fails when MCP config file has invalid JSON", async () => { + const configPath = homeDir + "/.claude.json"; + await fs.writeFile(configPath, "{broken"); + const ctx = createContext(fs); + const check = mcpConfigValidCheck("claude-code", configPath, "json", "mcpServers"); + const result = await check.run(ctx); + expect(result.status).toBe("fail"); + }); + + it("skips when MCP config file does not exist", async () => { + const configPath = homeDir + "/.claude.json"; + const ctx = createContext(fs); + const check = mcpConfigValidCheck("claude-code", configPath, "json", "mcpServers"); + const result = await check.run(ctx); + expect(result.status).toBe("skip"); + }); + + it("warns when config exists but has no MCP servers key", async () => { + const configPath = homeDir + "/.claude.json"; + await fs.writeFile(configPath, JSON.stringify({ foo: "bar" })); + const ctx = createContext(fs); + const check = mcpConfigValidCheck("claude-code", configPath, "json", "mcpServers"); + const result = await check.run(ctx); + expect(result.status).toBe("warn"); + }); + }); + + describe("mcpCommandExistsCheck", () => { + it("passes when MCP command binary is found", async () => { + const commandRunner = vi.fn(async (cmd: string) => { + if (cmd === "which") { + return { stdout: "/usr/local/bin/npx\n", stderr: "", exitCode: 0 }; + } + return { stdout: "", stderr: "", exitCode: 1 }; + }); + const ctx = createContext(fs, { commandRunner }); + const check = mcpCommandExistsCheck("claude-code", "poe-code", "npx"); + const result = await check.run(ctx); + expect(result.status).toBe("pass"); + }); + + it("warns when MCP command binary is not found", async () => { + const commandRunner = vi.fn( + async () => ({ stdout: "", stderr: "", exitCode: 1 }) + ); + const ctx = createContext(fs, { commandRunner }); + const check = mcpCommandExistsCheck("claude-code", "poe-code", "npx"); + const result = await check.run(ctx); + expect(result.status).toBe("warn"); + }); + }); +}); diff --git a/src/sdk/doctor/checks/mcp.ts b/src/sdk/doctor/checks/mcp.ts new file mode 100644 index 00000000..bb46ad2a --- /dev/null +++ b/src/sdk/doctor/checks/mcp.ts @@ -0,0 +1,90 @@ +import { isNotFound } from "@poe-code/config-mutations"; +import type { DoctorCheck, DoctorContext, CheckResult } from "../types.js"; + +export function mcpConfigValidCheck( + agentName: string, + configPath: string, + format: "json" | "toml", + configKey: string +): DoctorCheck { + return { + id: `mcp.${agentName}.config-valid`, + category: `mcp:${agentName}`, + description: `${agentName} MCP config valid`, + async run(ctx: DoctorContext): Promise { + try { + const raw = await ctx.fs.readFile(configPath, "utf8"); + if (format === "json") { + const parsed = JSON.parse(raw); + if ( + typeof parsed !== "object" || + parsed === null || + !(configKey in parsed) + ) { + return { + status: "warn", + message: `No "${configKey}" key in ${configPath}`, + fix: `Run "poe-code mcp configure ${agentName}" to add MCP servers.` + }; + } + return { + status: "pass", + message: `${agentName} MCP config is valid`, + detail: configPath + }; + } + // TOML: just check it's non-empty and parseable text + if (raw.trim().length === 0) { + return { + status: "warn", + message: `MCP config file is empty at ${configPath}` + }; + } + return { + status: "pass", + message: `${agentName} MCP config exists`, + detail: configPath + }; + } catch (error) { + if (isNotFound(error)) { + return { + status: "skip", + message: `No MCP config file at ${configPath}` + }; + } + return { + status: "fail", + message: `${agentName} MCP config is invalid: ${(error as Error).message}`, + fix: `Check or delete ${configPath} and run "poe-code mcp configure ${agentName}".` + }; + } + } + }; +} + +export function mcpCommandExistsCheck( + agentName: string, + serverName: string, + command: string +): DoctorCheck { + return { + id: `mcp.${agentName}.${serverName}.command`, + category: `mcp:${agentName}`, + description: `${serverName} MCP command exists`, + async run(ctx: DoctorContext): Promise { + const result = await ctx.runCommand("which", [command]); + if (result.exitCode === 0) { + return { + status: "pass", + message: `${command} found`, + detail: result.stdout.trim() + }; + } + return { + status: "warn", + message: `MCP server "${serverName}" command "${command}" not found on PATH`, + fix: `Install "${command}" or update MCP config for ${agentName}.` + }; + } + }; +} diff --git a/src/sdk/doctor/collect-checks.test.ts b/src/sdk/doctor/collect-checks.test.ts index 6f7c2817..fe0e9fea 100644 --- a/src/sdk/doctor/collect-checks.test.ts +++ b/src/sdk/doctor/collect-checks.test.ts @@ -122,6 +122,50 @@ describe("collectChecks", () => { expect(agentChecks).toHaveLength(0); }); + it("adds MCP config check when options provided and agent supports MCP", () => { + const provider = createProvider({ + name: "claude-code", + isolatedEnv: { agentBinary: "claude", requiresConfig: false, env: {} } + }); + const checks = collectChecks( + [provider], + { "claude-code": { files: [] } }, + undefined, + { homeDir: "/home/test", platform: "darwin" } + ); + const ids = checks.map((c) => c.id); + expect(ids).toContain("mcp.claude-code.config-valid"); + }); + + it("skips MCP checks when options not provided", () => { + const provider = createProvider({ + name: "claude-code", + isolatedEnv: { agentBinary: "claude", requiresConfig: false, env: {} } + }); + const checks = collectChecks( + [provider], + { "claude-code": { files: [] } } + ); + const ids = checks.map((c) => c.id); + const mcpIds = ids.filter((id) => id.startsWith("mcp.")); + expect(mcpIds).toHaveLength(0); + }); + + it("skips MCP checks for agents that do not support MCP", () => { + const provider = createProvider({ + name: "poe-agent" + }); + const checks = collectChecks( + [provider], + { "poe-agent": { files: [] } }, + undefined, + { homeDir: "/home/test", platform: "darwin" } + ); + const ids = checks.map((c) => c.id); + const mcpIds = ids.filter((id) => id.startsWith("mcp.")); + expect(mcpIds).toHaveLength(0); + }); + it("filters to a single agent when agentFilter is provided", () => { const codex = createProvider({ name: "codex", diff --git a/src/sdk/doctor/collect-checks.ts b/src/sdk/doctor/collect-checks.ts index 043ebca1..fa9a44a7 100644 --- a/src/sdk/doctor/collect-checks.ts +++ b/src/sdk/doctor/collect-checks.ts @@ -1,14 +1,26 @@ +import path from "node:path"; import type { DoctorCheck } from "./types.js"; import type { ProviderService } from "../../cli/service-registry.js"; import type { ConfiguredServiceMetadata } from "../../services/config.js"; +import { + resolveAgentSupport, + resolveConfigPath as resolveMcpPackagePath +} from "@poe-code/agent-mcp-config"; import { systemChecks } from "./checks/system.js"; import { authChecks } from "./checks/auth.js"; import { binaryCheck, configProbeCheck, serviceConfiguredCheck } from "./checks/agent.js"; +import { mcpConfigValidCheck } from "./checks/mcp.js"; + +export interface CollectChecksOptions { + homeDir: string; + platform: NodeJS.Platform; +} export function collectChecks( providers: ProviderService[], configuredServices: Record, - agentFilter?: string + agentFilter?: string, + options?: CollectChecksOptions ): DoctorCheck[] { const checks: DoctorCheck[] = [...systemChecks(), ...authChecks()]; @@ -16,25 +28,59 @@ export function collectChecks( if (provider.disabled) continue; if (!(provider.name in configuredServices)) continue; if (agentFilter && provider.name !== agentFilter) continue; - if (!provider.isolatedEnv) continue; - const category = `agent:${provider.name}`; + if (provider.isolatedEnv) { + const category = `agent:${provider.name}`; + + checks.push( + binaryCheck(category, provider.name, provider.isolatedEnv.agentBinary) + ); - checks.push( - binaryCheck(category, provider.name, provider.isolatedEnv.agentBinary) - ); + if ( + provider.isolatedEnv.configProbe && + provider.isolatedEnv.requiresConfig !== false + ) { + checks.push(configProbeCheck(category, provider)); + } - if ( - provider.isolatedEnv.configProbe && - provider.isolatedEnv.requiresConfig !== false - ) { - checks.push(configProbeCheck(category, provider)); + if (provider.configurePrompts?.model) { + checks.push(serviceConfiguredCheck(category, provider.name)); + } } - if (provider.configurePrompts?.model) { - checks.push(serviceConfiguredCheck(category, provider.name)); + if (options) { + const mcpChecks = collectMcpChecks( + provider.name, + options.homeDir, + options.platform + ); + checks.push(...mcpChecks); } } return checks; } + +function collectMcpChecks( + providerName: string, + homeDir: string, + platform: NodeJS.Platform +): DoctorCheck[] { + const support = resolveAgentSupport(providerName); + if (support.status !== "supported" || !support.config) { + return []; + } + + const config = support.config; + const rawPath = resolveMcpPackagePath( + config, + platform as "darwin" | "linux" | "win32" + ); + const configPath = rawPath.startsWith("~/") + ? path.join(homeDir, rawPath.slice(2)) + : rawPath; + + return [ + mcpConfigValidCheck(providerName, configPath, config.format, config.configKey) + ]; +} diff --git a/src/sdk/doctor/index.ts b/src/sdk/doctor/index.ts index 803bf74a..d231202f 100644 --- a/src/sdk/doctor/index.ts +++ b/src/sdk/doctor/index.ts @@ -1,4 +1,4 @@ -export { collectChecks } from "./collect-checks.js"; +export { collectChecks, type CollectChecksOptions } from "./collect-checks.js"; export { runChecks } from "./run.js"; export type { DoctorCheck, From df3e1651cf7a0850bfa41fe1cfac11d99e623b96 Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Sun, 1 Mar 2026 16:06:40 -0800 Subject: [PATCH 4/5] test(e2e): add doctor command E2E tests Verify doctor command in Docker container: - System and auth checks run without configured agents - Agent checks appear after configure - Agent filtering works with positional argument - Help text displays correctly --- e2e/doctor.test.ts | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 e2e/doctor.test.ts diff --git a/e2e/doctor.test.ts b/e2e/doctor.test.ts new file mode 100644 index 00000000..5d62a72b --- /dev/null +++ b/e2e/doctor.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import { useContainer } from '@poe-code/e2e-docker-test-runner'; + +describe('doctor', () => { + const container = useContainer({ testName: 'doctor' }); + + it('runs system and auth checks before any agent is configured', async () => { + const result = await container.exec('poe-code doctor'); + expect(result).toHaveExitCode(0); + expect(result).toHaveStdout('System'); + expect(result).toHaveStdout('Authentication'); + expect(result).toHaveStdout('Summary'); + }); + + it('includes agent checks after configure', async () => { + await container.execOrThrow('poe-code configure claude-code --yes'); + + const result = await container.exec('poe-code doctor'); + expect(result).toHaveExitCode(0); + expect(result).toHaveStdout('Agent: claude-code'); + }); + + it('filters to a single agent', async () => { + await container.execOrThrow('poe-code configure claude-code --yes'); + + const result = await container.exec('poe-code doctor claude-code'); + expect(result).toHaveExitCode(0); + expect(result).toHaveStdout('claude-code'); + }); + + it('shows help text', async () => { + const result = await container.exec('poe-code doctor --help'); + expect(result).toHaveExitCode(0); + expect(result).toHaveStdout('Validate Poe configuration and connectivity'); + }); +}); From 85afe0823efce288b9b1e6d3d8b4b51b83aaeabf Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Sun, 1 Mar 2026 16:20:00 -0800 Subject: [PATCH 5/5] fix(doctor): address code review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix homeDirCheck description: "Home directory writable" → "Home directory exists" - Remove dead mcpCommandExistsCheck (not wired into collectChecks) - Add proper TOML parsing via smol-toml instead of just checking non-empty - Warn when agent argument doesn't match any known provider - Align TOML validation with JSON: check configKey presence, report parse errors --- src/cli/commands/doctor-command.test.ts | 16 +++++ src/cli/commands/doctor.ts | 8 +++ src/sdk/doctor/checks/mcp.test.ts | 52 +++++++------- src/sdk/doctor/checks/mcp.ts | 91 +++++++++++-------------- src/sdk/doctor/checks/system.test.ts | 2 +- src/sdk/doctor/checks/system.ts | 2 +- 6 files changed, 91 insertions(+), 80 deletions(-) diff --git a/src/cli/commands/doctor-command.test.ts b/src/cli/commands/doctor-command.test.ts index bce429bf..dd90b6da 100644 --- a/src/cli/commands/doctor-command.test.ts +++ b/src/cli/commands/doctor-command.test.ts @@ -124,6 +124,22 @@ describe("doctor command", () => { expect(result.summary.fail).toBeGreaterThan(0); }); + it("warns when agent argument does not match any provider", async () => { + const messages: string[] = []; + await fs.mkdir(homeDir + "/.poe-code", { recursive: true }); + await fs.writeFile(configPath, JSON.stringify({ apiKey: "sk-test" })); + const { container } = createContainer({ + logger: (msg) => messages.push(msg) + }); + vi.spyOn(container, "readApiKey").mockResolvedValue("sk-test"); + + const program = createTestProgram(); + await executeDoctor(program, container, "nonexistent-agent", {}); + + const hasWarning = messages.some((m) => m.includes("nonexistent-agent")); + expect(hasWarning).toBe(true); + }); + it("respects dry-run mode", async () => { const messages: string[] = []; await fs.mkdir(homeDir + "/.poe-code", { recursive: true }); diff --git a/src/cli/commands/doctor.ts b/src/cli/commands/doctor.ts index 67c006da..4bc56cd7 100644 --- a/src/cli/commands/doctor.ts +++ b/src/cli/commands/doctor.ts @@ -56,6 +56,14 @@ export async function executeDoctor( }); const providers = container.registry.list(); + + if (agentArg && !providers.some((p) => p.name === agentArg)) { + const names = providers.map((p) => p.name).join(", "); + resources.logger.warn( + `Unknown agent "${agentArg}". Available agents: ${names}` + ); + } + const checks = collectChecks(providers, configuredServices, agentArg, { homeDir: container.env.homeDir, platform: container.env.platform diff --git a/src/sdk/doctor/checks/mcp.test.ts b/src/sdk/doctor/checks/mcp.test.ts index 8f41f3ce..686b2796 100644 --- a/src/sdk/doctor/checks/mcp.test.ts +++ b/src/sdk/doctor/checks/mcp.test.ts @@ -2,16 +2,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { createHomeFs } from "../../../../tests/test-helpers.js"; import type { FileSystem } from "../../../utils/file-system.js"; import type { DoctorContext } from "../types.js"; -import { mcpConfigValidCheck, mcpCommandExistsCheck } from "./mcp.js"; +import { mcpConfigValidCheck } from "./mcp.js"; const homeDir = "/home/test"; -function createContext( - fs: FileSystem, - overrides: { - commandRunner?: (...args: any[]) => Promise; - } = {} -): DoctorContext { +function createContext(fs: FileSystem): DoctorContext { return { fs, env: { @@ -27,9 +22,7 @@ function createContext( [homeDir, ...segments].join("/"), getVariable: () => undefined }, - runCommand: - overrides.commandRunner ?? - vi.fn(async () => ({ stdout: "", stderr: "", exitCode: 0 })), + runCommand: vi.fn(async () => ({ stdout: "", stderr: "", exitCode: 0 })), httpClient: vi.fn(), readApiKey: vi.fn(async () => null), verbose: false, @@ -90,28 +83,33 @@ describe("MCP checks", () => { const result = await check.run(ctx); expect(result.status).toBe("warn"); }); - }); - describe("mcpCommandExistsCheck", () => { - it("passes when MCP command binary is found", async () => { - const commandRunner = vi.fn(async (cmd: string) => { - if (cmd === "which") { - return { stdout: "/usr/local/bin/npx\n", stderr: "", exitCode: 0 }; - } - return { stdout: "", stderr: "", exitCode: 1 }; - }); - const ctx = createContext(fs, { commandRunner }); - const check = mcpCommandExistsCheck("claude-code", "poe-code", "npx"); + it("passes when TOML config is valid and has config key", async () => { + const configPath = homeDir + "/config.toml"; + await fs.writeFile( + configPath, + '[mcp_servers.poe-code]\ncommand = "npx"\nargs = ["--yes", "poe-code", "mcp", "serve"]\n' + ); + const ctx = createContext(fs); + const check = mcpConfigValidCheck("codex", configPath, "toml", "mcp_servers"); const result = await check.run(ctx); expect(result.status).toBe("pass"); }); - it("warns when MCP command binary is not found", async () => { - const commandRunner = vi.fn( - async () => ({ stdout: "", stderr: "", exitCode: 1 }) - ); - const ctx = createContext(fs, { commandRunner }); - const check = mcpCommandExistsCheck("claude-code", "poe-code", "npx"); + it("fails when TOML config has invalid syntax", async () => { + const configPath = homeDir + "/config.toml"; + await fs.writeFile(configPath, "[invalid\nbroken = "); + const ctx = createContext(fs); + const check = mcpConfigValidCheck("codex", configPath, "toml", "mcp_servers"); + const result = await check.run(ctx); + expect(result.status).toBe("fail"); + }); + + it("warns when TOML config has no config key", async () => { + const configPath = homeDir + "/config.toml"; + await fs.writeFile(configPath, '[other]\nfoo = "bar"\n'); + const ctx = createContext(fs); + const check = mcpConfigValidCheck("codex", configPath, "toml", "mcp_servers"); const result = await check.run(ctx); expect(result.status).toBe("warn"); }); diff --git a/src/sdk/doctor/checks/mcp.ts b/src/sdk/doctor/checks/mcp.ts index bb46ad2a..d6202f65 100644 --- a/src/sdk/doctor/checks/mcp.ts +++ b/src/sdk/doctor/checks/mcp.ts @@ -1,3 +1,4 @@ +import { parse as parseToml } from "smol-toml"; import { isNotFound } from "@poe-code/config-mutations"; import type { DoctorCheck, DoctorContext, CheckResult } from "../types.js"; @@ -15,36 +16,9 @@ export function mcpConfigValidCheck( try { const raw = await ctx.fs.readFile(configPath, "utf8"); if (format === "json") { - const parsed = JSON.parse(raw); - if ( - typeof parsed !== "object" || - parsed === null || - !(configKey in parsed) - ) { - return { - status: "warn", - message: `No "${configKey}" key in ${configPath}`, - fix: `Run "poe-code mcp configure ${agentName}" to add MCP servers.` - }; - } - return { - status: "pass", - message: `${agentName} MCP config is valid`, - detail: configPath - }; - } - // TOML: just check it's non-empty and parseable text - if (raw.trim().length === 0) { - return { - status: "warn", - message: `MCP config file is empty at ${configPath}` - }; + return validateJson(raw, agentName, configPath, configKey); } - return { - status: "pass", - message: `${agentName} MCP config exists`, - detail: configPath - }; + return validateToml(raw, agentName, configPath, configKey); } catch (error) { if (isNotFound(error)) { return { @@ -62,29 +36,44 @@ export function mcpConfigValidCheck( }; } -export function mcpCommandExistsCheck( +function validateJson( + raw: string, agentName: string, - serverName: string, - command: string -): DoctorCheck { + configPath: string, + configKey: string +): CheckResult { + const parsed = JSON.parse(raw); + if (typeof parsed !== "object" || parsed === null || !(configKey in parsed)) { + return { + status: "warn", + message: `No "${configKey}" key in ${configPath}`, + fix: `Run "poe-code mcp configure ${agentName}" to add MCP servers.` + }; + } return { - id: `mcp.${agentName}.${serverName}.command`, - category: `mcp:${agentName}`, - description: `${serverName} MCP command exists`, - async run(ctx: DoctorContext): Promise { - const result = await ctx.runCommand("which", [command]); - if (result.exitCode === 0) { - return { - status: "pass", - message: `${command} found`, - detail: result.stdout.trim() - }; - } - return { - status: "warn", - message: `MCP server "${serverName}" command "${command}" not found on PATH`, - fix: `Install "${command}" or update MCP config for ${agentName}.` - }; - } + status: "pass", + message: `${agentName} MCP config is valid`, + detail: configPath + }; +} + +function validateToml( + raw: string, + agentName: string, + configPath: string, + configKey: string +): CheckResult { + const parsed = parseToml(raw); + if (!(configKey in parsed)) { + return { + status: "warn", + message: `No "${configKey}" key in ${configPath}`, + fix: `Run "poe-code mcp configure ${agentName}" to add MCP servers.` + }; + } + return { + status: "pass", + message: `${agentName} MCP config is valid`, + detail: configPath }; } diff --git a/src/sdk/doctor/checks/system.test.ts b/src/sdk/doctor/checks/system.test.ts index d78aee27..16d8d9da 100644 --- a/src/sdk/doctor/checks/system.test.ts +++ b/src/sdk/doctor/checks/system.test.ts @@ -41,7 +41,7 @@ describe("system checks", () => { }); describe("system.home-dir", () => { - it("passes when .poe-code directory exists and is writable", async () => { + it("passes when .poe-code directory exists", async () => { await fs.mkdir(poeCodeDir, { recursive: true }); const ctx = createContext(fs); const checks = systemChecks(); diff --git a/src/sdk/doctor/checks/system.ts b/src/sdk/doctor/checks/system.ts index f0b00744..3a17bf2a 100644 --- a/src/sdk/doctor/checks/system.ts +++ b/src/sdk/doctor/checks/system.ts @@ -6,7 +6,7 @@ function homeDirCheck(): DoctorCheck { return { id: "system.home-dir", category: "system", - description: "Home directory writable", + description: "Home directory exists", async run(ctx: DoctorContext): Promise { const poeCodeDir = path.join(ctx.env.homeDir, ".poe-code"); try {