Merge pull request #1817 from code-yeongyu/fix/todo-continuation-always-fire

fix(todo-continuation-enforcer): fire continuation for all sessions with incomplete todos
This commit is contained in:
YeonGyu-Kim 2026-02-14 11:43:10 +09:00 committed by GitHub
commit c8cd6370e2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
58 changed files with 2050 additions and 3562 deletions

View File

@ -149,29 +149,21 @@ This command shows:
program program
.command("doctor") .command("doctor")
.description("Check oh-my-opencode installation health and diagnose issues") .description("Check oh-my-opencode installation health and diagnose issues")
.option("--status", "Show compact system dashboard")
.option("--verbose", "Show detailed diagnostic information") .option("--verbose", "Show detailed diagnostic information")
.option("--json", "Output results in JSON format") .option("--json", "Output results in JSON format")
.option("--category <category>", "Run only specific category")
.addHelpText("after", ` .addHelpText("after", `
Examples: Examples:
$ bunx oh-my-opencode doctor $ bunx oh-my-opencode doctor # Show problems only
$ bunx oh-my-opencode doctor --verbose $ bunx oh-my-opencode doctor --status # Compact dashboard
$ bunx oh-my-opencode doctor --json $ bunx oh-my-opencode doctor --verbose # Deep diagnostics
$ bunx oh-my-opencode doctor --category authentication $ bunx oh-my-opencode doctor --json # JSON output
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
`) `)
.action(async (options) => { .action(async (options) => {
const mode = options.status ? "status" : options.verbose ? "verbose" : "default"
const doctorOptions: DoctorOptions = { const doctorOptions: DoctorOptions = {
verbose: options.verbose ?? false, mode,
json: options.json ?? false, json: options.json ?? false,
category: options.category,
} }
const exitCode = await doctor(doctorOptions) const exitCode = await doctor(doctorOptions)
process.exit(exitCode) process.exit(exitCode)

View File

@ -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<typeof spyOn>
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)
})
})
})

View File

@ -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<AuthProviderId, { plugin: string; name: string }> = {
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<CheckResult> {
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<CheckResult> {
return checkAuthProvider("anthropic")
}
export async function checkOpenAIAuth(): Promise<CheckResult> {
return checkAuthProvider("openai")
}
export async function checkGoogleAuth(): Promise<CheckResult> {
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,
},
]
}

View File

@ -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" import * as config from "./config"
describe("config check", () => { describe("config check", () => {
describe("validateConfig", () => { describe("checkConfig", () => {
it("returns valid: false for non-existent file", () => { it("returns a valid CheckResult", async () => {
// given non-existent file path //#given config check is available
// when validating //#when running the consolidated config check
const result = config.validateConfig("/non/existent/path.json") const result = await config.checkConfig()
// then should indicate invalid //#then should return a properly shaped CheckResult
expect(result.valid).toBe(false) expect(result.name).toBe("Configuration")
expect(result.errors.length).toBeGreaterThan(0) expect(["pass", "fail", "warn", "skip"]).toContain(result.status)
}) expect(typeof result.message).toBe("string")
}) expect(Array.isArray(result.issues)).toBe(true)
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<typeof spyOn>
afterEach(() => {
getInfoSpy?.mockRestore()
}) })
it("returns pass when no config exists (uses defaults)", async () => { it("includes issues array even when config is valid", async () => {
// given no config file //#given a normal environment
getInfoSpy = spyOn(config, "getConfigInfo").mockReturnValue({ //#when running config check
exists: false, const result = await config.checkConfig()
path: null,
format: null,
valid: true,
errors: [],
})
// when checking validity //#then issues should be an array (possibly empty)
const result = await config.checkConfigValidity() expect(Array.isArray(result.issues)).toBe(true)
// 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)
}) })
}) })
}) })

View File

