Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions e2e/doctor.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
4 changes: 3 additions & 1 deletion packages/agent-mcp-config/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
158 changes: 158 additions & 0 deletions src/cli/commands/doctor-command.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
139 changes: 139 additions & 0 deletions src/cli/commands/doctor.ts
Original file line number Diff line number Diff line change
@@ -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<string, never>;

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<DoctorResult> {
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}`);
}
7 changes: 7 additions & 0 deletions src/cli/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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;
Expand Down
8 changes: 8 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
const [{ createProgram }, { createCliMain }] = await Promise.all([
Expand Down
Loading