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'); + }); +}); 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-command.test.ts b/src/cli/commands/doctor-command.test.ts new file mode 100644 index 00000000..dd90b6da --- /dev/null +++ b/src/cli/commands/doctor-command.test.ts @@ -0,0 +1,158 @@ +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("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 }); + 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..4bc56cd7 --- /dev/null +++ b/src/cli/commands/doctor.ts @@ -0,0 +1,139 @@ +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"; +import type { ScopedLogger } from "../logger.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(); + + 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 + }); + + 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)}`; + } + if (category.startsWith("mcp:")) { + return `MCP: ${category.slice("mcp:".length)}`; + } + return category; +} + +function logCheckResult( + logger: ScopedLogger, + 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..04a58dda --- /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, + serviceConfiguredCheck +} 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("serviceConfiguredCheck", () => { + 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 = serviceConfiguredCheck("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 = serviceConfiguredCheck("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 = 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 new file mode 100644 index 00000000..dbd12cbc --- /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 serviceConfiguredCheck( + category: string, + providerName: string +): DoctorCheck { + return { + id: `agent.${providerName}.configured`, + category, + description: `${providerName} 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/mcp.test.ts b/src/sdk/doctor/checks/mcp.test.ts new file mode 100644 index 00000000..686b2796 --- /dev/null +++ b/src/sdk/doctor/checks/mcp.test.ts @@ -0,0 +1,117 @@ +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 } from "./mcp.js"; + +const homeDir = "/home/test"; + +function createContext(fs: FileSystem): 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: 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"); + }); + + 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("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 new file mode 100644 index 00000000..d6202f65 --- /dev/null +++ b/src/sdk/doctor/checks/mcp.ts @@ -0,0 +1,79 @@ +import { parse as parseToml } from "smol-toml"; +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") { + return validateJson(raw, agentName, configPath, configKey); + } + return validateToml(raw, agentName, configPath, configKey); + } 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}".` + }; + } + } + }; +} + +function validateJson( + raw: string, + agentName: string, + 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 { + 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 new file mode 100644 index 00000000..16d8d9da --- /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", 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..3a17bf2a --- /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 exists", + 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..fe0e9fea --- /dev/null +++ b/src/sdk/doctor/collect-checks.test.ts @@ -0,0 +1,190 @@ +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 configured 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.configured"); + }); + + 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("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", + 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..fa9a44a7 --- /dev/null +++ b/src/sdk/doctor/collect-checks.ts @@ -0,0 +1,86 @@ +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, + options?: CollectChecksOptions +): 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) { + 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(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 new file mode 100644 index 00000000..d231202f --- /dev/null +++ b/src/sdk/doctor/index.ts @@ -0,0 +1,9 @@ +export { collectChecks, type CollectChecksOptions } 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..c6b9798f --- /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 = { previousResults: new Map() } 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; +}