@ -1,122 +1,164 @@
import { existsSync, readFileSync } from "node:fs" import { readFileSync } from "node:fs"
import { join } from "node:path" 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" }) import { OhMyOpenCodeConfigSchema } from "../../../config"
const USER_CONFIG_BASE = join(USER_CONFIG_DIR, `${PACKAGE_NAME}`) 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) const PROJECT_CONFIG_BASE = join(process.cwd(), ".opencode", PACKAGE_NAME)
function findConfigPath(): { path: string; format: "json" | "jsonc" } | null { interface ConfigValidationResult {
const projectDetected = detectConfigFile(PROJECT_CONFIG_BASE) exists: boolean
if (projectDetected.format !== "none") { path: string | null
return { path: projectDetected.path, format: projectDetected.format as "json" | "jsonc" } valid: boolean
} config: OmoConfig | null
errors: string[]
}
const userDetected = detectConfigFile(USER_CONFIG_BASE) function findConfigPath(): string | null {
if (userDetected.format !== "none") { const projectConfig = detectConfigFile(PROJECT_CONFIG_BASE)
return { path: userDetected.path, format: userDetected.format as "json" | "jsonc" } if (projectConfig.format !== "none") return projectConfig.path
}
const userConfig = detectConfigFile(USER_CONFIG_BASE)
if (userConfig.format !== "none") return userConfig.path
return null 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 { try {
const content = readFileSync(configPath, "utf-8") const content = readFileSync(configPath, "utf-8")
const rawConfig = parseJsonc<Record<string, unknown>>(content) const rawConfig = parseJsonc<OmoConfig>(content)
const result = OhMyOpenCodeConfigSchema.safeParse(rawConfig) const schemaResult = OhMyOpenCodeConfigSchema.safeParse(rawConfig)
if (!result.success) { if (!schemaResult.success) {
const errors = result.error.issues.map( return {
(i) => `${i.path.join(".")}: ${i.message}` exists: true,
) path: configPath,
return { valid: false, errors } valid: false,
config: rawConfig,
errors: schemaResult.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`),
}
} }
return { valid: true, errors: [] } return { exists: true, path: configPath, valid: true, config: rawConfig, errors: [] }
} catch (err) { } catch (error) {
return { return {
exists: true,
path: configPath,
valid: false, 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 { function collectModelResolutionIssues(config: OmoConfig): DoctorIssue[] {
const configPath = findConfigPath() const issues: DoctorIssue[] = []
const availableModels = loadAvailableModelsFromCache()
const resolution = getModelResolutionInfoWithOverrides(config)
if (!configPath) { const invalidAgentOverrides = resolution.agents.filter(
return { (agent) => agent.userOverride && !agent.userOverride.includes("/")
exists: false, )
path: null, const invalidCategoryOverrides = resolution.categories.filter(
format: null, (category) => category.userOverride && !category.userOverride.includes("/")
valid: true, )
errors: [],
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 issues
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,
}
} }
export async function checkConfigValidity(): Promise<CheckResult> { export async function checkConfig(): Promise<CheckResult> {
const info = getConfigInfo() const validation = validateConfig()
const issues: DoctorIssue[] = []
if (!info.exists) { if (!validation.exists) {
return { return {
name: CHECK_NAMES[CHECK_IDS.CONFIG_VALIDATION], name: CHECK_NAMES[CHECK_IDS.CONFIG],
status: "pass", status: "pass",
message: "Using default configuration", message: "No custom config found; defaults are used",
details: ["No custom config file found (optional)"], 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 { return {
name: CHECK_NAMES[CHECK_IDS.CONFIG_VALIDATION], name: CHECK_NAMES[CHECK_IDS.CONFIG],
status: "fail", status: "fail",
message: "Configuration has validation errors", message: `Configuration invalid (${issues.length} issue${issues.length > 1 ? "s" : ""})`,
details: [ details: validation.path ? [`Path: ${validation.path}`] : undefined,
`Path: ${info.path}`, issues,
...info.errors.map((e) => `Error: ${e}`),
],
} }
} }
return { if (validation.config) {
name: CHECK_NAMES[CHECK_IDS.CONFIG_VALIDATION], issues.push(...collectModelResolutionIssues(validation.config))
status: "pass",
message: `Valid ${info.format?.toUpperCase()} config`,
details: [`Path: ${info.path}`],
} }
}
export function getConfigCheckDefinition(): CheckDefinition {
return { return {
id: CHECK_IDS.CONFIG_VALIDATION, name: CHECK_NAMES[CHECK_IDS.CONFIG],
name: CHECK_NAMES[CHECK_IDS.CONFIG_VALIDATION], status: issues.length > 0 ? "warn" : "pass",
category: "configuration", message: issues.length > 0 ? `${issues.length} configuration warning(s)` : "Configuration is valid",
check: checkConfigValidity, details: validation.path ? [`Path: ${validation.path}`] : undefined,
critical: false, issues,
} }
} }

View File

@ -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" import * as deps from "./dependencies"
describe("dependencies check", () => { describe("dependencies check", () => {
describe("checkAstGrepCli", () => { describe("checkAstGrepCli", () => {
it("returns dependency info", async () => { it("returns valid dependency info", async () => {
// given //#given ast-grep cli check
// when checking ast-grep cli //#when checking
const info = await deps.checkAstGrepCli() const info = await deps.checkAstGrepCli()
// then should return valid info //#then should return valid DependencyInfo
expect(info.name).toBe("AST-Grep CLI") expect(info.name).toBe("AST-Grep CLI")
expect(info.required).toBe(false) expect(info.required).toBe(false)
expect(typeof info.installed).toBe("boolean") 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", () => { describe("checkAstGrepNapi", () => {
it("returns dependency info", async () => { it("returns valid dependency info", async () => {
// given //#given ast-grep napi check
// when checking ast-grep napi //#when checking
const info = await deps.checkAstGrepNapi() const info = await deps.checkAstGrepNapi()
// then should return valid info //#then should return valid DependencyInfo
expect(info.name).toBe("AST-Grep NAPI") expect(info.name).toBe("AST-Grep NAPI")
expect(info.required).toBe(false) expect(info.required).toBe(false)
expect(typeof info.installed).toBe("boolean") expect(typeof info.installed).toBe("boolean")
@ -29,124 +31,15 @@ describe("dependencies check", () => {
}) })
describe("checkCommentChecker", () => { describe("checkCommentChecker", () => {
it("returns dependency info", async () => { it("returns valid dependency info", async () => {
// given //#given comment checker check
// when checking comment checker //#when checking
const info = await deps.checkCommentChecker() const info = await deps.checkCommentChecker()
// then should return valid info //#then should return valid DependencyInfo
expect(info.name).toBe("Comment Checker") expect(info.name).toBe("Comment Checker")
expect(info.required).toBe(false) expect(info.required).toBe(false)
expect(typeof info.installed).toBe("boolean") expect(typeof info.installed).toBe("boolean")
}) })
}) })
describe("checkDependencyAstGrepCli", () => {
let checkSpy: ReturnType<typeof spyOn>
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<typeof spyOn>
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<typeof spyOn>
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)
})
})
}) })

View File

@ -1,5 +1,8 @@
import type { CheckResult, CheckDefinition, DependencyInfo } from "../types" import { existsSync } from "node:fs"
import { CHECK_IDS, CHECK_NAMES } from "../constants" 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 }> { async function checkBinaryExists(binary: string): Promise<{ exists: boolean; path: string | null }> {
try { try {
@ -99,10 +102,24 @@ export async function checkAstGrepNapi(): Promise<DependencyInfo> {
} }
} }
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<DependencyInfo> { export async function checkCommentChecker(): Promise<DependencyInfo> {
const binaryCheck = await checkBinaryExists("comment-checker") const binaryCheck = await checkBinaryExists("comment-checker")
const resolvedPath = binaryCheck.exists ? binaryCheck.path : findCommentCheckerPackageBinary()
if (!binaryCheck.exists) { if (!resolvedPath) {
return { return {
name: "Comment Checker", name: "Comment Checker",
required: false, required: false,
@ -113,72 +130,14 @@ export async function checkCommentChecker(): Promise<DependencyInfo> {
} }
} }
const version = await getBinaryVersion("comment-checker") const version = await getBinaryVersion(resolvedPath)
return { return {
name: "Comment Checker", name: "Comment Checker",
required: false, required: false,
installed: true, installed: true,
version, version,
path: binaryCheck.path, path: resolvedPath,
} }
} }
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<CheckResult> {
const info = await checkAstGrepCli()
return dependencyToCheckResult(info, CHECK_NAMES[CHECK_IDS.DEP_AST_GREP_CLI])
}
export async function checkDependencyAstGrepNapi(): Promise<CheckResult> {
const info = await checkAstGrepNapi()
return dependencyToCheckResult(info, CHECK_NAMES[CHECK_IDS.DEP_AST_GREP_NAPI])
}
export async function checkDependencyCommentChecker(): Promise<CheckResult> {
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,
},
]
}

View File

@ -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<typeof Bun.spawn>
}
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<typeof spyOn>
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")
})
})
})

View File

@ -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<string | null> {
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<GhCliInfo> {
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<CheckResult> {
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,
}
}

View File

@ -1,46 +1,36 @@
import type { CheckDefinition } from "../types" import type { CheckDefinition } from "../types"
import { getOpenCodeCheckDefinition } from "./opencode" import { CHECK_IDS, CHECK_NAMES } from "../constants"
import { getPluginCheckDefinition } from "./plugin" import { checkSystem, gatherSystemInfo } from "./system"
import { getConfigCheckDefinition } from "./config" import { checkConfig } from "./config"
import { getModelResolutionCheckDefinition } from "./model-resolution" import { checkTools, gatherToolsSummary } from "./tools"
import { getAuthCheckDefinitions } from "./auth" import { checkModels } from "./model-resolution"
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"
export * from "./opencode" export type { CheckDefinition }
export * from "./plugin"
export * from "./config"
export * from "./model-resolution"
export * from "./model-resolution-types" export * from "./model-resolution-types"
export * from "./model-resolution-cache" export { gatherSystemInfo, gatherToolsSummary }
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 function getAllCheckDefinitions(): CheckDefinition[] { export function getAllCheckDefinitions(): CheckDefinition[] {
return [ return [
getOpenCodeCheckDefinition(), {
getPluginCheckDefinition(), id: CHECK_IDS.SYSTEM,
getConfigCheckDefinition(), name: CHECK_NAMES[CHECK_IDS.SYSTEM],
getModelResolutionCheckDefinition(), check: checkSystem,
...getAuthCheckDefinitions(), critical: true,
...getDependencyCheckDefinitions(), },
getGhCliCheckDefinition(), {
getLspCheckDefinition(), id: CHECK_IDS.CONFIG,
...getMcpCheckDefinitions(), name: CHECK_NAMES[CHECK_IDS.CONFIG],
getMcpOAuthCheckDefinition(), check: checkConfig,
getVersionCheckDefinition(), },
{
id: CHECK_IDS.TOOLS,
name: CHECK_NAMES[CHECK_IDS.TOOLS],
check: checkTools,
},
{
id: CHECK_IDS.MODELS,
name: CHECK_NAMES[CHECK_IDS.MODELS],
check: checkModels,
},
] ]
} }

View File

@ -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<typeof spyOn>
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)
})
})
})

View File

@ -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<LspServerInfo[]> {
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<CheckResult> {
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,
}
}

View File

@ -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<typeof spyOn>
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)
})
})
})

View File

@ -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<string, OAuthTokenData>
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<CheckResult> {
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,
}
}

View File

@ -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<typeof spyOn>
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")
})
})
})

View File

@ -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<string, unknown>
}
function loadUserMcpConfig(): Record<string, unknown> {
const servers: Record<string, unknown> = {}
for (const configPath of MCP_CONFIG_PATHS) {
if (!existsSync(configPath)) continue
try {
const content = readFileSync(configPath, "utf-8")
const config = parseJsonc<McpConfig>(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<CheckResult> {
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<CheckResult> {
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,
},
]
}

View File

@ -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")
})
})
}) })

View File

@ -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 { CHECK_IDS, CHECK_NAMES } from "../constants"
import { import type { CheckResult, DoctorIssue } from "../types"
AGENT_MODEL_REQUIREMENTS,
CATEGORY_MODEL_REQUIREMENTS,
} from "../../../shared/model-requirements"
import type { OmoConfig, ModelResolutionInfo, AgentResolutionInfo, CategoryResolutionInfo } from "./model-resolution-types"
import { loadAvailableModelsFromCache } from "./model-resolution-cache" import { loadAvailableModelsFromCache } from "./model-resolution-cache"
import { loadOmoConfig } from "./model-resolution-config" import { loadOmoConfig } from "./model-resolution-config"
import { buildEffectiveResolution, getEffectiveModel } from "./model-resolution-effective-model"
import { buildModelResolutionDetails } from "./model-resolution-details" 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 { export function getModelResolutionInfo(): ModelResolutionInfo {
const agents: AgentResolutionInfo[] = Object.entries(AGENT_MODEL_REQUIREMENTS).map( const agents: AgentResolutionInfo[] = Object.entries(AGENT_MODEL_REQUIREMENTS).map(([name, requirement]) => ({
([name, requirement]) => ({ name,
name, requirement,
requirement, effectiveModel: getEffectiveModel(requirement),
effectiveModel: getEffectiveModel(requirement), effectiveResolution: buildEffectiveResolution(requirement),
effectiveResolution: buildEffectiveResolution(requirement), }))
}),
)
const categories: CategoryResolutionInfo[] = Object.entries(CATEGORY_MODEL_REQUIREMENTS).map( const categories: CategoryResolutionInfo[] = Object.entries(CATEGORY_MODEL_REQUIREMENTS).map(
([name, requirement]) => ({ ([name, requirement]) => ({
@ -26,27 +21,25 @@ export function getModelResolutionInfo(): ModelResolutionInfo {
requirement, requirement,
effectiveModel: getEffectiveModel(requirement), effectiveModel: getEffectiveModel(requirement),
effectiveResolution: buildEffectiveResolution(requirement), effectiveResolution: buildEffectiveResolution(requirement),
}), })
) )
return { agents, categories } return { agents, categories }
} }
export function getModelResolutionInfoWithOverrides(config: OmoConfig): ModelResolutionInfo { export function getModelResolutionInfoWithOverrides(config: OmoConfig): ModelResolutionInfo {
const agents: AgentResolutionInfo[] = Object.entries(AGENT_MODEL_REQUIREMENTS).map( const agents: AgentResolutionInfo[] = Object.entries(AGENT_MODEL_REQUIREMENTS).map(([name, requirement]) => {
([name, requirement]) => { const userOverride = config.agents?.[name]?.model
const userOverride = config.agents?.[name]?.model const userVariant = config.agents?.[name]?.variant
const userVariant = config.agents?.[name]?.variant return {
return { name,
name, requirement,
requirement, userOverride,
userOverride, userVariant,
userVariant, effectiveModel: getEffectiveModel(requirement, userOverride),
effectiveModel: getEffectiveModel(requirement, userOverride), effectiveResolution: buildEffectiveResolution(requirement, userOverride),
effectiveResolution: buildEffectiveResolution(requirement, userOverride), }
} })
},
)
const categories: CategoryResolutionInfo[] = Object.entries(CATEGORY_MODEL_REQUIREMENTS).map( const categories: CategoryResolutionInfo[] = Object.entries(CATEGORY_MODEL_REQUIREMENTS).map(
([name, requirement]) => { ([name, requirement]) => {
@ -60,40 +53,39 @@ export function getModelResolutionInfoWithOverrides(config: OmoConfig): ModelRes
effectiveModel: getEffectiveModel(requirement, userOverride), effectiveModel: getEffectiveModel(requirement, userOverride),
effectiveResolution: buildEffectiveResolution(requirement, userOverride), effectiveResolution: buildEffectiveResolution(requirement, userOverride),
} }
}, }
) )
return { agents, categories } return { agents, categories }
} }
export async function checkModelResolution(): Promise<CheckResult> { export async function checkModels(): Promise<CheckResult> {
const config = loadOmoConfig() ?? {} const config = loadOmoConfig() ?? {}
const info = getModelResolutionInfoWithOverrides(config) const info = getModelResolutionInfoWithOverrides(config)
const available = loadAvailableModelsFromCache() const available = loadAvailableModelsFromCache()
const issues: DoctorIssue[] = []
const agentCount = info.agents.length if (!available.cacheExists) {
const categoryCount = info.categories.length issues.push({
const agentOverrides = info.agents.filter((a) => a.userOverride).length title: "Model cache not found",
const categoryOverrides = info.categories.filter((c) => c.userOverride).length description: "OpenCode model cache is missing, so model availability cannot be validated.",
const totalOverrides = agentOverrides + categoryOverrides fix: "Run: opencode models --refresh",
severity: "warning",
affects: ["model resolution"],
})
}
const overrideNote = totalOverrides > 0 ? ` (${totalOverrides} override${totalOverrides > 1 ? "s" : ""})` : "" const overrideCount =
const cacheNote = available.cacheExists ? `, ${available.modelCount} available` : ", cache not found" info.agents.filter((agent) => Boolean(agent.userOverride)).length +
info.categories.filter((category) => Boolean(category.userOverride)).length
return { return {
name: CHECK_NAMES[CHECK_IDS.MODEL_RESOLUTION], name: CHECK_NAMES[CHECK_IDS.MODELS],
status: available.cacheExists ? "pass" : "warn", status: issues.length > 0 ? "warn" : "pass",
message: `${agentCount} agents, ${categoryCount} categories${overrideNote}${cacheNote}`, message: `${info.agents.length} agents, ${info.categories.length} categories, ${overrideCount} override${overrideCount === 1 ? "" : "s"}`,
details: buildModelResolutionDetails({ info, available, config }), details: buildModelResolutionDetails({ info, available, config }),
issues,
} }
} }
export function getModelResolutionCheckDefinition(): CheckDefinition { export const checkModelResolution = checkModels
return {
id: CHECK_IDS.MODEL_RESOLUTION,
name: CHECK_NAMES[CHECK_IDS.MODEL_RESOLUTION],
category: "configuration",
check: checkModelResolution,
critical: false,
}
}

View File

@ -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<typeof spyOn>
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()
})
})
})

View File

@ -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<string | null> {
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<OpenCodeInfo> {
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<CheckResult> {
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,
}
}

View File

@ -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<typeof spyOn>
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)
})
})
})

View File

@ -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<CheckResult> {
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,
}
}

View File

@ -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<OpenCodeBinaryInfo | null> {
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<string | null> {
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
}

View File

@ -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<string, string>
}
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<PackageJsonShape>(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<string | null> {
const channel = extractChannel(currentVersion)
return getLatestVersion(channel)
}

View File

@ -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<OpenCodeConfigShape>(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 }

View File

@ -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<Record<string, unknown>>(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<SystemInfo> {
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<CheckResult> {
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,
}
}

View File

@ -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<string | null> {
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<GhCliInfo> {
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,
}
}

View File

@ -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,
}
}

View File

@ -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<string, unknown>
}
function getMcpConfigPaths(): string[] {
return [
join(homedir(), ".claude", ".mcp.json"),
join(process.cwd(), ".mcp.json"),
join(process.cwd(), ".claude", ".mcp.json"),
]
}
function loadUserMcpConfig(): Record<string, unknown> {
const servers: Record<string, unknown> = {}
for (const configPath of getMcpConfigPaths()) {
if (!existsSync(configPath)) continue
try {
const content = readFileSync(configPath, "utf-8")
const config = parseJsonc<McpConfigShape>(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",
}
})
}

View File

@ -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<ToolsSummary> {
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<CheckResult> {
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,
}
}

View File

@ -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<typeof spyOn>
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)
})
})
})

View File

@ -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<VersionCheckInfo> {
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<CheckResult> {
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,
}
}

View File

@ -18,50 +18,17 @@ export const STATUS_COLORS = {
} as const } as const
export const CHECK_IDS = { export const CHECK_IDS = {
OPENCODE_INSTALLATION: "opencode-installation", SYSTEM: "system",
PLUGIN_REGISTRATION: "plugin-registration", CONFIG: "config",
CONFIG_VALIDATION: "config-validation", TOOLS: "tools",
MODEL_RESOLUTION: "model-resolution", MODELS: "models",
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",
} as const } as const
export const CHECK_NAMES: Record<string, string> = { export const CHECK_NAMES: Record<string, string> = {
[CHECK_IDS.OPENCODE_INSTALLATION]: "OpenCode Installation", [CHECK_IDS.SYSTEM]: "System",
[CHECK_IDS.PLUGIN_REGISTRATION]: "Plugin Registration", [CHECK_IDS.CONFIG]: "Configuration",
[CHECK_IDS.CONFIG_VALIDATION]: "Configuration Validity", [CHECK_IDS.TOOLS]: "Tools",
[CHECK_IDS.MODEL_RESOLUTION]: "Model Resolution", [CHECK_IDS.MODELS]: "Models",
[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",
} as const
export const CATEGORY_NAMES: Record<string, string> = {
installation: "Installation",
configuration: "Configuration",
authentication: "Authentication",
dependencies: "Dependencies",
tools: "Tools & Servers",
updates: "Updates",
} as const } as const
export const EXIT_CODES = { export const EXIT_CODES = {

View File

@ -0,0 +1,82 @@
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,
},
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")
})
})

View File

@ -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")
}

View File

@ -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(" oMoMoMoMo 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")
}

View File

@ -0,0 +1,35 @@
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, 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 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")
}

View File

@ -0,0 +1,79 @@
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, 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("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")
}

View File

@ -1,218 +1,126 @@
import { describe, it, expect } from "bun:test" import { afterEach, describe, expect, it, mock } from "bun:test"
import { import type { DoctorResult } from "./types"
formatStatusSymbol,
formatCheckResult, function createDoctorResult(): DoctorResult {
formatCategoryHeader, return {
formatSummary, results: [
formatHeader, { name: "System", status: "pass", message: "ok", issues: [] },
formatFooter, { name: "Configuration", status: "warn", message: "warn", issues: [] },
formatJsonOutput, ],
formatBox, systemInfo: {
formatHelpSuggestions, opencodeVersion: "1.0.200",
} from "./formatter" opencodePath: "/usr/local/bin/opencode",
import type { CheckResult, DoctorSummary, DoctorResult } from "./types" pluginVersion: "3.4.0",
loadedVersion: "3.4.0",
bunVersion: "1.2.0",
configPath: "/tmp/opencode.jsonc",
configValid: true,
isLocalDev: false,
},
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("formatter", () => {
describe("formatStatusSymbol", () => { afterEach(() => {
it("returns green check for pass", () => { mock.restore()
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")
})
}) })
describe("formatCheckResult", () => { describe("formatDoctorOutput", () => {
it("includes name and message", () => { it("dispatches to default formatter for default mode", async () => {
const result: CheckResult = { //#given
name: "Test Check", const formatDefaultMock = mock(() => "default-output")
status: "pass", const formatStatusMock = mock(() => "status-output")
message: "All good", 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") //#then
expect(output).toContain("All good") expect(output).toBe("default-output")
expect(formatDefaultMock).toHaveBeenCalledTimes(1)
expect(formatStatusMock).toHaveBeenCalledTimes(0)
expect(formatVerboseMock).toHaveBeenCalledTimes(0)
}) })
it("includes details when verbose", () => { it("dispatches to status formatter for status mode", async () => {
const result: CheckResult = { //#given
name: "Test Check", const formatDefaultMock = mock(() => "default-output")
status: "pass", const formatStatusMock = mock(() => "status-output")
message: "OK", const formatVerboseMock = mock(() => "verbose-output")
details: ["Detail 1", "Detail 2"], 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") //#then
expect(output).toContain("Detail 2") expect(output).toBe("status-output")
expect(formatDefaultMock).toHaveBeenCalledTimes(0)
expect(formatStatusMock).toHaveBeenCalledTimes(1)
expect(formatVerboseMock).toHaveBeenCalledTimes(0)
}) })
it("hides details when not verbose", () => { it("dispatches to verbose formatter for verbose mode", async () => {
const result: CheckResult = { //#given
name: "Test Check", const formatDefaultMock = mock(() => "default-output")
status: "pass", const formatStatusMock = mock(() => "status-output")
message: "OK", const formatVerboseMock = mock(() => "verbose-output")
details: ["Detail 1"], 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") //#then
}) expect(output).toBe("verbose-output")
}) expect(formatDefaultMock).toHaveBeenCalledTimes(0)
expect(formatStatusMock).toHaveBeenCalledTimes(0)
describe("formatCategoryHeader", () => { expect(formatVerboseMock).toHaveBeenCalledTimes(1)
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")
}) })
}) })
describe("formatJsonOutput", () => { describe("formatJsonOutput", () => {
it("returns valid JSON", () => { it("returns valid JSON payload", async () => {
const result: DoctorResult = { //#given
results: [{ name: "Test", status: "pass", message: "OK" }], const { formatJsonOutput } = await import(`./formatter?json=${Date.now()}`)
summary: { total: 1, passed: 1, failed: 0, warnings: 0, skipped: 0, duration: 50 }, const result = createDoctorResult()
exitCode: 0,
}
//#when
const output = formatJsonOutput(result) const output = formatJsonOutput(result)
const parsed = JSON.parse(output) const parsed = JSON.parse(output) as DoctorResult
expect(parsed.results.length).toBe(1) //#then
expect(parsed.summary.total).toBe(1) 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) 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)
})
})
}) })

View File

@ -1,140 +1,19 @@
import color from "picocolors" import type { DoctorResult, DoctorMode } from "./types"
import type { CheckResult, DoctorSummary, CheckCategory, DoctorResult } from "./types" import { formatDefault } from "./format-default"
import { SYMBOLS, STATUS_COLORS, CATEGORY_NAMES } from "./constants" import { formatStatus } from "./format-status"
import { formatVerbose } from "./format-verbose"
export function formatStatusSymbol(status: CheckResult["status"]): string { export function formatDoctorOutput(result: DoctorResult, mode: DoctorMode): string {
switch (status) { switch (mode) {
case "pass": case "default":
return SYMBOLS.check return formatDefault(result)
case "fail": case "status":
return SYMBOLS.cross return formatStatus(result)
case "warn": case "verbose":
return SYMBOLS.warn return formatVerbose(result)
case "skip":
return SYMBOLS.skip
} }
} }
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 { export function formatJsonOutput(result: DoctorResult): string {
return JSON.stringify(result, null, 2) 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
}

View File

@ -1,11 +1,11 @@
import type { DoctorOptions } from "./types" import type { DoctorOptions } from "./types"
import { runDoctor } from "./runner" import { runDoctor } from "./runner"
export async function doctor(options: DoctorOptions = {}): Promise<number> { export async function doctor(options: DoctorOptions = { mode: "default" }): Promise<number> {
const result = await runDoctor(options) const result = await runDoctor(options)
return result.exitCode return result.exitCode
} }
export * from "./types" export * from "./types"
export { runDoctor } from "./runner" export { runDoctor } from "./runner"
export { formatJsonOutput } from "./formatter" export { formatDoctorOutput, formatJsonOutput } from "./formatter"

View File

@ -1,153 +1,233 @@
import { describe, it, expect, spyOn, afterEach } from "bun:test" import { afterEach, describe, expect, it, mock } from "bun:test"
import { import type { CheckDefinition, CheckResult, DoctorResult, SystemInfo, ToolsSummary } from "./types"
runCheck,
calculateSummary, function createSystemInfo(): SystemInfo {
determineExitCode, return {
filterChecksByCategory, opencodeVersion: "1.0.200",
groupChecksByCategory, opencodePath: "/usr/local/bin/opencode",
} from "./runner" pluginVersion: "3.4.0",
import type { CheckResult, CheckDefinition, CheckCategory } from "./types" loadedVersion: "3.4.0",
bunVersion: "1.2.0",
configPath: "/tmp/opencode.json",
configValid: true,
isLocalDev: 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<CheckResult>
resolve: (value: CheckResult) => void
} {
let resolvePromise: (value: CheckResult) => void = () => {}
const promise = new Promise<CheckResult>((resolve) => {
resolvePromise = resolve
})
return { promise, resolve: resolvePromise }
}
describe("runner", () => { describe("runner", () => {
afterEach(() => {
mock.restore()
})
describe("runCheck", () => { describe("runCheck", () => {
it("returns result from check function", async () => { it("returns fail result with issue when check throws", async () => {
//#given
const check: CheckDefinition = { const check: CheckDefinition = {
id: "test", id: "system",
name: "Test Check", name: "System",
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",
check: async () => { check: async () => {
await new Promise((r) => setTimeout(r, 50)) throw new Error("boom")
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")
}, },
} }
const { runCheck } = await import(`./runner?run-check-error=${Date.now()}`)
//#when
const result = await runCheck(check) const result = await runCheck(check)
//#then
expect(result.status).toBe("fail") 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", () => { describe("calculateSummary", () => {
it("counts each status correctly", () => { it("counts statuses correctly", async () => {
//#given
const { calculateSummary } = await import(`./runner?summary=${Date.now()}`)
const results: CheckResult[] = [ const results: CheckResult[] = [
{ name: "1", status: "pass", message: "" }, { name: "1", status: "pass", message: "", issues: [] },
{ name: "2", status: "pass", message: "" }, { name: "2", status: "pass", message: "", issues: [] },
{ name: "3", status: "fail", message: "" }, { name: "3", status: "fail", message: "", issues: [] },
{ name: "4", status: "warn", message: "" }, { name: "4", status: "warn", message: "", issues: [] },
{ name: "5", status: "skip", message: "" }, { 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.total).toBe(5)
expect(summary.passed).toBe(2) expect(summary.passed).toBe(2)
expect(summary.failed).toBe(1) expect(summary.failed).toBe(1)
expect(summary.warnings).toBe(1) expect(summary.warnings).toBe(1)
expect(summary.skipped).toBe(1) expect(summary.skipped).toBe(1)
expect(summary.duration).toBe(100) expect(summary.duration).toBe(20)
}) })
}) })
describe("determineExitCode", () => { 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[] = [ const results: CheckResult[] = [
{ name: "1", status: "pass", message: "" }, { name: "1", status: "pass", message: "", issues: [] },
{ name: "2", status: "pass", message: "" }, { 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[] = [ const results: CheckResult[] = [
{ name: "1", status: "pass", message: "" }, { name: "1", status: "pass", message: "", issues: [] },
{ name: "2", status: "warn", message: "" }, { name: "2", status: "fail", message: "", issues: [] },
] ]
expect(determineExitCode(results)).toBe(0) //#when
}) const code = determineExitCode(results)
it("returns 1 when any failures", () => { //#then
const results: CheckResult[] = [ expect(code).toBe(1)
{ name: "1", status: "pass", message: "" },
{ name: "2", status: "fail", message: "" },
]
expect(determineExitCode(results)).toBe(1)
}) })
}) })
describe("filterChecksByCategory", () => { describe("runDoctor", () => {
const checks: CheckDefinition[] = [ it("starts all checks in parallel and returns collected result", async () => {
{ id: "1", name: "Install", category: "installation", check: async () => ({ name: "", status: "pass", message: "" }) }, //#given
{ id: "2", name: "Config", category: "configuration", check: async () => ({ name: "", status: "pass", message: "" }) }, const startedChecks: string[] = []
{ id: "3", name: "Auth", category: "authentication", check: async () => ({ name: "", status: "pass", message: "" }) }, const deferredOne = createDeferred()
] const deferredTwo = createDeferred()
const deferredThree = createDeferred()
const deferredFour = createDeferred()
it("returns all checks when no category", () => { const checks: CheckDefinition[] = [
const filtered = filterChecksByCategory(checks) {
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: "tools",
name: "Tools",
check: async () => {
startedChecks.push("tools")
return deferredThree.promise
},
},
{
id: "models",
name: "Models",
check: async () => {
startedChecks.push("models")
return deferredFour.promise
},
},
]
expect(filtered.length).toBe(3) const expectedResult: DoctorResult = {
}) results: [
createPassResult("System"),
createPassResult("Configuration"),
createPassResult("Tools"),
createPassResult("Models"),
],
systemInfo: createSystemInfo(),
tools: createTools(),
summary: {
total: 4,
passed: 4,
failed: 0,
warnings: 0,
skipped: 0,
duration: 0,
},
exitCode: 0,
}
it("filters to specific category", () => { const formatDoctorOutputMock = mock((result: DoctorResult) => result.summary.total.toString())
const filtered = filterChecksByCategory(checks, "installation") const formatJsonOutputMock = mock((result: DoctorResult) => JSON.stringify(result))
expect(filtered.length).toBe(1) mock.module("./checks", () => ({
expect(filtered[0].name).toBe("Install") getAllCheckDefinitions: () => checks,
}) gatherSystemInfo: async () => expectedResult.systemInfo,
}) gatherToolsSummary: async () => expectedResult.tools,
}))
mock.module("./formatter", () => ({
formatDoctorOutput: formatDoctorOutputMock,
formatJsonOutput: formatJsonOutputMock,
}))
describe("groupChecksByCategory", () => { const logSpy = mock(() => {})
const checks: CheckDefinition[] = [ const originalLog = console.log
{ id: "1", name: "Install1", category: "installation", check: async () => ({ name: "", status: "pass", message: "" }) }, console.log = logSpy
{ id: "2", name: "Install2", category: "installation", check: async () => ({ name: "", status: "pass", message: "" }) },
{ id: "3", name: "Config", category: "configuration", check: async () => ({ name: "", status: "pass", message: "" }) },
]
it("groups checks by category", () => { const { runDoctor } = await import(`./runner?parallel=${Date.now()}`)
const groups = groupChecksByCategory(checks) const runPromise = runDoctor({ mode: "default" })
expect(groups.get("installation")?.length).toBe(2) //#when
expect(groups.get("configuration")?.length).toBe(1) await Promise.resolve()
}) const startedBeforeResolve = [...startedChecks]
deferredOne.resolve(createPassResult("System"))
deferredTwo.resolve(createPassResult("Configuration"))
deferredThree.resolve(createPassResult("Tools"))
deferredFour.resolve(createPassResult("Models"))
const result = await runPromise
it("maintains order within categories", () => { //#then
const groups = groupChecksByCategory(checks) console.log = originalLog
const installChecks = groups.get("installation")! expect(startedBeforeResolve.sort()).toEqual(["config", "models", "system", "tools"])
expect(result.results.length).toBe(4)
expect(installChecks[0].name).toBe("Install1") expect(result.exitCode).toBe(0)
expect(installChecks[1].name).toBe("Install2") expect(formatDoctorOutputMock).toHaveBeenCalledTimes(1)
expect(formatJsonOutputMock).toHaveBeenCalledTimes(0)
}) })
}) })
}) })

View File

@ -1,21 +1,7 @@
import type { import type { DoctorOptions, DoctorResult, CheckDefinition, CheckResult, DoctorSummary } from "./types"
DoctorOptions, import { getAllCheckDefinitions, gatherSystemInfo, gatherToolsSummary } from "./checks"
DoctorResult, import { EXIT_CODES } from "./constants"
CheckDefinition, import { formatDoctorOutput, formatJsonOutput } from "./formatter"
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"
export async function runCheck(check: CheckDefinition): Promise<CheckResult> { export async function runCheck(check: CheckDefinition): Promise<CheckResult> {
const start = performance.now() const start = performance.now()
@ -28,6 +14,7 @@ export async function runCheck(check: CheckDefinition): Promise<CheckResult> {
name: check.name, name: check.name,
status: "fail", status: "fail",
message: err instanceof Error ? err.message : "Unknown error", message: err instanceof Error ? err.message : "Unknown error",
issues: [{ title: check.name, description: String(err), severity: "error" }],
duration: Math.round(performance.now() - start), duration: Math.round(performance.now() - start),
} }
} }
@ -45,70 +32,18 @@ export function calculateSummary(results: CheckResult[], duration: number): Doct
} }
export function determineExitCode(results: CheckResult[]): number { export function determineExitCode(results: CheckResult[]): number {
const hasFailures = results.some((r) => r.status === "fail") return results.some((r) => r.status === "fail") ? EXIT_CODES.FAILURE : EXIT_CODES.SUCCESS
return hasFailures ? 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<CheckCategory, CheckDefinition[]> {
const groups = new Map<CheckCategory, CheckDefinition[]>()
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<DoctorResult> { export async function runDoctor(options: DoctorOptions): Promise<DoctorResult> {
const start = performance.now() const start = performance.now()
const allChecks = getAllCheckDefinitions() const allChecks = getAllCheckDefinitions()
const filteredChecks = filterChecksByCategory(allChecks, options.category) const [results, systemInfo, tools] = await Promise.all([
const groupedChecks = groupChecksByCategory(filteredChecks) Promise.all(allChecks.map(runCheck)),
gatherSystemInfo(),
const results: CheckResult[] = [] gatherToolsSummary(),
])
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 duration = performance.now() - start const duration = performance.now() - start
const summary = calculateSummary(results, duration) const summary = calculateSummary(results, duration)
@ -116,6 +51,8 @@ export async function runDoctor(options: DoctorOptions): Promise<DoctorResult> {
const doctorResult: DoctorResult = { const doctorResult: DoctorResult = {
results, results,
systemInfo,
tools,
summary, summary,
exitCode, exitCode,
} }
@ -123,9 +60,7 @@ export async function runDoctor(options: DoctorOptions): Promise<DoctorResult> {
if (options.json) { if (options.json) {
console.log(formatJsonOutput(doctorResult)) console.log(formatJsonOutput(doctorResult))
} else { } else {
console.log("") console.log(formatDoctorOutput(doctorResult, options.mode))
console.log(formatSummary(summary))
console.log(formatFooter(summary))
} }
return doctorResult return doctorResult

View File

@ -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 type CheckStatus = "pass" | "fail" | "warn" | "skip"
export interface CheckResult { export interface CheckResult {
@ -5,31 +22,39 @@ export interface CheckResult {
status: CheckStatus status: CheckStatus
message: string message: string
details?: string[] details?: string[]
issues: DoctorIssue[]
duration?: number duration?: number
} }
export type CheckFunction = () => Promise<CheckResult> export type CheckFunction = () => Promise<CheckResult>
export type CheckCategory =
| "installation"
| "configuration"
| "authentication"
| "dependencies"
| "tools"
| "updates"
export interface CheckDefinition { export interface CheckDefinition {
id: string id: string
name: string name: string
category: CheckCategory
check: CheckFunction check: CheckFunction
critical?: boolean critical?: boolean
} }
export interface DoctorOptions { export interface SystemInfo {
verbose?: boolean opencodeVersion: string | null
json?: boolean opencodePath: string | null
category?: CheckCategory pluginVersion: string | null
loadedVersion: string | null
bunVersion: string | null
configPath: string | null
configValid: boolean
isLocalDev: 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 { export interface DoctorSummary {
@ -43,10 +68,22 @@ export interface DoctorSummary {
export interface DoctorResult { export interface DoctorResult {
results: CheckResult[] results: CheckResult[]
systemInfo: SystemInfo
tools: ToolsSummary
summary: DoctorSummary summary: DoctorSummary
exitCode: number exitCode: number
} }
// ===== Legacy types (used by existing checks until migration) =====
export type CheckCategory =
| "installation"
| "configuration"
| "authentication"
| "dependencies"
| "tools"
| "updates"
export interface OpenCodeInfo { export interface OpenCodeInfo {
installed: boolean installed: boolean
version: string | null version: string | null

View File

@ -1,4 +1,5 @@
export * from "./types" export * from "./types"
export { BackgroundManager, type SubagentSessionCreatedEvent, type OnSubagentSessionCreated } from "./manager" export { BackgroundManager, type SubagentSessionCreatedEvent, type OnSubagentSessionCreated } from "./manager"
export { TaskHistory, type TaskHistoryEntry } from "./task-history"
export { ConcurrencyManager } from "./concurrency" export { ConcurrencyManager } from "./concurrency"
export { TaskStateManager } from "./state" export { TaskStateManager } from "./state"

View File

@ -5,6 +5,7 @@ import type {
LaunchInput, LaunchInput,
ResumeInput, ResumeInput,
} from "./types" } from "./types"
import { TaskHistory } from "./task-history"
import { log, getAgentToolRestrictions, promptWithModelSuggestionRetry } from "../../shared" import { log, getAgentToolRestrictions, promptWithModelSuggestionRetry } from "../../shared"
import { ConcurrencyManager } from "./concurrency" import { ConcurrencyManager } from "./concurrency"
import type { BackgroundTaskConfig, TmuxConfig } from "../../config/schema" import type { BackgroundTaskConfig, TmuxConfig } from "../../config/schema"
@ -90,6 +91,7 @@ export class BackgroundManager {
private completionTimers: Map<string, ReturnType<typeof setTimeout>> = new Map() private completionTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
private idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>> = new Map() private idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
private notificationQueueByParent: Map<string, Promise<void>> = new Map() private notificationQueueByParent: Map<string, Promise<void>> = new Map()
readonly taskHistory = new TaskHistory()
constructor( constructor(
ctx: PluginInput, ctx: PluginInput,
@ -144,6 +146,7 @@ export class BackgroundManager {
} }
this.tasks.set(task.id, task) 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) // Track for batched notifications immediately (pending state)
if (input.parentSessionID) { if (input.parentSessionID) {
@ -291,6 +294,7 @@ export class BackgroundManager {
task.concurrencyKey = concurrencyKey task.concurrencyKey = concurrencyKey
task.concurrencyGroup = 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() this.startPolling()
log("[background-agent] Launching task:", { taskId: task.id, sessionID, agent: input.agent }) 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) this.tasks.set(task.id, task)
subagentSessions.add(input.sessionID) subagentSessions.add(input.sessionID)
this.startPolling() 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) { if (input.parentSessionID) {
const pending = this.pendingByParent.get(input.parentSessionID) ?? new Set() const pending = this.pendingByParent.get(input.parentSessionID) ?? new Set()
@ -741,6 +746,7 @@ export class BackgroundManager {
task.status = "error" task.status = "error"
task.error = errorMessage ?? "Session error" task.error = errorMessage ?? "Session error"
task.completedAt = new Date() 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) { if (task.concurrencyKey) {
this.concurrencyManager.release(task.concurrencyKey) this.concurrencyManager.release(task.concurrencyKey)
@ -951,6 +957,7 @@ export class BackgroundManager {
if (reason) { if (reason) {
task.error = 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) { if (task.concurrencyKey) {
this.concurrencyManager.release(task.concurrencyKey) this.concurrencyManager.release(task.concurrencyKey)
@ -1095,6 +1102,7 @@ export class BackgroundManager {
// Atomically mark as completed to prevent race conditions // Atomically mark as completed to prevent race conditions
task.status = "completed" task.status = "completed"
task.completedAt = new Date() 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 // Release concurrency BEFORE any async operations to prevent slot leaks
if (task.concurrencyKey) { if (task.concurrencyKey) {

View File

@ -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")
})
})
})

View File

@ -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<string, TaskHistoryEntry[]> = 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")
}
}

View File

@ -1,3 +1,4 @@
import type { BackgroundManager } from "../../features/background-agent"
import { import {
createSystemDirective, createSystemDirective,
SystemDirectiveTypes, 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. 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. This context is critical for maintaining continuity after compaction.
` `
export function createCompactionContextInjector() { export function createCompactionContextInjector(backgroundManager?: BackgroundManager) {
return (): string => COMPACTION_CONTEXT_PROMPT 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
}
} }

View File

@ -15,6 +15,7 @@ mock.module("../../shared/system-directive", () => ({
})) }))
import { createCompactionContextInjector } from "./index" import { createCompactionContextInjector } from "./index"
import { TaskHistory } from "../../features/background-agent/task-history"
describe("createCompactionContextInjector", () => { describe("createCompactionContextInjector", () => {
describe("Agent Verification State preservation", () => { describe("Agent Verification State preservation", () => {
@ -69,4 +70,47 @@ describe("createCompactionContextInjector", () => {
expect(prompt).toContain("Do NOT invent") expect(prompt).toContain("Do NOT invent")
expect(prompt).toContain("Quote constraints verbatim") 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")
})
})
}) })

View File

@ -18,4 +18,3 @@ export const COUNTDOWN_GRACE_PERIOD_MS = 500
export const ABORT_WINDOW_MS = 3000 export const ABORT_WINDOW_MS = 3000
export const CONTINUATION_COOLDOWN_MS = 30_000 export const CONTINUATION_COOLDOWN_MS = 30_000
export const MAX_UNCHANGED_CYCLES = 3

View File

@ -1,8 +1,6 @@
import type { PluginInput } from "@opencode-ai/plugin" import type { PluginInput } from "@opencode-ai/plugin"
import type { BackgroundManager } from "../../features/background-agent" 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 type { ToolPermission } from "../../features/hook-message-injector"
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
@ -11,7 +9,6 @@ import {
CONTINUATION_COOLDOWN_MS, CONTINUATION_COOLDOWN_MS,
DEFAULT_SKIP_AGENTS, DEFAULT_SKIP_AGENTS,
HOOK_NAME, HOOK_NAME,
MAX_UNCHANGED_CYCLES,
} from "./constants" } from "./constants"
import { isLastAssistantMessageAborted } from "./abort-detection" import { isLastAssistantMessageAborted } from "./abort-detection"
import { getIncompleteCount } from "./todo" import { getIncompleteCount } from "./todo"
@ -38,16 +35,6 @@ export async function handleSessionIdle(args: {
log(`[${HOOK_NAME}] session.idle`, { sessionID }) 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) const state = sessionStateStore.getState(sessionID)
if (state.isRecovering) { if (state.isRecovering) {
log(`[${HOOK_NAME}] Skipped: in recovery`, { sessionID }) log(`[${HOOK_NAME}] Skipped: in recovery`, { sessionID })
@ -117,19 +104,6 @@ export async function handleSessionIdle(args: {
return 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 resolvedInfo: ResolvedMessageInfo | undefined
let hasCompactionMessage = false let hasCompactionMessage = false
try { try {

View File

@ -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 { afterEach, beforeEach, describe, expect, test } from "bun:test"
import type { BackgroundManager } from "../../features/background-agent" import type { BackgroundManager } from "../../features/background-agent"
@ -122,32 +120,6 @@ function createFakeTimers(): FakeTimers {
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)) const wait = (ms: number) => new Promise<void>((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", () => { describe("todo-continuation-enforcer", () => {
let promptCalls: Array<{ sessionID: string; agent?: string; model?: { providerID?: string; modelID?: string }; text: string }> let promptCalls: Array<{ sessionID: string; agent?: string; model?: { providerID?: string; modelID?: string }; text: string }>
let toastCalls: Array<{ title: string; message: string }> let toastCalls: Array<{ title: string; message: string }>
@ -224,14 +196,13 @@ describe("todo-continuation-enforcer", () => {
afterEach(() => { afterEach(() => {
fakeTimers.restore() fakeTimers.restore()
_resetForTesting() _resetForTesting()
cleanupBoulderFile()
}) })
test("should inject continuation when idle with incomplete todos", async () => { test("should inject continuation when idle with incomplete todos", async () => {
fakeTimers.restore() fakeTimers.restore()
// given - main session with incomplete todos // given - main session with incomplete todos
const sessionID = "main-123" const sessionID = "main-123"
setupMainSessionWithBoulder(sessionID) setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), { const hook = createTodoContinuationEnforcer(createMockPluginInput(), {
backgroundManager: createMockBackgroundManager(false), backgroundManager: createMockBackgroundManager(false),
@ -256,7 +227,7 @@ describe("todo-continuation-enforcer", () => {
test("should not inject when all todos are complete", async () => { test("should not inject when all todos are complete", async () => {
// given - session with all todos complete // given - session with all todos complete
const sessionID = "main-456" const sessionID = "main-456"
setupMainSessionWithBoulder(sessionID) setMainSession(sessionID)
const mockInput = createMockPluginInput() const mockInput = createMockPluginInput()
mockInput.client.session.todo = async () => ({ data: [ mockInput.client.session.todo = async () => ({ data: [
@ -279,7 +250,7 @@ describe("todo-continuation-enforcer", () => {
test("should not inject when background tasks are running", async () => { test("should not inject when background tasks are running", async () => {
// given - session with running background tasks // given - session with running background tasks
const sessionID = "main-789" const sessionID = "main-789"
setupMainSessionWithBoulder(sessionID) setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), { const hook = createTodoContinuationEnforcer(createMockPluginInput(), {
backgroundManager: createMockBackgroundManager(true), backgroundManager: createMockBackgroundManager(true),
@ -296,23 +267,23 @@ describe("todo-continuation-enforcer", () => {
expect(promptCalls).toHaveLength(0) expect(promptCalls).toHaveLength(0)
}) })
test("should not inject for non-main session", async () => { test("should inject for any session with incomplete todos", async () => {
// given - main session set, different session goes idle fakeTimers.restore()
setMainSession("main-session") //#given — any session, not necessarily main session
const otherSession = "other-session" const otherSession = "other-session"
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// when - non-main session goes idle //#when — session goes idle
await hook.handler({ await hook.handler({
event: { type: "session.idle", properties: { sessionID: otherSession } }, event: { type: "session.idle", properties: { sessionID: otherSession } },
}) })
await fakeTimers.advanceBy(3000) //#then — continuation injected regardless of session type
await wait(2500)
// then - no continuation injected expect(promptCalls.length).toBe(1)
expect(promptCalls).toHaveLength(0) expect(promptCalls[0].sessionID).toBe(otherSession)
}) }, { timeout: 15000 })
test("should inject for background task session (subagent)", async () => { test("should inject for background task session (subagent)", async () => {
fakeTimers.restore() fakeTimers.restore()
@ -339,7 +310,7 @@ describe("todo-continuation-enforcer", () => {
test("should cancel countdown on user message after grace period", async () => { test("should cancel countdown on user message after grace period", async () => {
// given - session starting countdown // given - session starting countdown
const sessionID = "main-cancel" const sessionID = "main-cancel"
setupMainSessionWithBoulder(sessionID) setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
@ -366,7 +337,7 @@ describe("todo-continuation-enforcer", () => {
fakeTimers.restore() fakeTimers.restore()
// given - session starting countdown // given - session starting countdown
const sessionID = "main-grace" const sessionID = "main-grace"
setupMainSessionWithBoulder(sessionID) setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
@ -392,7 +363,7 @@ describe("todo-continuation-enforcer", () => {
test("should cancel countdown on assistant activity", async () => { test("should cancel countdown on assistant activity", async () => {
// given - session starting countdown // given - session starting countdown
const sessionID = "main-assistant" const sessionID = "main-assistant"
setupMainSessionWithBoulder(sessionID) setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
@ -419,7 +390,7 @@ describe("todo-continuation-enforcer", () => {
test("should cancel countdown on tool execution", async () => { test("should cancel countdown on tool execution", async () => {
// given - session starting countdown // given - session starting countdown
const sessionID = "main-tool" const sessionID = "main-tool"
setupMainSessionWithBoulder(sessionID) setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
@ -443,7 +414,7 @@ describe("todo-continuation-enforcer", () => {
test("should skip injection during recovery mode", async () => { test("should skip injection during recovery mode", async () => {
// given - session in recovery mode // given - session in recovery mode
const sessionID = "main-recovery" const sessionID = "main-recovery"
setupMainSessionWithBoulder(sessionID) setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
@ -465,7 +436,7 @@ describe("todo-continuation-enforcer", () => {
fakeTimers.restore() fakeTimers.restore()
// given - session was in recovery, now complete // given - session was in recovery, now complete
const sessionID = "main-recovery-done" const sessionID = "main-recovery-done"
setupMainSessionWithBoulder(sessionID) setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
@ -487,7 +458,7 @@ describe("todo-continuation-enforcer", () => {
test("should cleanup on session deleted", async () => { test("should cleanup on session deleted", async () => {
// given - session starting countdown // given - session starting countdown
const sessionID = "main-delete" const sessionID = "main-delete"
setupMainSessionWithBoulder(sessionID) setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
@ -511,7 +482,7 @@ describe("todo-continuation-enforcer", () => {
test("should not inject again when cooldown is active", async () => { test("should not inject again when cooldown is active", async () => {
//#given //#given
const sessionID = "main-cooldown-active" const sessionID = "main-cooldown-active"
setupMainSessionWithBoulder(sessionID) setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
//#when //#when
@ -531,7 +502,7 @@ describe("todo-continuation-enforcer", () => {
test("should inject again when cooldown expires", async () => { test("should inject again when cooldown expires", async () => {
//#given //#given
const sessionID = "main-cooldown-expired" const sessionID = "main-cooldown-expired"
setupMainSessionWithBoulder(sessionID) setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
//#when //#when
@ -549,19 +520,22 @@ describe("todo-continuation-enforcer", () => {
expect(promptCalls).toHaveLength(2) 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 //#given
const sessionID = "main-stagnation-cap" const sessionID = "main-no-stagnation-cap"
setupMainSessionWithBoulder(sessionID) setMainSession(sessionID)
let mutableTodoStatus: "pending" | "in_progress" = "pending"
const mockInput = createMockPluginInput() const mockInput = createMockPluginInput()
mockInput.client.session.todo = async () => ({ data: [ 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" }, { id: "2", content: "Task 2", status: "completed", priority: "medium" },
]}) ]})
const hook = createTodoContinuationEnforcer(mockInput, {}) 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 hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500, true) await fakeTimers.advanceBy(2500, true)
await fakeTimers.advanceBy(CONTINUATION_COOLDOWN_MS, 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 hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500, true) await fakeTimers.advanceBy(2500, true)
mutableTodoStatus = "in_progress" //#then — all 5 injections should fire (no stagnation cap)
await fakeTimers.advanceBy(CONTINUATION_COOLDOWN_MS, true) expect(promptCalls).toHaveLength(5)
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500, true)
//#then
expect(promptCalls).toHaveLength(4)
}) })
test("should skip idle handling while injection is in flight", async () => { test("should skip idle handling while injection is in flight", async () => {
//#given //#given
const sessionID = "main-in-flight" const sessionID = "main-in-flight"
setupMainSessionWithBoulder(sessionID) setMainSession(sessionID)
let resolvePrompt: (() => void) | undefined let resolvePrompt: (() => void) | undefined
const mockInput = createMockPluginInput() const mockInput = createMockPluginInput()
mockInput.client.session.promptAsync = async (opts: any) => { mockInput.client.session.promptAsync = async (opts: any) => {
@ -623,10 +592,10 @@ describe("todo-continuation-enforcer", () => {
await Promise.resolve() await Promise.resolve()
}) })
test("should clear cooldown and stagnation state on session deleted", async () => { test("should clear cooldown state on session deleted", async () => {
//#given //#given
const sessionID = "main-delete-state-reset" const sessionID = "main-delete-state-reset"
setupMainSessionWithBoulder(sessionID) setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
//#when //#when
@ -649,7 +618,7 @@ describe("todo-continuation-enforcer", () => {
test("should accept skipAgents option without error", async () => { test("should accept skipAgents option without error", async () => {
// given - session with skipAgents configured for Prometheus // given - session with skipAgents configured for Prometheus
const sessionID = "main-prometheus-option" const sessionID = "main-prometheus-option"
setupMainSessionWithBoulder(sessionID) setMainSession(sessionID)
// when - create hook with skipAgents option (should not throw) // when - create hook with skipAgents option (should not throw)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), { const hook = createTodoContinuationEnforcer(createMockPluginInput(), {
@ -669,7 +638,7 @@ describe("todo-continuation-enforcer", () => {
fakeTimers.restore() fakeTimers.restore()
// given - session with incomplete todos // given - session with incomplete todos
const sessionID = "main-toast" const sessionID = "main-toast"
setupMainSessionWithBoulder(sessionID) setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
@ -687,7 +656,7 @@ describe("todo-continuation-enforcer", () => {
test("should not have 10s throttle between injections", async () => { test("should not have 10s throttle between injections", async () => {
// given - new hook instance (no prior state) // given - new hook instance (no prior state)
const sessionID = "main-no-throttle" const sessionID = "main-no-throttle"
setupMainSessionWithBoulder(sessionID) setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
@ -720,7 +689,7 @@ describe("todo-continuation-enforcer", () => {
fakeTimers.restore() fakeTimers.restore()
// given - session with incomplete todos // given - session with incomplete todos
const sessionID = "main-noabort-error" const sessionID = "main-noabort-error"
setupMainSessionWithBoulder(sessionID) setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
@ -759,7 +728,7 @@ describe("todo-continuation-enforcer", () => {
test("should skip injection when last assistant message has MessageAbortedError", async () => { test("should skip injection when last assistant message has MessageAbortedError", async () => {
// given - session where last assistant message was aborted // given - session where last assistant message was aborted
const sessionID = "main-api-abort" const sessionID = "main-api-abort"
setupMainSessionWithBoulder(sessionID) setMainSession(sessionID)
mockMessages = [ mockMessages = [
{ info: { id: "msg-1", role: "user" } }, { info: { id: "msg-1", role: "user" } },
@ -783,7 +752,7 @@ describe("todo-continuation-enforcer", () => {
fakeTimers.restore() fakeTimers.restore()
// given - session where last assistant message completed normally // given - session where last assistant message completed normally
const sessionID = "main-api-no-error" const sessionID = "main-api-no-error"
setupMainSessionWithBoulder(sessionID) setMainSession(sessionID)
mockMessages = [ mockMessages = [
{ info: { id: "msg-1", role: "user" } }, { info: { id: "msg-1", role: "user" } },
@ -807,7 +776,7 @@ describe("todo-continuation-enforcer", () => {
fakeTimers.restore() fakeTimers.restore()
// given - session where last message is from user // given - session where last message is from user
const sessionID = "main-api-user-last" const sessionID = "main-api-user-last"
setupMainSessionWithBoulder(sessionID) setMainSession(sessionID)
mockMessages = [ mockMessages = [
{ info: { id: "msg-1", role: "assistant" } }, { 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 () => { test("should skip when last assistant message has any abort-like error", async () => {
// given - session where last assistant message has AbortError (DOMException style) // given - session where last assistant message has AbortError (DOMException style)
const sessionID = "main-api-abort-dom" const sessionID = "main-api-abort-dom"
setupMainSessionWithBoulder(sessionID) setMainSession(sessionID)
mockMessages = [ mockMessages = [
{ info: { id: "msg-1", role: "user" } }, { 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 () => { test("should skip injection when abort detected via session.error event (event-based, primary)", async () => {
// given - session with incomplete todos // given - session with incomplete todos
const sessionID = "main-event-abort" const sessionID = "main-event-abort"
setupMainSessionWithBoulder(sessionID) setMainSession(sessionID)
mockMessages = [ mockMessages = [
{ info: { id: "msg-1", role: "user" } }, { info: { id: "msg-1", role: "user" } },
{ info: { id: "msg-2", role: "assistant" } }, { 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 () => { test("should skip injection when AbortError detected via session.error event", async () => {
// given - session with incomplete todos // given - session with incomplete todos
const sessionID = "main-event-abort-dom" const sessionID = "main-event-abort-dom"
setupMainSessionWithBoulder(sessionID) setMainSession(sessionID)
mockMessages = [ mockMessages = [
{ info: { id: "msg-1", role: "user" } }, { info: { id: "msg-1", role: "user" } },
{ info: { id: "msg-2", role: "assistant" } }, { info: { id: "msg-2", role: "assistant" } },
@ -914,7 +883,7 @@ describe("todo-continuation-enforcer", () => {
fakeTimers.restore() fakeTimers.restore()
// given - session with incomplete todos and old abort timestamp // given - session with incomplete todos and old abort timestamp
const sessionID = "main-stale-abort" const sessionID = "main-stale-abort"
setupMainSessionWithBoulder(sessionID) setMainSession(sessionID)
mockMessages = [ mockMessages = [
{ info: { id: "msg-1", role: "user" } }, { info: { id: "msg-1", role: "user" } },
{ info: { id: "msg-2", role: "assistant" } }, { info: { id: "msg-2", role: "assistant" } },
@ -947,7 +916,7 @@ describe("todo-continuation-enforcer", () => {
fakeTimers.restore() fakeTimers.restore()
// given - session with abort detected // given - session with abort detected
const sessionID = "main-clear-on-user" const sessionID = "main-clear-on-user"
setupMainSessionWithBoulder(sessionID) setMainSession(sessionID)
mockMessages = [ mockMessages = [
{ info: { id: "msg-1", role: "user" } }, { info: { id: "msg-1", role: "user" } },
{ info: { id: "msg-2", role: "assistant" } }, { info: { id: "msg-2", role: "assistant" } },
@ -987,7 +956,7 @@ describe("todo-continuation-enforcer", () => {
fakeTimers.restore() fakeTimers.restore()
// given - session with abort detected // given - session with abort detected
const sessionID = "main-clear-on-assistant" const sessionID = "main-clear-on-assistant"
setupMainSessionWithBoulder(sessionID) setMainSession(sessionID)
mockMessages = [ mockMessages = [
{ info: { id: "msg-1", role: "user" } }, { info: { id: "msg-1", role: "user" } },
{ info: { id: "msg-2", role: "assistant" } }, { info: { id: "msg-2", role: "assistant" } },
@ -1026,7 +995,7 @@ describe("todo-continuation-enforcer", () => {
fakeTimers.restore() fakeTimers.restore()
// given - session with abort detected // given - session with abort detected
const sessionID = "main-clear-on-tool" const sessionID = "main-clear-on-tool"
setupMainSessionWithBoulder(sessionID) setMainSession(sessionID)
mockMessages = [ mockMessages = [
{ info: { id: "msg-1", role: "user" } }, { info: { id: "msg-1", role: "user" } },
{ info: { id: "msg-2", role: "assistant" } }, { 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 () => { 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 // given - session with abort event but API shows no error
const sessionID = "main-event-wins" const sessionID = "main-event-wins"
setupMainSessionWithBoulder(sessionID) setMainSession(sessionID)
mockMessages = [ mockMessages = [
{ info: { id: "msg-1", role: "user" } }, { info: { id: "msg-1", role: "user" } },
{ info: { id: "msg-2", role: "assistant" } }, { 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 () => { test("should use API fallback when event is missed but API shows abort", async () => {
// given - session where event was missed but API shows abort // given - session where event was missed but API shows abort
const sessionID = "main-api-fallback" const sessionID = "main-api-fallback"
setupMainSessionWithBoulder(sessionID) setMainSession(sessionID)
mockMessages = [ mockMessages = [
{ info: { id: "msg-1", role: "user" } }, { info: { id: "msg-1", role: "user" } },
{ info: { id: "msg-2", role: "assistant", error: { name: "MessageAbortedError" } } }, { info: { id: "msg-2", role: "assistant", error: { name: "MessageAbortedError" } } },
@ -1117,7 +1086,7 @@ describe("todo-continuation-enforcer", () => {
fakeTimers.restore() fakeTimers.restore()
// given - session with incomplete todos, no prior message context available // given - session with incomplete todos, no prior message context available
const sessionID = "main-model-preserve" const sessionID = "main-model-preserve"
setupMainSessionWithBoulder(sessionID) setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), { const hook = createTodoContinuationEnforcer(createMockPluginInput(), {
backgroundManager: createMockBackgroundManager(false), backgroundManager: createMockBackgroundManager(false),
@ -1139,7 +1108,7 @@ describe("todo-continuation-enforcer", () => {
test("should extract model from assistant message with flat modelID/providerID", async () => { 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) // given - session with assistant message that has flat modelID/providerID (OpenCode API format)
const sessionID = "main-assistant-model" const sessionID = "main-assistant-model"
setupMainSessionWithBoulder(sessionID) setMainSession(sessionID)
// OpenCode returns assistant messages with flat modelID/providerID, not nested model object // OpenCode returns assistant messages with flat modelID/providerID, not nested model object
const mockMessagesWithAssistant = [ const mockMessagesWithAssistant = [
@ -1200,7 +1169,7 @@ describe("todo-continuation-enforcer", () => {
test("should skip compaction agent messages when resolving agent info", async () => { test("should skip compaction agent messages when resolving agent info", async () => {
// given - session where last message is from compaction agent but previous was Sisyphus // given - session where last message is from compaction agent but previous was Sisyphus
const sessionID = "main-compaction-filter" const sessionID = "main-compaction-filter"
setupMainSessionWithBoulder(sessionID) setMainSession(sessionID)
const mockMessagesWithCompaction = [ const mockMessagesWithCompaction = [
{ info: { id: "msg-1", role: "user", agent: "sisyphus", model: { providerID: "anthropic", modelID: "claude-sonnet-4-5" } } }, { 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 () => { test("should skip injection when only compaction agent messages exist", async () => {
// given - session with only compaction agent (post-compaction, no prior agent info) // given - session with only compaction agent (post-compaction, no prior agent info)
const sessionID = "main-only-compaction" const sessionID = "main-only-compaction"
setupMainSessionWithBoulder(sessionID) setMainSession(sessionID)
const mockMessagesOnlyCompaction = [ const mockMessagesOnlyCompaction = [
{ info: { id: "msg-1", role: "assistant", agent: "compaction" } }, { 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 () => { test("should skip injection when prometheus agent is after compaction", async () => {
// given - prometheus session that was compacted // given - prometheus session that was compacted
const sessionID = "main-prometheus-compacted" const sessionID = "main-prometheus-compacted"
setupMainSessionWithBoulder(sessionID) setMainSession(sessionID)
const mockMessagesPrometheusCompacted = [ const mockMessagesPrometheusCompacted = [
{ info: { id: "msg-1", role: "user", agent: "prometheus" } }, { info: { id: "msg-1", role: "user", agent: "prometheus" } },
@ -1364,7 +1333,7 @@ describe("todo-continuation-enforcer", () => {
fakeTimers.restore() fakeTimers.restore()
// given - session with no agent info but skipAgents is empty // given - session with no agent info but skipAgents is empty
const sessionID = "main-no-agent-no-skip" const sessionID = "main-no-agent-no-skip"
setupMainSessionWithBoulder(sessionID) setMainSession(sessionID)
const mockMessagesNoAgent = [ const mockMessagesNoAgent = [
{ info: { id: "msg-1", role: "user" } }, { info: { id: "msg-1", role: "user" } },
@ -1420,7 +1389,7 @@ describe("todo-continuation-enforcer", () => {
test("should not inject when isContinuationStopped returns true", async () => { test("should not inject when isContinuationStopped returns true", async () => {
// given - session with continuation stopped // given - session with continuation stopped
const sessionID = "main-stopped" const sessionID = "main-stopped"
setupMainSessionWithBoulder(sessionID) setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), { const hook = createTodoContinuationEnforcer(createMockPluginInput(), {
isContinuationStopped: (id) => id === sessionID, isContinuationStopped: (id) => id === sessionID,
@ -1441,7 +1410,7 @@ describe("todo-continuation-enforcer", () => {
fakeTimers.restore() fakeTimers.restore()
// given - session with continuation not stopped // given - session with continuation not stopped
const sessionID = "main-not-stopped" const sessionID = "main-not-stopped"
setupMainSessionWithBoulder(sessionID) setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), { const hook = createTodoContinuationEnforcer(createMockPluginInput(), {
isContinuationStopped: () => false, isContinuationStopped: () => false,
@ -1462,7 +1431,7 @@ describe("todo-continuation-enforcer", () => {
// given - multiple sessions with running countdowns // given - multiple sessions with running countdowns
const session1 = "main-cancel-all-1" const session1 = "main-cancel-all-1"
const session2 = "main-cancel-all-2" const session2 = "main-cancel-all-2"
setupMainSessionWithBoulder(session1) setMainSession(session1)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
@ -1482,97 +1451,4 @@ describe("todo-continuation-enforcer", () => {
expect(promptCalls).toHaveLength(0) 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 })
}) })

View File

@ -29,8 +29,6 @@ export interface SessionState {
abortDetectedAt?: number abortDetectedAt?: number
lastInjectedAt?: number lastInjectedAt?: number
inFlight?: boolean inFlight?: boolean
lastTodoHash?: string
unchangedCycles?: number
} }
export interface MessageInfo { export interface MessageInfo {

View File

@ -82,7 +82,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
if (!hooks.compactionContextInjector) { if (!hooks.compactionContextInjector) {
return return
} }
output.context.push(hooks.compactionContextInjector()) output.context.push(hooks.compactionContextInjector(_input.sessionID))
}, },
} }
} }

View File

@ -53,7 +53,7 @@ export function createContinuationHooks(args: {
: null : null
const compactionContextInjector = isHookEnabled("compaction-context-injector") const compactionContextInjector = isHookEnabled("compaction-context-injector")
? safeHook("compaction-context-injector", () => createCompactionContextInjector()) ? safeHook("compaction-context-injector", () => createCompactionContextInjector(backgroundManager))
: null : null
const compactionTodoPreserver = isHookEnabled("compaction-todo-preserver") const compactionTodoPreserver = isHookEnabled("compaction-todo-preserver")

View File

@ -31,14 +31,13 @@ export function createSlashcommandTool(options: SlashcommandToolOptions = {}): T
const buildDescription = async (): Promise<string> => { const buildDescription = async (): Promise<string> => {
if (cachedDescription) return cachedDescription if (cachedDescription) return cachedDescription
const allItems = await getAllItems() const commands = getCommands()
cachedDescription = buildDescriptionFromItems(allItems) cachedDescription = buildDescriptionFromItems(commands)
return cachedDescription return cachedDescription
} }
if (options.commands !== undefined && options.skills !== undefined) { if (options.commands !== undefined) {
const allItems = [...options.commands, ...options.skills.map(skillToCommandInfo)] cachedDescription = buildDescriptionFromItems(options.commands)
cachedDescription = buildDescriptionFromItems(allItems)
} else { } else {
void buildDescription() void buildDescription()
} }

View File

@ -29,7 +29,7 @@ function createMockSkill(name: string, description = ""): LoadedSkill {
} }
describe("slashcommand tool - synchronous description", () => { 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 // given
const commands = [createMockCommand("commit", "Create a git commit")] const commands = [createMockCommand("commit", "Create a git commit")]
const skills = [createMockSkill("playwright", "Browser automation via Playwright MCP")] const skills = [createMockSkill("playwright", "Browser automation via Playwright MCP")]
@ -38,12 +38,11 @@ describe("slashcommand tool - synchronous description", () => {
const tool = createSlashcommandTool({ commands, skills }) const tool = createSlashcommandTool({ commands, skills })
// then // then
expect(tool.description).toContain("<available_skills>")
expect(tool.description).toContain("commit") 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 // given
const commands = [ const commands = [
createMockCommand("commit", "Git commit"), createMockCommand("commit", "Git commit"),
@ -61,9 +60,9 @@ describe("slashcommand tool - synchronous description", () => {
// then // then
expect(tool.description).toContain("commit") expect(tool.description).toContain("commit")
expect(tool.description).toContain("plan") expect(tool.description).toContain("plan")
expect(tool.description).toContain("playwright") expect(tool.description).not.toContain("playwright")
expect(tool.description).toContain("frontend-ui-ux") expect(tool.description).not.toContain("frontend-ui-ux")
expect(tool.description).toContain("git-master") expect(tool.description).not.toContain("git-master")
}) })
it("shows prefix-only description when both commands and skills are empty", () => { it("shows prefix-only description when both commands and skills are empty", () => {