From 2ba148be12aeab5dc565172f9792f0ac7962bae4 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 13 Feb 2026 17:29:38 +0900 Subject: [PATCH] refactor(doctor): redesign with 3-tier output and consolidated checks Consolidate 16 separate checks into 5 (system, config, providers, tools, models). Add 3-tier formatting: default (problems-only), --status (dashboard), --verbose (deep diagnostics). Read actual loaded plugin version from opencode cache directory. Check environment variables for provider authentication. --- src/cli/cli-program.ts | 22 +- src/cli/doctor/checks/auth.test.ts | 114 ------ src/cli/doctor/checks/auth.ts | 114 ------ src/cli/doctor/checks/config.test.ts | 110 +----- src/cli/doctor/checks/config.ts | 202 ++++++----- src/cli/doctor/checks/dependencies.test.ts | 137 +------- src/cli/doctor/checks/dependencies.ts | 61 +--- src/cli/doctor/checks/gh.test.ts | 151 -------- src/cli/doctor/checks/gh.ts | 172 --------- src/cli/doctor/checks/index.ts | 72 ++-- src/cli/doctor/checks/lsp.test.ts | 134 ------- src/cli/doctor/checks/lsp.ts | 77 ---- src/cli/doctor/checks/mcp-oauth.test.ts | 133 ------- src/cli/doctor/checks/mcp-oauth.ts | 80 ----- src/cli/doctor/checks/mcp.test.ts | 115 ------ src/cli/doctor/checks/mcp.ts | 128 ------- .../doctor/checks/model-resolution.test.ts | 12 - src/cli/doctor/checks/model-resolution.ts | 94 +++-- src/cli/doctor/checks/opencode.test.ts | 331 ------------------ src/cli/doctor/checks/opencode.ts | 227 ------------ src/cli/doctor/checks/plugin.test.ts | 109 ------ src/cli/doctor/checks/plugin.ts | 127 ------- src/cli/doctor/checks/providers.ts | 101 ++++++ src/cli/doctor/checks/system-binary.ts | 144 ++++++++ .../doctor/checks/system-loaded-version.ts | 79 +++++ src/cli/doctor/checks/system-plugin.ts | 95 +++++ src/cli/doctor/checks/system.ts | 129 +++++++ src/cli/doctor/checks/tools-gh.ts | 105 ++++++ src/cli/doctor/checks/tools-lsp.ts | 25 ++ src/cli/doctor/checks/tools-mcp.ts | 62 ++++ src/cli/doctor/checks/tools.ts | 118 +++++++ src/cli/doctor/checks/version.test.ts | 148 -------- src/cli/doctor/checks/version.ts | 135 ------- src/cli/doctor/constants.ts | 59 ++-- src/cli/doctor/format-default.test.ts | 83 +++++ src/cli/doctor/format-default.ts | 35 ++ src/cli/doctor/format-shared.ts | 49 +++ src/cli/doctor/format-status.ts | 41 +++ src/cli/doctor/format-verbose.ts | 89 +++++ src/cli/doctor/formatter.test.ts | 295 ++++++---------- src/cli/doctor/formatter.ts | 145 +------- src/cli/doctor/index.ts | 4 +- src/cli/doctor/runner.test.ts | 304 ++++++++++------ src/cli/doctor/runner.ts | 97 +---- src/cli/doctor/types.ts | 72 +++- 45 files changed, 1808 insertions(+), 3328 deletions(-) delete mode 100644 src/cli/doctor/checks/auth.test.ts delete mode 100644 src/cli/doctor/checks/auth.ts delete mode 100644 src/cli/doctor/checks/gh.test.ts delete mode 100644 src/cli/doctor/checks/gh.ts delete mode 100644 src/cli/doctor/checks/lsp.test.ts delete mode 100644 src/cli/doctor/checks/lsp.ts delete mode 100644 src/cli/doctor/checks/mcp-oauth.test.ts delete mode 100644 src/cli/doctor/checks/mcp-oauth.ts delete mode 100644 src/cli/doctor/checks/mcp.test.ts delete mode 100644 src/cli/doctor/checks/mcp.ts delete mode 100644 src/cli/doctor/checks/opencode.test.ts delete mode 100644 src/cli/doctor/checks/opencode.ts delete mode 100644 src/cli/doctor/checks/plugin.test.ts delete mode 100644 src/cli/doctor/checks/plugin.ts create mode 100644 src/cli/doctor/checks/providers.ts create mode 100644 src/cli/doctor/checks/system-binary.ts create mode 100644 src/cli/doctor/checks/system-loaded-version.ts create mode 100644 src/cli/doctor/checks/system-plugin.ts create mode 100644 src/cli/doctor/checks/system.ts create mode 100644 src/cli/doctor/checks/tools-gh.ts create mode 100644 src/cli/doctor/checks/tools-lsp.ts create mode 100644 src/cli/doctor/checks/tools-mcp.ts create mode 100644 src/cli/doctor/checks/tools.ts delete mode 100644 src/cli/doctor/checks/version.test.ts delete mode 100644 src/cli/doctor/checks/version.ts create mode 100644 src/cli/doctor/format-default.test.ts create mode 100644 src/cli/doctor/format-default.ts create mode 100644 src/cli/doctor/format-shared.ts create mode 100644 src/cli/doctor/format-status.ts create mode 100644 src/cli/doctor/format-verbose.ts diff --git a/src/cli/cli-program.ts b/src/cli/cli-program.ts index 8cc80be0..846564fe 100644 --- a/src/cli/cli-program.ts +++ b/src/cli/cli-program.ts @@ -149,29 +149,21 @@ This command shows: program .command("doctor") .description("Check oh-my-opencode installation health and diagnose issues") + .option("--status", "Show compact system dashboard") .option("--verbose", "Show detailed diagnostic information") .option("--json", "Output results in JSON format") - .option("--category ", "Run only specific category") .addHelpText("after", ` Examples: - $ bunx oh-my-opencode doctor - $ bunx oh-my-opencode doctor --verbose - $ bunx oh-my-opencode doctor --json - $ bunx oh-my-opencode doctor --category authentication - -Categories: - installation Check OpenCode and plugin installation - configuration Validate configuration files - authentication Check auth provider status - dependencies Check external dependencies - tools Check LSP and MCP servers - updates Check for version updates + $ bunx oh-my-opencode doctor # Show problems only + $ bunx oh-my-opencode doctor --status # Compact dashboard + $ bunx oh-my-opencode doctor --verbose # Deep diagnostics + $ bunx oh-my-opencode doctor --json # JSON output `) .action(async (options) => { + const mode = options.status ? "status" : options.verbose ? "verbose" : "default" const doctorOptions: DoctorOptions = { - verbose: options.verbose ?? false, + mode, json: options.json ?? false, - category: options.category, } const exitCode = await doctor(doctorOptions) process.exit(exitCode) diff --git a/src/cli/doctor/checks/auth.test.ts b/src/cli/doctor/checks/auth.test.ts deleted file mode 100644 index 4d5f3bb3..00000000 --- a/src/cli/doctor/checks/auth.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { describe, it, expect, spyOn, afterEach } from "bun:test" -import * as auth from "./auth" - -describe("auth check", () => { - describe("getAuthProviderInfo", () => { - it("returns anthropic as always available", () => { - // given anthropic provider - // when getting info - const info = auth.getAuthProviderInfo("anthropic") - - // then should show plugin installed (builtin) - expect(info.id).toBe("anthropic") - expect(info.pluginInstalled).toBe(true) - }) - - it("returns correct name for each provider", () => { - // given each provider - // when getting info - // then should have correct names - expect(auth.getAuthProviderInfo("anthropic").name).toContain("Claude") - expect(auth.getAuthProviderInfo("openai").name).toContain("ChatGPT") - expect(auth.getAuthProviderInfo("google").name).toContain("Gemini") - }) - }) - - describe("checkAuthProvider", () => { - let getInfoSpy: ReturnType - - afterEach(() => { - getInfoSpy?.mockRestore() - }) - - it("returns pass when plugin installed", async () => { - // given plugin installed - getInfoSpy = spyOn(auth, "getAuthProviderInfo").mockReturnValue({ - id: "anthropic", - name: "Anthropic (Claude)", - pluginInstalled: true, - configured: true, - }) - - // when checking - const result = await auth.checkAuthProvider("anthropic") - - // then should pass - expect(result.status).toBe("pass") - }) - - it("returns skip when plugin not installed", async () => { - // given plugin not installed - getInfoSpy = spyOn(auth, "getAuthProviderInfo").mockReturnValue({ - id: "openai", - name: "OpenAI (ChatGPT)", - pluginInstalled: false, - configured: false, - }) - - // when checking - const result = await auth.checkAuthProvider("openai") - - // then should skip - expect(result.status).toBe("skip") - expect(result.message).toContain("not installed") - }) - }) - - describe("checkAnthropicAuth", () => { - it("returns a check result", async () => { - // given - // when checking anthropic - const result = await auth.checkAnthropicAuth() - - // then should return valid result - expect(result.name).toBeDefined() - expect(["pass", "fail", "warn", "skip"]).toContain(result.status) - }) - }) - - describe("checkOpenAIAuth", () => { - it("returns a check result", async () => { - // given - // when checking openai - const result = await auth.checkOpenAIAuth() - - // then should return valid result - expect(result.name).toBeDefined() - expect(["pass", "fail", "warn", "skip"]).toContain(result.status) - }) - }) - - describe("checkGoogleAuth", () => { - it("returns a check result", async () => { - // given - // when checking google - const result = await auth.checkGoogleAuth() - - // then should return valid result - expect(result.name).toBeDefined() - expect(["pass", "fail", "warn", "skip"]).toContain(result.status) - }) - }) - - describe("getAuthCheckDefinitions", () => { - it("returns definitions for all three providers", () => { - // given - // when getting definitions - const defs = auth.getAuthCheckDefinitions() - - // then should have 3 definitions - expect(defs.length).toBe(3) - expect(defs.every((d) => d.category === "authentication")).toBe(true) - }) - }) -}) diff --git a/src/cli/doctor/checks/auth.ts b/src/cli/doctor/checks/auth.ts deleted file mode 100644 index 00688bdc..00000000 --- a/src/cli/doctor/checks/auth.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { existsSync, readFileSync } from "node:fs" -import { join } from "node:path" -import type { CheckResult, CheckDefinition, AuthProviderInfo, AuthProviderId } from "../types" -import { CHECK_IDS, CHECK_NAMES } from "../constants" -import { parseJsonc, getOpenCodeConfigDir } from "../../../shared" - -const OPENCODE_CONFIG_DIR = getOpenCodeConfigDir({ binary: "opencode" }) -const OPENCODE_JSON = join(OPENCODE_CONFIG_DIR, "opencode.json") -const OPENCODE_JSONC = join(OPENCODE_CONFIG_DIR, "opencode.jsonc") - -const AUTH_PLUGINS: Record = { - anthropic: { plugin: "builtin", name: "Anthropic (Claude)" }, - openai: { plugin: "opencode-openai-codex-auth", name: "OpenAI (ChatGPT)" }, - google: { plugin: "opencode-antigravity-auth", name: "Google (Gemini)" }, -} - -function getOpenCodeConfig(): { plugin?: string[] } | null { - const configPath = existsSync(OPENCODE_JSONC) ? OPENCODE_JSONC : OPENCODE_JSON - if (!existsSync(configPath)) return null - - try { - const content = readFileSync(configPath, "utf-8") - return parseJsonc<{ plugin?: string[] }>(content) - } catch { - return null - } -} - -function isPluginInstalled(plugins: string[], pluginName: string): boolean { - if (pluginName === "builtin") return true - return plugins.some((p) => p === pluginName || p.startsWith(`${pluginName}@`)) -} - -export function getAuthProviderInfo(providerId: AuthProviderId): AuthProviderInfo { - const config = getOpenCodeConfig() - const plugins = config?.plugin ?? [] - const authConfig = AUTH_PLUGINS[providerId] - - const pluginInstalled = isPluginInstalled(plugins, authConfig.plugin) - - return { - id: providerId, - name: authConfig.name, - pluginInstalled, - configured: pluginInstalled, - } -} - -export async function checkAuthProvider(providerId: AuthProviderId): Promise { - const info = getAuthProviderInfo(providerId) - const checkId = `auth-${providerId}` as keyof typeof CHECK_NAMES - const checkName = CHECK_NAMES[checkId] || info.name - - if (!info.pluginInstalled) { - return { - name: checkName, - status: "skip", - message: "Auth plugin not installed", - details: [ - `Plugin: ${AUTH_PLUGINS[providerId].plugin}`, - "Run: bunx oh-my-opencode install", - ], - } - } - - return { - name: checkName, - status: "pass", - message: "Auth plugin available", - details: [ - providerId === "anthropic" - ? "Run: opencode auth login (select Anthropic)" - : `Plugin: ${AUTH_PLUGINS[providerId].plugin}`, - ], - } -} - -export async function checkAnthropicAuth(): Promise { - return checkAuthProvider("anthropic") -} - -export async function checkOpenAIAuth(): Promise { - return checkAuthProvider("openai") -} - -export async function checkGoogleAuth(): Promise { - return checkAuthProvider("google") -} - -export function getAuthCheckDefinitions(): CheckDefinition[] { - return [ - { - id: CHECK_IDS.AUTH_ANTHROPIC, - name: CHECK_NAMES[CHECK_IDS.AUTH_ANTHROPIC], - category: "authentication", - check: checkAnthropicAuth, - critical: false, - }, - { - id: CHECK_IDS.AUTH_OPENAI, - name: CHECK_NAMES[CHECK_IDS.AUTH_OPENAI], - category: "authentication", - check: checkOpenAIAuth, - critical: false, - }, - { - id: CHECK_IDS.AUTH_GOOGLE, - name: CHECK_NAMES[CHECK_IDS.AUTH_GOOGLE], - category: "authentication", - check: checkGoogleAuth, - critical: false, - }, - ] -} diff --git a/src/cli/doctor/checks/config.test.ts b/src/cli/doctor/checks/config.test.ts index 6ece3a56..8289329f 100644 --- a/src/cli/doctor/checks/config.test.ts +++ b/src/cli/doctor/checks/config.test.ts @@ -1,103 +1,27 @@ -import { describe, it, expect, spyOn, afterEach } from "bun:test" +import { describe, it, expect } from "bun:test" import * as config from "./config" describe("config check", () => { - describe("validateConfig", () => { - it("returns valid: false for non-existent file", () => { - // given non-existent file path - // when validating - const result = config.validateConfig("/non/existent/path.json") + describe("checkConfig", () => { + it("returns a valid CheckResult", async () => { + //#given config check is available + //#when running the consolidated config check + const result = await config.checkConfig() - // then should indicate invalid - expect(result.valid).toBe(false) - expect(result.errors.length).toBeGreaterThan(0) - }) - }) - - describe("getConfigInfo", () => { - it("returns exists: false when no config found", () => { - // given no config file exists - // when getting config info - const info = config.getConfigInfo() - - // then should handle gracefully - expect(typeof info.exists).toBe("boolean") - expect(typeof info.valid).toBe("boolean") - }) - }) - - describe("checkConfigValidity", () => { - let getInfoSpy: ReturnType - - afterEach(() => { - getInfoSpy?.mockRestore() + //#then should return a properly shaped CheckResult + expect(result.name).toBe("Configuration") + expect(["pass", "fail", "warn", "skip"]).toContain(result.status) + expect(typeof result.message).toBe("string") + expect(Array.isArray(result.issues)).toBe(true) }) - it("returns pass when no config exists (uses defaults)", async () => { - // given no config file - getInfoSpy = spyOn(config, "getConfigInfo").mockReturnValue({ - exists: false, - path: null, - format: null, - valid: true, - errors: [], - }) + it("includes issues array even when config is valid", async () => { + //#given a normal environment + //#when running config check + const result = await config.checkConfig() - // when checking validity - const result = await config.checkConfigValidity() - - // then should pass with default message - expect(result.status).toBe("pass") - expect(result.message).toContain("default") - }) - - it("returns pass when config is valid", async () => { - // given valid config - getInfoSpy = spyOn(config, "getConfigInfo").mockReturnValue({ - exists: true, - path: "/home/user/.config/opencode/oh-my-opencode.json", - format: "json", - valid: true, - errors: [], - }) - - // when checking validity - const result = await config.checkConfigValidity() - - // then should pass - expect(result.status).toBe("pass") - expect(result.message).toContain("JSON") - }) - - it("returns fail when config has validation errors", async () => { - // given invalid config - getInfoSpy = spyOn(config, "getConfigInfo").mockReturnValue({ - exists: true, - path: "/home/user/.config/opencode/oh-my-opencode.json", - format: "json", - valid: false, - errors: ["agents.oracle: Invalid model format"], - }) - - // when checking validity - const result = await config.checkConfigValidity() - - // then should fail with errors - expect(result.status).toBe("fail") - expect(result.details?.some((d) => d.includes("Error"))).toBe(true) - }) - }) - - describe("getConfigCheckDefinition", () => { - it("returns valid check definition", () => { - // given - // when getting definition - const def = config.getConfigCheckDefinition() - - // then should have required properties - expect(def.id).toBe("config-validation") - expect(def.category).toBe("configuration") - expect(def.critical).toBe(false) + //#then issues should be an array (possibly empty) + expect(Array.isArray(result.issues)).toBe(true) }) }) }) diff --git a/src/cli/doctor/checks/config.ts b/src/cli/doctor/checks/config.ts index c2adc670..4f8fc549 100644 --- a/src/cli/doctor/checks/config.ts +++ b/src/cli/doctor/checks/config.ts @@ -1,122 +1,164 @@ -import { existsSync, readFileSync } from "node:fs" +import { readFileSync } from "node:fs" import { join } from "node:path" -import type { CheckResult, CheckDefinition, ConfigInfo } from "../types" -import { CHECK_IDS, CHECK_NAMES, PACKAGE_NAME } from "../constants" -import { parseJsonc, detectConfigFile, getOpenCodeConfigDir } from "../../../shared" -import { OhMyOpenCodeConfigSchema } from "../../../config" -const USER_CONFIG_DIR = getOpenCodeConfigDir({ binary: "opencode" }) -const USER_CONFIG_BASE = join(USER_CONFIG_DIR, `${PACKAGE_NAME}`) +import { OhMyOpenCodeConfigSchema } from "../../../config" +import { detectConfigFile, getOpenCodeConfigDir, parseJsonc } from "../../../shared" +import { CHECK_IDS, CHECK_NAMES, PACKAGE_NAME } from "../constants" +import type { CheckResult, DoctorIssue } from "../types" +import { loadAvailableModelsFromCache } from "./model-resolution-cache" +import { getModelResolutionInfoWithOverrides } from "./model-resolution" +import type { OmoConfig } from "./model-resolution-types" + +const USER_CONFIG_BASE = join(getOpenCodeConfigDir({ binary: "opencode" }), PACKAGE_NAME) const PROJECT_CONFIG_BASE = join(process.cwd(), ".opencode", PACKAGE_NAME) -function findConfigPath(): { path: string; format: "json" | "jsonc" } | null { - const projectDetected = detectConfigFile(PROJECT_CONFIG_BASE) - if (projectDetected.format !== "none") { - return { path: projectDetected.path, format: projectDetected.format as "json" | "jsonc" } - } +interface ConfigValidationResult { + exists: boolean + path: string | null + valid: boolean + config: OmoConfig | null + errors: string[] +} - const userDetected = detectConfigFile(USER_CONFIG_BASE) - if (userDetected.format !== "none") { - return { path: userDetected.path, format: userDetected.format as "json" | "jsonc" } - } +function findConfigPath(): string | null { + const projectConfig = detectConfigFile(PROJECT_CONFIG_BASE) + if (projectConfig.format !== "none") return projectConfig.path + + const userConfig = detectConfigFile(USER_CONFIG_BASE) + if (userConfig.format !== "none") return userConfig.path return null } -export function validateConfig(configPath: string): { valid: boolean; errors: string[] } { +function validateConfig(): ConfigValidationResult { + const configPath = findConfigPath() + if (!configPath) { + return { exists: false, path: null, valid: true, config: null, errors: [] } + } + try { const content = readFileSync(configPath, "utf-8") - const rawConfig = parseJsonc>(content) - const result = OhMyOpenCodeConfigSchema.safeParse(rawConfig) + const rawConfig = parseJsonc(content) + const schemaResult = OhMyOpenCodeConfigSchema.safeParse(rawConfig) - if (!result.success) { - const errors = result.error.issues.map( - (i) => `${i.path.join(".")}: ${i.message}` - ) - return { valid: false, errors } + if (!schemaResult.success) { + return { + exists: true, + path: configPath, + valid: false, + config: rawConfig, + errors: schemaResult.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`), + } } - return { valid: true, errors: [] } - } catch (err) { + return { exists: true, path: configPath, valid: true, config: rawConfig, errors: [] } + } catch (error) { return { + exists: true, + path: configPath, valid: false, - errors: [err instanceof Error ? err.message : "Failed to parse config"], + config: null, + errors: [error instanceof Error ? error.message : "Failed to parse config"], } } } -export function getConfigInfo(): ConfigInfo { - const configPath = findConfigPath() +function collectModelResolutionIssues(config: OmoConfig): DoctorIssue[] { + const issues: DoctorIssue[] = [] + const availableModels = loadAvailableModelsFromCache() + const resolution = getModelResolutionInfoWithOverrides(config) - if (!configPath) { - return { - exists: false, - path: null, - format: null, - valid: true, - errors: [], + const invalidAgentOverrides = resolution.agents.filter( + (agent) => agent.userOverride && !agent.userOverride.includes("/") + ) + const invalidCategoryOverrides = resolution.categories.filter( + (category) => category.userOverride && !category.userOverride.includes("/") + ) + + for (const invalidAgent of invalidAgentOverrides) { + issues.push({ + title: `Invalid agent override: ${invalidAgent.name}`, + description: `Override '${invalidAgent.userOverride}' must be in provider/model format.`, + severity: "warning", + affects: [invalidAgent.name], + }) + } + + for (const invalidCategory of invalidCategoryOverrides) { + issues.push({ + title: `Invalid category override: ${invalidCategory.name}`, + description: `Override '${invalidCategory.userOverride}' must be in provider/model format.`, + severity: "warning", + affects: [invalidCategory.name], + }) + } + + if (availableModels.cacheExists) { + const providerSet = new Set(availableModels.providers) + const unknownProviders = [ + ...resolution.agents.map((agent) => agent.userOverride), + ...resolution.categories.map((category) => category.userOverride), + ] + .filter((value): value is string => Boolean(value)) + .map((value) => value.split("/")[0]) + .filter((provider) => provider.length > 0 && !providerSet.has(provider)) + + if (unknownProviders.length > 0) { + const uniqueProviders = [...new Set(unknownProviders)] + issues.push({ + title: "Model override uses unavailable provider", + description: `Provider(s) not found in OpenCode model cache: ${uniqueProviders.join(", ")}`, + severity: "warning", + affects: ["model resolution"], + }) } } - if (!existsSync(configPath.path)) { - return { - exists: false, - path: configPath.path, - format: configPath.format, - valid: true, - errors: [], - } - } - - const validation = validateConfig(configPath.path) - - return { - exists: true, - path: configPath.path, - format: configPath.format, - valid: validation.valid, - errors: validation.errors, - } + return issues } -export async function checkConfigValidity(): Promise { - const info = getConfigInfo() +export async function checkConfig(): Promise { + const validation = validateConfig() + const issues: DoctorIssue[] = [] - if (!info.exists) { + if (!validation.exists) { return { - name: CHECK_NAMES[CHECK_IDS.CONFIG_VALIDATION], + name: CHECK_NAMES[CHECK_IDS.CONFIG], status: "pass", - message: "Using default configuration", - details: ["No custom config file found (optional)"], + message: "No custom config found; defaults are used", + details: undefined, + issues, } } - if (!info.valid) { + if (!validation.valid) { + issues.push( + ...validation.errors.map((error) => ({ + title: "Invalid configuration", + description: error, + severity: "error" as const, + affects: ["plugin startup"], + })) + ) + return { - name: CHECK_NAMES[CHECK_IDS.CONFIG_VALIDATION], + name: CHECK_NAMES[CHECK_IDS.CONFIG], status: "fail", - message: "Configuration has validation errors", - details: [ - `Path: ${info.path}`, - ...info.errors.map((e) => `Error: ${e}`), - ], + message: `Configuration invalid (${issues.length} issue${issues.length > 1 ? "s" : ""})`, + details: validation.path ? [`Path: ${validation.path}`] : undefined, + issues, } } - return { - name: CHECK_NAMES[CHECK_IDS.CONFIG_VALIDATION], - status: "pass", - message: `Valid ${info.format?.toUpperCase()} config`, - details: [`Path: ${info.path}`], + if (validation.config) { + issues.push(...collectModelResolutionIssues(validation.config)) } -} -export function getConfigCheckDefinition(): CheckDefinition { return { - id: CHECK_IDS.CONFIG_VALIDATION, - name: CHECK_NAMES[CHECK_IDS.CONFIG_VALIDATION], - category: "configuration", - check: checkConfigValidity, - critical: false, + name: CHECK_NAMES[CHECK_IDS.CONFIG], + status: issues.length > 0 ? "warn" : "pass", + message: issues.length > 0 ? `${issues.length} configuration warning(s)` : "Configuration is valid", + details: validation.path ? [`Path: ${validation.path}`] : undefined, + issues, } } diff --git a/src/cli/doctor/checks/dependencies.test.ts b/src/cli/doctor/checks/dependencies.test.ts index 284eed9c..3fd37163 100644 --- a/src/cli/doctor/checks/dependencies.test.ts +++ b/src/cli/doctor/checks/dependencies.test.ts @@ -1,27 +1,29 @@ -import { describe, it, expect, spyOn, afterEach } from "bun:test" +import { describe, it, expect } from "bun:test" import * as deps from "./dependencies" describe("dependencies check", () => { describe("checkAstGrepCli", () => { - it("returns dependency info", async () => { - // given - // when checking ast-grep cli + it("returns valid dependency info", async () => { + //#given ast-grep cli check + //#when checking const info = await deps.checkAstGrepCli() - // then should return valid info + //#then should return valid DependencyInfo expect(info.name).toBe("AST-Grep CLI") expect(info.required).toBe(false) expect(typeof info.installed).toBe("boolean") + expect(typeof info.version === "string" || info.version === null).toBe(true) + expect(typeof info.path === "string" || info.path === null).toBe(true) }) }) describe("checkAstGrepNapi", () => { - it("returns dependency info", async () => { - // given - // when checking ast-grep napi + it("returns valid dependency info", async () => { + //#given ast-grep napi check + //#when checking const info = await deps.checkAstGrepNapi() - // then should return valid info + //#then should return valid DependencyInfo expect(info.name).toBe("AST-Grep NAPI") expect(info.required).toBe(false) expect(typeof info.installed).toBe("boolean") @@ -29,124 +31,15 @@ describe("dependencies check", () => { }) describe("checkCommentChecker", () => { - it("returns dependency info", async () => { - // given - // when checking comment checker + it("returns valid dependency info", async () => { + //#given comment checker check + //#when checking const info = await deps.checkCommentChecker() - // then should return valid info + //#then should return valid DependencyInfo expect(info.name).toBe("Comment Checker") expect(info.required).toBe(false) expect(typeof info.installed).toBe("boolean") }) }) - - describe("checkDependencyAstGrepCli", () => { - let checkSpy: ReturnType - - afterEach(() => { - checkSpy?.mockRestore() - }) - - it("returns pass when installed", async () => { - // given ast-grep installed - checkSpy = spyOn(deps, "checkAstGrepCli").mockResolvedValue({ - name: "AST-Grep CLI", - required: false, - installed: true, - version: "0.25.0", - path: "/usr/local/bin/sg", - }) - - // when checking - const result = await deps.checkDependencyAstGrepCli() - - // then should pass - expect(result.status).toBe("pass") - expect(result.message).toContain("0.25.0") - }) - - it("returns warn when not installed", async () => { - // given ast-grep not installed - checkSpy = spyOn(deps, "checkAstGrepCli").mockResolvedValue({ - name: "AST-Grep CLI", - required: false, - installed: false, - version: null, - path: null, - installHint: "Install: npm install -g @ast-grep/cli", - }) - - // when checking - const result = await deps.checkDependencyAstGrepCli() - - // then should warn (optional) - expect(result.status).toBe("warn") - expect(result.message).toContain("optional") - }) - }) - - describe("checkDependencyAstGrepNapi", () => { - let checkSpy: ReturnType - - afterEach(() => { - checkSpy?.mockRestore() - }) - - it("returns pass when installed", async () => { - // given napi installed - checkSpy = spyOn(deps, "checkAstGrepNapi").mockResolvedValue({ - name: "AST-Grep NAPI", - required: false, - installed: true, - version: null, - path: null, - }) - - // when checking - const result = await deps.checkDependencyAstGrepNapi() - - // then should pass - expect(result.status).toBe("pass") - }) - }) - - describe("checkDependencyCommentChecker", () => { - let checkSpy: ReturnType - - afterEach(() => { - checkSpy?.mockRestore() - }) - - it("returns warn when not installed", async () => { - // given comment checker not installed - checkSpy = spyOn(deps, "checkCommentChecker").mockResolvedValue({ - name: "Comment Checker", - required: false, - installed: false, - version: null, - path: null, - installHint: "Hook will be disabled if not available", - }) - - // when checking - const result = await deps.checkDependencyCommentChecker() - - // then should warn - expect(result.status).toBe("warn") - }) - }) - - describe("getDependencyCheckDefinitions", () => { - it("returns definitions for all dependencies", () => { - // given - // when getting definitions - const defs = deps.getDependencyCheckDefinitions() - - // then should have 3 definitions - expect(defs.length).toBe(3) - expect(defs.every((d) => d.category === "dependencies")).toBe(true) - expect(defs.every((d) => d.critical === false)).toBe(true) - }) - }) }) diff --git a/src/cli/doctor/checks/dependencies.ts b/src/cli/doctor/checks/dependencies.ts index 9b105812..52eb8c9c 100644 --- a/src/cli/doctor/checks/dependencies.ts +++ b/src/cli/doctor/checks/dependencies.ts @@ -1,5 +1,4 @@ -import type { CheckResult, CheckDefinition, DependencyInfo } from "../types" -import { CHECK_IDS, CHECK_NAMES } from "../constants" +import type { DependencyInfo } from "../types" async function checkBinaryExists(binary: string): Promise<{ exists: boolean; path: string | null }> { try { @@ -124,61 +123,3 @@ export async function checkCommentChecker(): Promise { } } -function dependencyToCheckResult(dep: DependencyInfo, checkName: string): CheckResult { - if (dep.installed) { - return { - name: checkName, - status: "pass", - message: dep.version ?? "installed", - details: dep.path ? [`Path: ${dep.path}`] : undefined, - } - } - - return { - name: checkName, - status: "warn", - message: "Not installed (optional)", - details: dep.installHint ? [dep.installHint] : undefined, - } -} - -export async function checkDependencyAstGrepCli(): Promise { - const info = await checkAstGrepCli() - return dependencyToCheckResult(info, CHECK_NAMES[CHECK_IDS.DEP_AST_GREP_CLI]) -} - -export async function checkDependencyAstGrepNapi(): Promise { - const info = await checkAstGrepNapi() - return dependencyToCheckResult(info, CHECK_NAMES[CHECK_IDS.DEP_AST_GREP_NAPI]) -} - -export async function checkDependencyCommentChecker(): Promise { - const info = await checkCommentChecker() - return dependencyToCheckResult(info, CHECK_NAMES[CHECK_IDS.DEP_COMMENT_CHECKER]) -} - -export function getDependencyCheckDefinitions(): CheckDefinition[] { - return [ - { - id: CHECK_IDS.DEP_AST_GREP_CLI, - name: CHECK_NAMES[CHECK_IDS.DEP_AST_GREP_CLI], - category: "dependencies", - check: checkDependencyAstGrepCli, - critical: false, - }, - { - id: CHECK_IDS.DEP_AST_GREP_NAPI, - name: CHECK_NAMES[CHECK_IDS.DEP_AST_GREP_NAPI], - category: "dependencies", - check: checkDependencyAstGrepNapi, - critical: false, - }, - { - id: CHECK_IDS.DEP_COMMENT_CHECKER, - name: CHECK_NAMES[CHECK_IDS.DEP_COMMENT_CHECKER], - category: "dependencies", - check: checkDependencyCommentChecker, - critical: false, - }, - ] -} diff --git a/src/cli/doctor/checks/gh.test.ts b/src/cli/doctor/checks/gh.test.ts deleted file mode 100644 index 4d989dad..00000000 --- a/src/cli/doctor/checks/gh.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { describe, it, expect, spyOn, afterEach } from "bun:test" -import * as gh from "./gh" - -describe("gh cli check", () => { - describe("getGhCliInfo", () => { - function createProc(opts: { stdout?: string; stderr?: string; exitCode?: number }) { - const stdoutText = opts.stdout ?? "" - const stderrText = opts.stderr ?? "" - const exitCode = opts.exitCode ?? 0 - const encoder = new TextEncoder() - - return { - stdout: new ReadableStream({ - start(controller) { - if (stdoutText) controller.enqueue(encoder.encode(stdoutText)) - controller.close() - }, - }), - stderr: new ReadableStream({ - start(controller) { - if (stderrText) controller.enqueue(encoder.encode(stderrText)) - controller.close() - }, - }), - exited: Promise.resolve(exitCode), - exitCode, - } as unknown as ReturnType - } - - it("returns gh cli info structure", async () => { - const spawnSpy = spyOn(Bun, "spawn").mockImplementation((cmd) => { - if (Array.isArray(cmd) && (cmd[0] === "which" || cmd[0] === "where") && cmd[1] === "gh") { - return createProc({ stdout: "/usr/bin/gh\n" }) - } - - if (Array.isArray(cmd) && cmd[0] === "gh" && cmd[1] === "--version") { - return createProc({ stdout: "gh version 2.40.0\n" }) - } - - if (Array.isArray(cmd) && cmd[0] === "gh" && cmd[1] === "auth" && cmd[2] === "status") { - return createProc({ - exitCode: 0, - stderr: "Logged in to github.com account octocat (keyring)\nToken scopes: 'repo', 'read:org'\n", - }) - } - - throw new Error(`Unexpected Bun.spawn call: ${Array.isArray(cmd) ? cmd.join(" ") : String(cmd)}`) - }) - - try { - const info = await gh.getGhCliInfo() - - expect(info.installed).toBe(true) - expect(info.version).toBe("2.40.0") - expect(typeof info.authenticated).toBe("boolean") - expect(Array.isArray(info.scopes)).toBe(true) - } finally { - spawnSpy.mockRestore() - } - }) - }) - - describe("checkGhCli", () => { - let getInfoSpy: ReturnType - - afterEach(() => { - getInfoSpy?.mockRestore() - }) - - it("returns warn when gh is not installed", async () => { - // given gh not installed - getInfoSpy = spyOn(gh, "getGhCliInfo").mockResolvedValue({ - installed: false, - version: null, - path: null, - authenticated: false, - username: null, - scopes: [], - error: null, - }) - - // when checking - const result = await gh.checkGhCli() - - // then should warn (optional) - expect(result.status).toBe("warn") - expect(result.message).toContain("Not installed") - expect(result.details).toContain("Install: https://cli.github.com/") - }) - - it("returns warn when gh is installed but not authenticated", async () => { - // given gh installed but not authenticated - getInfoSpy = spyOn(gh, "getGhCliInfo").mockResolvedValue({ - installed: true, - version: "2.40.0", - path: "/usr/local/bin/gh", - authenticated: false, - username: null, - scopes: [], - error: "not logged in", - }) - - // when checking - const result = await gh.checkGhCli() - - // then should warn about auth - expect(result.status).toBe("warn") - expect(result.message).toContain("2.40.0") - expect(result.message).toContain("not authenticated") - expect(result.details).toContain("Authenticate: gh auth login") - }) - - it("returns pass when gh is installed and authenticated", async () => { - // given gh installed and authenticated - getInfoSpy = spyOn(gh, "getGhCliInfo").mockResolvedValue({ - installed: true, - version: "2.40.0", - path: "/usr/local/bin/gh", - authenticated: true, - username: "octocat", - scopes: ["repo", "read:org"], - error: null, - }) - - // when checking - const result = await gh.checkGhCli() - - // then should pass - expect(result.status).toBe("pass") - expect(result.message).toContain("2.40.0") - expect(result.message).toContain("octocat") - expect(result.details).toContain("Account: octocat") - expect(result.details).toContain("Scopes: repo, read:org") - }) - }) - - describe("getGhCliCheckDefinition", () => { - it("returns correct check definition", () => { - // given - // when getting definition - const def = gh.getGhCliCheckDefinition() - - // then should have correct properties - expect(def.id).toBe("gh-cli") - expect(def.name).toBe("GitHub CLI") - expect(def.category).toBe("tools") - expect(def.critical).toBe(false) - expect(typeof def.check).toBe("function") - }) - }) -}) diff --git a/src/cli/doctor/checks/gh.ts b/src/cli/doctor/checks/gh.ts deleted file mode 100644 index af7ade8e..00000000 --- a/src/cli/doctor/checks/gh.ts +++ /dev/null @@ -1,172 +0,0 @@ -import type { CheckResult, CheckDefinition } from "../types" -import { CHECK_IDS, CHECK_NAMES } from "../constants" - -export interface GhCliInfo { - installed: boolean - version: string | null - path: string | null - authenticated: boolean - username: string | null - scopes: string[] - error: string | null -} - -async function checkBinaryExists(binary: string): Promise<{ exists: boolean; path: string | null }> { - try { - const whichCmd = process.platform === "win32" ? "where" : "which" - const proc = Bun.spawn([whichCmd, binary], { stdout: "pipe", stderr: "pipe" }) - const output = await new Response(proc.stdout).text() - await proc.exited - if (proc.exitCode === 0) { - return { exists: true, path: output.trim() } - } - } catch { - // intentionally empty - binary not found - } - return { exists: false, path: null } -} - -async function getGhVersion(): Promise { - try { - const proc = Bun.spawn(["gh", "--version"], { stdout: "pipe", stderr: "pipe" }) - const output = await new Response(proc.stdout).text() - await proc.exited - if (proc.exitCode === 0) { - const match = output.match(/gh version (\S+)/) - return match?.[1] ?? output.trim().split("\n")[0] - } - } catch { - // intentionally empty - version unavailable - } - return null -} - -async function getGhAuthStatus(): Promise<{ - authenticated: boolean - username: string | null - scopes: string[] - error: string | null -}> { - try { - const proc = Bun.spawn(["gh", "auth", "status"], { - stdout: "pipe", - stderr: "pipe", - env: { ...process.env, GH_NO_UPDATE_NOTIFIER: "1" }, - }) - const stdout = await new Response(proc.stdout).text() - const stderr = await new Response(proc.stderr).text() - await proc.exited - - const output = stderr || stdout - - if (proc.exitCode === 0) { - const usernameMatch = output.match(/Logged in to github\.com account (\S+)/) - const username = usernameMatch?.[1]?.replace(/[()]/g, "") ?? null - - const scopesMatch = output.match(/Token scopes?:\s*(.+)/i) - const scopes = scopesMatch?.[1] - ? scopesMatch[1] - .split(/,\s*/) - .map((s) => s.replace(/['"]/g, "").trim()) - .filter(Boolean) - : [] - - return { authenticated: true, username, scopes, error: null } - } - - const errorMatch = output.match(/error[:\s]+(.+)/i) - return { - authenticated: false, - username: null, - scopes: [], - error: errorMatch?.[1]?.trim() ?? "Not authenticated", - } - } catch (err) { - return { - authenticated: false, - username: null, - scopes: [], - error: err instanceof Error ? err.message : "Failed to check auth status", - } - } -} - -export async function getGhCliInfo(): Promise { - const binaryCheck = await checkBinaryExists("gh") - - if (!binaryCheck.exists) { - return { - installed: false, - version: null, - path: null, - authenticated: false, - username: null, - scopes: [], - error: null, - } - } - - const [version, authStatus] = await Promise.all([getGhVersion(), getGhAuthStatus()]) - - return { - installed: true, - version, - path: binaryCheck.path, - authenticated: authStatus.authenticated, - username: authStatus.username, - scopes: authStatus.scopes, - error: authStatus.error, - } -} - -export async function checkGhCli(): Promise { - const info = await getGhCliInfo() - const name = CHECK_NAMES[CHECK_IDS.GH_CLI] - - if (!info.installed) { - return { - name, - status: "warn", - message: "Not installed (optional)", - details: [ - "GitHub CLI is used by librarian agent and scripts", - "Install: https://cli.github.com/", - ], - } - } - - if (!info.authenticated) { - return { - name, - status: "warn", - message: `${info.version ?? "installed"} - not authenticated`, - details: [ - info.path ? `Path: ${info.path}` : null, - "Authenticate: gh auth login", - info.error ? `Error: ${info.error}` : null, - ].filter((d): d is string => d !== null), - } - } - - const details: string[] = [] - if (info.path) details.push(`Path: ${info.path}`) - if (info.username) details.push(`Account: ${info.username}`) - if (info.scopes.length > 0) details.push(`Scopes: ${info.scopes.join(", ")}`) - - return { - name, - status: "pass", - message: `${info.version ?? "installed"} - authenticated as ${info.username ?? "unknown"}`, - details: details.length > 0 ? details : undefined, - } -} - -export function getGhCliCheckDefinition(): CheckDefinition { - return { - id: CHECK_IDS.GH_CLI, - name: CHECK_NAMES[CHECK_IDS.GH_CLI], - category: "tools", - check: checkGhCli, - critical: false, - } -} diff --git a/src/cli/doctor/checks/index.ts b/src/cli/doctor/checks/index.ts index 9135cd1b..20960d7b 100644 --- a/src/cli/doctor/checks/index.ts +++ b/src/cli/doctor/checks/index.ts @@ -1,46 +1,42 @@ import type { CheckDefinition } from "../types" -import { getOpenCodeCheckDefinition } from "./opencode" -import { getPluginCheckDefinition } from "./plugin" -import { getConfigCheckDefinition } from "./config" -import { getModelResolutionCheckDefinition } from "./model-resolution" -import { getAuthCheckDefinitions } from "./auth" -import { getDependencyCheckDefinitions } from "./dependencies" -import { getGhCliCheckDefinition } from "./gh" -import { getLspCheckDefinition } from "./lsp" -import { getMcpCheckDefinitions } from "./mcp" -import { getMcpOAuthCheckDefinition } from "./mcp-oauth" -import { getVersionCheckDefinition } from "./version" +import { CHECK_IDS, CHECK_NAMES } from "../constants" +import { checkSystem, gatherSystemInfo } from "./system" +import { checkConfig } from "./config" +import { checkProviders, gatherProviderStatuses } from "./providers" +import { checkTools, gatherToolsSummary } from "./tools" +import { checkModels } from "./model-resolution" -export * from "./opencode" -export * from "./plugin" -export * from "./config" -export * from "./model-resolution" +export type { CheckDefinition } export * from "./model-resolution-types" -export * from "./model-resolution-cache" -export * from "./model-resolution-config" -export * from "./model-resolution-effective-model" -export * from "./model-resolution-variant" -export * from "./model-resolution-details" -export * from "./auth" -export * from "./dependencies" -export * from "./gh" -export * from "./lsp" -export * from "./mcp" -export * from "./mcp-oauth" -export * from "./version" +export { gatherSystemInfo, gatherProviderStatuses, gatherToolsSummary } export function getAllCheckDefinitions(): CheckDefinition[] { return [ - getOpenCodeCheckDefinition(), - getPluginCheckDefinition(), - getConfigCheckDefinition(), - getModelResolutionCheckDefinition(), - ...getAuthCheckDefinitions(), - ...getDependencyCheckDefinitions(), - getGhCliCheckDefinition(), - getLspCheckDefinition(), - ...getMcpCheckDefinitions(), - getMcpOAuthCheckDefinition(), - getVersionCheckDefinition(), + { + id: CHECK_IDS.SYSTEM, + name: CHECK_NAMES[CHECK_IDS.SYSTEM], + check: checkSystem, + critical: true, + }, + { + id: CHECK_IDS.CONFIG, + name: CHECK_NAMES[CHECK_IDS.CONFIG], + check: checkConfig, + }, + { + id: CHECK_IDS.PROVIDERS, + name: CHECK_NAMES[CHECK_IDS.PROVIDERS], + check: checkProviders, + }, + { + id: CHECK_IDS.TOOLS, + name: CHECK_NAMES[CHECK_IDS.TOOLS], + check: checkTools, + }, + { + id: CHECK_IDS.MODELS, + name: CHECK_NAMES[CHECK_IDS.MODELS], + check: checkModels, + }, ] } diff --git a/src/cli/doctor/checks/lsp.test.ts b/src/cli/doctor/checks/lsp.test.ts deleted file mode 100644 index 285c7a76..00000000 --- a/src/cli/doctor/checks/lsp.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { describe, it, expect, spyOn, afterEach } from "bun:test" -import * as lsp from "./lsp" -import type { LspServerInfo } from "../types" - -describe("lsp check", () => { - describe("getLspServersInfo", () => { - it("returns array of server info", async () => { - // given - // when getting servers info - const servers = await lsp.getLspServersInfo() - - // then should return array with expected structure - expect(Array.isArray(servers)).toBe(true) - servers.forEach((s) => { - expect(s.id).toBeDefined() - expect(typeof s.installed).toBe("boolean") - expect(Array.isArray(s.extensions)).toBe(true) - }) - }) - - it("does not spawn 'which' command (windows compatibility)", async () => { - // given - const spawnSpy = spyOn(Bun, "spawn") - - try { - // when getting servers info - await lsp.getLspServersInfo() - - // then should not spawn which - const calls = spawnSpy.mock.calls - const whichCalls = calls.filter((c) => Array.isArray(c) && Array.isArray(c[0]) && c[0][0] === "which") - expect(whichCalls.length).toBe(0) - } finally { - spawnSpy.mockRestore() - } - }) - }) - - describe("getLspServerStats", () => { - it("counts installed servers correctly", () => { - // given servers with mixed installation status - const servers = [ - { id: "ts", installed: true, extensions: [".ts"], source: "builtin" as const }, - { id: "py", installed: false, extensions: [".py"], source: "builtin" as const }, - { id: "go", installed: true, extensions: [".go"], source: "builtin" as const }, - ] - - // when getting stats - const stats = lsp.getLspServerStats(servers) - - // then should count correctly - expect(stats.installed).toBe(2) - expect(stats.total).toBe(3) - }) - - it("handles empty array", () => { - // given no servers - const servers: LspServerInfo[] = [] - - // when getting stats - const stats = lsp.getLspServerStats(servers) - - // then should return zeros - expect(stats.installed).toBe(0) - expect(stats.total).toBe(0) - }) - }) - - describe("checkLspServers", () => { - let getServersSpy: ReturnType - - afterEach(() => { - getServersSpy?.mockRestore() - }) - - it("returns warn when no servers installed", async () => { - // given no servers installed - getServersSpy = spyOn(lsp, "getLspServersInfo").mockResolvedValue([ - { id: "typescript-language-server", installed: false, extensions: [".ts"], source: "builtin" }, - { id: "pyright", installed: false, extensions: [".py"], source: "builtin" }, - ]) - - // when checking - const result = await lsp.checkLspServers() - - // then should warn - expect(result.status).toBe("warn") - expect(result.message).toContain("No LSP servers") - }) - - it("returns pass when servers installed", async () => { - // given some servers installed - getServersSpy = spyOn(lsp, "getLspServersInfo").mockResolvedValue([ - { id: "typescript-language-server", installed: true, extensions: [".ts"], source: "builtin" }, - { id: "pyright", installed: false, extensions: [".py"], source: "builtin" }, - ]) - - // when checking - const result = await lsp.checkLspServers() - - // then should pass with count - expect(result.status).toBe("pass") - expect(result.message).toContain("1/2") - }) - - it("lists installed and missing servers in details", async () => { - // given mixed installation - getServersSpy = spyOn(lsp, "getLspServersInfo").mockResolvedValue([ - { id: "typescript-language-server", installed: true, extensions: [".ts"], source: "builtin" }, - { id: "pyright", installed: false, extensions: [".py"], source: "builtin" }, - ]) - - // when checking - const result = await lsp.checkLspServers() - - // then should list both - expect(result.details?.some((d) => d.includes("Installed"))).toBe(true) - expect(result.details?.some((d) => d.includes("Not found"))).toBe(true) - }) - }) - - describe("getLspCheckDefinition", () => { - it("returns valid check definition", () => { - // given - // when getting definition - const def = lsp.getLspCheckDefinition() - - // then should have required properties - expect(def.id).toBe("lsp-servers") - expect(def.category).toBe("tools") - expect(def.critical).toBe(false) - }) - }) -}) diff --git a/src/cli/doctor/checks/lsp.ts b/src/cli/doctor/checks/lsp.ts deleted file mode 100644 index 254e3d67..00000000 --- a/src/cli/doctor/checks/lsp.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { CheckResult, CheckDefinition, LspServerInfo } from "../types" -import { CHECK_IDS, CHECK_NAMES } from "../constants" - -const DEFAULT_LSP_SERVERS: Array<{ - id: string - binary: string - extensions: string[] -}> = [ - { id: "typescript-language-server", binary: "typescript-language-server", extensions: [".ts", ".tsx", ".js", ".jsx"] }, - { id: "pyright", binary: "pyright-langserver", extensions: [".py"] }, - { id: "rust-analyzer", binary: "rust-analyzer", extensions: [".rs"] }, - { id: "gopls", binary: "gopls", extensions: [".go"] }, -] - -import { isServerInstalled } from "../../../tools/lsp/config" - -export async function getLspServersInfo(): Promise { - const servers: LspServerInfo[] = [] - - for (const server of DEFAULT_LSP_SERVERS) { - const installed = isServerInstalled([server.binary]) - servers.push({ - id: server.id, - installed, - extensions: server.extensions, - source: "builtin", - }) - } - - return servers -} - -export function getLspServerStats(servers: LspServerInfo[]): { installed: number; total: number } { - const installed = servers.filter((s) => s.installed).length - return { installed, total: servers.length } -} - -export async function checkLspServers(): Promise { - const servers = await getLspServersInfo() - const stats = getLspServerStats(servers) - const installedServers = servers.filter((s) => s.installed) - const missingServers = servers.filter((s) => !s.installed) - - if (stats.installed === 0) { - return { - name: CHECK_NAMES[CHECK_IDS.LSP_SERVERS], - status: "warn", - message: "No LSP servers detected", - details: [ - "LSP tools will have limited functionality", - ...missingServers.map((s) => `Missing: ${s.id}`), - ], - } - } - - const details = [ - ...installedServers.map((s) => `Installed: ${s.id}`), - ...missingServers.map((s) => `Not found: ${s.id} (optional)`), - ] - - return { - name: CHECK_NAMES[CHECK_IDS.LSP_SERVERS], - status: "pass", - message: `${stats.installed}/${stats.total} servers available`, - details, - } -} - -export function getLspCheckDefinition(): CheckDefinition { - return { - id: CHECK_IDS.LSP_SERVERS, - name: CHECK_NAMES[CHECK_IDS.LSP_SERVERS], - category: "tools", - check: checkLspServers, - critical: false, - } -} diff --git a/src/cli/doctor/checks/mcp-oauth.test.ts b/src/cli/doctor/checks/mcp-oauth.test.ts deleted file mode 100644 index dea0a0b2..00000000 --- a/src/cli/doctor/checks/mcp-oauth.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { describe, it, expect, spyOn, afterEach } from "bun:test" -import * as mcpOauth from "./mcp-oauth" - -describe("mcp-oauth check", () => { - describe("getMcpOAuthCheckDefinition", () => { - it("returns check definition with correct properties", () => { - // given - // when getting definition - const def = mcpOauth.getMcpOAuthCheckDefinition() - - // then should have correct structure - expect(def.id).toBe("mcp-oauth-tokens") - expect(def.name).toBe("MCP OAuth Tokens") - expect(def.category).toBe("tools") - expect(def.critical).toBe(false) - expect(typeof def.check).toBe("function") - }) - }) - - describe("checkMcpOAuthTokens", () => { - let readStoreSpy: ReturnType - - afterEach(() => { - readStoreSpy?.mockRestore() - }) - - it("returns skip when no tokens stored", async () => { - // given no OAuth tokens configured - readStoreSpy = spyOn(mcpOauth, "readTokenStore").mockReturnValue(null) - - // when checking OAuth tokens - const result = await mcpOauth.checkMcpOAuthTokens() - - // then should skip - expect(result.status).toBe("skip") - expect(result.message).toContain("No OAuth") - }) - - it("returns pass when all tokens valid", async () => { - // given valid tokens with future expiry (expiresAt is in epoch seconds) - const futureTime = Math.floor(Date.now() / 1000) + 3600 - readStoreSpy = spyOn(mcpOauth, "readTokenStore").mockReturnValue({ - "example.com/resource1": { - accessToken: "token1", - expiresAt: futureTime, - }, - "example.com/resource2": { - accessToken: "token2", - expiresAt: futureTime, - }, - }) - - // when checking OAuth tokens - const result = await mcpOauth.checkMcpOAuthTokens() - - // then should pass - expect(result.status).toBe("pass") - expect(result.message).toContain("2") - expect(result.message).toContain("valid") - }) - - it("returns warn when some tokens expired", async () => { - // given mix of valid and expired tokens (expiresAt is in epoch seconds) - const futureTime = Math.floor(Date.now() / 1000) + 3600 - const pastTime = Math.floor(Date.now() / 1000) - 3600 - readStoreSpy = spyOn(mcpOauth, "readTokenStore").mockReturnValue({ - "example.com/resource1": { - accessToken: "token1", - expiresAt: futureTime, - }, - "example.com/resource2": { - accessToken: "token2", - expiresAt: pastTime, - }, - }) - - // when checking OAuth tokens - const result = await mcpOauth.checkMcpOAuthTokens() - - // then should warn - expect(result.status).toBe("warn") - expect(result.message).toContain("1") - expect(result.message).toContain("expired") - expect(result.details?.some((d: string) => d.includes("Expired"))).toBe( - true - ) - }) - - it("returns pass when tokens have no expiry", async () => { - // given tokens without expiry info - readStoreSpy = spyOn(mcpOauth, "readTokenStore").mockReturnValue({ - "example.com/resource1": { - accessToken: "token1", - }, - }) - - // when checking OAuth tokens - const result = await mcpOauth.checkMcpOAuthTokens() - - // then should pass (no expiry = assume valid) - expect(result.status).toBe("pass") - expect(result.message).toContain("1") - }) - - it("includes token details in output", async () => { - // given multiple tokens - const futureTime = Math.floor(Date.now() / 1000) + 3600 - readStoreSpy = spyOn(mcpOauth, "readTokenStore").mockReturnValue({ - "api.example.com/v1": { - accessToken: "token1", - expiresAt: futureTime, - }, - "auth.example.com/oauth": { - accessToken: "token2", - expiresAt: futureTime, - }, - }) - - // when checking OAuth tokens - const result = await mcpOauth.checkMcpOAuthTokens() - - // then should list tokens in details - expect(result.details).toBeDefined() - expect(result.details?.length).toBeGreaterThan(0) - expect( - result.details?.some((d: string) => d.includes("api.example.com")) - ).toBe(true) - expect( - result.details?.some((d: string) => d.includes("auth.example.com")) - ).toBe(true) - }) - }) -}) diff --git a/src/cli/doctor/checks/mcp-oauth.ts b/src/cli/doctor/checks/mcp-oauth.ts deleted file mode 100644 index 9c1dd62a..00000000 --- a/src/cli/doctor/checks/mcp-oauth.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { CheckResult, CheckDefinition } from "../types" -import { CHECK_IDS, CHECK_NAMES } from "../constants" -import { getMcpOauthStoragePath } from "../../../features/mcp-oauth/storage" -import { existsSync, readFileSync } from "node:fs" - -interface OAuthTokenData { - accessToken: string - refreshToken?: string - expiresAt?: number - clientInfo?: { - clientId: string - clientSecret?: string - } -} - -type TokenStore = Record - -export function readTokenStore(): TokenStore | null { - const filePath = getMcpOauthStoragePath() - if (!existsSync(filePath)) { - return null - } - - try { - const content = readFileSync(filePath, "utf-8") - return JSON.parse(content) as TokenStore - } catch { - return null - } -} - -export async function checkMcpOAuthTokens(): Promise { - const store = readTokenStore() - - if (!store || Object.keys(store).length === 0) { - return { - name: CHECK_NAMES[CHECK_IDS.MCP_OAUTH_TOKENS], - status: "skip", - message: "No OAuth tokens configured", - details: ["Optional: Configure OAuth tokens for MCP servers"], - } - } - - const now = Math.floor(Date.now() / 1000) - const tokens = Object.entries(store) - const expiredTokens = tokens.filter( - ([, token]) => token.expiresAt && token.expiresAt < now - ) - - if (expiredTokens.length > 0) { - return { - name: CHECK_NAMES[CHECK_IDS.MCP_OAUTH_TOKENS], - status: "warn", - message: `${expiredTokens.length} of ${tokens.length} token(s) expired`, - details: [ - ...tokens - .filter(([, token]) => !token.expiresAt || token.expiresAt >= now) - .map(([key]) => `Valid: ${key}`), - ...expiredTokens.map(([key]) => `Expired: ${key}`), - ], - } - } - - return { - name: CHECK_NAMES[CHECK_IDS.MCP_OAUTH_TOKENS], - status: "pass", - message: `${tokens.length} OAuth token(s) valid`, - details: tokens.map(([key]) => `Configured: ${key}`), - } -} - -export function getMcpOAuthCheckDefinition(): CheckDefinition { - return { - id: CHECK_IDS.MCP_OAUTH_TOKENS, - name: CHECK_NAMES[CHECK_IDS.MCP_OAUTH_TOKENS], - category: "tools", - check: checkMcpOAuthTokens, - critical: false, - } -} diff --git a/src/cli/doctor/checks/mcp.test.ts b/src/cli/doctor/checks/mcp.test.ts deleted file mode 100644 index 6ef98198..00000000 --- a/src/cli/doctor/checks/mcp.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { describe, it, expect, spyOn, afterEach } from "bun:test" -import * as mcp from "./mcp" - -describe("mcp check", () => { - describe("getBuiltinMcpInfo", () => { - it("returns builtin servers", () => { - // given - // when getting builtin info - const servers = mcp.getBuiltinMcpInfo() - - // then should include expected servers - expect(servers.length).toBe(2) - expect(servers.every((s) => s.type === "builtin")).toBe(true) - expect(servers.every((s) => s.enabled === true)).toBe(true) - expect(servers.map((s) => s.id)).toContain("context7") - expect(servers.map((s) => s.id)).toContain("grep_app") - }) - }) - - describe("getUserMcpInfo", () => { - it("returns empty array when no user config", () => { - // given no user config exists - // when getting user info - const servers = mcp.getUserMcpInfo() - - // then should return array (may be empty) - expect(Array.isArray(servers)).toBe(true) - }) - }) - - describe("checkBuiltinMcpServers", () => { - it("returns pass with server count", async () => { - // given - // when checking builtin servers - const result = await mcp.checkBuiltinMcpServers() - - // then should pass - expect(result.status).toBe("pass") - expect(result.message).toContain("2") - expect(result.message).toContain("enabled") - }) - - it("lists enabled servers in details", async () => { - // given - // when checking builtin servers - const result = await mcp.checkBuiltinMcpServers() - - // then should list servers - expect(result.details?.some((d) => d.includes("context7"))).toBe(true) - expect(result.details?.some((d) => d.includes("grep_app"))).toBe(true) - }) - }) - - describe("checkUserMcpServers", () => { - let getUserSpy: ReturnType - - afterEach(() => { - getUserSpy?.mockRestore() - }) - - it("returns skip when no user config", async () => { - // given no user servers - getUserSpy = spyOn(mcp, "getUserMcpInfo").mockReturnValue([]) - - // when checking - const result = await mcp.checkUserMcpServers() - - // then should skip - expect(result.status).toBe("skip") - expect(result.message).toContain("No user MCP") - }) - - it("returns pass when valid user servers", async () => { - // given valid user servers - getUserSpy = spyOn(mcp, "getUserMcpInfo").mockReturnValue([ - { id: "custom-mcp", type: "user", enabled: true, valid: true }, - ]) - - // when checking - const result = await mcp.checkUserMcpServers() - - // then should pass - expect(result.status).toBe("pass") - expect(result.message).toContain("1") - }) - - it("returns warn when servers have issues", async () => { - // given invalid server config - getUserSpy = spyOn(mcp, "getUserMcpInfo").mockReturnValue([ - { id: "bad-mcp", type: "user", enabled: true, valid: false, error: "Missing command" }, - ]) - - // when checking - const result = await mcp.checkUserMcpServers() - - // then should warn - expect(result.status).toBe("warn") - expect(result.details?.some((d) => d.includes("Invalid"))).toBe(true) - }) - }) - - describe("getMcpCheckDefinitions", () => { - it("returns definitions for builtin and user", () => { - // given - // when getting definitions - const defs = mcp.getMcpCheckDefinitions() - - // then should have 2 definitions - expect(defs.length).toBe(2) - expect(defs.every((d) => d.category === "tools")).toBe(true) - expect(defs.map((d) => d.id)).toContain("mcp-builtin") - expect(defs.map((d) => d.id)).toContain("mcp-user") - }) - }) -}) diff --git a/src/cli/doctor/checks/mcp.ts b/src/cli/doctor/checks/mcp.ts deleted file mode 100644 index 77eeb093..00000000 --- a/src/cli/doctor/checks/mcp.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { existsSync, readFileSync } from "node:fs" -import { homedir } from "node:os" -import { join } from "node:path" -import type { CheckResult, CheckDefinition, McpServerInfo } from "../types" -import { CHECK_IDS, CHECK_NAMES } from "../constants" -import { parseJsonc } from "../../../shared" - -const BUILTIN_MCP_SERVERS = ["context7", "grep_app"] - -const MCP_CONFIG_PATHS = [ - join(homedir(), ".claude", ".mcp.json"), - join(process.cwd(), ".mcp.json"), - join(process.cwd(), ".claude", ".mcp.json"), -] - -interface McpConfig { - mcpServers?: Record -} - -function loadUserMcpConfig(): Record { - const servers: Record = {} - - for (const configPath of MCP_CONFIG_PATHS) { - if (!existsSync(configPath)) continue - - try { - const content = readFileSync(configPath, "utf-8") - const config = parseJsonc(content) - if (config.mcpServers) { - Object.assign(servers, config.mcpServers) - } - } catch { - // intentionally empty - skip invalid configs - } - } - - return servers -} - -export function getBuiltinMcpInfo(): McpServerInfo[] { - return BUILTIN_MCP_SERVERS.map((id) => ({ - id, - type: "builtin" as const, - enabled: true, - valid: true, - })) -} - -export function getUserMcpInfo(): McpServerInfo[] { - const userServers = loadUserMcpConfig() - const servers: McpServerInfo[] = [] - - for (const [id, config] of Object.entries(userServers)) { - const isValid = typeof config === "object" && config !== null - servers.push({ - id, - type: "user", - enabled: true, - valid: isValid, - error: isValid ? undefined : "Invalid configuration format", - }) - } - - return servers -} - -export async function checkBuiltinMcpServers(): Promise { - const servers = getBuiltinMcpInfo() - - return { - name: CHECK_NAMES[CHECK_IDS.MCP_BUILTIN], - status: "pass", - message: `${servers.length} built-in servers enabled`, - details: servers.map((s) => `Enabled: ${s.id}`), - } -} - -export async function checkUserMcpServers(): Promise { - const servers = getUserMcpInfo() - - if (servers.length === 0) { - return { - name: CHECK_NAMES[CHECK_IDS.MCP_USER], - status: "skip", - message: "No user MCP configuration found", - details: ["Optional: Add .mcp.json for custom MCP servers"], - } - } - - const invalidServers = servers.filter((s) => !s.valid) - if (invalidServers.length > 0) { - return { - name: CHECK_NAMES[CHECK_IDS.MCP_USER], - status: "warn", - message: `${invalidServers.length} server(s) have configuration issues`, - details: [ - ...servers.filter((s) => s.valid).map((s) => `Valid: ${s.id}`), - ...invalidServers.map((s) => `Invalid: ${s.id} - ${s.error}`), - ], - } - } - - return { - name: CHECK_NAMES[CHECK_IDS.MCP_USER], - status: "pass", - message: `${servers.length} user server(s) configured`, - details: servers.map((s) => `Configured: ${s.id}`), - } -} - -export function getMcpCheckDefinitions(): CheckDefinition[] { - return [ - { - id: CHECK_IDS.MCP_BUILTIN, - name: CHECK_NAMES[CHECK_IDS.MCP_BUILTIN], - category: "tools", - check: checkBuiltinMcpServers, - critical: false, - }, - { - id: CHECK_IDS.MCP_USER, - name: CHECK_NAMES[CHECK_IDS.MCP_USER], - category: "tools", - check: checkUserMcpServers, - critical: false, - }, - ] -} diff --git a/src/cli/doctor/checks/model-resolution.test.ts b/src/cli/doctor/checks/model-resolution.test.ts index 781564d5..cca2f58b 100644 --- a/src/cli/doctor/checks/model-resolution.test.ts +++ b/src/cli/doctor/checks/model-resolution.test.ts @@ -165,16 +165,4 @@ describe("model-resolution check", () => { }) }) - describe("getModelResolutionCheckDefinition", () => { - it("returns valid check definition", async () => { - const { getModelResolutionCheckDefinition } = await import("./model-resolution") - - const def = getModelResolutionCheckDefinition() - - expect(def.id).toBe("model-resolution") - expect(def.name).toBe("Model Resolution") - expect(def.category).toBe("configuration") - expect(typeof def.check).toBe("function") - }) - }) }) diff --git a/src/cli/doctor/checks/model-resolution.ts b/src/cli/doctor/checks/model-resolution.ts index c2f8a77f..c9cc0c0b 100644 --- a/src/cli/doctor/checks/model-resolution.ts +++ b/src/cli/doctor/checks/model-resolution.ts @@ -1,24 +1,19 @@ -import type { CheckResult, CheckDefinition } from "../types" +import { AGENT_MODEL_REQUIREMENTS, CATEGORY_MODEL_REQUIREMENTS } from "../../../shared/model-requirements" import { CHECK_IDS, CHECK_NAMES } from "../constants" -import { - AGENT_MODEL_REQUIREMENTS, - CATEGORY_MODEL_REQUIREMENTS, -} from "../../../shared/model-requirements" -import type { OmoConfig, ModelResolutionInfo, AgentResolutionInfo, CategoryResolutionInfo } from "./model-resolution-types" +import type { CheckResult, DoctorIssue } from "../types" import { loadAvailableModelsFromCache } from "./model-resolution-cache" import { loadOmoConfig } from "./model-resolution-config" -import { buildEffectiveResolution, getEffectiveModel } from "./model-resolution-effective-model" import { buildModelResolutionDetails } from "./model-resolution-details" +import { buildEffectiveResolution, getEffectiveModel } from "./model-resolution-effective-model" +import type { AgentResolutionInfo, CategoryResolutionInfo, ModelResolutionInfo, OmoConfig } from "./model-resolution-types" export function getModelResolutionInfo(): ModelResolutionInfo { - const agents: AgentResolutionInfo[] = Object.entries(AGENT_MODEL_REQUIREMENTS).map( - ([name, requirement]) => ({ - name, - requirement, - effectiveModel: getEffectiveModel(requirement), - effectiveResolution: buildEffectiveResolution(requirement), - }), - ) + const agents: AgentResolutionInfo[] = Object.entries(AGENT_MODEL_REQUIREMENTS).map(([name, requirement]) => ({ + name, + requirement, + effectiveModel: getEffectiveModel(requirement), + effectiveResolution: buildEffectiveResolution(requirement), + })) const categories: CategoryResolutionInfo[] = Object.entries(CATEGORY_MODEL_REQUIREMENTS).map( ([name, requirement]) => ({ @@ -26,27 +21,25 @@ export function getModelResolutionInfo(): ModelResolutionInfo { requirement, effectiveModel: getEffectiveModel(requirement), effectiveResolution: buildEffectiveResolution(requirement), - }), + }) ) return { agents, categories } } export function getModelResolutionInfoWithOverrides(config: OmoConfig): ModelResolutionInfo { - const agents: AgentResolutionInfo[] = Object.entries(AGENT_MODEL_REQUIREMENTS).map( - ([name, requirement]) => { - const userOverride = config.agents?.[name]?.model - const userVariant = config.agents?.[name]?.variant - return { - name, - requirement, - userOverride, - userVariant, - effectiveModel: getEffectiveModel(requirement, userOverride), - effectiveResolution: buildEffectiveResolution(requirement, userOverride), - } - }, - ) + const agents: AgentResolutionInfo[] = Object.entries(AGENT_MODEL_REQUIREMENTS).map(([name, requirement]) => { + const userOverride = config.agents?.[name]?.model + const userVariant = config.agents?.[name]?.variant + return { + name, + requirement, + userOverride, + userVariant, + effectiveModel: getEffectiveModel(requirement, userOverride), + effectiveResolution: buildEffectiveResolution(requirement, userOverride), + } + }) const categories: CategoryResolutionInfo[] = Object.entries(CATEGORY_MODEL_REQUIREMENTS).map( ([name, requirement]) => { @@ -60,40 +53,39 @@ export function getModelResolutionInfoWithOverrides(config: OmoConfig): ModelRes effectiveModel: getEffectiveModel(requirement, userOverride), effectiveResolution: buildEffectiveResolution(requirement, userOverride), } - }, + } ) return { agents, categories } } -export async function checkModelResolution(): Promise { +export async function checkModels(): Promise { const config = loadOmoConfig() ?? {} const info = getModelResolutionInfoWithOverrides(config) const available = loadAvailableModelsFromCache() + const issues: DoctorIssue[] = [] - const agentCount = info.agents.length - const categoryCount = info.categories.length - const agentOverrides = info.agents.filter((a) => a.userOverride).length - const categoryOverrides = info.categories.filter((c) => c.userOverride).length - const totalOverrides = agentOverrides + categoryOverrides + if (!available.cacheExists) { + issues.push({ + title: "Model cache not found", + description: "OpenCode model cache is missing, so model availability cannot be validated.", + fix: "Run: opencode models --refresh", + severity: "warning", + affects: ["model resolution"], + }) + } - const overrideNote = totalOverrides > 0 ? ` (${totalOverrides} override${totalOverrides > 1 ? "s" : ""})` : "" - const cacheNote = available.cacheExists ? `, ${available.modelCount} available` : ", cache not found" + const overrideCount = + info.agents.filter((agent) => Boolean(agent.userOverride)).length + + info.categories.filter((category) => Boolean(category.userOverride)).length return { - name: CHECK_NAMES[CHECK_IDS.MODEL_RESOLUTION], - status: available.cacheExists ? "pass" : "warn", - message: `${agentCount} agents, ${categoryCount} categories${overrideNote}${cacheNote}`, + name: CHECK_NAMES[CHECK_IDS.MODELS], + status: issues.length > 0 ? "warn" : "pass", + message: `${info.agents.length} agents, ${info.categories.length} categories, ${overrideCount} override${overrideCount === 1 ? "" : "s"}`, details: buildModelResolutionDetails({ info, available, config }), + issues, } } -export function getModelResolutionCheckDefinition(): CheckDefinition { - return { - id: CHECK_IDS.MODEL_RESOLUTION, - name: CHECK_NAMES[CHECK_IDS.MODEL_RESOLUTION], - category: "configuration", - check: checkModelResolution, - critical: false, - } -} +export const checkModelResolution = checkModels diff --git a/src/cli/doctor/checks/opencode.test.ts b/src/cli/doctor/checks/opencode.test.ts deleted file mode 100644 index 1820a455..00000000 --- a/src/cli/doctor/checks/opencode.test.ts +++ /dev/null @@ -1,331 +0,0 @@ -import { describe, it, expect, spyOn, beforeEach, afterEach } from "bun:test" -import * as opencode from "./opencode" -import { MIN_OPENCODE_VERSION } from "../constants" - -describe("opencode check", () => { - describe("compareVersions", () => { - it("returns true when current >= minimum", () => { - // given versions where current is greater - // when comparing - // then should return true - expect(opencode.compareVersions("1.0.200", "1.0.150")).toBe(true) - expect(opencode.compareVersions("1.1.0", "1.0.150")).toBe(true) - expect(opencode.compareVersions("2.0.0", "1.0.150")).toBe(true) - }) - - it("returns true when versions are equal", () => { - // given equal versions - // when comparing - // then should return true - expect(opencode.compareVersions("1.0.150", "1.0.150")).toBe(true) - }) - - it("returns false when current < minimum", () => { - // given version below minimum - // when comparing - // then should return false - expect(opencode.compareVersions("1.0.100", "1.0.150")).toBe(false) - expect(opencode.compareVersions("0.9.0", "1.0.150")).toBe(false) - }) - - it("handles version prefixes", () => { - // given version with v prefix - // when comparing - // then should strip prefix and compare correctly - expect(opencode.compareVersions("v1.0.200", "1.0.150")).toBe(true) - }) - - it("handles prerelease versions", () => { - // given prerelease version - // when comparing - // then should use base version - expect(opencode.compareVersions("1.0.200-beta.1", "1.0.150")).toBe(true) - }) - }) - - describe("command helpers", () => { - it("selects where on Windows", () => { - // given win32 platform - // when selecting lookup command - // then should use where - expect(opencode.getBinaryLookupCommand("win32")).toBe("where") - }) - - it("selects which on non-Windows", () => { - // given linux platform - // when selecting lookup command - // then should use which - expect(opencode.getBinaryLookupCommand("linux")).toBe("which") - expect(opencode.getBinaryLookupCommand("darwin")).toBe("which") - }) - - it("parses command output into paths", () => { - // given raw output with multiple lines and spaces - const output = "C:\\\\bin\\\\opencode.ps1\r\nC:\\\\bin\\\\opencode.exe\n\n" - - // when parsing - const paths = opencode.parseBinaryPaths(output) - - // then should return trimmed, non-empty paths - expect(paths).toEqual(["C:\\\\bin\\\\opencode.ps1", "C:\\\\bin\\\\opencode.exe"]) - }) - - it("prefers exe/cmd/bat over ps1 on Windows", () => { - // given windows paths - const paths = [ - "C:\\\\bin\\\\opencode.ps1", - "C:\\\\bin\\\\opencode.cmd", - "C:\\\\bin\\\\opencode.exe", - ] - - // when selecting binary - const selected = opencode.selectBinaryPath(paths, "win32") - - // then should prefer exe - expect(selected).toBe("C:\\\\bin\\\\opencode.exe") - }) - - it("falls back to ps1 when it is the only Windows candidate", () => { - // given only ps1 path - const paths = ["C:\\\\bin\\\\opencode.ps1"] - - // when selecting binary - const selected = opencode.selectBinaryPath(paths, "win32") - - // then should return ps1 path - expect(selected).toBe("C:\\\\bin\\\\opencode.ps1") - }) - - it("builds PowerShell command for ps1 on Windows", () => { - // given a ps1 path on Windows - const command = opencode.buildVersionCommand( - "C:\\\\bin\\\\opencode.ps1", - "win32" - ) - - // when building command - // then should use PowerShell - expect(command).toEqual([ - "powershell", - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - "C:\\\\bin\\\\opencode.ps1", - "--version", - ]) - }) - - it("builds direct command for non-ps1 binaries", () => { - // given an exe on Windows and a binary on linux - const winCommand = opencode.buildVersionCommand( - "C:\\\\bin\\\\opencode.exe", - "win32" - ) - const linuxCommand = opencode.buildVersionCommand("opencode", "linux") - - // when building commands - // then should execute directly - expect(winCommand).toEqual(["C:\\\\bin\\\\opencode.exe", "--version"]) - expect(linuxCommand).toEqual(["opencode", "--version"]) - }) - }) - - describe("getOpenCodeInfo", () => { - it("returns installed: false when binary not found", async () => { - // given no opencode binary - const spy = spyOn(opencode, "findOpenCodeBinary").mockResolvedValue(null) - - // when getting info - const info = await opencode.getOpenCodeInfo() - - // then should indicate not installed - expect(info.installed).toBe(false) - expect(info.version).toBeNull() - expect(info.path).toBeNull() - expect(info.binary).toBeNull() - - spy.mockRestore() - }) - }) - - describe("checkOpenCodeInstallation", () => { - let getInfoSpy: ReturnType - - afterEach(() => { - getInfoSpy?.mockRestore() - }) - - it("returns fail when not installed", async () => { - // given opencode not installed - getInfoSpy = spyOn(opencode, "getOpenCodeInfo").mockResolvedValue({ - installed: false, - version: null, - path: null, - binary: null, - }) - - // when checking installation - const result = await opencode.checkOpenCodeInstallation() - - // then should fail with installation hint - expect(result.status).toBe("fail") - expect(result.message).toContain("not installed") - expect(result.details).toBeDefined() - expect(result.details?.some((d) => d.includes("opencode.ai"))).toBe(true) - }) - - it("returns warn when version below minimum", async () => { - // given old version installed - getInfoSpy = spyOn(opencode, "getOpenCodeInfo").mockResolvedValue({ - installed: true, - version: "1.0.100", - path: "/usr/local/bin/opencode", - binary: "opencode", - }) - - // when checking installation - const result = await opencode.checkOpenCodeInstallation() - - // then should warn about old version - expect(result.status).toBe("warn") - expect(result.message).toContain("below minimum") - expect(result.details?.some((d) => d.includes(MIN_OPENCODE_VERSION))).toBe(true) - }) - - it("returns pass when properly installed", async () => { - // given current version installed - getInfoSpy = spyOn(opencode, "getOpenCodeInfo").mockResolvedValue({ - installed: true, - version: "1.0.200", - path: "/usr/local/bin/opencode", - binary: "opencode", - }) - - // when checking installation - const result = await opencode.checkOpenCodeInstallation() - - // then should pass - expect(result.status).toBe("pass") - expect(result.message).toContain("1.0.200") - }) - }) - - describe("getOpenCodeCheckDefinition", () => { - it("returns valid check definition", () => { - // given - // when getting definition - const def = opencode.getOpenCodeCheckDefinition() - - // then should have required properties - expect(def.id).toBe("opencode-installation") - expect(def.category).toBe("installation") - expect(def.critical).toBe(true) - expect(typeof def.check).toBe("function") - }) - }) - - describe("getDesktopAppPaths", () => { - it("returns macOS desktop app paths for darwin platform", () => { - // given darwin platform - const platform: NodeJS.Platform = "darwin" - - // when getting desktop paths - const paths = opencode.getDesktopAppPaths(platform) - - // then should include macOS app bundle paths with correct binary name - expect(paths).toContain("/Applications/OpenCode.app/Contents/MacOS/OpenCode") - expect(paths.some((p) => p.includes("Applications/OpenCode.app"))).toBe(true) - }) - - it("returns Windows desktop app paths for win32 platform when env vars set", () => { - // given win32 platform with env vars set - const platform: NodeJS.Platform = "win32" - const originalProgramFiles = process.env.ProgramFiles - const originalLocalAppData = process.env.LOCALAPPDATA - process.env.ProgramFiles = "C:\\Program Files" - process.env.LOCALAPPDATA = "C:\\Users\\Test\\AppData\\Local" - - // when getting desktop paths - const paths = opencode.getDesktopAppPaths(platform) - - // then should include Windows program paths with correct binary name - expect(paths.some((p) => p.includes("Program Files"))).toBe(true) - expect(paths.some((p) => p.endsWith("OpenCode.exe"))).toBe(true) - expect(paths.every((p) => p.startsWith("C:\\"))).toBe(true) - - // cleanup - process.env.ProgramFiles = originalProgramFiles - process.env.LOCALAPPDATA = originalLocalAppData - }) - - it("returns empty array for win32 when all env vars undefined", () => { - // given win32 platform with no env vars - const platform: NodeJS.Platform = "win32" - const originalProgramFiles = process.env.ProgramFiles - const originalLocalAppData = process.env.LOCALAPPDATA - delete process.env.ProgramFiles - delete process.env.LOCALAPPDATA - - // when getting desktop paths - const paths = opencode.getDesktopAppPaths(platform) - - // then should return empty array (no relative paths) - expect(paths).toEqual([]) - - // cleanup - process.env.ProgramFiles = originalProgramFiles - process.env.LOCALAPPDATA = originalLocalAppData - }) - - it("returns Linux desktop app paths for linux platform", () => { - // given linux platform - const platform: NodeJS.Platform = "linux" - - // when getting desktop paths - const paths = opencode.getDesktopAppPaths(platform) - - // then should include verified Linux installation paths - expect(paths).toContain("/usr/bin/opencode") - expect(paths).toContain("/usr/lib/opencode/opencode") - expect(paths.some((p) => p.includes("AppImage"))).toBe(true) - }) - - it("returns empty array for unsupported platforms", () => { - // given unsupported platform - const platform = "freebsd" as NodeJS.Platform - - // when getting desktop paths - const paths = opencode.getDesktopAppPaths(platform) - - // then should return empty array - expect(paths).toEqual([]) - }) - }) - - describe("findOpenCodeBinary with desktop fallback", () => { - it("falls back to desktop paths when PATH binary not found", async () => { - // given no binary in PATH but desktop app exists - const existsSyncMock = (p: string) => - p === "/Applications/OpenCode.app/Contents/MacOS/OpenCode" - - // when finding binary with mocked filesystem - const result = await opencode.findDesktopBinary("darwin", existsSyncMock) - - // then should find desktop app - expect(result).not.toBeNull() - expect(result?.path).toBe("/Applications/OpenCode.app/Contents/MacOS/OpenCode") - }) - - it("returns null when no desktop binary found", async () => { - // given no binary exists - const existsSyncMock = () => false - - // when finding binary - const result = await opencode.findDesktopBinary("darwin", existsSyncMock) - - // then should return null - expect(result).toBeNull() - }) - }) -}) diff --git a/src/cli/doctor/checks/opencode.ts b/src/cli/doctor/checks/opencode.ts deleted file mode 100644 index 1bf91515..00000000 --- a/src/cli/doctor/checks/opencode.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { existsSync } from "node:fs" -import { homedir } from "node:os" -import { join } from "node:path" -import type { CheckResult, CheckDefinition, OpenCodeInfo } from "../types" -import { CHECK_IDS, CHECK_NAMES, MIN_OPENCODE_VERSION, OPENCODE_BINARIES } from "../constants" - -const WINDOWS_EXECUTABLE_EXTS = [".exe", ".cmd", ".bat", ".ps1"] - -export function getDesktopAppPaths(platform: NodeJS.Platform): string[] { - const home = homedir() - - switch (platform) { - case "darwin": - return [ - "/Applications/OpenCode.app/Contents/MacOS/OpenCode", - join(home, "Applications", "OpenCode.app", "Contents", "MacOS", "OpenCode"), - ] - case "win32": { - const programFiles = process.env.ProgramFiles - const localAppData = process.env.LOCALAPPDATA - - const paths: string[] = [] - if (programFiles) { - paths.push(join(programFiles, "OpenCode", "OpenCode.exe")) - } - if (localAppData) { - paths.push(join(localAppData, "OpenCode", "OpenCode.exe")) - } - return paths - } - case "linux": - return [ - "/usr/bin/opencode", - "/usr/lib/opencode/opencode", - join(home, "Applications", "opencode-desktop-linux-x86_64.AppImage"), - join(home, "Applications", "opencode-desktop-linux-aarch64.AppImage"), - ] - default: - return [] - } -} - -export function getBinaryLookupCommand(platform: NodeJS.Platform): "which" | "where" { - return platform === "win32" ? "where" : "which" -} - -export function parseBinaryPaths(output: string): string[] { - return output - .split(/\r?\n/) - .map((line) => line.trim()) - .filter((line) => line.length > 0) -} - -export function selectBinaryPath( - paths: string[], - platform: NodeJS.Platform -): string | null { - if (paths.length === 0) return null - if (platform !== "win32") return paths[0] - - const normalized = paths.map((path) => path.toLowerCase()) - for (const ext of WINDOWS_EXECUTABLE_EXTS) { - const index = normalized.findIndex((path) => path.endsWith(ext)) - if (index !== -1) return paths[index] - } - - return paths[0] -} - -export function buildVersionCommand( - binaryPath: string, - platform: NodeJS.Platform -): string[] { - if ( - platform === "win32" && - binaryPath.toLowerCase().endsWith(".ps1") - ) { - return [ - "powershell", - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - binaryPath, - "--version", - ] - } - - return [binaryPath, "--version"] -} - -export function findDesktopBinary( - platform: NodeJS.Platform = process.platform, - checkExists: (path: string) => boolean = existsSync -): { binary: string; path: string } | null { - const desktopPaths = getDesktopAppPaths(platform) - for (const desktopPath of desktopPaths) { - if (checkExists(desktopPath)) { - return { binary: "opencode", path: desktopPath } - } - } - return null -} - -export async function findOpenCodeBinary(): Promise<{ binary: string; path: string } | null> { - for (const binary of OPENCODE_BINARIES) { - try { - const path = Bun.which(binary) - if (path) { - return { binary, path } - } - } catch { - continue - } - } - - const desktopResult = findDesktopBinary() - if (desktopResult) { - return desktopResult - } - - return null -} - -export async function getOpenCodeVersion( - binaryPath: string, - platform: NodeJS.Platform = process.platform -): Promise { - try { - const command = buildVersionCommand(binaryPath, platform) - const proc = Bun.spawn(command, { stdout: "pipe", stderr: "pipe" }) - const output = await new Response(proc.stdout).text() - await proc.exited - if (proc.exitCode === 0) { - return output.trim() - } - } catch { - return null - } - return null -} - -export function compareVersions(current: string, minimum: string): boolean { - const parseVersion = (v: string): number[] => { - const cleaned = v.replace(/^v/, "").split("-")[0] - return cleaned.split(".").map((n) => parseInt(n, 10) || 0) - } - - const curr = parseVersion(current) - const min = parseVersion(minimum) - - for (let i = 0; i < Math.max(curr.length, min.length); i++) { - const c = curr[i] ?? 0 - const m = min[i] ?? 0 - if (c > m) return true - if (c < m) return false - } - return true -} - -export async function getOpenCodeInfo(): Promise { - const binaryInfo = await findOpenCodeBinary() - - if (!binaryInfo) { - return { - installed: false, - version: null, - path: null, - binary: null, - } - } - - const version = await getOpenCodeVersion(binaryInfo.path ?? binaryInfo.binary) - - return { - installed: true, - version, - path: binaryInfo.path, - binary: binaryInfo.binary as "opencode" | "opencode-desktop", - } -} - -export async function checkOpenCodeInstallation(): Promise { - const info = await getOpenCodeInfo() - - if (!info.installed) { - return { - name: CHECK_NAMES[CHECK_IDS.OPENCODE_INSTALLATION], - status: "fail", - message: "OpenCode is not installed", - details: [ - "Visit: https://opencode.ai/docs for installation instructions", - "Run: npm install -g opencode", - ], - } - } - - if (info.version && !compareVersions(info.version, MIN_OPENCODE_VERSION)) { - return { - name: CHECK_NAMES[CHECK_IDS.OPENCODE_INSTALLATION], - status: "warn", - message: `Version ${info.version} is below minimum ${MIN_OPENCODE_VERSION}`, - details: [ - `Current: ${info.version}`, - `Required: >= ${MIN_OPENCODE_VERSION}`, - "Run: npm update -g opencode", - ], - } - } - - return { - name: CHECK_NAMES[CHECK_IDS.OPENCODE_INSTALLATION], - status: "pass", - message: info.version ?? "installed", - details: info.path ? [`Path: ${info.path}`] : undefined, - } -} - -export function getOpenCodeCheckDefinition(): CheckDefinition { - return { - id: CHECK_IDS.OPENCODE_INSTALLATION, - name: CHECK_NAMES[CHECK_IDS.OPENCODE_INSTALLATION], - category: "installation", - check: checkOpenCodeInstallation, - critical: true, - } -} diff --git a/src/cli/doctor/checks/plugin.test.ts b/src/cli/doctor/checks/plugin.test.ts deleted file mode 100644 index 40071d7f..00000000 --- a/src/cli/doctor/checks/plugin.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { describe, it, expect, spyOn, afterEach } from "bun:test" -import * as plugin from "./plugin" - -describe("plugin check", () => { - describe("getPluginInfo", () => { - it("returns registered: false when config not found", () => { - // given no config file exists - // when getting plugin info - // then should indicate not registered - const info = plugin.getPluginInfo() - expect(typeof info.registered).toBe("boolean") - expect(typeof info.isPinned).toBe("boolean") - }) - }) - - describe("checkPluginRegistration", () => { - let getInfoSpy: ReturnType - - afterEach(() => { - getInfoSpy?.mockRestore() - }) - - it("returns fail when config file not found", async () => { - // given no config file - getInfoSpy = spyOn(plugin, "getPluginInfo").mockReturnValue({ - registered: false, - configPath: null, - entry: null, - isPinned: false, - pinnedVersion: null, - }) - - // when checking registration - const result = await plugin.checkPluginRegistration() - - // then should fail with hint - expect(result.status).toBe("fail") - expect(result.message).toContain("not found") - }) - - it("returns fail when plugin not registered", async () => { - // given config exists but plugin not registered - getInfoSpy = spyOn(plugin, "getPluginInfo").mockReturnValue({ - registered: false, - configPath: "/home/user/.config/opencode/opencode.json", - entry: null, - isPinned: false, - pinnedVersion: null, - }) - - // when checking registration - const result = await plugin.checkPluginRegistration() - - // then should fail - expect(result.status).toBe("fail") - expect(result.message).toContain("not registered") - }) - - it("returns pass when plugin registered", async () => { - // given plugin registered - getInfoSpy = spyOn(plugin, "getPluginInfo").mockReturnValue({ - registered: true, - configPath: "/home/user/.config/opencode/opencode.json", - entry: "oh-my-opencode", - isPinned: false, - pinnedVersion: null, - }) - - // when checking registration - const result = await plugin.checkPluginRegistration() - - // then should pass - expect(result.status).toBe("pass") - expect(result.message).toContain("Registered") - }) - - it("indicates pinned version when applicable", async () => { - // given plugin pinned to version - getInfoSpy = spyOn(plugin, "getPluginInfo").mockReturnValue({ - registered: true, - configPath: "/home/user/.config/opencode/opencode.json", - entry: "oh-my-opencode@2.7.0", - isPinned: true, - pinnedVersion: "2.7.0", - }) - - // when checking registration - const result = await plugin.checkPluginRegistration() - - // then should show pinned version - expect(result.status).toBe("pass") - expect(result.message).toContain("pinned") - expect(result.message).toContain("2.7.0") - }) - }) - - describe("getPluginCheckDefinition", () => { - it("returns valid check definition", () => { - // given - // when getting definition - const def = plugin.getPluginCheckDefinition() - - // then should have required properties - expect(def.id).toBe("plugin-registration") - expect(def.category).toBe("installation") - expect(def.critical).toBe(true) - }) - }) -}) diff --git a/src/cli/doctor/checks/plugin.ts b/src/cli/doctor/checks/plugin.ts deleted file mode 100644 index d7ed9d63..00000000 --- a/src/cli/doctor/checks/plugin.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { existsSync, readFileSync } from "node:fs" -import type { CheckResult, CheckDefinition, PluginInfo } from "../types" -import { CHECK_IDS, CHECK_NAMES, PACKAGE_NAME } from "../constants" -import { parseJsonc, getOpenCodeConfigPaths } from "../../../shared" - -function detectConfigPath(): { path: string; format: "json" | "jsonc" } | null { - const paths = getOpenCodeConfigPaths({ binary: "opencode", version: null }) - - if (existsSync(paths.configJsonc)) { - return { path: paths.configJsonc, format: "jsonc" } - } - if (existsSync(paths.configJson)) { - return { path: paths.configJson, format: "json" } - } - return null -} - -function findPluginEntry(plugins: string[]): { entry: string; isPinned: boolean; version: string | null } | null { - for (const plugin of plugins) { - if (plugin === PACKAGE_NAME || plugin.startsWith(`${PACKAGE_NAME}@`)) { - const isPinned = plugin.includes("@") - const version = isPinned ? plugin.split("@")[1] : null - return { entry: plugin, isPinned, version } - } - if (plugin.startsWith("file://") && plugin.includes(PACKAGE_NAME)) { - return { entry: plugin, isPinned: false, version: "local-dev" } - } - } - return null -} - -export function getPluginInfo(): PluginInfo { - const configInfo = detectConfigPath() - - if (!configInfo) { - return { - registered: false, - configPath: null, - entry: null, - isPinned: false, - pinnedVersion: null, - } - } - - try { - const content = readFileSync(configInfo.path, "utf-8") - const config = parseJsonc<{ plugin?: string[] }>(content) - const plugins = config.plugin ?? [] - const pluginEntry = findPluginEntry(plugins) - - if (!pluginEntry) { - return { - registered: false, - configPath: configInfo.path, - entry: null, - isPinned: false, - pinnedVersion: null, - } - } - - return { - registered: true, - configPath: configInfo.path, - entry: pluginEntry.entry, - isPinned: pluginEntry.isPinned, - pinnedVersion: pluginEntry.version, - } - } catch { - return { - registered: false, - configPath: configInfo.path, - entry: null, - isPinned: false, - pinnedVersion: null, - } - } -} - -export async function checkPluginRegistration(): Promise { - const info = getPluginInfo() - - if (!info.configPath) { - const expectedPaths = getOpenCodeConfigPaths({ binary: "opencode", version: null }) - return { - name: CHECK_NAMES[CHECK_IDS.PLUGIN_REGISTRATION], - status: "fail", - message: "OpenCode config file not found", - details: [ - "Run: bunx oh-my-opencode install", - `Expected: ${expectedPaths.configJson} or ${expectedPaths.configJsonc}`, - ], - } - } - - if (!info.registered) { - return { - name: CHECK_NAMES[CHECK_IDS.PLUGIN_REGISTRATION], - status: "fail", - message: "Plugin not registered in config", - details: [ - "Run: bunx oh-my-opencode install", - `Config: ${info.configPath}`, - ], - } - } - - const message = info.isPinned - ? `Registered (pinned: ${info.pinnedVersion})` - : "Registered" - - return { - name: CHECK_NAMES[CHECK_IDS.PLUGIN_REGISTRATION], - status: "pass", - message, - details: [`Config: ${info.configPath}`], - } -} - -export function getPluginCheckDefinition(): CheckDefinition { - return { - id: CHECK_IDS.PLUGIN_REGISTRATION, - name: CHECK_NAMES[CHECK_IDS.PLUGIN_REGISTRATION], - category: "installation", - check: checkPluginRegistration, - critical: true, - } -} diff --git a/src/cli/doctor/checks/providers.ts b/src/cli/doctor/checks/providers.ts new file mode 100644 index 00000000..c34ebdb3 --- /dev/null +++ b/src/cli/doctor/checks/providers.ts @@ -0,0 +1,101 @@ +import { existsSync, readFileSync } from "node:fs" + +import { AGENT_MODEL_REQUIREMENTS } from "../../../shared/model-requirements" +import { getOpenCodeConfigPaths, parseJsonc } from "../../../shared" +import { AUTH_ENV_VARS, AUTH_PLUGINS, CHECK_IDS, CHECK_NAMES } from "../constants" +import type { CheckResult, DoctorIssue, ProviderStatus } from "../types" + +interface OpenCodeConfigShape { + plugin?: string[] +} + +function loadOpenCodePlugins(): string[] { + const configPaths = getOpenCodeConfigPaths({ binary: "opencode", version: null }) + const targetPath = existsSync(configPaths.configJsonc) + ? configPaths.configJsonc + : configPaths.configJson + + if (!existsSync(targetPath)) return [] + + try { + const content = readFileSync(targetPath, "utf-8") + const parsed = parseJsonc(content) + return parsed.plugin ?? [] + } catch { + return [] + } +} + +function hasProviderPlugin(plugins: string[], providerId: string): boolean { + const definition = AUTH_PLUGINS[providerId] + if (!definition) return false + if (definition.plugin === "builtin") return true + return plugins.some((plugin) => plugin === definition.plugin || plugin.startsWith(`${definition.plugin}@`)) +} + +function hasProviderEnvVar(providerId: string): boolean { + const envVarNames = AUTH_ENV_VARS[providerId] ?? [] + return envVarNames.some((envVarName) => Boolean(process.env[envVarName])) +} + +function getAffectedAgents(providerId: string): string[] { + const affectedAgents: string[] = [] + + for (const [agentName, requirement] of Object.entries(AGENT_MODEL_REQUIREMENTS)) { + const usesProvider = requirement.fallbackChain.some((entry) => entry.providers.includes(providerId)) + if (usesProvider) { + affectedAgents.push(agentName) + } + } + + return affectedAgents +} + +export function gatherProviderStatuses(): ProviderStatus[] { + const plugins = loadOpenCodePlugins() + + return Object.entries(AUTH_PLUGINS).map(([providerId, definition]) => { + const hasPlugin = hasProviderPlugin(plugins, providerId) + const hasEnvVar = hasProviderEnvVar(providerId) + return { + id: providerId, + name: definition.name, + available: hasPlugin && hasEnvVar, + hasPlugin, + hasEnvVar, + } + }) +} + +export async function checkProviders(): Promise { + const statuses = gatherProviderStatuses() + const issues: DoctorIssue[] = [] + + for (const status of statuses) { + if (status.available) continue + + const missingParts: string[] = [] + if (!status.hasPlugin) missingParts.push("auth plugin") + if (!status.hasEnvVar) missingParts.push("environment variable") + + issues.push({ + title: `${status.name} authentication missing`, + description: `Missing ${missingParts.join(" and ")} for ${status.name}.`, + fix: `Configure ${status.name} provider in OpenCode and set ${(AUTH_ENV_VARS[status.id] ?? []).join(" or ")}`, + affects: getAffectedAgents(status.id), + severity: "warning", + }) + } + + const status = issues.length === 0 ? "pass" : "warn" + return { + name: CHECK_NAMES[CHECK_IDS.PROVIDERS], + status, + message: issues.length === 0 ? "All provider auth checks passed" : `${issues.length} provider issue(s) detected`, + details: statuses.map( + (providerStatus) => + `${providerStatus.name}: plugin=${providerStatus.hasPlugin ? "yes" : "no"}, env=${providerStatus.hasEnvVar ? "yes" : "no"}` + ), + issues, + } +} diff --git a/src/cli/doctor/checks/system-binary.ts b/src/cli/doctor/checks/system-binary.ts new file mode 100644 index 00000000..670d7ce1 --- /dev/null +++ b/src/cli/doctor/checks/system-binary.ts @@ -0,0 +1,144 @@ +import { existsSync } from "node:fs" +import { homedir } from "node:os" +import { join } from "node:path" + +import { OPENCODE_BINARIES } from "../constants" + +const WINDOWS_EXECUTABLE_EXTS = [".exe", ".cmd", ".bat", ".ps1"] + +export interface OpenCodeBinaryInfo { + binary: string + path: string +} + +export function getDesktopAppPaths(platform: NodeJS.Platform): string[] { + const home = homedir() + + switch (platform) { + case "darwin": + return [ + "/Applications/OpenCode.app/Contents/MacOS/OpenCode", + join(home, "Applications", "OpenCode.app", "Contents", "MacOS", "OpenCode"), + ] + case "win32": { + const programFiles = process.env.ProgramFiles + const localAppData = process.env.LOCALAPPDATA + const paths: string[] = [] + + if (programFiles) { + paths.push(join(programFiles, "OpenCode", "OpenCode.exe")) + } + if (localAppData) { + paths.push(join(localAppData, "OpenCode", "OpenCode.exe")) + } + + return paths + } + case "linux": + return [ + "/usr/bin/opencode", + "/usr/lib/opencode/opencode", + join(home, "Applications", "opencode-desktop-linux-x86_64.AppImage"), + join(home, "Applications", "opencode-desktop-linux-aarch64.AppImage"), + ] + default: + return [] + } +} + +export function getBinaryLookupCommand(platform: NodeJS.Platform): "which" | "where" { + return platform === "win32" ? "where" : "which" +} + +export function parseBinaryPaths(output: string): string[] { + return output + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0) +} + +export function selectBinaryPath(paths: string[], platform: NodeJS.Platform): string | null { + if (paths.length === 0) return null + if (platform !== "win32") return paths[0] ?? null + + const normalizedPaths = paths.map((path) => path.toLowerCase()) + for (const extension of WINDOWS_EXECUTABLE_EXTS) { + const pathIndex = normalizedPaths.findIndex((path) => path.endsWith(extension)) + if (pathIndex !== -1) { + return paths[pathIndex] ?? null + } + } + + return paths[0] ?? null +} + +export function buildVersionCommand(binaryPath: string, platform: NodeJS.Platform): string[] { + if (platform === "win32" && binaryPath.toLowerCase().endsWith(".ps1")) { + return ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", binaryPath, "--version"] + } + + return [binaryPath, "--version"] +} + +export function findDesktopBinary( + platform: NodeJS.Platform = process.platform, + checkExists: (path: string) => boolean = existsSync +): OpenCodeBinaryInfo | null { + for (const desktopPath of getDesktopAppPaths(platform)) { + if (checkExists(desktopPath)) { + return { binary: "opencode", path: desktopPath } + } + } + + return null +} + +export async function findOpenCodeBinary(): Promise { + for (const binary of OPENCODE_BINARIES) { + const path = Bun.which(binary) + if (path) { + return { binary, path } + } + } + + return findDesktopBinary() +} + +export async function getOpenCodeVersion( + binaryPath: string, + platform: NodeJS.Platform = process.platform +): Promise { + try { + const command = buildVersionCommand(binaryPath, platform) + const processResult = Bun.spawn(command, { stdout: "pipe", stderr: "pipe" }) + const output = await new Response(processResult.stdout).text() + await processResult.exited + + if (processResult.exitCode !== 0) return null + return output.trim() || null + } catch { + return null + } +} + +export function compareVersions(current: string, minimum: string): boolean { + const parseVersion = (version: string): number[] => + version + .replace(/^v/, "") + .split("-")[0] + .split(".") + .map((part) => Number.parseInt(part, 10) || 0) + + const currentParts = parseVersion(current) + const minimumParts = parseVersion(minimum) + const length = Math.max(currentParts.length, minimumParts.length) + + for (let index = 0; index < length; index++) { + const currentPart = currentParts[index] ?? 0 + const minimumPart = minimumParts[index] ?? 0 + if (currentPart > minimumPart) return true + if (currentPart < minimumPart) return false + } + + return true +} diff --git a/src/cli/doctor/checks/system-loaded-version.ts b/src/cli/doctor/checks/system-loaded-version.ts new file mode 100644 index 00000000..968766ec --- /dev/null +++ b/src/cli/doctor/checks/system-loaded-version.ts @@ -0,0 +1,79 @@ +import { existsSync, readFileSync } from "node:fs" +import { homedir } from "node:os" +import { join } from "node:path" + +import { getLatestVersion } from "../../../hooks/auto-update-checker/checker" +import { extractChannel } from "../../../hooks/auto-update-checker" +import { PACKAGE_NAME } from "../constants" +import { getOpenCodeCacheDir, parseJsonc } from "../../../shared" + +interface PackageJsonShape { + version?: string + dependencies?: Record +} + +export interface LoadedVersionInfo { + cacheDir: string + cachePackagePath: string + installedPackagePath: string + expectedVersion: string | null + loadedVersion: string | null +} + +function getPlatformDefaultCacheDir(platform: NodeJS.Platform = process.platform): string { + if (platform === "darwin") return join(homedir(), "Library", "Caches") + if (platform === "win32") return process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local") + return join(homedir(), ".cache") +} + +function resolveOpenCodeCacheDir(): string { + const xdgCacheHome = process.env.XDG_CACHE_HOME + if (xdgCacheHome) return join(xdgCacheHome, "opencode") + + const fromShared = getOpenCodeCacheDir() + const platformDefault = join(getPlatformDefaultCacheDir(), "opencode") + if (existsSync(fromShared) || !existsSync(platformDefault)) return fromShared + return platformDefault +} + +function readPackageJson(filePath: string): PackageJsonShape | null { + if (!existsSync(filePath)) return null + + try { + const content = readFileSync(filePath, "utf-8") + return parseJsonc(content) + } catch { + return null + } +} + +function normalizeVersion(value: string | undefined): string | null { + if (!value) return null + const match = value.match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/) + return match?.[0] ?? null +} + +export function getLoadedPluginVersion(): LoadedVersionInfo { + const cacheDir = resolveOpenCodeCacheDir() + const cachePackagePath = join(cacheDir, "package.json") + const installedPackagePath = join(cacheDir, "node_modules", PACKAGE_NAME, "package.json") + + const cachePackage = readPackageJson(cachePackagePath) + const installedPackage = readPackageJson(installedPackagePath) + + const expectedVersion = normalizeVersion(cachePackage?.dependencies?.[PACKAGE_NAME]) + const loadedVersion = normalizeVersion(installedPackage?.version) + + return { + cacheDir, + cachePackagePath, + installedPackagePath, + expectedVersion, + loadedVersion, + } +} + +export async function getLatestPluginVersion(currentVersion: string | null): Promise { + const channel = extractChannel(currentVersion) + return getLatestVersion(channel) +} diff --git a/src/cli/doctor/checks/system-plugin.ts b/src/cli/doctor/checks/system-plugin.ts new file mode 100644 index 00000000..8c8c7b2f --- /dev/null +++ b/src/cli/doctor/checks/system-plugin.ts @@ -0,0 +1,95 @@ +import { existsSync, readFileSync } from "node:fs" + +import { PACKAGE_NAME } from "../constants" +import { getOpenCodeConfigPaths, parseJsonc } from "../../../shared" + +export interface PluginInfo { + registered: boolean + configPath: string | null + entry: string | null + isPinned: boolean + pinnedVersion: string | null + isLocalDev: boolean +} + +interface OpenCodeConfigShape { + plugin?: string[] +} + +function detectConfigPath(): string | null { + const paths = getOpenCodeConfigPaths({ binary: "opencode", version: null }) + if (existsSync(paths.configJsonc)) return paths.configJsonc + if (existsSync(paths.configJson)) return paths.configJson + return null +} + +function parsePluginVersion(entry: string): string | null { + if (!entry.startsWith(`${PACKAGE_NAME}@`)) return null + const value = entry.slice(PACKAGE_NAME.length + 1) + if (!value || value === "latest") return null + return value +} + +function findPluginEntry(entries: string[]): { entry: string; isLocalDev: boolean } | null { + for (const entry of entries) { + if (entry === PACKAGE_NAME || entry.startsWith(`${PACKAGE_NAME}@`)) { + return { entry, isLocalDev: false } + } + if (entry.startsWith("file://") && entry.includes(PACKAGE_NAME)) { + return { entry, isLocalDev: true } + } + } + + return null +} + +export function getPluginInfo(): PluginInfo { + const configPath = detectConfigPath() + if (!configPath) { + return { + registered: false, + configPath: null, + entry: null, + isPinned: false, + pinnedVersion: null, + isLocalDev: false, + } + } + + try { + const content = readFileSync(configPath, "utf-8") + const parsedConfig = parseJsonc(content) + const pluginEntry = findPluginEntry(parsedConfig.plugin ?? []) + if (!pluginEntry) { + return { + registered: false, + configPath, + entry: null, + isPinned: false, + pinnedVersion: null, + isLocalDev: false, + } + } + + const pinnedVersion = parsePluginVersion(pluginEntry.entry) + return { + registered: true, + configPath, + entry: pluginEntry.entry, + isPinned: pinnedVersion !== null, + pinnedVersion, + isLocalDev: pluginEntry.isLocalDev, + } + } catch { + return { + registered: false, + configPath, + entry: null, + isPinned: false, + pinnedVersion: null, + isLocalDev: false, + } + } +} + +export { detectConfigPath, findPluginEntry } diff --git a/src/cli/doctor/checks/system.ts b/src/cli/doctor/checks/system.ts new file mode 100644 index 00000000..05d32d68 --- /dev/null +++ b/src/cli/doctor/checks/system.ts @@ -0,0 +1,129 @@ +import { existsSync, readFileSync } from "node:fs" + +import { MIN_OPENCODE_VERSION, CHECK_IDS, CHECK_NAMES } from "../constants" +import type { CheckResult, DoctorIssue, SystemInfo } from "../types" +import { findOpenCodeBinary, getOpenCodeVersion, compareVersions } from "./system-binary" +import { getPluginInfo } from "./system-plugin" +import { getLatestPluginVersion, getLoadedPluginVersion } from "./system-loaded-version" +import { parseJsonc } from "../../../shared" + +function isConfigValid(configPath: string | null): boolean { + if (!configPath) return true + if (!existsSync(configPath)) return false + + try { + parseJsonc>(readFileSync(configPath, "utf-8")) + return true + } catch { + return false + } +} + +function getResultStatus(issues: DoctorIssue[]): CheckResult["status"] { + if (issues.some((issue) => issue.severity === "error")) return "fail" + if (issues.some((issue) => issue.severity === "warning")) return "warn" + return "pass" +} + +function buildMessage(status: CheckResult["status"], issues: DoctorIssue[]): string { + if (status === "pass") return "System checks passed" + if (status === "fail") return `${issues.length} system issue(s) detected` + return `${issues.length} system warning(s) detected` +} + +export async function gatherSystemInfo(): Promise { + const [binaryInfo, pluginInfo] = await Promise.all([findOpenCodeBinary(), Promise.resolve(getPluginInfo())]) + const loadedInfo = getLoadedPluginVersion() + + const opencodeVersion = binaryInfo ? await getOpenCodeVersion(binaryInfo.path) : null + const pluginVersion = pluginInfo.pinnedVersion ?? loadedInfo.expectedVersion + + return { + opencodeVersion, + opencodePath: binaryInfo?.path ?? null, + pluginVersion, + loadedVersion: loadedInfo.loadedVersion, + bunVersion: Bun.version, + configPath: pluginInfo.configPath, + configValid: isConfigValid(pluginInfo.configPath), + isLocalDev: pluginInfo.isLocalDev, + } +} + +export async function checkSystem(): Promise { + const [systemInfo, pluginInfo] = await Promise.all([gatherSystemInfo(), Promise.resolve(getPluginInfo())]) + const loadedInfo = getLoadedPluginVersion() + const latestVersion = await getLatestPluginVersion(systemInfo.loadedVersion) + const issues: DoctorIssue[] = [] + + if (!systemInfo.opencodePath) { + issues.push({ + title: "OpenCode binary not found", + description: "Install OpenCode CLI or desktop and ensure the binary is available.", + fix: "Install from https://opencode.ai/docs", + severity: "error", + affects: ["doctor", "run"], + }) + } + + if ( + systemInfo.opencodeVersion && + !compareVersions(systemInfo.opencodeVersion, MIN_OPENCODE_VERSION) + ) { + issues.push({ + title: "OpenCode version below minimum", + description: `Detected ${systemInfo.opencodeVersion}; required >= ${MIN_OPENCODE_VERSION}.`, + fix: "Update OpenCode to the latest stable release", + severity: "warning", + affects: ["tooling", "doctor"], + }) + } + + if (!pluginInfo.registered) { + issues.push({ + title: "oh-my-opencode is not registered", + description: "Plugin entry is missing from OpenCode configuration.", + fix: "Run: bunx oh-my-opencode install", + severity: "error", + affects: ["all agents"], + }) + } + + if (loadedInfo.expectedVersion && loadedInfo.loadedVersion && loadedInfo.expectedVersion !== loadedInfo.loadedVersion) { + issues.push({ + title: "Loaded plugin version mismatch", + description: `Cache expects ${loadedInfo.expectedVersion} but loaded ${loadedInfo.loadedVersion}.`, + fix: "Reinstall plugin dependencies in OpenCode cache", + severity: "warning", + affects: ["plugin loading"], + }) + } + + if ( + systemInfo.loadedVersion && + latestVersion && + !compareVersions(systemInfo.loadedVersion, latestVersion) + ) { + issues.push({ + title: "Loaded plugin is outdated", + description: `Loaded ${systemInfo.loadedVersion}, latest ${latestVersion}.`, + fix: "Update: cd ~/.config/opencode && bun update oh-my-opencode", + severity: "warning", + affects: ["plugin features"], + }) + } + + const status = getResultStatus(issues) + return { + name: CHECK_NAMES[CHECK_IDS.SYSTEM], + status, + message: buildMessage(status, issues), + details: [ + systemInfo.opencodeVersion ? `OpenCode: ${systemInfo.opencodeVersion}` : "OpenCode: not detected", + `Plugin expected: ${systemInfo.pluginVersion ?? "unknown"}`, + `Plugin loaded: ${systemInfo.loadedVersion ?? "unknown"}`, + `Bun: ${systemInfo.bunVersion ?? "unknown"}`, + ], + issues, + } +} diff --git a/src/cli/doctor/checks/tools-gh.ts b/src/cli/doctor/checks/tools-gh.ts new file mode 100644 index 00000000..a9ac59a9 --- /dev/null +++ b/src/cli/doctor/checks/tools-gh.ts @@ -0,0 +1,105 @@ +export interface GhCliInfo { + installed: boolean + version: string | null + path: string | null + authenticated: boolean + username: string | null + scopes: string[] + error: string | null +} + +async function checkBinaryExists(binary: string): Promise<{ exists: boolean; path: string | null }> { + try { + const binaryPath = Bun.which(binary) + return { exists: Boolean(binaryPath), path: binaryPath ?? null } + } catch { + return { exists: false, path: null } + } +} + +async function getGhVersion(): Promise { + try { + const processResult = Bun.spawn(["gh", "--version"], { stdout: "pipe", stderr: "pipe" }) + const output = await new Response(processResult.stdout).text() + await processResult.exited + if (processResult.exitCode !== 0) return null + + const matchedVersion = output.match(/gh version (\S+)/) + return matchedVersion?.[1] ?? output.trim().split("\n")[0] ?? null + } catch { + return null + } +} + +async function getGhAuthStatus(): Promise<{ + authenticated: boolean + username: string | null + scopes: string[] + error: string | null +}> { + try { + const processResult = Bun.spawn(["gh", "auth", "status"], { + stdout: "pipe", + stderr: "pipe", + env: { ...process.env, GH_NO_UPDATE_NOTIFIER: "1" }, + }) + + const stdout = await new Response(processResult.stdout).text() + const stderr = await new Response(processResult.stderr).text() + await processResult.exited + + const output = stderr || stdout + if (processResult.exitCode === 0) { + const usernameMatch = output.match(/Logged in to github\.com account (\S+)/) + const scopesMatch = output.match(/Token scopes?:\s*(.+)/i) + + return { + authenticated: true, + username: usernameMatch?.[1]?.replace(/[()]/g, "") ?? null, + scopes: scopesMatch?.[1]?.split(/,\s*/).map((scope) => scope.trim()).filter(Boolean) ?? [], + error: null, + } + } + + const errorMatch = output.match(/error[:\s]+(.+)/i) + return { + authenticated: false, + username: null, + scopes: [], + error: errorMatch?.[1]?.trim() ?? "Not authenticated", + } + } catch (error) { + return { + authenticated: false, + username: null, + scopes: [], + error: error instanceof Error ? error.message : "Failed to check auth status", + } + } +} + +export async function getGhCliInfo(): Promise { + const binaryStatus = await checkBinaryExists("gh") + if (!binaryStatus.exists) { + return { + installed: false, + version: null, + path: null, + authenticated: false, + username: null, + scopes: [], + error: null, + } + } + + const [version, authStatus] = await Promise.all([getGhVersion(), getGhAuthStatus()]) + return { + installed: true, + version, + path: binaryStatus.path, + authenticated: authStatus.authenticated, + username: authStatus.username, + scopes: authStatus.scopes, + error: authStatus.error, + } +} diff --git a/src/cli/doctor/checks/tools-lsp.ts b/src/cli/doctor/checks/tools-lsp.ts new file mode 100644 index 00000000..900a93e7 --- /dev/null +++ b/src/cli/doctor/checks/tools-lsp.ts @@ -0,0 +1,25 @@ +import type { LspServerInfo } from "../types" +import { isServerInstalled } from "../../../tools/lsp/config" + +const DEFAULT_LSP_SERVERS: Array<{ id: string; binary: string; extensions: string[] }> = [ + { id: "typescript-language-server", binary: "typescript-language-server", extensions: [".ts", ".tsx", ".js", ".jsx"] }, + { id: "pyright", binary: "pyright-langserver", extensions: [".py"] }, + { id: "rust-analyzer", binary: "rust-analyzer", extensions: [".rs"] }, + { id: "gopls", binary: "gopls", extensions: [".go"] }, +] + +export function getLspServersInfo(): LspServerInfo[] { + return DEFAULT_LSP_SERVERS.map((server) => ({ + id: server.id, + installed: isServerInstalled([server.binary]), + extensions: server.extensions, + source: "builtin", + })) +} + +export function getLspServerStats(servers: LspServerInfo[]): { installed: number; total: number } { + return { + installed: servers.filter((server) => server.installed).length, + total: servers.length, + } +} diff --git a/src/cli/doctor/checks/tools-mcp.ts b/src/cli/doctor/checks/tools-mcp.ts new file mode 100644 index 00000000..cd3a1406 --- /dev/null +++ b/src/cli/doctor/checks/tools-mcp.ts @@ -0,0 +1,62 @@ +import { existsSync, readFileSync } from "node:fs" +import { homedir } from "node:os" +import { join } from "node:path" + +import type { McpServerInfo } from "../types" +import { parseJsonc } from "../../../shared" + +const BUILTIN_MCP_SERVERS = ["context7", "grep_app"] + +interface McpConfigShape { + mcpServers?: Record +} + +function getMcpConfigPaths(): string[] { + return [ + join(homedir(), ".claude", ".mcp.json"), + join(process.cwd(), ".mcp.json"), + join(process.cwd(), ".claude", ".mcp.json"), + ] +} + +function loadUserMcpConfig(): Record { + const servers: Record = {} + + for (const configPath of getMcpConfigPaths()) { + if (!existsSync(configPath)) continue + + try { + const content = readFileSync(configPath, "utf-8") + const config = parseJsonc(content) + if (config.mcpServers) { + Object.assign(servers, config.mcpServers) + } + } catch { + continue + } + } + + return servers +} + +export function getBuiltinMcpInfo(): McpServerInfo[] { + return BUILTIN_MCP_SERVERS.map((serverId) => ({ + id: serverId, + type: "builtin", + enabled: true, + valid: true, + })) +} + +export function getUserMcpInfo(): McpServerInfo[] { + return Object.entries(loadUserMcpConfig()).map(([serverId, value]) => { + const valid = typeof value === "object" && value !== null + return { + id: serverId, + type: "user", + enabled: true, + valid, + error: valid ? undefined : "Invalid configuration format", + } + }) +} diff --git a/src/cli/doctor/checks/tools.ts b/src/cli/doctor/checks/tools.ts new file mode 100644 index 00000000..a182a063 --- /dev/null +++ b/src/cli/doctor/checks/tools.ts @@ -0,0 +1,118 @@ +import { checkAstGrepCli, checkAstGrepNapi, checkCommentChecker } from "./dependencies" +import { getGhCliInfo } from "./tools-gh" +import { getLspServerStats, getLspServersInfo } from "./tools-lsp" +import { getBuiltinMcpInfo, getUserMcpInfo } from "./tools-mcp" +import { CHECK_IDS, CHECK_NAMES } from "../constants" +import type { CheckResult, DoctorIssue, ToolsSummary } from "../types" + +export async function gatherToolsSummary(): Promise { + const [astGrepCliInfo, astGrepNapiInfo, commentCheckerInfo, ghInfo] = await Promise.all([ + checkAstGrepCli(), + checkAstGrepNapi(), + checkCommentChecker(), + getGhCliInfo(), + ]) + + const lspServers = getLspServersInfo() + const lspStats = getLspServerStats(lspServers) + const builtinMcp = getBuiltinMcpInfo() + const userMcp = getUserMcpInfo() + + return { + lspInstalled: lspStats.installed, + lspTotal: lspStats.total, + astGrepCli: astGrepCliInfo.installed, + astGrepNapi: astGrepNapiInfo.installed, + commentChecker: commentCheckerInfo.installed, + ghCli: { + installed: ghInfo.installed, + authenticated: ghInfo.authenticated, + username: ghInfo.username, + }, + mcpBuiltin: builtinMcp.map((server) => server.id), + mcpUser: userMcp.map((server) => server.id), + } +} + +function buildToolIssues(summary: ToolsSummary): DoctorIssue[] { + const issues: DoctorIssue[] = [] + + if (!summary.astGrepCli && !summary.astGrepNapi) { + issues.push({ + title: "AST-Grep unavailable", + description: "Neither AST-Grep CLI nor NAPI backend is available.", + fix: "Install @ast-grep/cli globally or add @ast-grep/napi", + severity: "warning", + affects: ["ast_grep_search", "ast_grep_replace"], + }) + } + + if (!summary.commentChecker) { + issues.push({ + title: "Comment checker unavailable", + description: "Comment checker binary is not installed.", + fix: "Install @code-yeongyu/comment-checker", + severity: "warning", + affects: ["comment-checker hook"], + }) + } + + if (summary.lspInstalled === 0) { + issues.push({ + title: "No LSP servers detected", + description: "LSP-dependent tools will be limited until at least one server is installed.", + severity: "warning", + affects: ["lsp diagnostics", "rename", "references"], + }) + } + + if (!summary.ghCli.installed) { + issues.push({ + title: "GitHub CLI missing", + description: "gh CLI is not installed.", + fix: "Install from https://cli.github.com/", + severity: "warning", + affects: ["GitHub automation"], + }) + } else if (!summary.ghCli.authenticated) { + issues.push({ + title: "GitHub CLI not authenticated", + description: "gh CLI is installed but not logged in.", + fix: "Run: gh auth login", + severity: "warning", + affects: ["GitHub automation"], + }) + } + + return issues +} + +export async function checkTools(): Promise { + const summary = await gatherToolsSummary() + const userMcpServers = getUserMcpInfo() + const invalidUserMcpServers = userMcpServers.filter((server) => !server.valid) + const issues = buildToolIssues(summary) + + if (invalidUserMcpServers.length > 0) { + issues.push({ + title: "Invalid MCP server configuration", + description: `${invalidUserMcpServers.length} user MCP server(s) have invalid config format.`, + severity: "warning", + affects: ["custom MCP tools"], + }) + } + + return { + name: CHECK_NAMES[CHECK_IDS.TOOLS], + status: issues.length === 0 ? "pass" : "warn", + message: issues.length === 0 ? "All tools checks passed" : `${issues.length} tools issue(s) detected`, + details: [ + `AST-Grep: cli=${summary.astGrepCli ? "yes" : "no"}, napi=${summary.astGrepNapi ? "yes" : "no"}`, + `Comment checker: ${summary.commentChecker ? "yes" : "no"}`, + `LSP: ${summary.lspInstalled}/${summary.lspTotal}`, + `GH CLI: ${summary.ghCli.installed ? "installed" : "missing"}${summary.ghCli.authenticated ? " (authenticated)" : ""}`, + `MCP: builtin=${summary.mcpBuiltin.length}, user=${summary.mcpUser.length}`, + ], + issues, + } +} diff --git a/src/cli/doctor/checks/version.test.ts b/src/cli/doctor/checks/version.test.ts deleted file mode 100644 index 9f51cea8..00000000 --- a/src/cli/doctor/checks/version.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { describe, it, expect, spyOn, afterEach } from "bun:test" -import * as version from "./version" - -describe("version check", () => { - describe("getVersionInfo", () => { - it("returns version check info structure", async () => { - // given - // when getting version info - const info = await version.getVersionInfo() - - // then should have expected structure - expect(typeof info.isUpToDate).toBe("boolean") - expect(typeof info.isLocalDev).toBe("boolean") - expect(typeof info.isPinned).toBe("boolean") - }) - }) - - describe("checkVersionStatus", () => { - let getInfoSpy: ReturnType - - afterEach(() => { - getInfoSpy?.mockRestore() - }) - - it("returns pass when in local dev mode", async () => { - // given local dev mode - getInfoSpy = spyOn(version, "getVersionInfo").mockResolvedValue({ - currentVersion: "local-dev", - latestVersion: "2.7.0", - isUpToDate: true, - isLocalDev: true, - isPinned: false, - }) - - // when checking - const result = await version.checkVersionStatus() - - // then should pass with dev message - expect(result.status).toBe("pass") - expect(result.message).toContain("local development") - }) - - it("returns pass when pinned", async () => { - // given pinned version - getInfoSpy = spyOn(version, "getVersionInfo").mockResolvedValue({ - currentVersion: "2.6.0", - latestVersion: "2.7.0", - isUpToDate: true, - isLocalDev: false, - isPinned: true, - }) - - // when checking - const result = await version.checkVersionStatus() - - // then should pass with pinned message - expect(result.status).toBe("pass") - expect(result.message).toContain("Pinned") - }) - - it("returns warn when unable to determine version", async () => { - // given no version info - getInfoSpy = spyOn(version, "getVersionInfo").mockResolvedValue({ - currentVersion: null, - latestVersion: "2.7.0", - isUpToDate: false, - isLocalDev: false, - isPinned: false, - }) - - // when checking - const result = await version.checkVersionStatus() - - // then should warn - expect(result.status).toBe("warn") - expect(result.message).toContain("Unable to determine") - }) - - it("returns warn when network error", async () => { - // given network error - getInfoSpy = spyOn(version, "getVersionInfo").mockResolvedValue({ - currentVersion: "2.6.0", - latestVersion: null, - isUpToDate: true, - isLocalDev: false, - isPinned: false, - }) - - // when checking - const result = await version.checkVersionStatus() - - // then should warn - expect(result.status).toBe("warn") - expect(result.details?.some((d) => d.includes("network"))).toBe(true) - }) - - it("returns warn when update available", async () => { - // given update available - getInfoSpy = spyOn(version, "getVersionInfo").mockResolvedValue({ - currentVersion: "2.6.0", - latestVersion: "2.7.0", - isUpToDate: false, - isLocalDev: false, - isPinned: false, - }) - - // when checking - const result = await version.checkVersionStatus() - - // then should warn with update info - expect(result.status).toBe("warn") - expect(result.message).toContain("Update available") - expect(result.message).toContain("2.6.0") - expect(result.message).toContain("2.7.0") - }) - - it("returns pass when up to date", async () => { - // given up to date - getInfoSpy = spyOn(version, "getVersionInfo").mockResolvedValue({ - currentVersion: "2.7.0", - latestVersion: "2.7.0", - isUpToDate: true, - isLocalDev: false, - isPinned: false, - }) - - // when checking - const result = await version.checkVersionStatus() - - // then should pass - expect(result.status).toBe("pass") - expect(result.message).toContain("Up to date") - }) - }) - - describe("getVersionCheckDefinition", () => { - it("returns valid check definition", () => { - // given - // when getting definition - const def = version.getVersionCheckDefinition() - - // then should have required properties - expect(def.id).toBe("version-status") - expect(def.category).toBe("updates") - expect(def.critical).toBe(false) - }) - }) -}) diff --git a/src/cli/doctor/checks/version.ts b/src/cli/doctor/checks/version.ts deleted file mode 100644 index 0bde1393..00000000 --- a/src/cli/doctor/checks/version.ts +++ /dev/null @@ -1,135 +0,0 @@ -import type { CheckResult, CheckDefinition, VersionCheckInfo } from "../types" -import { CHECK_IDS, CHECK_NAMES } from "../constants" -import { - getCachedVersion, - getLatestVersion, - isLocalDevMode, - findPluginEntry, -} from "../../../hooks/auto-update-checker/checker" - -function compareVersions(current: string, latest: string): boolean { - const parseVersion = (v: string): number[] => { - const cleaned = v.replace(/^v/, "").split("-")[0] - return cleaned.split(".").map((n) => parseInt(n, 10) || 0) - } - - const curr = parseVersion(current) - const lat = parseVersion(latest) - - for (let i = 0; i < Math.max(curr.length, lat.length); i++) { - const c = curr[i] ?? 0 - const l = lat[i] ?? 0 - if (c < l) return false - if (c > l) return true - } - return true -} - -export async function getVersionInfo(): Promise { - const cwd = process.cwd() - - if (isLocalDevMode(cwd)) { - return { - currentVersion: "local-dev", - latestVersion: null, - isUpToDate: true, - isLocalDev: true, - isPinned: false, - } - } - - const pluginInfo = findPluginEntry(cwd) - if (pluginInfo?.isPinned) { - return { - currentVersion: pluginInfo.pinnedVersion, - latestVersion: null, - isUpToDate: true, - isLocalDev: false, - isPinned: true, - } - } - - const currentVersion = getCachedVersion() - const { extractChannel } = await import("../../../hooks/auto-update-checker/index") - const channel = extractChannel(pluginInfo?.pinnedVersion ?? currentVersion) - const latestVersion = await getLatestVersion(channel) - - const isUpToDate = - !currentVersion || - !latestVersion || - compareVersions(currentVersion, latestVersion) - - return { - currentVersion, - latestVersion, - isUpToDate, - isLocalDev: false, - isPinned: false, - } -} - -export async function checkVersionStatus(): Promise { - const info = await getVersionInfo() - - if (info.isLocalDev) { - return { - name: CHECK_NAMES[CHECK_IDS.VERSION_STATUS], - status: "pass", - message: "Running in local development mode", - details: ["Using file:// protocol from config"], - } - } - - if (info.isPinned) { - return { - name: CHECK_NAMES[CHECK_IDS.VERSION_STATUS], - status: "pass", - message: `Pinned to version ${info.currentVersion}`, - details: ["Update check skipped for pinned versions"], - } - } - - if (!info.currentVersion) { - return { - name: CHECK_NAMES[CHECK_IDS.VERSION_STATUS], - status: "warn", - message: "Unable to determine current version", - details: ["Run: bunx oh-my-opencode get-local-version"], - } - } - - if (!info.latestVersion) { - return { - name: CHECK_NAMES[CHECK_IDS.VERSION_STATUS], - status: "warn", - message: `Current: ${info.currentVersion}`, - details: ["Unable to check for updates (network error)"], - } - } - - if (!info.isUpToDate) { - return { - name: CHECK_NAMES[CHECK_IDS.VERSION_STATUS], - status: "warn", - message: `Update available: ${info.currentVersion} -> ${info.latestVersion}`, - details: ["Run: cd ~/.config/opencode && bun update oh-my-opencode"], - } - } - - return { - name: CHECK_NAMES[CHECK_IDS.VERSION_STATUS], - status: "pass", - message: `Up to date (${info.currentVersion})`, - details: info.latestVersion ? [`Latest: ${info.latestVersion}`] : undefined, - } -} - -export function getVersionCheckDefinition(): CheckDefinition { - return { - id: CHECK_IDS.VERSION_STATUS, - name: CHECK_NAMES[CHECK_IDS.VERSION_STATUS], - category: "updates", - check: checkVersionStatus, - critical: false, - } -} diff --git a/src/cli/doctor/constants.ts b/src/cli/doctor/constants.ts index df6f8800..bfd66afe 100644 --- a/src/cli/doctor/constants.ts +++ b/src/cli/doctor/constants.ts @@ -18,50 +18,31 @@ export const STATUS_COLORS = { } as const export const CHECK_IDS = { - OPENCODE_INSTALLATION: "opencode-installation", - PLUGIN_REGISTRATION: "plugin-registration", - CONFIG_VALIDATION: "config-validation", - MODEL_RESOLUTION: "model-resolution", - AUTH_ANTHROPIC: "auth-anthropic", - AUTH_OPENAI: "auth-openai", - AUTH_GOOGLE: "auth-google", - DEP_AST_GREP_CLI: "dep-ast-grep-cli", - DEP_AST_GREP_NAPI: "dep-ast-grep-napi", - DEP_COMMENT_CHECKER: "dep-comment-checker", - GH_CLI: "gh-cli", - LSP_SERVERS: "lsp-servers", - MCP_BUILTIN: "mcp-builtin", - MCP_USER: "mcp-user", - MCP_OAUTH_TOKENS: "mcp-oauth-tokens", - VERSION_STATUS: "version-status", + SYSTEM: "system", + CONFIG: "config", + PROVIDERS: "providers", + TOOLS: "tools", + MODELS: "models", } as const export const CHECK_NAMES: Record = { - [CHECK_IDS.OPENCODE_INSTALLATION]: "OpenCode Installation", - [CHECK_IDS.PLUGIN_REGISTRATION]: "Plugin Registration", - [CHECK_IDS.CONFIG_VALIDATION]: "Configuration Validity", - [CHECK_IDS.MODEL_RESOLUTION]: "Model Resolution", - [CHECK_IDS.AUTH_ANTHROPIC]: "Anthropic (Claude) Auth", - [CHECK_IDS.AUTH_OPENAI]: "OpenAI (ChatGPT) Auth", - [CHECK_IDS.AUTH_GOOGLE]: "Google (Gemini) Auth", - [CHECK_IDS.DEP_AST_GREP_CLI]: "AST-Grep CLI", - [CHECK_IDS.DEP_AST_GREP_NAPI]: "AST-Grep NAPI", - [CHECK_IDS.DEP_COMMENT_CHECKER]: "Comment Checker", - [CHECK_IDS.GH_CLI]: "GitHub CLI", - [CHECK_IDS.LSP_SERVERS]: "LSP Servers", - [CHECK_IDS.MCP_BUILTIN]: "Built-in MCP Servers", - [CHECK_IDS.MCP_USER]: "User MCP Configuration", - [CHECK_IDS.MCP_OAUTH_TOKENS]: "MCP OAuth Tokens", - [CHECK_IDS.VERSION_STATUS]: "Version Status", + [CHECK_IDS.SYSTEM]: "System", + [CHECK_IDS.CONFIG]: "Configuration", + [CHECK_IDS.PROVIDERS]: "Providers", + [CHECK_IDS.TOOLS]: "Tools", + [CHECK_IDS.MODELS]: "Models", } as const -export const CATEGORY_NAMES: Record = { - installation: "Installation", - configuration: "Configuration", - authentication: "Authentication", - dependencies: "Dependencies", - tools: "Tools & Servers", - updates: "Updates", +export const AUTH_ENV_VARS: Record = { + anthropic: ["ANTHROPIC_API_KEY"], + openai: ["OPENAI_API_KEY"], + google: ["GOOGLE_API_KEY", "GEMINI_API_KEY"], +} as const + +export const AUTH_PLUGINS: Record = { + anthropic: { plugin: "builtin", name: "Anthropic" }, + openai: { plugin: "opencode-openai-codex-auth", name: "OpenAI" }, + google: { plugin: "opencode-antigravity-auth", name: "Google" }, } as const export const EXIT_CODES = { diff --git a/src/cli/doctor/format-default.test.ts b/src/cli/doctor/format-default.test.ts new file mode 100644 index 00000000..60b09674 --- /dev/null +++ b/src/cli/doctor/format-default.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "bun:test" +import { formatDefault } from "./format-default" +import { stripAnsi } from "./format-shared" +import type { DoctorResult } from "./types" + +function createBaseResult(): DoctorResult { + return { + results: [ + { name: "System", status: "pass", message: "ok", issues: [] }, + { name: "Configuration", status: "pass", message: "ok", issues: [] }, + ], + systemInfo: { + opencodeVersion: "1.0.200", + opencodePath: "/usr/local/bin/opencode", + pluginVersion: "3.4.0", + loadedVersion: "3.4.0", + bunVersion: "1.2.0", + configPath: "/tmp/opencode.jsonc", + configValid: true, + isLocalDev: false, + }, + providers: [], + tools: { + lspInstalled: 0, + lspTotal: 0, + astGrepCli: false, + astGrepNapi: false, + commentChecker: false, + ghCli: { installed: false, authenticated: false, username: null }, + mcpBuiltin: [], + mcpUser: [], + }, + summary: { total: 2, passed: 2, failed: 0, warnings: 0, skipped: 0, duration: 10 }, + exitCode: 0, + } +} + +describe("formatDefault", () => { + it("prints a single System OK line when no issues exist", () => { + //#given + const result = createBaseResult() + + //#when + const output = stripAnsi(formatDefault(result)) + + //#then + expect(output).toContain("System OK (opencode 1.0.200") + expect(output).not.toContain("found:") + }) + + it("prints numbered issue list when issues exist", () => { + //#given + const result = createBaseResult() + result.results = [ + { + name: "System", + status: "fail", + message: "failed", + issues: [ + { + title: "OpenCode binary not found", + description: "Install OpenCode", + fix: "Install from https://opencode.ai/docs", + severity: "error", + }, + { + title: "Loaded plugin is outdated", + description: "Loaded 3.0.0, latest 3.4.0", + severity: "warning", + }, + ], + }, + ] + + //#when + const output = stripAnsi(formatDefault(result)) + + //#then + expect(output).toContain("2 issues found:") + expect(output).toContain("1. OpenCode binary not found") + expect(output).toContain("2. Loaded plugin is outdated") + }) +}) diff --git a/src/cli/doctor/format-default.ts b/src/cli/doctor/format-default.ts new file mode 100644 index 00000000..dbf9f108 --- /dev/null +++ b/src/cli/doctor/format-default.ts @@ -0,0 +1,35 @@ +import color from "picocolors" +import type { DoctorResult } from "./types" +import { SYMBOLS } from "./constants" +import { formatHeader, formatIssue } from "./format-shared" + +export function formatDefault(result: DoctorResult): string { + const lines: string[] = [] + + lines.push(formatHeader()) + + const allIssues = result.results.flatMap((r) => r.issues) + + if (allIssues.length === 0) { + const opencodeVer = result.systemInfo.opencodeVersion ?? "unknown" + const pluginVer = result.systemInfo.pluginVersion ?? "unknown" + lines.push( + ` ${color.green(SYMBOLS.check)} ${color.green( + `System OK (opencode ${opencodeVer} · oh-my-opencode ${pluginVer})` + )}` + ) + } else { + const issueCount = allIssues.filter((i) => i.severity === "error").length + const warnCount = allIssues.filter((i) => i.severity === "warning").length + + const totalStr = `${issueCount + warnCount} ${issueCount + warnCount === 1 ? "issue" : "issues"}` + lines.push(` ${color.yellow(SYMBOLS.warn)} ${totalStr} found:\n`) + + allIssues.forEach((issue, index) => { + lines.push(formatIssue(issue, index + 1)) + lines.push("") + }) + } + + return lines.join("\n") +} diff --git a/src/cli/doctor/format-shared.ts b/src/cli/doctor/format-shared.ts new file mode 100644 index 00000000..b6c37bc5 --- /dev/null +++ b/src/cli/doctor/format-shared.ts @@ -0,0 +1,49 @@ +import color from "picocolors" +import type { CheckStatus, DoctorIssue } from "./types" +import { SYMBOLS, STATUS_COLORS } from "./constants" + +export function formatStatusSymbol(status: CheckStatus): string { + const colorFn = STATUS_COLORS[status] + switch (status) { + case "pass": + return colorFn(SYMBOLS.check) + case "fail": + return colorFn(SYMBOLS.cross) + case "warn": + return colorFn(SYMBOLS.warn) + case "skip": + return colorFn(SYMBOLS.skip) + } +} + +export function formatStatusMark(available: boolean): string { + return available ? color.green(SYMBOLS.check) : color.red(SYMBOLS.cross) +} + +export function stripAnsi(str: string): string { + const ESC = String.fromCharCode(27) + const pattern = ESC + "\\[[0-9;]*m" + return str.replace(new RegExp(pattern, "g"), "") +} + +export function formatHeader(): string { + return `\n${color.bgMagenta(color.white(" oMo Doctor "))}\n` +} + +export function formatIssue(issue: DoctorIssue, index: number): string { + const lines: string[] = [] + const severityColor = issue.severity === "error" ? color.red : color.yellow + + lines.push(`${index}. ${severityColor(issue.title)}`) + lines.push(` ${color.dim(issue.description)}`) + + if (issue.fix) { + lines.push(` ${color.cyan("Fix:")} ${color.dim(issue.fix)}`) + } + + if (issue.affects && issue.affects.length > 0) { + lines.push(` ${color.cyan("Affects:")} ${color.dim(issue.affects.join(", "))}`) + } + + return lines.join("\n") +} diff --git a/src/cli/doctor/format-status.ts b/src/cli/doctor/format-status.ts new file mode 100644 index 00000000..c73a11dc --- /dev/null +++ b/src/cli/doctor/format-status.ts @@ -0,0 +1,41 @@ +import color from "picocolors" +import type { DoctorResult } from "./types" +import { formatHeader, formatStatusMark } from "./format-shared" + +export function formatStatus(result: DoctorResult): string { + const lines: string[] = [] + + lines.push(formatHeader()) + + const { systemInfo, providers, tools } = result + const padding = " " + + const opencodeVer = systemInfo.opencodeVersion ?? "unknown" + const pluginVer = systemInfo.pluginVersion ?? "unknown" + const bunVer = systemInfo.bunVersion ?? "unknown" + lines.push(` ${padding}System ${opencodeVer} · ${pluginVer} · Bun ${bunVer}`) + + const configPath = systemInfo.configPath ?? "unknown" + const configStatus = systemInfo.configValid ? color.green("(valid)") : color.red("(invalid)") + lines.push(` ${padding}Config ${configPath} ${configStatus}`) + + const providerParts = providers.map((p) => { + const mark = formatStatusMark(p.available) + return `${p.name}${mark}` + }) + lines.push(` ${padding}Providers ${providerParts.join(" ")}`) + + const lspText = `LSP ${tools.lspInstalled}/${tools.lspTotal}` + const astGrepMark = formatStatusMark(tools.astGrepCli) + const ghMark = formatStatusMark(tools.ghCli.installed && tools.ghCli.authenticated) + const ghUser = tools.ghCli.username ?? "" + lines.push(` ${padding}Tools ${lspText} · AST-Grep ${astGrepMark} · gh ${ghMark}${ghUser ? ` (${ghUser})` : ""}`) + + const builtinCount = tools.mcpBuiltin.length + const userCount = tools.mcpUser.length + const builtinText = builtinCount > 0 ? tools.mcpBuiltin.join(" · ") : "none" + const userText = userCount > 0 ? `+ ${userCount} user` : "" + lines.push(` ${padding}MCPs ${builtinText} ${userText}`) + + return lines.join("\n") +} diff --git a/src/cli/doctor/format-verbose.ts b/src/cli/doctor/format-verbose.ts new file mode 100644 index 00000000..b491651a --- /dev/null +++ b/src/cli/doctor/format-verbose.ts @@ -0,0 +1,89 @@ +import color from "picocolors" +import type { DoctorResult } from "./types" +import { formatHeader, formatStatusSymbol, formatIssue } from "./format-shared" + +export function formatVerbose(result: DoctorResult): string { + const lines: string[] = [] + + lines.push(formatHeader()) + + const { systemInfo, providers, tools, results, summary } = result + + lines.push(`${color.bold("System Information")}`) + lines.push(`${color.dim("\u2500".repeat(40))}`) + lines.push(` ${formatStatusSymbol("pass")} opencode ${systemInfo.opencodeVersion ?? "unknown"}`) + lines.push(` ${formatStatusSymbol("pass")} oh-my-opencode ${systemInfo.pluginVersion ?? "unknown"}`) + if (systemInfo.loadedVersion) { + lines.push(` ${formatStatusSymbol("pass")} loaded ${systemInfo.loadedVersion}`) + } + if (systemInfo.bunVersion) { + lines.push(` ${formatStatusSymbol("pass")} bun ${systemInfo.bunVersion}`) + } + lines.push(` ${formatStatusSymbol("pass")} path ${systemInfo.opencodePath ?? "unknown"}`) + if (systemInfo.isLocalDev) { + lines.push(` ${color.yellow("*")} ${color.dim("(local development mode)")}`) + } + lines.push("") + + lines.push(`${color.bold("Configuration")}`) + lines.push(`${color.dim("\u2500".repeat(40))}`) + const configStatus = systemInfo.configValid ? color.green("valid") : color.red("invalid") + lines.push(` ${formatStatusSymbol(systemInfo.configValid ? "pass" : "fail")} ${systemInfo.configPath ?? "unknown"} (${configStatus})`) + lines.push("") + + lines.push(`${color.bold("Providers")}`) + lines.push(`${color.dim("\u2500".repeat(40))}`) + for (const provider of providers) { + const availableMark = provider.available ? color.green("✓") : color.red("✗") + const pluginMark = provider.hasPlugin ? color.green("plugin") : color.dim("no plugin") + const envMark = provider.hasEnvVar ? color.green("env") : color.dim("no env") + lines.push(` ${availableMark} ${provider.name} ${pluginMark} · ${envMark}`) + } + lines.push("") + + lines.push(`${color.bold("Tools")}`) + lines.push(`${color.dim("\u2500".repeat(40))}`) + lines.push(` ${formatStatusSymbol("pass")} LSP ${tools.lspInstalled}/${tools.lspTotal} installed`) + lines.push(` ${formatStatusSymbol(tools.astGrepCli ? "pass" : "fail")} ast-grep CLI ${tools.astGrepCli ? "installed" : "not found"}`) + lines.push(` ${formatStatusSymbol(tools.astGrepNapi ? "pass" : "fail")} ast-grep napi ${tools.astGrepNapi ? "installed" : "not found"}`) + lines.push(` ${formatStatusSymbol(tools.commentChecker ? "pass" : "fail")} comment-checker ${tools.commentChecker ? "installed" : "not found"}`) + lines.push(` ${formatStatusSymbol(tools.ghCli.installed && tools.ghCli.authenticated ? "pass" : "fail")} gh CLI ${tools.ghCli.installed ? "installed" : "not found"}${tools.ghCli.authenticated && tools.ghCli.username ? ` (${tools.ghCli.username})` : ""}`) + lines.push("") + + lines.push(`${color.bold("MCPs")}`) + lines.push(`${color.dim("\u2500".repeat(40))}`) + if (tools.mcpBuiltin.length === 0) { + lines.push(` ${color.dim("No built-in MCPs")}`) + } else { + for (const mcp of tools.mcpBuiltin) { + lines.push(` ${formatStatusSymbol("pass")} ${mcp}`) + } + } + if (tools.mcpUser.length > 0) { + lines.push(` ${color.cyan("+")} ${tools.mcpUser.length} user MCP(s):`) + for (const mcp of tools.mcpUser) { + lines.push(` ${formatStatusSymbol("pass")} ${mcp}`) + } + } + lines.push("") + + const allIssues = results.flatMap((r) => r.issues) + if (allIssues.length > 0) { + lines.push(`${color.bold("Issues")}`) + lines.push(`${color.dim("\u2500".repeat(40))}`) + allIssues.forEach((issue, index) => { + lines.push(formatIssue(issue, index + 1)) + lines.push("") + }) + } + + lines.push(`${color.bold("Summary")}`) + lines.push(`${color.dim("\u2500".repeat(40))}`) + const passText = summary.passed > 0 ? color.green(`${summary.passed} passed`) : `${summary.passed} passed` + const failText = summary.failed > 0 ? color.red(`${summary.failed} failed`) : `${summary.failed} failed` + const warnText = summary.warnings > 0 ? color.yellow(`${summary.warnings} warnings`) : `${summary.warnings} warnings` + lines.push(` ${passText}, ${failText}, ${warnText}`) + lines.push(` ${color.dim(`Total: ${summary.total} checks in ${summary.duration}ms`)}`) + + return lines.join("\n") +} diff --git a/src/cli/doctor/formatter.test.ts b/src/cli/doctor/formatter.test.ts index 062d6c6e..a14104d0 100644 --- a/src/cli/doctor/formatter.test.ts +++ b/src/cli/doctor/formatter.test.ts @@ -1,218 +1,127 @@ -import { describe, it, expect } from "bun:test" -import { - formatStatusSymbol, - formatCheckResult, - formatCategoryHeader, - formatSummary, - formatHeader, - formatFooter, - formatJsonOutput, - formatBox, - formatHelpSuggestions, -} from "./formatter" -import type { CheckResult, DoctorSummary, DoctorResult } from "./types" +import { afterEach, describe, expect, it, mock } from "bun:test" +import type { DoctorResult } from "./types" + +function createDoctorResult(): DoctorResult { + return { + results: [ + { name: "System", status: "pass", message: "ok", issues: [] }, + { name: "Configuration", status: "warn", message: "warn", issues: [] }, + ], + systemInfo: { + opencodeVersion: "1.0.200", + opencodePath: "/usr/local/bin/opencode", + pluginVersion: "3.4.0", + loadedVersion: "3.4.0", + bunVersion: "1.2.0", + configPath: "/tmp/opencode.jsonc", + configValid: true, + isLocalDev: false, + }, + providers: [{ id: "anthropic", name: "Anthropic", available: true, hasEnvVar: true, hasPlugin: true }], + tools: { + lspInstalled: 2, + lspTotal: 4, + astGrepCli: true, + astGrepNapi: false, + commentChecker: true, + ghCli: { installed: true, authenticated: true, username: "yeongyu" }, + mcpBuiltin: ["context7", "grep_app"], + mcpUser: ["custom"], + }, + summary: { + total: 2, + passed: 1, + failed: 0, + warnings: 1, + skipped: 0, + duration: 12, + }, + exitCode: 0, + } +} describe("formatter", () => { - describe("formatStatusSymbol", () => { - it("returns green check for pass", () => { - const symbol = formatStatusSymbol("pass") - expect(symbol).toContain("\u2713") - }) - - it("returns red cross for fail", () => { - const symbol = formatStatusSymbol("fail") - expect(symbol).toContain("\u2717") - }) - - it("returns yellow warning for warn", () => { - const symbol = formatStatusSymbol("warn") - expect(symbol).toContain("\u26A0") - }) - - it("returns dim circle for skip", () => { - const symbol = formatStatusSymbol("skip") - expect(symbol).toContain("\u25CB") - }) + afterEach(() => { + mock.restore() }) - describe("formatCheckResult", () => { - it("includes name and message", () => { - const result: CheckResult = { - name: "Test Check", - status: "pass", - message: "All good", - } + describe("formatDoctorOutput", () => { + it("dispatches to default formatter for default mode", async () => { + //#given + const formatDefaultMock = mock(() => "default-output") + const formatStatusMock = mock(() => "status-output") + const formatVerboseMock = mock(() => "verbose-output") + mock.module("./format-default", () => ({ formatDefault: formatDefaultMock })) + mock.module("./format-status", () => ({ formatStatus: formatStatusMock })) + mock.module("./format-verbose", () => ({ formatVerbose: formatVerboseMock })) + const { formatDoctorOutput } = await import(`./formatter?default=${Date.now()}`) - const output = formatCheckResult(result, false) + //#when + const output = formatDoctorOutput(createDoctorResult(), "default") - expect(output).toContain("Test Check") - expect(output).toContain("All good") + //#then + expect(output).toBe("default-output") + expect(formatDefaultMock).toHaveBeenCalledTimes(1) + expect(formatStatusMock).toHaveBeenCalledTimes(0) + expect(formatVerboseMock).toHaveBeenCalledTimes(0) }) - it("includes details when verbose", () => { - const result: CheckResult = { - name: "Test Check", - status: "pass", - message: "OK", - details: ["Detail 1", "Detail 2"], - } + it("dispatches to status formatter for status mode", async () => { + //#given + const formatDefaultMock = mock(() => "default-output") + const formatStatusMock = mock(() => "status-output") + const formatVerboseMock = mock(() => "verbose-output") + mock.module("./format-default", () => ({ formatDefault: formatDefaultMock })) + mock.module("./format-status", () => ({ formatStatus: formatStatusMock })) + mock.module("./format-verbose", () => ({ formatVerbose: formatVerboseMock })) + const { formatDoctorOutput } = await import(`./formatter?status=${Date.now()}`) - const output = formatCheckResult(result, true) + //#when + const output = formatDoctorOutput(createDoctorResult(), "status") - expect(output).toContain("Detail 1") - expect(output).toContain("Detail 2") + //#then + expect(output).toBe("status-output") + expect(formatDefaultMock).toHaveBeenCalledTimes(0) + expect(formatStatusMock).toHaveBeenCalledTimes(1) + expect(formatVerboseMock).toHaveBeenCalledTimes(0) }) - it("hides details when not verbose", () => { - const result: CheckResult = { - name: "Test Check", - status: "pass", - message: "OK", - details: ["Detail 1"], - } + it("dispatches to verbose formatter for verbose mode", async () => { + //#given + const formatDefaultMock = mock(() => "default-output") + const formatStatusMock = mock(() => "status-output") + const formatVerboseMock = mock(() => "verbose-output") + mock.module("./format-default", () => ({ formatDefault: formatDefaultMock })) + mock.module("./format-status", () => ({ formatStatus: formatStatusMock })) + mock.module("./format-verbose", () => ({ formatVerbose: formatVerboseMock })) + const { formatDoctorOutput } = await import(`./formatter?verbose=${Date.now()}`) - const output = formatCheckResult(result, false) + //#when + const output = formatDoctorOutput(createDoctorResult(), "verbose") - expect(output).not.toContain("Detail 1") - }) - }) - - describe("formatCategoryHeader", () => { - it("formats category name with styling", () => { - const header = formatCategoryHeader("installation") - - expect(header).toContain("Installation") - }) - }) - - describe("formatSummary", () => { - it("shows all counts", () => { - const summary: DoctorSummary = { - total: 10, - passed: 7, - failed: 1, - warnings: 2, - skipped: 0, - duration: 150, - } - - const output = formatSummary(summary) - - expect(output).toContain("7 passed") - expect(output).toContain("1 failed") - expect(output).toContain("2 warnings") - expect(output).toContain("10 checks") - expect(output).toContain("150ms") - }) - }) - - describe("formatHeader", () => { - it("includes doctor branding", () => { - const header = formatHeader() - - expect(header).toContain("Doctor") - }) - }) - - describe("formatFooter", () => { - it("shows error message when failures", () => { - const summary: DoctorSummary = { - total: 5, - passed: 4, - failed: 1, - warnings: 0, - skipped: 0, - duration: 100, - } - - const footer = formatFooter(summary) - - expect(footer).toContain("Issues detected") - }) - - it("shows warning message when warnings only", () => { - const summary: DoctorSummary = { - total: 5, - passed: 4, - failed: 0, - warnings: 1, - skipped: 0, - duration: 100, - } - - const footer = formatFooter(summary) - - expect(footer).toContain("warnings") - }) - - it("shows success message when all pass", () => { - const summary: DoctorSummary = { - total: 5, - passed: 5, - failed: 0, - warnings: 0, - skipped: 0, - duration: 100, - } - - const footer = formatFooter(summary) - - expect(footer).toContain("operational") + //#then + expect(output).toBe("verbose-output") + expect(formatDefaultMock).toHaveBeenCalledTimes(0) + expect(formatStatusMock).toHaveBeenCalledTimes(0) + expect(formatVerboseMock).toHaveBeenCalledTimes(1) }) }) describe("formatJsonOutput", () => { - it("returns valid JSON", () => { - const result: DoctorResult = { - results: [{ name: "Test", status: "pass", message: "OK" }], - summary: { total: 1, passed: 1, failed: 0, warnings: 0, skipped: 0, duration: 50 }, - exitCode: 0, - } + it("returns valid JSON payload", async () => { + //#given + const { formatJsonOutput } = await import(`./formatter?json=${Date.now()}`) + const result = createDoctorResult() + //#when const output = formatJsonOutput(result) - const parsed = JSON.parse(output) + const parsed = JSON.parse(output) as DoctorResult - expect(parsed.results.length).toBe(1) - expect(parsed.summary.total).toBe(1) + //#then + expect(parsed.summary.total).toBe(2) + expect(parsed.systemInfo.pluginVersion).toBe("3.4.0") + expect(parsed.tools.ghCli.username).toBe("yeongyu") expect(parsed.exitCode).toBe(0) }) }) - - describe("formatBox", () => { - it("wraps content in box", () => { - const box = formatBox("Test content") - - expect(box).toContain("Test content") - expect(box).toContain("\u2500") - }) - - it("includes title when provided", () => { - const box = formatBox("Content", "My Title") - - expect(box).toContain("My Title") - }) - }) - - describe("formatHelpSuggestions", () => { - it("extracts suggestions from failed checks", () => { - const results: CheckResult[] = [ - { name: "Test", status: "fail", message: "Error", details: ["Run: fix-command"] }, - { name: "OK", status: "pass", message: "Good" }, - ] - - const suggestions = formatHelpSuggestions(results) - - expect(suggestions).toContain("Run: fix-command") - }) - - it("returns empty array when no failures", () => { - const results: CheckResult[] = [ - { name: "OK", status: "pass", message: "Good" }, - ] - - const suggestions = formatHelpSuggestions(results) - - expect(suggestions.length).toBe(0) - }) - }) }) diff --git a/src/cli/doctor/formatter.ts b/src/cli/doctor/formatter.ts index 976a328a..18b125c1 100644 --- a/src/cli/doctor/formatter.ts +++ b/src/cli/doctor/formatter.ts @@ -1,140 +1,19 @@ -import color from "picocolors" -import type { CheckResult, DoctorSummary, CheckCategory, DoctorResult } from "./types" -import { SYMBOLS, STATUS_COLORS, CATEGORY_NAMES } from "./constants" +import type { DoctorResult, DoctorMode } from "./types" +import { formatDefault } from "./format-default" +import { formatStatus } from "./format-status" +import { formatVerbose } from "./format-verbose" -export function formatStatusSymbol(status: CheckResult["status"]): string { - switch (status) { - case "pass": - return SYMBOLS.check - case "fail": - return SYMBOLS.cross - case "warn": - return SYMBOLS.warn - case "skip": - return SYMBOLS.skip +export function formatDoctorOutput(result: DoctorResult, mode: DoctorMode): string { + switch (mode) { + case "default": + return formatDefault(result) + case "status": + return formatStatus(result) + case "verbose": + return formatVerbose(result) } } -export function formatCheckResult(result: CheckResult, verbose: boolean): string { - const symbol = formatStatusSymbol(result.status) - const colorFn = STATUS_COLORS[result.status] - const name = colorFn(result.name) - const message = color.dim(result.message) - - let line = ` ${symbol} ${name}` - if (result.message) { - line += ` ${SYMBOLS.arrow} ${message}` - } - - if (verbose && result.details && result.details.length > 0) { - const detailLines = result.details.map((d) => ` ${SYMBOLS.bullet} ${color.dim(d)}`).join("\n") - line += "\n" + detailLines - } - - return line -} - -export function formatCategoryHeader(category: CheckCategory): string { - const name = CATEGORY_NAMES[category] || category - return `\n${color.bold(color.white(name))}\n${color.dim("\u2500".repeat(40))}` -} - -export function formatSummary(summary: DoctorSummary): string { - const lines: string[] = [] - - lines.push(color.bold(color.white("Summary"))) - lines.push(color.dim("\u2500".repeat(40))) - lines.push("") - - const passText = summary.passed > 0 ? color.green(`${summary.passed} passed`) : color.dim("0 passed") - const failText = summary.failed > 0 ? color.red(`${summary.failed} failed`) : color.dim("0 failed") - const warnText = summary.warnings > 0 ? color.yellow(`${summary.warnings} warnings`) : color.dim("0 warnings") - const skipText = summary.skipped > 0 ? color.dim(`${summary.skipped} skipped`) : "" - - const parts = [passText, failText, warnText] - if (skipText) parts.push(skipText) - - lines.push(` ${parts.join(", ")}`) - lines.push(` ${color.dim(`Total: ${summary.total} checks in ${summary.duration}ms`)}`) - - return lines.join("\n") -} - -export function formatHeader(): string { - return `\n${color.bgMagenta(color.white(" oMoMoMoMo... Doctor "))}\n` -} - -export function formatFooter(summary: DoctorSummary): string { - if (summary.failed > 0) { - return `\n${SYMBOLS.cross} ${color.red("Issues detected. Please review the errors above.")}\n` - } - if (summary.warnings > 0) { - return `\n${SYMBOLS.warn} ${color.yellow("All systems operational with warnings.")}\n` - } - return `\n${SYMBOLS.check} ${color.green("All systems operational!")}\n` -} - -export function formatProgress(current: number, total: number, name: string): string { - const progress = color.dim(`[${current}/${total}]`) - return `${progress} Checking ${name}...` -} - export function formatJsonOutput(result: DoctorResult): string { return JSON.stringify(result, null, 2) } - -export function formatDetails(details: string[]): string { - return details.map((d) => ` ${SYMBOLS.bullet} ${color.dim(d)}`).join("\n") -} - -function stripAnsi(str: string): string { - // eslint-disable-next-line no-control-regex - return str.replace(/\x1b\[[0-9;]*m/g, "") -} - -export function formatBox(content: string, title?: string): string { - const lines = content.split("\n") - const maxWidth = Math.max(...lines.map((l) => stripAnsi(l).length), title?.length ?? 0) + 4 - const border = color.dim("\u2500".repeat(maxWidth)) - - const output: string[] = [] - output.push("") - - if (title) { - output.push( - color.dim("\u250C\u2500") + - color.bold(` ${title} `) + - color.dim("\u2500".repeat(maxWidth - title.length - 4)) + - color.dim("\u2510") - ) - } else { - output.push(color.dim("\u250C") + border + color.dim("\u2510")) - } - - for (const line of lines) { - const stripped = stripAnsi(line) - const padding = maxWidth - stripped.length - output.push(color.dim("\u2502") + ` ${line}${" ".repeat(padding - 1)}` + color.dim("\u2502")) - } - - output.push(color.dim("\u2514") + border + color.dim("\u2518")) - output.push("") - - return output.join("\n") -} - -export function formatHelpSuggestions(results: CheckResult[]): string[] { - const suggestions: string[] = [] - - for (const result of results) { - if (result.status === "fail" && result.details) { - for (const detail of result.details) { - if (detail.includes("Run:") || detail.includes("Install:") || detail.includes("Visit:")) { - suggestions.push(detail) - } - } - } - } - - return suggestions -} diff --git a/src/cli/doctor/index.ts b/src/cli/doctor/index.ts index 40de646b..2beef3c6 100644 --- a/src/cli/doctor/index.ts +++ b/src/cli/doctor/index.ts @@ -1,11 +1,11 @@ import type { DoctorOptions } from "./types" import { runDoctor } from "./runner" -export async function doctor(options: DoctorOptions = {}): Promise { +export async function doctor(options: DoctorOptions = { mode: "default" }): Promise { const result = await runDoctor(options) return result.exitCode } export * from "./types" export { runDoctor } from "./runner" -export { formatJsonOutput } from "./formatter" +export { formatDoctorOutput, formatJsonOutput } from "./formatter" diff --git a/src/cli/doctor/runner.test.ts b/src/cli/doctor/runner.test.ts index dbd55bcb..2e65f323 100644 --- a/src/cli/doctor/runner.test.ts +++ b/src/cli/doctor/runner.test.ts @@ -1,153 +1,253 @@ -import { describe, it, expect, spyOn, afterEach } from "bun:test" -import { - runCheck, - calculateSummary, - determineExitCode, - filterChecksByCategory, - groupChecksByCategory, -} from "./runner" -import type { CheckResult, CheckDefinition, CheckCategory } from "./types" +import { afterEach, describe, expect, it, mock } from "bun:test" +import type { CheckDefinition, CheckResult, DoctorResult, ProviderStatus, SystemInfo, ToolsSummary } from "./types" + +function createSystemInfo(): SystemInfo { + return { + opencodeVersion: "1.0.200", + opencodePath: "/usr/local/bin/opencode", + pluginVersion: "3.4.0", + loadedVersion: "3.4.0", + bunVersion: "1.2.0", + configPath: "/tmp/opencode.json", + configValid: true, + isLocalDev: false, + } +} + +function createProviders(): ProviderStatus[] { + return [ + { id: "anthropic", name: "Anthropic", available: true, hasEnvVar: true, hasPlugin: true }, + { id: "openai", name: "OpenAI", available: false, hasEnvVar: false, hasPlugin: false }, + ] +} + +function createTools(): ToolsSummary { + return { + lspInstalled: 1, + lspTotal: 4, + astGrepCli: true, + astGrepNapi: false, + commentChecker: true, + ghCli: { installed: true, authenticated: true, username: "yeongyu" }, + mcpBuiltin: ["context7"], + mcpUser: ["custom-mcp"], + } +} + +function createPassResult(name: string): CheckResult { + return { name, status: "pass", message: "ok", issues: [] } +} + +function createDeferred(): { + promise: Promise + resolve: (value: CheckResult) => void +} { + let resolvePromise: (value: CheckResult) => void = () => {} + const promise = new Promise((resolve) => { + resolvePromise = resolve + }) + return { promise, resolve: resolvePromise } +} describe("runner", () => { + afterEach(() => { + mock.restore() + }) + describe("runCheck", () => { - it("returns result from check function", async () => { + it("returns fail result with issue when check throws", async () => { + //#given const check: CheckDefinition = { - id: "test", - name: "Test Check", - category: "installation", - check: async () => ({ name: "Test Check", status: "pass", message: "OK" }), - } - - const result = await runCheck(check) - - expect(result.name).toBe("Test Check") - expect(result.status).toBe("pass") - }) - - it("measures duration", async () => { - const check: CheckDefinition = { - id: "test", - name: "Test Check", - category: "installation", + id: "system", + name: "System", check: async () => { - await new Promise((r) => setTimeout(r, 50)) - return { name: "Test", status: "pass", message: "OK" } - }, - } - - const result = await runCheck(check) - - expect(result.duration).toBeGreaterThanOrEqual(10) - }) - - it("returns fail on error", async () => { - const check: CheckDefinition = { - id: "test", - name: "Test Check", - category: "installation", - check: async () => { - throw new Error("Test error") + throw new Error("boom") }, } + const { runCheck } = await import(`./runner?run-check-error=${Date.now()}`) + //#when const result = await runCheck(check) + //#then expect(result.status).toBe("fail") - expect(result.message).toContain("Test error") + expect(result.message).toBe("boom") + expect(result.issues[0]?.title).toBe("System") + expect(result.issues[0]?.severity).toBe("error") + expect(typeof result.duration).toBe("number") }) }) describe("calculateSummary", () => { - it("counts each status correctly", () => { + it("counts statuses correctly", async () => { + //#given + const { calculateSummary } = await import(`./runner?summary=${Date.now()}`) const results: CheckResult[] = [ - { name: "1", status: "pass", message: "" }, - { name: "2", status: "pass", message: "" }, - { name: "3", status: "fail", message: "" }, - { name: "4", status: "warn", message: "" }, - { name: "5", status: "skip", message: "" }, + { name: "1", status: "pass", message: "", issues: [] }, + { name: "2", status: "pass", message: "", issues: [] }, + { name: "3", status: "fail", message: "", issues: [] }, + { name: "4", status: "warn", message: "", issues: [] }, + { name: "5", status: "skip", message: "", issues: [] }, ] - const summary = calculateSummary(results, 100) + //#when + const summary = calculateSummary(results, 19.9) + //#then expect(summary.total).toBe(5) expect(summary.passed).toBe(2) expect(summary.failed).toBe(1) expect(summary.warnings).toBe(1) expect(summary.skipped).toBe(1) - expect(summary.duration).toBe(100) + expect(summary.duration).toBe(20) }) }) describe("determineExitCode", () => { - it("returns 0 when all pass", () => { + it("returns zero when no failures exist", async () => { + //#given + const { determineExitCode } = await import(`./runner?exit-ok=${Date.now()}`) const results: CheckResult[] = [ - { name: "1", status: "pass", message: "" }, - { name: "2", status: "pass", message: "" }, + { name: "1", status: "pass", message: "", issues: [] }, + { name: "2", status: "warn", message: "", issues: [] }, ] - expect(determineExitCode(results)).toBe(0) + //#when + const code = determineExitCode(results) + + //#then + expect(code).toBe(0) }) - it("returns 0 when only warnings", () => { + it("returns one when any failure exists", async () => { + //#given + const { determineExitCode } = await import(`./runner?exit-fail=${Date.now()}`) const results: CheckResult[] = [ - { name: "1", status: "pass", message: "" }, - { name: "2", status: "warn", message: "" }, + { name: "1", status: "pass", message: "", issues: [] }, + { name: "2", status: "fail", message: "", issues: [] }, ] - expect(determineExitCode(results)).toBe(0) - }) + //#when + const code = determineExitCode(results) - it("returns 1 when any failures", () => { - const results: CheckResult[] = [ - { name: "1", status: "pass", message: "" }, - { name: "2", status: "fail", message: "" }, - ] - - expect(determineExitCode(results)).toBe(1) + //#then + expect(code).toBe(1) }) }) - describe("filterChecksByCategory", () => { - const checks: CheckDefinition[] = [ - { id: "1", name: "Install", category: "installation", check: async () => ({ name: "", status: "pass", message: "" }) }, - { id: "2", name: "Config", category: "configuration", check: async () => ({ name: "", status: "pass", message: "" }) }, - { id: "3", name: "Auth", category: "authentication", check: async () => ({ name: "", status: "pass", message: "" }) }, - ] + describe("runDoctor", () => { + it("starts all checks in parallel and returns collected result", async () => { + //#given + const startedChecks: string[] = [] + const deferredOne = createDeferred() + const deferredTwo = createDeferred() + const deferredThree = createDeferred() + const deferredFour = createDeferred() + const deferredFive = createDeferred() - it("returns all checks when no category", () => { - const filtered = filterChecksByCategory(checks) + const checks: CheckDefinition[] = [ + { + id: "system", + name: "System", + check: async () => { + startedChecks.push("system") + return deferredOne.promise + }, + }, + { + id: "config", + name: "Configuration", + check: async () => { + startedChecks.push("config") + return deferredTwo.promise + }, + }, + { + id: "providers", + name: "Providers", + check: async () => { + startedChecks.push("providers") + return deferredThree.promise + }, + }, + { + id: "tools", + name: "Tools", + check: async () => { + startedChecks.push("tools") + return deferredFour.promise + }, + }, + { + id: "models", + name: "Models", + check: async () => { + startedChecks.push("models") + return deferredFive.promise + }, + }, + ] - expect(filtered.length).toBe(3) - }) + const expectedResult: DoctorResult = { + results: [ + createPassResult("System"), + createPassResult("Configuration"), + createPassResult("Providers"), + createPassResult("Tools"), + createPassResult("Models"), + ], + systemInfo: createSystemInfo(), + providers: createProviders(), + tools: createTools(), + summary: { + total: 5, + passed: 5, + failed: 0, + warnings: 0, + skipped: 0, + duration: 0, + }, + exitCode: 0, + } - it("filters to specific category", () => { - const filtered = filterChecksByCategory(checks, "installation") + const formatDoctorOutputMock = mock((result: DoctorResult) => result.summary.total.toString()) + const formatJsonOutputMock = mock((result: DoctorResult) => JSON.stringify(result)) - expect(filtered.length).toBe(1) - expect(filtered[0].name).toBe("Install") - }) - }) + mock.module("./checks", () => ({ + getAllCheckDefinitions: () => checks, + gatherSystemInfo: async () => expectedResult.systemInfo, + gatherProviderStatuses: () => expectedResult.providers, + gatherToolsSummary: async () => expectedResult.tools, + })) + mock.module("./formatter", () => ({ + formatDoctorOutput: formatDoctorOutputMock, + formatJsonOutput: formatJsonOutputMock, + })) - describe("groupChecksByCategory", () => { - const checks: CheckDefinition[] = [ - { id: "1", name: "Install1", category: "installation", check: async () => ({ name: "", status: "pass", message: "" }) }, - { id: "2", name: "Install2", category: "installation", check: async () => ({ name: "", status: "pass", message: "" }) }, - { id: "3", name: "Config", category: "configuration", check: async () => ({ name: "", status: "pass", message: "" }) }, - ] + const logSpy = mock(() => {}) + const originalLog = console.log + console.log = logSpy - it("groups checks by category", () => { - const groups = groupChecksByCategory(checks) + const { runDoctor } = await import(`./runner?parallel=${Date.now()}`) + const runPromise = runDoctor({ mode: "default" }) - expect(groups.get("installation")?.length).toBe(2) - expect(groups.get("configuration")?.length).toBe(1) - }) + //#when + await Promise.resolve() + const startedBeforeResolve = [...startedChecks] + deferredOne.resolve(createPassResult("System")) + deferredTwo.resolve(createPassResult("Configuration")) + deferredThree.resolve(createPassResult("Providers")) + deferredFour.resolve(createPassResult("Tools")) + deferredFive.resolve(createPassResult("Models")) + const result = await runPromise - it("maintains order within categories", () => { - const groups = groupChecksByCategory(checks) - const installChecks = groups.get("installation")! - - expect(installChecks[0].name).toBe("Install1") - expect(installChecks[1].name).toBe("Install2") + //#then + console.log = originalLog + expect(startedBeforeResolve.sort()).toEqual(["config", "models", "providers", "system", "tools"]) + expect(result.results.length).toBe(5) + expect(result.exitCode).toBe(0) + expect(formatDoctorOutputMock).toHaveBeenCalledTimes(1) + expect(formatJsonOutputMock).toHaveBeenCalledTimes(0) }) }) }) diff --git a/src/cli/doctor/runner.ts b/src/cli/doctor/runner.ts index af4c3168..ec9e0152 100644 --- a/src/cli/doctor/runner.ts +++ b/src/cli/doctor/runner.ts @@ -1,21 +1,7 @@ -import type { - DoctorOptions, - DoctorResult, - CheckDefinition, - CheckResult, - DoctorSummary, - CheckCategory, -} from "./types" -import { getAllCheckDefinitions } from "./checks" -import { EXIT_CODES, CATEGORY_NAMES } from "./constants" -import { - formatHeader, - formatCategoryHeader, - formatCheckResult, - formatSummary, - formatFooter, - formatJsonOutput, -} from "./formatter" +import type { DoctorOptions, DoctorResult, CheckDefinition, CheckResult, DoctorSummary } from "./types" +import { getAllCheckDefinitions, gatherSystemInfo, gatherProviderStatuses, gatherToolsSummary } from "./checks" +import { EXIT_CODES } from "./constants" +import { formatDoctorOutput, formatJsonOutput } from "./formatter" export async function runCheck(check: CheckDefinition): Promise { const start = performance.now() @@ -28,6 +14,7 @@ export async function runCheck(check: CheckDefinition): Promise { name: check.name, status: "fail", message: err instanceof Error ? err.message : "Unknown error", + issues: [{ title: check.name, description: String(err), severity: "error" }], duration: Math.round(performance.now() - start), } } @@ -45,70 +32,19 @@ export function calculateSummary(results: CheckResult[], duration: number): Doct } export function determineExitCode(results: CheckResult[]): number { - const hasFailures = results.some((r) => r.status === "fail") - return hasFailures ? EXIT_CODES.FAILURE : EXIT_CODES.SUCCESS + return results.some((r) => r.status === "fail") ? EXIT_CODES.FAILURE : EXIT_CODES.SUCCESS } -export function filterChecksByCategory( - checks: CheckDefinition[], - category?: CheckCategory -): CheckDefinition[] { - if (!category) return checks - return checks.filter((c) => c.category === category) -} - -export function groupChecksByCategory( - checks: CheckDefinition[] -): Map { - const groups = new Map() - - for (const check of checks) { - const existing = groups.get(check.category) ?? [] - existing.push(check) - groups.set(check.category, existing) - } - - return groups -} - -const CATEGORY_ORDER: CheckCategory[] = [ - "installation", - "configuration", - "authentication", - "dependencies", - "tools", - "updates", -] - export async function runDoctor(options: DoctorOptions): Promise { const start = performance.now() + const allChecks = getAllCheckDefinitions() - const filteredChecks = filterChecksByCategory(allChecks, options.category) - const groupedChecks = groupChecksByCategory(filteredChecks) - - const results: CheckResult[] = [] - - if (!options.json) { - console.log(formatHeader()) - } - - for (const category of CATEGORY_ORDER) { - const checks = groupedChecks.get(category) - if (!checks || checks.length === 0) continue - - if (!options.json) { - console.log(formatCategoryHeader(category)) - } - - for (const check of checks) { - const result = await runCheck(check) - results.push(result) - - if (!options.json) { - console.log(formatCheckResult(result, options.verbose ?? false)) - } - } - } + const [results, systemInfo, providers, tools] = await Promise.all([ + Promise.all(allChecks.map(runCheck)), + gatherSystemInfo(), + Promise.resolve(gatherProviderStatuses()), + gatherToolsSummary(), + ]) const duration = performance.now() - start const summary = calculateSummary(results, duration) @@ -116,6 +52,9 @@ export async function runDoctor(options: DoctorOptions): Promise { const doctorResult: DoctorResult = { results, + systemInfo, + providers, + tools, summary, exitCode, } @@ -123,9 +62,7 @@ export async function runDoctor(options: DoctorOptions): Promise { if (options.json) { console.log(formatJsonOutput(doctorResult)) } else { - console.log("") - console.log(formatSummary(summary)) - console.log(formatFooter(summary)) + console.log(formatDoctorOutput(doctorResult, options.mode)) } return doctorResult diff --git a/src/cli/doctor/types.ts b/src/cli/doctor/types.ts index b512c6de..cb725c42 100644 --- a/src/cli/doctor/types.ts +++ b/src/cli/doctor/types.ts @@ -1,3 +1,20 @@ +// ===== New 3-tier doctor types ===== + +export type DoctorMode = "default" | "status" | "verbose" + +export interface DoctorOptions { + mode: DoctorMode + json?: boolean +} + +export interface DoctorIssue { + title: string + description: string + fix?: string + affects?: string[] + severity: "error" | "warning" +} + export type CheckStatus = "pass" | "fail" | "warn" | "skip" export interface CheckResult { @@ -5,31 +22,47 @@ export interface CheckResult { status: CheckStatus message: string details?: string[] + issues: DoctorIssue[] duration?: number } export type CheckFunction = () => Promise -export type CheckCategory = - | "installation" - | "configuration" - | "authentication" - | "dependencies" - | "tools" - | "updates" - export interface CheckDefinition { id: string name: string - category: CheckCategory check: CheckFunction critical?: boolean } -export interface DoctorOptions { - verbose?: boolean - json?: boolean - category?: CheckCategory +export interface SystemInfo { + opencodeVersion: string | null + opencodePath: string | null + pluginVersion: string | null + loadedVersion: string | null + bunVersion: string | null + configPath: string | null + configValid: boolean + isLocalDev: boolean +} + +export interface ProviderStatus { + id: string + name: string + available: boolean + hasEnvVar: boolean + hasPlugin: boolean +} + +export interface ToolsSummary { + lspInstalled: number + lspTotal: number + astGrepCli: boolean + astGrepNapi: boolean + commentChecker: boolean + ghCli: { installed: boolean; authenticated: boolean; username: string | null } + mcpBuiltin: string[] + mcpUser: string[] } export interface DoctorSummary { @@ -43,10 +76,23 @@ export interface DoctorSummary { export interface DoctorResult { results: CheckResult[] + systemInfo: SystemInfo + providers: ProviderStatus[] + tools: ToolsSummary summary: DoctorSummary exitCode: number } +// ===== Legacy types (used by existing checks until migration) ===== + +export type CheckCategory = + | "installation" + | "configuration" + | "authentication" + | "dependencies" + | "tools" + | "updates" + export interface OpenCodeInfo { installed: boolean version: string | null