From 2ba148be12aeab5dc565172f9792f0ac7962bae4 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 13 Feb 2026 17:29:38 +0900 Subject: [PATCH 1/7] 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 From a7b56a0391ac0ba070afd7320c10caa923044034 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 13 Feb 2026 17:35:36 +0900 Subject: [PATCH 2/7] fix(doctor): oMoMoMoMo branding, remove providers check, fix comment-checker detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename header to oMoMoMoMo Doctor to match installation guide branding. Remove providers check entirely — no longer meaningful for diagnostics. Fix comment-checker detection by resolving @code-yeongyu/comment-checker package path in addition to PATH lookup. --- src/cli/doctor/checks/dependencies.ts | 24 +++++- src/cli/doctor/checks/index.ts | 8 +- src/cli/doctor/checks/providers.ts | 101 -------------------------- src/cli/doctor/constants.ts | 14 ---- src/cli/doctor/format-default.test.ts | 1 - src/cli/doctor/format-shared.ts | 2 +- src/cli/doctor/format-status.ts | 8 +- src/cli/doctor/format-verbose.ts | 12 +-- src/cli/doctor/formatter.test.ts | 1 - src/cli/doctor/runner.test.ts | 38 +++------- src/cli/doctor/runner.ts | 6 +- src/cli/doctor/types.ts | 9 --- 12 files changed, 36 insertions(+), 188 deletions(-) delete mode 100644 src/cli/doctor/checks/providers.ts diff --git a/src/cli/doctor/checks/dependencies.ts b/src/cli/doctor/checks/dependencies.ts index 52eb8c9c..da22afcf 100644 --- a/src/cli/doctor/checks/dependencies.ts +++ b/src/cli/doctor/checks/dependencies.ts @@ -1,3 +1,7 @@ +import { existsSync } from "node:fs" +import { createRequire } from "node:module" +import { dirname, join } from "node:path" + import type { DependencyInfo } from "../types" async function checkBinaryExists(binary: string): Promise<{ exists: boolean; path: string | null }> { @@ -98,10 +102,24 @@ export async function checkAstGrepNapi(): Promise { } } +function findCommentCheckerPackageBinary(): string | null { + const binaryName = process.platform === "win32" ? "comment-checker.exe" : "comment-checker" + try { + const require = createRequire(import.meta.url) + const pkgPath = require.resolve("@code-yeongyu/comment-checker/package.json") + const binaryPath = join(dirname(pkgPath), "bin", binaryName) + if (existsSync(binaryPath)) return binaryPath + } catch { + // intentionally empty - package not installed + } + return null +} + export async function checkCommentChecker(): Promise { const binaryCheck = await checkBinaryExists("comment-checker") + const resolvedPath = binaryCheck.exists ? binaryCheck.path : findCommentCheckerPackageBinary() - if (!binaryCheck.exists) { + if (!resolvedPath) { return { name: "Comment Checker", required: false, @@ -112,14 +130,14 @@ export async function checkCommentChecker(): Promise { } } - const version = await getBinaryVersion("comment-checker") + const version = await getBinaryVersion(resolvedPath) return { name: "Comment Checker", required: false, installed: true, version, - path: binaryCheck.path, + path: resolvedPath, } } diff --git a/src/cli/doctor/checks/index.ts b/src/cli/doctor/checks/index.ts index 20960d7b..0ad6821f 100644 --- a/src/cli/doctor/checks/index.ts +++ b/src/cli/doctor/checks/index.ts @@ -2,13 +2,12 @@ import type { CheckDefinition } from "../types" 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 type { CheckDefinition } export * from "./model-resolution-types" -export { gatherSystemInfo, gatherProviderStatuses, gatherToolsSummary } +export { gatherSystemInfo, gatherToolsSummary } export function getAllCheckDefinitions(): CheckDefinition[] { return [ @@ -23,11 +22,6 @@ export function getAllCheckDefinitions(): CheckDefinition[] { 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], diff --git a/src/cli/doctor/checks/providers.ts b/src/cli/doctor/checks/providers.ts deleted file mode 100644 index c34ebdb3..00000000 --- a/src/cli/doctor/checks/providers.ts +++ /dev/null @@ -1,101 +0,0 @@ -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/constants.ts b/src/cli/doctor/constants.ts index bfd66afe..ff41f836 100644 --- a/src/cli/doctor/constants.ts +++ b/src/cli/doctor/constants.ts @@ -20,7 +20,6 @@ export const STATUS_COLORS = { export const CHECK_IDS = { SYSTEM: "system", CONFIG: "config", - PROVIDERS: "providers", TOOLS: "tools", MODELS: "models", } as const @@ -28,23 +27,10 @@ export const CHECK_IDS = { export const CHECK_NAMES: Record = { [CHECK_IDS.SYSTEM]: "System", [CHECK_IDS.CONFIG]: "Configuration", - [CHECK_IDS.PROVIDERS]: "Providers", [CHECK_IDS.TOOLS]: "Tools", [CHECK_IDS.MODELS]: "Models", } as const -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 = { SUCCESS: 0, FAILURE: 1, diff --git a/src/cli/doctor/format-default.test.ts b/src/cli/doctor/format-default.test.ts index 60b09674..76b85169 100644 --- a/src/cli/doctor/format-default.test.ts +++ b/src/cli/doctor/format-default.test.ts @@ -19,7 +19,6 @@ function createBaseResult(): DoctorResult { configValid: true, isLocalDev: false, }, - providers: [], tools: { lspInstalled: 0, lspTotal: 0, diff --git a/src/cli/doctor/format-shared.ts b/src/cli/doctor/format-shared.ts index b6c37bc5..401533dc 100644 --- a/src/cli/doctor/format-shared.ts +++ b/src/cli/doctor/format-shared.ts @@ -27,7 +27,7 @@ export function stripAnsi(str: string): string { } export function formatHeader(): string { - return `\n${color.bgMagenta(color.white(" oMo Doctor "))}\n` + return `\n${color.bgMagenta(color.white(" oMoMoMoMo Doctor "))}\n` } export function formatIssue(issue: DoctorIssue, index: number): string { diff --git a/src/cli/doctor/format-status.ts b/src/cli/doctor/format-status.ts index c73a11dc..6b4930a1 100644 --- a/src/cli/doctor/format-status.ts +++ b/src/cli/doctor/format-status.ts @@ -7,7 +7,7 @@ export function formatStatus(result: DoctorResult): string { lines.push(formatHeader()) - const { systemInfo, providers, tools } = result + const { systemInfo, tools } = result const padding = " " const opencodeVer = systemInfo.opencodeVersion ?? "unknown" @@ -19,12 +19,6 @@ export function formatStatus(result: DoctorResult): string { 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) diff --git a/src/cli/doctor/format-verbose.ts b/src/cli/doctor/format-verbose.ts index b491651a..f965b675 100644 --- a/src/cli/doctor/format-verbose.ts +++ b/src/cli/doctor/format-verbose.ts @@ -7,7 +7,7 @@ export function formatVerbose(result: DoctorResult): string { lines.push(formatHeader()) - const { systemInfo, providers, tools, results, summary } = result + const { systemInfo, tools, results, summary } = result lines.push(`${color.bold("System Information")}`) lines.push(`${color.dim("\u2500".repeat(40))}`) @@ -31,16 +31,6 @@ export function formatVerbose(result: DoctorResult): string { 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`) diff --git a/src/cli/doctor/formatter.test.ts b/src/cli/doctor/formatter.test.ts index a14104d0..5884997a 100644 --- a/src/cli/doctor/formatter.test.ts +++ b/src/cli/doctor/formatter.test.ts @@ -17,7 +17,6 @@ function createDoctorResult(): DoctorResult { configValid: true, isLocalDev: false, }, - providers: [{ id: "anthropic", name: "Anthropic", available: true, hasEnvVar: true, hasPlugin: true }], tools: { lspInstalled: 2, lspTotal: 4, diff --git a/src/cli/doctor/runner.test.ts b/src/cli/doctor/runner.test.ts index 2e65f323..ca96b079 100644 --- a/src/cli/doctor/runner.test.ts +++ b/src/cli/doctor/runner.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, mock } from "bun:test" -import type { CheckDefinition, CheckResult, DoctorResult, ProviderStatus, SystemInfo, ToolsSummary } from "./types" +import type { CheckDefinition, CheckResult, DoctorResult, SystemInfo, ToolsSummary } from "./types" function createSystemInfo(): SystemInfo { return { @@ -14,13 +14,6 @@ function createSystemInfo(): SystemInfo { } } -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, @@ -143,7 +136,6 @@ describe("runner", () => { const deferredTwo = createDeferred() const deferredThree = createDeferred() const deferredFour = createDeferred() - const deferredFive = createDeferred() const checks: CheckDefinition[] = [ { @@ -162,20 +154,12 @@ describe("runner", () => { 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 + return deferredThree.promise }, }, { @@ -183,7 +167,7 @@ describe("runner", () => { name: "Models", check: async () => { startedChecks.push("models") - return deferredFive.promise + return deferredFour.promise }, }, ] @@ -192,16 +176,14 @@ describe("runner", () => { results: [ createPassResult("System"), createPassResult("Configuration"), - createPassResult("Providers"), createPassResult("Tools"), createPassResult("Models"), ], systemInfo: createSystemInfo(), - providers: createProviders(), tools: createTools(), summary: { - total: 5, - passed: 5, + total: 4, + passed: 4, failed: 0, warnings: 0, skipped: 0, @@ -216,7 +198,6 @@ describe("runner", () => { mock.module("./checks", () => ({ getAllCheckDefinitions: () => checks, gatherSystemInfo: async () => expectedResult.systemInfo, - gatherProviderStatuses: () => expectedResult.providers, gatherToolsSummary: async () => expectedResult.tools, })) mock.module("./formatter", () => ({ @@ -236,15 +217,14 @@ describe("runner", () => { const startedBeforeResolve = [...startedChecks] deferredOne.resolve(createPassResult("System")) deferredTwo.resolve(createPassResult("Configuration")) - deferredThree.resolve(createPassResult("Providers")) - deferredFour.resolve(createPassResult("Tools")) - deferredFive.resolve(createPassResult("Models")) + deferredThree.resolve(createPassResult("Tools")) + deferredFour.resolve(createPassResult("Models")) const result = await runPromise //#then console.log = originalLog - expect(startedBeforeResolve.sort()).toEqual(["config", "models", "providers", "system", "tools"]) - expect(result.results.length).toBe(5) + expect(startedBeforeResolve.sort()).toEqual(["config", "models", "system", "tools"]) + expect(result.results.length).toBe(4) 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 ec9e0152..75342bec 100644 --- a/src/cli/doctor/runner.ts +++ b/src/cli/doctor/runner.ts @@ -1,5 +1,5 @@ import type { DoctorOptions, DoctorResult, CheckDefinition, CheckResult, DoctorSummary } from "./types" -import { getAllCheckDefinitions, gatherSystemInfo, gatherProviderStatuses, gatherToolsSummary } from "./checks" +import { getAllCheckDefinitions, gatherSystemInfo, gatherToolsSummary } from "./checks" import { EXIT_CODES } from "./constants" import { formatDoctorOutput, formatJsonOutput } from "./formatter" @@ -39,10 +39,9 @@ export async function runDoctor(options: DoctorOptions): Promise { const start = performance.now() const allChecks = getAllCheckDefinitions() - const [results, systemInfo, providers, tools] = await Promise.all([ + const [results, systemInfo, tools] = await Promise.all([ Promise.all(allChecks.map(runCheck)), gatherSystemInfo(), - Promise.resolve(gatherProviderStatuses()), gatherToolsSummary(), ]) @@ -53,7 +52,6 @@ export async function runDoctor(options: DoctorOptions): Promise { const doctorResult: DoctorResult = { results, systemInfo, - providers, tools, summary, exitCode, diff --git a/src/cli/doctor/types.ts b/src/cli/doctor/types.ts index cb725c42..4c165566 100644 --- a/src/cli/doctor/types.ts +++ b/src/cli/doctor/types.ts @@ -46,14 +46,6 @@ export interface SystemInfo { isLocalDev: boolean } -export interface ProviderStatus { - id: string - name: string - available: boolean - hasEnvVar: boolean - hasPlugin: boolean -} - export interface ToolsSummary { lspInstalled: number lspTotal: number @@ -77,7 +69,6 @@ export interface DoctorSummary { export interface DoctorResult { results: CheckResult[] systemInfo: SystemInfo - providers: ProviderStatus[] tools: ToolsSummary summary: DoctorSummary exitCode: number From a413e576762830b1ad9bf5fb0863ac6b2cfdb1c2 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 13 Feb 2026 17:40:12 +0900 Subject: [PATCH 3/7] feat(background-agent): add TaskHistory class for persistent task tracking In-memory tracker that survives BackgroundManager's cleanup cycles. Records agent delegations with defensive copies, MAX 100 cap per parent, undefined-safe upsert, and newline-sanitized formatForCompaction output. --- .../background-agent/task-history.test.ts | 170 ++++++++++++++++++ src/features/background-agent/task-history.ts | 75 ++++++++ 2 files changed, 245 insertions(+) create mode 100644 src/features/background-agent/task-history.test.ts create mode 100644 src/features/background-agent/task-history.ts diff --git a/src/features/background-agent/task-history.test.ts b/src/features/background-agent/task-history.test.ts new file mode 100644 index 00000000..0afb426b --- /dev/null +++ b/src/features/background-agent/task-history.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from "bun:test" +import { TaskHistory } from "./task-history" + +describe("TaskHistory", () => { + describe("record", () => { + it("stores an entry for a parent session", () => { + //#given + const history = new TaskHistory() + + //#when + history.record("parent-1", { id: "t1", agent: "explore", description: "Find auth", status: "pending" }) + + //#then + const entries = history.getByParentSession("parent-1") + expect(entries).toHaveLength(1) + expect(entries[0].id).toBe("t1") + expect(entries[0].agent).toBe("explore") + expect(entries[0].status).toBe("pending") + }) + + it("ignores undefined parentSessionID", () => { + //#given + const history = new TaskHistory() + + //#when + history.record(undefined, { id: "t1", agent: "explore", description: "Find auth", status: "pending" }) + + //#then + expect(history.getByParentSession("undefined")).toHaveLength(0) + }) + + it("upserts without clobbering undefined fields", () => { + //#given + const history = new TaskHistory() + history.record("parent-1", { id: "t1", agent: "explore", description: "Find auth", status: "pending", category: "quick" }) + + //#when + history.record("parent-1", { id: "t1", agent: "explore", description: "Find auth", status: "running" }) + + //#then + const entries = history.getByParentSession("parent-1") + expect(entries).toHaveLength(1) + expect(entries[0].status).toBe("running") + expect(entries[0].category).toBe("quick") + }) + + it("caps entries at MAX_ENTRIES_PER_PARENT (100)", () => { + //#given + const history = new TaskHistory() + + //#when + for (let i = 0; i < 105; i++) { + history.record("parent-1", { id: `t${i}`, agent: "explore", description: `Task ${i}`, status: "completed" }) + } + + //#then + const entries = history.getByParentSession("parent-1") + expect(entries).toHaveLength(100) + expect(entries[0].id).toBe("t5") + expect(entries[99].id).toBe("t104") + }) + }) + + describe("getByParentSession", () => { + it("returns defensive copies", () => { + //#given + const history = new TaskHistory() + history.record("parent-1", { id: "t1", agent: "explore", description: "Find auth", status: "pending" }) + + //#when + const entries = history.getByParentSession("parent-1") + entries[0].status = "completed" + + //#then + const fresh = history.getByParentSession("parent-1") + expect(fresh[0].status).toBe("pending") + }) + + it("returns empty array for unknown parent", () => { + //#given + const history = new TaskHistory() + + //#when + const entries = history.getByParentSession("nonexistent") + + //#then + expect(entries).toHaveLength(0) + }) + }) + + describe("clearSession", () => { + it("removes all entries for a parent session", () => { + //#given + const history = new TaskHistory() + history.record("parent-1", { id: "t1", agent: "explore", description: "Find auth", status: "pending" }) + history.record("parent-2", { id: "t2", agent: "oracle", description: "Review", status: "running" }) + + //#when + history.clearSession("parent-1") + + //#then + expect(history.getByParentSession("parent-1")).toHaveLength(0) + expect(history.getByParentSession("parent-2")).toHaveLength(1) + }) + }) + + describe("formatForCompaction", () => { + it("returns null when no entries exist", () => { + //#given + const history = new TaskHistory() + + //#when + const result = history.formatForCompaction("nonexistent") + + //#then + expect(result).toBeNull() + }) + + it("formats entries with agent, status, and description", () => { + //#given + const history = new TaskHistory() + history.record("parent-1", { id: "t1", agent: "explore", description: "Find auth patterns", status: "completed" }) + + //#when + const result = history.formatForCompaction("parent-1") + + //#then + expect(result).toContain("**explore**") + expect(result).toContain("(completed)") + expect(result).toContain("Find auth patterns") + }) + + it("includes category when present", () => { + //#given + const history = new TaskHistory() + history.record("parent-1", { id: "t1", agent: "explore", description: "Find auth", status: "running", category: "quick" }) + + //#when + const result = history.formatForCompaction("parent-1") + + //#then + expect(result).toContain("[quick]") + }) + + it("includes session_id when present", () => { + //#given + const history = new TaskHistory() + history.record("parent-1", { id: "t1", sessionID: "ses_abc123", agent: "oracle", description: "Review arch", status: "completed" }) + + //#when + const result = history.formatForCompaction("parent-1") + + //#then + expect(result).toContain("`ses_abc123`") + }) + + it("sanitizes newlines in description", () => { + //#given + const history = new TaskHistory() + history.record("parent-1", { id: "t1", agent: "explore", description: "Line1\nLine2\rLine3", status: "pending" }) + + //#when + const result = history.formatForCompaction("parent-1") + + //#then + expect(result).not.toContain("\n\n") + expect(result).toContain("Line1 Line2 Line3") + }) + }) +}) diff --git a/src/features/background-agent/task-history.ts b/src/features/background-agent/task-history.ts new file mode 100644 index 00000000..333340c6 --- /dev/null +++ b/src/features/background-agent/task-history.ts @@ -0,0 +1,75 @@ +import type { BackgroundTaskStatus } from "./types" + +const MAX_ENTRIES_PER_PARENT = 100 + +export interface TaskHistoryEntry { + id: string + sessionID?: string + agent: string + description: string + status: BackgroundTaskStatus + category?: string + startedAt?: Date + completedAt?: Date +} + +export class TaskHistory { + private entries: Map = new Map() + + record(parentSessionID: string | undefined, entry: TaskHistoryEntry): void { + if (!parentSessionID) return + + const list = this.entries.get(parentSessionID) ?? [] + const existing = list.findIndex((e) => e.id === entry.id) + + if (existing !== -1) { + const current = list[existing] + list[existing] = { + ...current, + ...(entry.sessionID !== undefined ? { sessionID: entry.sessionID } : {}), + ...(entry.agent !== undefined ? { agent: entry.agent } : {}), + ...(entry.description !== undefined ? { description: entry.description } : {}), + ...(entry.status !== undefined ? { status: entry.status } : {}), + ...(entry.category !== undefined ? { category: entry.category } : {}), + ...(entry.startedAt !== undefined ? { startedAt: entry.startedAt } : {}), + ...(entry.completedAt !== undefined ? { completedAt: entry.completedAt } : {}), + } + } else { + if (list.length >= MAX_ENTRIES_PER_PARENT) { + list.shift() + } + list.push({ ...entry }) + } + + this.entries.set(parentSessionID, list) + } + + getByParentSession(parentSessionID: string): TaskHistoryEntry[] { + const list = this.entries.get(parentSessionID) + if (!list) return [] + return list.map((e) => ({ ...e })) + } + + clearSession(parentSessionID: string): void { + this.entries.delete(parentSessionID) + } + + formatForCompaction(parentSessionID: string): string | null { + const list = this.getByParentSession(parentSessionID) + if (list.length === 0) return null + + const lines = list.map((e) => { + const desc = e.description.replace(/[\n\r]+/g, " ").trim() + const parts = [ + `- **${e.agent}**`, + e.category ? `[${e.category}]` : null, + `(${e.status})`, + `: ${desc}`, + e.sessionID ? ` | session: \`${e.sessionID}\`` : null, + ] + return parts.filter(Boolean).join("") + }) + + return lines.join("\n") + } +} From 0946a6c8f3d10f7beb325496541ecd00d649c3b2 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 13 Feb 2026 17:40:29 +0900 Subject: [PATCH 4/7] feat(compaction): add delegated agent sessions section with resume directive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds §8 to compaction prompt instructing the LLM to preserve spawned agent session IDs and resume them post-compaction instead of starting fresh. Injects actual TaskHistory data when BackgroundManager is available. --- src/hooks/compaction-context-injector/hook.ts | 21 ++++++++- .../compaction-context-injector/index.test.ts | 44 +++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/src/hooks/compaction-context-injector/hook.ts b/src/hooks/compaction-context-injector/hook.ts index 639f3e96..0f3a912f 100644 --- a/src/hooks/compaction-context-injector/hook.ts +++ b/src/hooks/compaction-context-injector/hook.ts @@ -1,3 +1,4 @@ +import type { BackgroundManager } from "../../features/background-agent" import { createSystemDirective, SystemDirectiveTypes, @@ -47,9 +48,25 @@ When summarizing this session, you MUST include the following sections in your s This section is CRITICAL for reviewer agents (momus, oracle) to maintain continuity. +## 8. Delegated Agent Sessions +- List ALL background agent tasks spawned during this session +- For each: agent name, category, status, description, and **session_id** +- **RESUME, DON'T RESTART.** Each listed session retains full context. After compaction, use \`session_id\` to continue existing agent sessions instead of spawning new ones. This saves tokens, preserves learned context, and prevents duplicate work. + This context is critical for maintaining continuity after compaction. ` -export function createCompactionContextInjector() { - return (): string => COMPACTION_CONTEXT_PROMPT +export function createCompactionContextInjector(backgroundManager?: BackgroundManager) { + return (sessionID?: string): string => { + let prompt = COMPACTION_CONTEXT_PROMPT + + if (backgroundManager && sessionID) { + const history = backgroundManager.taskHistory.formatForCompaction(sessionID) + if (history) { + prompt += `\n### Active/Recent Delegated Sessions\n${history}\n` + } + } + + return prompt + } } diff --git a/src/hooks/compaction-context-injector/index.test.ts b/src/hooks/compaction-context-injector/index.test.ts index 85348e09..a2813916 100644 --- a/src/hooks/compaction-context-injector/index.test.ts +++ b/src/hooks/compaction-context-injector/index.test.ts @@ -15,6 +15,7 @@ mock.module("../../shared/system-directive", () => ({ })) import { createCompactionContextInjector } from "./index" +import { TaskHistory } from "../../features/background-agent/task-history" describe("createCompactionContextInjector", () => { describe("Agent Verification State preservation", () => { @@ -69,4 +70,47 @@ describe("createCompactionContextInjector", () => { expect(prompt).toContain("Do NOT invent") expect(prompt).toContain("Quote constraints verbatim") }) + + describe("Delegated Agent Sessions", () => { + it("includes delegated sessions section in compaction prompt", async () => { + //#given + const injector = createCompactionContextInjector() + + //#when + const prompt = injector() + + //#then + expect(prompt).toContain("Delegated Agent Sessions") + expect(prompt).toContain("RESUME, DON'T RESTART") + expect(prompt).toContain("session_id") + }) + + it("injects actual task history when backgroundManager and sessionID provided", async () => { + //#given + const mockManager = { taskHistory: new TaskHistory() } as any + mockManager.taskHistory.record("ses_parent", { id: "t1", sessionID: "ses_child", agent: "explore", description: "Find patterns", status: "completed", category: "quick" }) + const injector = createCompactionContextInjector(mockManager) + + //#when + const prompt = injector("ses_parent") + + //#then + expect(prompt).toContain("Active/Recent Delegated Sessions") + expect(prompt).toContain("**explore**") + expect(prompt).toContain("[quick]") + expect(prompt).toContain("`ses_child`") + }) + + it("does not inject task history section when no entries exist", async () => { + //#given + const mockManager = { taskHistory: new TaskHistory() } as any + const injector = createCompactionContextInjector(mockManager) + + //#when + const prompt = injector("ses_empty") + + //#then + expect(prompt).not.toContain("Active/Recent Delegated Sessions") + }) + }) }) From e3924437cebef023064cec26091bf01359d6f730 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 13 Feb 2026 17:40:44 +0900 Subject: [PATCH 5/7] feat(compaction): wire TaskHistory into BackgroundManager and compaction pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records task history at 6 status transitions (pending, running×2, error, cancelled, completed). Exports TaskHistory from background-agent barrel. Passes backgroundManager and sessionID through compaction hook chain. --- src/features/background-agent/index.ts | 1 + src/features/background-agent/manager.ts | 8 ++++++++ src/index.ts | 2 +- src/plugin/hooks/create-continuation-hooks.ts | 2 +- 4 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/features/background-agent/index.ts b/src/features/background-agent/index.ts index 6dc61829..5d3905d2 100644 --- a/src/features/background-agent/index.ts +++ b/src/features/background-agent/index.ts @@ -1,4 +1,5 @@ export * from "./types" export { BackgroundManager, type SubagentSessionCreatedEvent, type OnSubagentSessionCreated } from "./manager" +export { TaskHistory, type TaskHistoryEntry } from "./task-history" export { ConcurrencyManager } from "./concurrency" export { TaskStateManager } from "./state" diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index cbe16999..bfab927a 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -5,6 +5,7 @@ import type { LaunchInput, ResumeInput, } from "./types" +import { TaskHistory } from "./task-history" import { log, getAgentToolRestrictions, promptWithModelSuggestionRetry } from "../../shared" import { ConcurrencyManager } from "./concurrency" import type { BackgroundTaskConfig, TmuxConfig } from "../../config/schema" @@ -90,6 +91,7 @@ export class BackgroundManager { private completionTimers: Map> = new Map() private idleDeferralTimers: Map> = new Map() private notificationQueueByParent: Map> = new Map() + readonly taskHistory = new TaskHistory() constructor( ctx: PluginInput, @@ -144,6 +146,7 @@ export class BackgroundManager { } this.tasks.set(task.id, task) + this.taskHistory.record(input.parentSessionID, { id: task.id, agent: input.agent, description: input.description, status: "pending", category: input.category }) // Track for batched notifications immediately (pending state) if (input.parentSessionID) { @@ -291,6 +294,7 @@ export class BackgroundManager { task.concurrencyKey = concurrencyKey task.concurrencyGroup = concurrencyKey + this.taskHistory.record(input.parentSessionID, { id: task.id, sessionID, agent: input.agent, description: input.description, status: "running", category: input.category, startedAt: task.startedAt }) this.startPolling() log("[background-agent] Launching task:", { taskId: task.id, sessionID, agent: input.agent }) @@ -486,6 +490,7 @@ export class BackgroundManager { this.tasks.set(task.id, task) subagentSessions.add(input.sessionID) this.startPolling() + this.taskHistory.record(input.parentSessionID, { id: task.id, sessionID: input.sessionID, agent: input.agent || "task", description: input.description, status: "running", startedAt: task.startedAt }) if (input.parentSessionID) { const pending = this.pendingByParent.get(input.parentSessionID) ?? new Set() @@ -741,6 +746,7 @@ export class BackgroundManager { task.status = "error" task.error = errorMessage ?? "Session error" task.completedAt = new Date() + this.taskHistory.record(task.parentSessionID, { id: task.id, sessionID: task.sessionID, agent: task.agent, description: task.description, status: "error", category: task.category, startedAt: task.startedAt, completedAt: task.completedAt }) if (task.concurrencyKey) { this.concurrencyManager.release(task.concurrencyKey) @@ -951,6 +957,7 @@ export class BackgroundManager { if (reason) { task.error = reason } + this.taskHistory.record(task.parentSessionID, { id: task.id, sessionID: task.sessionID, agent: task.agent, description: task.description, status: "cancelled", category: task.category, startedAt: task.startedAt, completedAt: task.completedAt }) if (task.concurrencyKey) { this.concurrencyManager.release(task.concurrencyKey) @@ -1095,6 +1102,7 @@ export class BackgroundManager { // Atomically mark as completed to prevent race conditions task.status = "completed" task.completedAt = new Date() + this.taskHistory.record(task.parentSessionID, { id: task.id, sessionID: task.sessionID, agent: task.agent, description: task.description, status: "completed", category: task.category, startedAt: task.startedAt, completedAt: task.completedAt }) // Release concurrency BEFORE any async operations to prevent slot leaks if (task.concurrencyKey) { diff --git a/src/index.ts b/src/index.ts index 69cae8cd..74707851 100644 --- a/src/index.ts +++ b/src/index.ts @@ -82,7 +82,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { if (!hooks.compactionContextInjector) { return } - output.context.push(hooks.compactionContextInjector()) + output.context.push(hooks.compactionContextInjector(_input.sessionID)) }, } } diff --git a/src/plugin/hooks/create-continuation-hooks.ts b/src/plugin/hooks/create-continuation-hooks.ts index dbc2d3d0..96bf5de0 100644 --- a/src/plugin/hooks/create-continuation-hooks.ts +++ b/src/plugin/hooks/create-continuation-hooks.ts @@ -53,7 +53,7 @@ export function createContinuationHooks(args: { : null const compactionContextInjector = isHookEnabled("compaction-context-injector") - ? safeHook("compaction-context-injector", () => createCompactionContextInjector()) + ? safeHook("compaction-context-injector", () => createCompactionContextInjector(backgroundManager)) : null const compactionTodoPreserver = isHookEnabled("compaction-todo-preserver") From 9742f7d0b994b67f79de709ece3805b23d278b06 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 13 Feb 2026 17:51:38 +0900 Subject: [PATCH 6/7] fix(slashcommand): exclude skills from tool description to avoid duplication with skill tool --- src/tools/slashcommand/slashcommand-tool.ts | 9 ++++----- src/tools/slashcommand/tools.test.ts | 13 ++++++------- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/tools/slashcommand/slashcommand-tool.ts b/src/tools/slashcommand/slashcommand-tool.ts index f436c3ac..3de86a4a 100644 --- a/src/tools/slashcommand/slashcommand-tool.ts +++ b/src/tools/slashcommand/slashcommand-tool.ts @@ -31,14 +31,13 @@ export function createSlashcommandTool(options: SlashcommandToolOptions = {}): T const buildDescription = async (): Promise => { if (cachedDescription) return cachedDescription - const allItems = await getAllItems() - cachedDescription = buildDescriptionFromItems(allItems) + const commands = getCommands() + cachedDescription = buildDescriptionFromItems(commands) return cachedDescription } - if (options.commands !== undefined && options.skills !== undefined) { - const allItems = [...options.commands, ...options.skills.map(skillToCommandInfo)] - cachedDescription = buildDescriptionFromItems(allItems) + if (options.commands !== undefined) { + cachedDescription = buildDescriptionFromItems(options.commands) } else { void buildDescription() } diff --git a/src/tools/slashcommand/tools.test.ts b/src/tools/slashcommand/tools.test.ts index f33c5ab0..aec487b5 100644 --- a/src/tools/slashcommand/tools.test.ts +++ b/src/tools/slashcommand/tools.test.ts @@ -29,7 +29,7 @@ function createMockSkill(name: string, description = ""): LoadedSkill { } describe("slashcommand tool - synchronous description", () => { - it("includes available_skills immediately when commands and skills are pre-provided", () => { + it("includes only commands in description, not skills", () => { // given const commands = [createMockCommand("commit", "Create a git commit")] const skills = [createMockSkill("playwright", "Browser automation via Playwright MCP")] @@ -38,12 +38,11 @@ describe("slashcommand tool - synchronous description", () => { const tool = createSlashcommandTool({ commands, skills }) // then - expect(tool.description).toContain("") expect(tool.description).toContain("commit") - expect(tool.description).toContain("playwright") + expect(tool.description).not.toContain("playwright") }) - it("includes all pre-provided commands and skills in description immediately", () => { + it("lists all commands but excludes skills from description", () => { // given const commands = [ createMockCommand("commit", "Git commit"), @@ -61,9 +60,9 @@ describe("slashcommand tool - synchronous description", () => { // then expect(tool.description).toContain("commit") expect(tool.description).toContain("plan") - expect(tool.description).toContain("playwright") - expect(tool.description).toContain("frontend-ui-ux") - expect(tool.description).toContain("git-master") + expect(tool.description).not.toContain("playwright") + expect(tool.description).not.toContain("frontend-ui-ux") + expect(tool.description).not.toContain("git-master") }) it("shows prefix-only description when both commands and skills are empty", () => { From 7a3df05e47a340cb5af883129a32b3407e1b1231 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 13 Feb 2026 18:50:53 +0900 Subject: [PATCH 7/7] fix(todo-continuation-enforcer): fire continuation for all sessions with incomplete todos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove boulder session restriction (f84ef532) and stagnation cap (10a60854) that prevented continuation from firing in regular sessions. Changes: - Remove boulder/subagent session gate in idle-event.ts — continuation now fires for ANY session with incomplete todos, as originally intended - Remove stagnation cap (MAX_UNCHANGED_CYCLES) — agent must keep rolling the boulder until all todos are complete, no giving up after 3 attempts - Remove lastTodoHash and unchangedCycles from SessionState type - Keep 30s cooldown (CONTINUATION_COOLDOWN_MS) as safety net against re-injection loops - Update tests: remove boulder gate tests, update stagnation test to verify continuous injection, update non-main-session test to verify injection 42 tests pass, typecheck and build clean. --- .../todo-continuation-enforcer/constants.ts | 1 - .../todo-continuation-enforcer/idle-event.ts | 26 -- .../todo-continuation-enforcer.test.ts | 244 +++++------------- src/hooks/todo-continuation-enforcer/types.ts | 2 - 4 files changed, 60 insertions(+), 213 deletions(-) diff --git a/src/hooks/todo-continuation-enforcer/constants.ts b/src/hooks/todo-continuation-enforcer/constants.ts index f8b94fde..b57a8164 100644 --- a/src/hooks/todo-continuation-enforcer/constants.ts +++ b/src/hooks/todo-continuation-enforcer/constants.ts @@ -18,4 +18,3 @@ export const COUNTDOWN_GRACE_PERIOD_MS = 500 export const ABORT_WINDOW_MS = 3000 export const CONTINUATION_COOLDOWN_MS = 30_000 -export const MAX_UNCHANGED_CYCLES = 3 diff --git a/src/hooks/todo-continuation-enforcer/idle-event.ts b/src/hooks/todo-continuation-enforcer/idle-event.ts index c5181d48..2bfb96bf 100644 --- a/src/hooks/todo-continuation-enforcer/idle-event.ts +++ b/src/hooks/todo-continuation-enforcer/idle-event.ts @@ -1,8 +1,6 @@ import type { PluginInput } from "@opencode-ai/plugin" import type { BackgroundManager } from "../../features/background-agent" -import { readBoulderState } from "../../features/boulder-state" -import { subagentSessions } from "../../features/claude-code-session-state" import type { ToolPermission } from "../../features/hook-message-injector" import { log } from "../../shared/logger" @@ -11,7 +9,6 @@ import { CONTINUATION_COOLDOWN_MS, DEFAULT_SKIP_AGENTS, HOOK_NAME, - MAX_UNCHANGED_CYCLES, } from "./constants" import { isLastAssistantMessageAborted } from "./abort-detection" import { getIncompleteCount } from "./todo" @@ -38,16 +35,6 @@ export async function handleSessionIdle(args: { log(`[${HOOK_NAME}] session.idle`, { sessionID }) - const isBackgroundTaskSession = subagentSessions.has(sessionID) - const boulderState = readBoulderState(ctx.directory) - const isBoulderSession = boulderState?.session_ids.includes(sessionID) ?? false - - // Continuation is restricted to boulder/background sessions to prevent accidental continuation in regular sessions, ensuring controlled task resumption. - if (!isBackgroundTaskSession && !isBoulderSession) { - log(`[${HOOK_NAME}] Skipped: not boulder or background task session`, { sessionID }) - return - } - const state = sessionStateStore.getState(sessionID) if (state.isRecovering) { log(`[${HOOK_NAME}] Skipped: in recovery`, { sessionID }) @@ -117,19 +104,6 @@ export async function handleSessionIdle(args: { return } - const incompleteTodos = todos.filter((todo) => todo.status !== "completed" && todo.status !== "cancelled") - const todoHash = incompleteTodos.map((todo) => `${todo.id}:${todo.status}`).join("|") - if (state.lastTodoHash === todoHash) { - state.unchangedCycles = (state.unchangedCycles ?? 0) + 1 - if (state.unchangedCycles >= MAX_UNCHANGED_CYCLES) { - log(`[${HOOK_NAME}] Skipped: stagnation cap reached`, { sessionID, cycles: state.unchangedCycles }) - return - } - } else { - state.unchangedCycles = 0 - } - state.lastTodoHash = todoHash - let resolvedInfo: ResolvedMessageInfo | undefined let hasCompactionMessage = false try { diff --git a/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts b/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts index 43c681ba..91cb173b 100644 --- a/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts +++ b/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts @@ -1,5 +1,3 @@ -import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs" -import { join } from "node:path" import { afterEach, beforeEach, describe, expect, test } from "bun:test" import type { BackgroundManager } from "../../features/background-agent" @@ -122,32 +120,6 @@ function createFakeTimers(): FakeTimers { const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) -const TEST_BOULDER_DIR = join("/tmp/test", ".sisyphus") -const TEST_BOULDER_FILE = join(TEST_BOULDER_DIR, "boulder.json") - -function writeBoulderJsonForSession(sessionIds: string[]): void { - if (!existsSync(TEST_BOULDER_DIR)) { - mkdirSync(TEST_BOULDER_DIR, { recursive: true }) - } - writeFileSync(TEST_BOULDER_FILE, JSON.stringify({ - active_plan: "/tmp/test/.sisyphus/plans/test-plan.md", - started_at: new Date().toISOString(), - session_ids: sessionIds, - plan_name: "test-plan", - }), "utf-8") -} - -function cleanupBoulderFile(): void { - if (existsSync(TEST_BOULDER_FILE)) { - rmSync(TEST_BOULDER_FILE) - } -} - -function setupMainSessionWithBoulder(sessionID: string): void { - setMainSession(sessionID) - writeBoulderJsonForSession([sessionID]) -} - describe("todo-continuation-enforcer", () => { let promptCalls: Array<{ sessionID: string; agent?: string; model?: { providerID?: string; modelID?: string }; text: string }> let toastCalls: Array<{ title: string; message: string }> @@ -224,14 +196,13 @@ describe("todo-continuation-enforcer", () => { afterEach(() => { fakeTimers.restore() _resetForTesting() - cleanupBoulderFile() }) test("should inject continuation when idle with incomplete todos", async () => { fakeTimers.restore() // given - main session with incomplete todos const sessionID = "main-123" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) const hook = createTodoContinuationEnforcer(createMockPluginInput(), { backgroundManager: createMockBackgroundManager(false), @@ -256,7 +227,7 @@ describe("todo-continuation-enforcer", () => { test("should not inject when all todos are complete", async () => { // given - session with all todos complete const sessionID = "main-456" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) const mockInput = createMockPluginInput() mockInput.client.session.todo = async () => ({ data: [ @@ -279,7 +250,7 @@ describe("todo-continuation-enforcer", () => { test("should not inject when background tasks are running", async () => { // given - session with running background tasks const sessionID = "main-789" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) const hook = createTodoContinuationEnforcer(createMockPluginInput(), { backgroundManager: createMockBackgroundManager(true), @@ -296,23 +267,23 @@ describe("todo-continuation-enforcer", () => { expect(promptCalls).toHaveLength(0) }) - test("should not inject for non-main session", async () => { - // given - main session set, different session goes idle - setMainSession("main-session") + test("should inject for any session with incomplete todos", async () => { + fakeTimers.restore() + //#given — any session, not necessarily main session const otherSession = "other-session" const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) - // when - non-main session goes idle + //#when — session goes idle await hook.handler({ event: { type: "session.idle", properties: { sessionID: otherSession } }, }) - await fakeTimers.advanceBy(3000) - - // then - no continuation injected - expect(promptCalls).toHaveLength(0) - }) + //#then — continuation injected regardless of session type + await wait(2500) + expect(promptCalls.length).toBe(1) + expect(promptCalls[0].sessionID).toBe(otherSession) + }, { timeout: 15000 }) test("should inject for background task session (subagent)", async () => { fakeTimers.restore() @@ -339,7 +310,7 @@ describe("todo-continuation-enforcer", () => { test("should cancel countdown on user message after grace period", async () => { // given - session starting countdown const sessionID = "main-cancel" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) @@ -366,7 +337,7 @@ describe("todo-continuation-enforcer", () => { fakeTimers.restore() // given - session starting countdown const sessionID = "main-grace" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) @@ -392,7 +363,7 @@ describe("todo-continuation-enforcer", () => { test("should cancel countdown on assistant activity", async () => { // given - session starting countdown const sessionID = "main-assistant" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) @@ -419,7 +390,7 @@ describe("todo-continuation-enforcer", () => { test("should cancel countdown on tool execution", async () => { // given - session starting countdown const sessionID = "main-tool" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) @@ -443,7 +414,7 @@ describe("todo-continuation-enforcer", () => { test("should skip injection during recovery mode", async () => { // given - session in recovery mode const sessionID = "main-recovery" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) @@ -465,7 +436,7 @@ describe("todo-continuation-enforcer", () => { fakeTimers.restore() // given - session was in recovery, now complete const sessionID = "main-recovery-done" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) @@ -487,7 +458,7 @@ describe("todo-continuation-enforcer", () => { test("should cleanup on session deleted", async () => { // given - session starting countdown const sessionID = "main-delete" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) @@ -511,7 +482,7 @@ describe("todo-continuation-enforcer", () => { test("should not inject again when cooldown is active", async () => { //#given const sessionID = "main-cooldown-active" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) //#when @@ -531,7 +502,7 @@ describe("todo-continuation-enforcer", () => { test("should inject again when cooldown expires", async () => { //#given const sessionID = "main-cooldown-expired" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) //#when @@ -549,19 +520,22 @@ describe("todo-continuation-enforcer", () => { expect(promptCalls).toHaveLength(2) }) - test("should stop after stagnation cap and reset when todo hash changes", async () => { + test("should keep injecting even when todos remain unchanged across cycles", async () => { //#given - const sessionID = "main-stagnation-cap" - setupMainSessionWithBoulder(sessionID) - let mutableTodoStatus: "pending" | "in_progress" = "pending" + const sessionID = "main-no-stagnation-cap" + setMainSession(sessionID) const mockInput = createMockPluginInput() mockInput.client.session.todo = async () => ({ data: [ - { id: "1", content: "Task 1", status: mutableTodoStatus, priority: "high" }, + { id: "1", content: "Task 1", status: "pending", priority: "high" }, { id: "2", content: "Task 2", status: "completed", priority: "medium" }, ]}) const hook = createTodoContinuationEnforcer(mockInput, {}) - //#when + //#when — 5 consecutive idle cycles with unchanged todos + await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) + await fakeTimers.advanceBy(2500, true) + await fakeTimers.advanceBy(CONTINUATION_COOLDOWN_MS, true) + await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) await fakeTimers.advanceBy(2500, true) await fakeTimers.advanceBy(CONTINUATION_COOLDOWN_MS, true) @@ -577,19 +551,14 @@ describe("todo-continuation-enforcer", () => { await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) await fakeTimers.advanceBy(2500, true) - mutableTodoStatus = "in_progress" - await fakeTimers.advanceBy(CONTINUATION_COOLDOWN_MS, true) - await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) - await fakeTimers.advanceBy(2500, true) - - //#then - expect(promptCalls).toHaveLength(4) + //#then — all 5 injections should fire (no stagnation cap) + expect(promptCalls).toHaveLength(5) }) test("should skip idle handling while injection is in flight", async () => { //#given const sessionID = "main-in-flight" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) let resolvePrompt: (() => void) | undefined const mockInput = createMockPluginInput() mockInput.client.session.promptAsync = async (opts: any) => { @@ -623,10 +592,10 @@ describe("todo-continuation-enforcer", () => { await Promise.resolve() }) - test("should clear cooldown and stagnation state on session deleted", async () => { + test("should clear cooldown state on session deleted", async () => { //#given const sessionID = "main-delete-state-reset" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) //#when @@ -649,7 +618,7 @@ describe("todo-continuation-enforcer", () => { test("should accept skipAgents option without error", async () => { // given - session with skipAgents configured for Prometheus const sessionID = "main-prometheus-option" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) // when - create hook with skipAgents option (should not throw) const hook = createTodoContinuationEnforcer(createMockPluginInput(), { @@ -669,7 +638,7 @@ describe("todo-continuation-enforcer", () => { fakeTimers.restore() // given - session with incomplete todos const sessionID = "main-toast" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) @@ -687,7 +656,7 @@ describe("todo-continuation-enforcer", () => { test("should not have 10s throttle between injections", async () => { // given - new hook instance (no prior state) const sessionID = "main-no-throttle" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) @@ -720,7 +689,7 @@ describe("todo-continuation-enforcer", () => { fakeTimers.restore() // given - session with incomplete todos const sessionID = "main-noabort-error" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) @@ -759,7 +728,7 @@ describe("todo-continuation-enforcer", () => { test("should skip injection when last assistant message has MessageAbortedError", async () => { // given - session where last assistant message was aborted const sessionID = "main-api-abort" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) mockMessages = [ { info: { id: "msg-1", role: "user" } }, @@ -783,7 +752,7 @@ describe("todo-continuation-enforcer", () => { fakeTimers.restore() // given - session where last assistant message completed normally const sessionID = "main-api-no-error" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) mockMessages = [ { info: { id: "msg-1", role: "user" } }, @@ -807,7 +776,7 @@ describe("todo-continuation-enforcer", () => { fakeTimers.restore() // given - session where last message is from user const sessionID = "main-api-user-last" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) mockMessages = [ { info: { id: "msg-1", role: "assistant" } }, @@ -830,7 +799,7 @@ describe("todo-continuation-enforcer", () => { test("should skip when last assistant message has any abort-like error", async () => { // given - session where last assistant message has AbortError (DOMException style) const sessionID = "main-api-abort-dom" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) mockMessages = [ { info: { id: "msg-1", role: "user" } }, @@ -853,7 +822,7 @@ describe("todo-continuation-enforcer", () => { test("should skip injection when abort detected via session.error event (event-based, primary)", async () => { // given - session with incomplete todos const sessionID = "main-event-abort" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) mockMessages = [ { info: { id: "msg-1", role: "user" } }, { info: { id: "msg-2", role: "assistant" } }, @@ -883,7 +852,7 @@ describe("todo-continuation-enforcer", () => { test("should skip injection when AbortError detected via session.error event", async () => { // given - session with incomplete todos const sessionID = "main-event-abort-dom" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) mockMessages = [ { info: { id: "msg-1", role: "user" } }, { info: { id: "msg-2", role: "assistant" } }, @@ -914,7 +883,7 @@ describe("todo-continuation-enforcer", () => { fakeTimers.restore() // given - session with incomplete todos and old abort timestamp const sessionID = "main-stale-abort" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) mockMessages = [ { info: { id: "msg-1", role: "user" } }, { info: { id: "msg-2", role: "assistant" } }, @@ -947,7 +916,7 @@ describe("todo-continuation-enforcer", () => { fakeTimers.restore() // given - session with abort detected const sessionID = "main-clear-on-user" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) mockMessages = [ { info: { id: "msg-1", role: "user" } }, { info: { id: "msg-2", role: "assistant" } }, @@ -987,7 +956,7 @@ describe("todo-continuation-enforcer", () => { fakeTimers.restore() // given - session with abort detected const sessionID = "main-clear-on-assistant" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) mockMessages = [ { info: { id: "msg-1", role: "user" } }, { info: { id: "msg-2", role: "assistant" } }, @@ -1026,7 +995,7 @@ describe("todo-continuation-enforcer", () => { fakeTimers.restore() // given - session with abort detected const sessionID = "main-clear-on-tool" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) mockMessages = [ { info: { id: "msg-1", role: "user" } }, { info: { id: "msg-2", role: "assistant" } }, @@ -1064,7 +1033,7 @@ describe("todo-continuation-enforcer", () => { test("should use event-based detection even when API indicates no abort (event wins)", async () => { // given - session with abort event but API shows no error const sessionID = "main-event-wins" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) mockMessages = [ { info: { id: "msg-1", role: "user" } }, { info: { id: "msg-2", role: "assistant" } }, @@ -1094,7 +1063,7 @@ describe("todo-continuation-enforcer", () => { test("should use API fallback when event is missed but API shows abort", async () => { // given - session where event was missed but API shows abort const sessionID = "main-api-fallback" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) mockMessages = [ { info: { id: "msg-1", role: "user" } }, { info: { id: "msg-2", role: "assistant", error: { name: "MessageAbortedError" } } }, @@ -1117,7 +1086,7 @@ describe("todo-continuation-enforcer", () => { fakeTimers.restore() // given - session with incomplete todos, no prior message context available const sessionID = "main-model-preserve" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) const hook = createTodoContinuationEnforcer(createMockPluginInput(), { backgroundManager: createMockBackgroundManager(false), @@ -1139,7 +1108,7 @@ describe("todo-continuation-enforcer", () => { test("should extract model from assistant message with flat modelID/providerID", async () => { // given - session with assistant message that has flat modelID/providerID (OpenCode API format) const sessionID = "main-assistant-model" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) // OpenCode returns assistant messages with flat modelID/providerID, not nested model object const mockMessagesWithAssistant = [ @@ -1200,7 +1169,7 @@ describe("todo-continuation-enforcer", () => { test("should skip compaction agent messages when resolving agent info", async () => { // given - session where last message is from compaction agent but previous was Sisyphus const sessionID = "main-compaction-filter" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) const mockMessagesWithCompaction = [ { info: { id: "msg-1", role: "user", agent: "sisyphus", model: { providerID: "anthropic", modelID: "claude-sonnet-4-5" } } }, @@ -1255,7 +1224,7 @@ describe("todo-continuation-enforcer", () => { test("should skip injection when only compaction agent messages exist", async () => { // given - session with only compaction agent (post-compaction, no prior agent info) const sessionID = "main-only-compaction" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) const mockMessagesOnlyCompaction = [ { info: { id: "msg-1", role: "assistant", agent: "compaction" } }, @@ -1308,7 +1277,7 @@ describe("todo-continuation-enforcer", () => { test("should skip injection when prometheus agent is after compaction", async () => { // given - prometheus session that was compacted const sessionID = "main-prometheus-compacted" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) const mockMessagesPrometheusCompacted = [ { info: { id: "msg-1", role: "user", agent: "prometheus" } }, @@ -1364,7 +1333,7 @@ describe("todo-continuation-enforcer", () => { fakeTimers.restore() // given - session with no agent info but skipAgents is empty const sessionID = "main-no-agent-no-skip" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) const mockMessagesNoAgent = [ { info: { id: "msg-1", role: "user" } }, @@ -1420,7 +1389,7 @@ describe("todo-continuation-enforcer", () => { test("should not inject when isContinuationStopped returns true", async () => { // given - session with continuation stopped const sessionID = "main-stopped" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) const hook = createTodoContinuationEnforcer(createMockPluginInput(), { isContinuationStopped: (id) => id === sessionID, @@ -1441,7 +1410,7 @@ describe("todo-continuation-enforcer", () => { fakeTimers.restore() // given - session with continuation not stopped const sessionID = "main-not-stopped" - setupMainSessionWithBoulder(sessionID) + setMainSession(sessionID) const hook = createTodoContinuationEnforcer(createMockPluginInput(), { isContinuationStopped: () => false, @@ -1462,7 +1431,7 @@ describe("todo-continuation-enforcer", () => { // given - multiple sessions with running countdowns const session1 = "main-cancel-all-1" const session2 = "main-cancel-all-2" - setupMainSessionWithBoulder(session1) + setMainSession(session1) const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) @@ -1482,97 +1451,4 @@ describe("todo-continuation-enforcer", () => { expect(promptCalls).toHaveLength(0) }) - // ============================================================ - // BOULDER SESSION GATE TESTS - // These tests verify that todo-continuation-enforcer only fires - // when the session is registered in boulder.json's session_ids - // (i.e., /start-work was executed in the session) - // ============================================================ - - test("should NOT inject for main session when session is NOT in boulder.json session_ids", async () => { - // given - main session that is NOT registered in boulder.json - const sessionID = "main-no-boulder-entry" - setMainSession(sessionID) - writeBoulderJsonForSession(["some-other-session"]) - - const hook = createTodoContinuationEnforcer(createMockPluginInput(), { - backgroundManager: createMockBackgroundManager(false), - }) - - // when - session goes idle - await hook.handler({ - event: { type: "session.idle", properties: { sessionID } }, - }) - - await fakeTimers.advanceBy(3000) - - // then - no continuation injected (session not in boulder) - expect(promptCalls).toHaveLength(0) - }) - - test("should inject for main session when session IS in boulder.json session_ids", async () => { - fakeTimers.restore() - // given - main session that IS registered in boulder.json - const sessionID = "main-in-boulder" - setMainSession(sessionID) - writeBoulderJsonForSession([sessionID]) - - const hook = createTodoContinuationEnforcer(createMockPluginInput(), { - backgroundManager: createMockBackgroundManager(false), - }) - - // when - session goes idle - await hook.handler({ - event: { type: "session.idle", properties: { sessionID } }, - }) - - await wait(2500) - - // then - continuation injected (session is in boulder) - expect(promptCalls.length).toBe(1) - expect(promptCalls[0].text).toContain("TODO CONTINUATION") - }, { timeout: 15000 }) - - test("should NOT inject for main session when no boulder.json exists", async () => { - // given - main session with no boulder.json at all - const sessionID = "main-no-boulder-file" - setMainSession(sessionID) - cleanupBoulderFile() - - const hook = createTodoContinuationEnforcer(createMockPluginInput(), { - backgroundManager: createMockBackgroundManager(false), - }) - - // when - session goes idle - await hook.handler({ - event: { type: "session.idle", properties: { sessionID } }, - }) - - await fakeTimers.advanceBy(3000) - - // then - no continuation injected (no boulder state) - expect(promptCalls).toHaveLength(0) - }) - - test("should still inject for background task session regardless of boulder state", async () => { - fakeTimers.restore() - // given - background task session with no boulder entry - setMainSession("main-session") - const bgTaskSession = "bg-task-boulder-test" - subagentSessions.add(bgTaskSession) - cleanupBoulderFile() - - const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) - - // when - background task session goes idle - await hook.handler({ - event: { type: "session.idle", properties: { sessionID: bgTaskSession } }, - }) - - await wait(2500) - - // then - continuation still injected (background tasks bypass boulder check) - expect(promptCalls.length).toBe(1) - expect(promptCalls[0].sessionID).toBe(bgTaskSession) - }, { timeout: 15000 }) }) diff --git a/src/hooks/todo-continuation-enforcer/types.ts b/src/hooks/todo-continuation-enforcer/types.ts index c3384aa0..7d702b0e 100644 --- a/src/hooks/todo-continuation-enforcer/types.ts +++ b/src/hooks/todo-continuation-enforcer/types.ts @@ -29,8 +29,6 @@ export interface SessionState { abortDetectedAt?: number lastInjectedAt?: number inFlight?: boolean - lastTodoHash?: string - unchangedCycles?: number } export interface MessageInfo {