refactor(doctor): redesign with 3-tier output and consolidated checks

Consolidate 16 separate checks into 5 (system, config, providers, tools, models).
Add 3-tier formatting: default (problems-only), --status (dashboard), --verbose (deep diagnostics).
Read actual loaded plugin version from opencode cache directory.
Check environment variables for provider authentication.
This commit is contained in:
YeonGyu-Kim
2026-02-13 17:29:38 +09:00
parent 6df24d3592
commit 2ba148be12
45 changed files with 1808 additions and 3328 deletions

View File

@@ -149,29 +149,21 @@ This command shows:
program
.command("doctor")
.description("Check oh-my-opencode installation health and diagnose issues")
.option("--status", "Show compact system dashboard")
.option("--verbose", "Show detailed diagnostic information")
.option("--json", "Output results in JSON format")
.option("--category <category>", "Run only specific category")
.addHelpText("after", `
Examples:
$ bunx oh-my-opencode doctor
$ bunx oh-my-opencode doctor --verbose
$ bunx oh-my-opencode doctor --json
$ bunx oh-my-opencode doctor --category authentication
Categories:
installation Check OpenCode and plugin installation
configuration Validate configuration files
authentication Check auth provider status
dependencies Check external dependencies
tools Check LSP and MCP servers
updates Check for version updates
$ bunx oh-my-opencode doctor # Show problems only
$ bunx oh-my-opencode doctor --status # Compact dashboard
$ bunx oh-my-opencode doctor --verbose # Deep diagnostics
$ bunx oh-my-opencode doctor --json # JSON output
`)
.action(async (options) => {
const mode = options.status ? "status" : options.verbose ? "verbose" : "default"
const doctorOptions: DoctorOptions = {
verbose: options.verbose ?? false,
mode,
json: options.json ?? false,
category: options.category,
}
const exitCode = await doctor(doctorOptions)
process.exit(exitCode)

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"
describe("config check", () => {
describe("validateConfig", () => {
it("returns valid: false for non-existent file", () => {
// given non-existent file path
// when validating
const result = config.validateConfig("/non/existent/path.json")
describe("checkConfig", () => {
it("returns a valid CheckResult", async () => {
//#given config check is available
//#when running the consolidated config check
const result = await config.checkConfig()
// then should indicate invalid
expect(result.valid).toBe(false)
expect(result.errors.length).toBeGreaterThan(0)
})
})
describe("getConfigInfo", () => {
it("returns exists: false when no config found", () => {
// given no config file exists
// when getting config info
const info = config.getConfigInfo()
// then should handle gracefully
expect(typeof info.exists).toBe("boolean")
expect(typeof info.valid).toBe("boolean")
})
})
describe("checkConfigValidity", () => {
let getInfoSpy: ReturnType<typeof spyOn>
afterEach(() => {
getInfoSpy?.mockRestore()
//#then should return a properly shaped CheckResult
expect(result.name).toBe("Configuration")
expect(["pass", "fail", "warn", "skip"]).toContain(result.status)
expect(typeof result.message).toBe("string")
expect(Array.isArray(result.issues)).toBe(true)
})
it("returns pass when no config exists (uses defaults)", async () => {
// given no config file
getInfoSpy = spyOn(config, "getConfigInfo").mockReturnValue({
exists: false,
path: null,
format: null,
valid: true,
errors: [],
})
it("includes issues array even when config is valid", async () => {
//#given a normal environment
//#when running config check
const result = await config.checkConfig()
// when checking validity
const result = await config.checkConfigValidity()
// then should pass with default message
expect(result.status).toBe("pass")
expect(result.message).toContain("default")
})
it("returns pass when config is valid", async () => {
// given valid config
getInfoSpy = spyOn(config, "getConfigInfo").mockReturnValue({
exists: true,
path: "/home/user/.config/opencode/oh-my-opencode.json",
format: "json",
valid: true,
errors: [],
})
// when checking validity
const result = await config.checkConfigValidity()
// then should pass
expect(result.status).toBe("pass")
expect(result.message).toContain("JSON")
})
it("returns fail when config has validation errors", async () => {
// given invalid config
getInfoSpy = spyOn(config, "getConfigInfo").mockReturnValue({
exists: true,
path: "/home/user/.config/opencode/oh-my-opencode.json",
format: "json",
valid: false,
errors: ["agents.oracle: Invalid model format"],
})
// when checking validity
const result = await config.checkConfigValidity()
// then should fail with errors
expect(result.status).toBe("fail")
expect(result.details?.some((d) => d.includes("Error"))).toBe(true)
})
})
describe("getConfigCheckDefinition", () => {
it("returns valid check definition", () => {
// given
// when getting definition
const def = config.getConfigCheckDefinition()
// then should have required properties
expect(def.id).toBe("config-validation")
expect(def.category).toBe("configuration")
expect(def.critical).toBe(false)
//#then issues should be an array (possibly empty)
expect(Array.isArray(result.issues)).toBe(true)
})
})
})

View File

@@ -1,122 +1,164 @@
import { existsSync, readFileSync } from "node:fs"
import { readFileSync } from "node:fs"
import { join } from "node:path"
import type { CheckResult, CheckDefinition, ConfigInfo } from "../types"
import { CHECK_IDS, CHECK_NAMES, PACKAGE_NAME } from "../constants"
import { parseJsonc, detectConfigFile, getOpenCodeConfigDir } from "../../../shared"
import { OhMyOpenCodeConfigSchema } from "../../../config"
const USER_CONFIG_DIR = getOpenCodeConfigDir({ binary: "opencode" })
const USER_CONFIG_BASE = join(USER_CONFIG_DIR, `${PACKAGE_NAME}`)
import { OhMyOpenCodeConfigSchema } from "../../../config"
import { detectConfigFile, getOpenCodeConfigDir, parseJsonc } from "../../../shared"
import { CHECK_IDS, CHECK_NAMES, PACKAGE_NAME } from "../constants"
import type { CheckResult, DoctorIssue } from "../types"
import { loadAvailableModelsFromCache } from "./model-resolution-cache"
import { getModelResolutionInfoWithOverrides } from "./model-resolution"
import type { OmoConfig } from "./model-resolution-types"
const USER_CONFIG_BASE = join(getOpenCodeConfigDir({ binary: "opencode" }), PACKAGE_NAME)
const PROJECT_CONFIG_BASE = join(process.cwd(), ".opencode", PACKAGE_NAME)
function findConfigPath(): { path: string; format: "json" | "jsonc" } | null {
const projectDetected = detectConfigFile(PROJECT_CONFIG_BASE)
if (projectDetected.format !== "none") {
return { path: projectDetected.path, format: projectDetected.format as "json" | "jsonc" }
}
interface ConfigValidationResult {
exists: boolean
path: string | null
valid: boolean
config: OmoConfig | null
errors: string[]
}
const userDetected = detectConfigFile(USER_CONFIG_BASE)
if (userDetected.format !== "none") {
return { path: userDetected.path, format: userDetected.format as "json" | "jsonc" }
}
function findConfigPath(): string | null {
const projectConfig = detectConfigFile(PROJECT_CONFIG_BASE)
if (projectConfig.format !== "none") return projectConfig.path
const userConfig = detectConfigFile(USER_CONFIG_BASE)
if (userConfig.format !== "none") return userConfig.path
return null
}
export function validateConfig(configPath: string): { valid: boolean; errors: string[] } {
function validateConfig(): ConfigValidationResult {
const configPath = findConfigPath()
if (!configPath) {
return { exists: false, path: null, valid: true, config: null, errors: [] }
}
try {
const content = readFileSync(configPath, "utf-8")
const rawConfig = parseJsonc<Record<string, unknown>>(content)
const result = OhMyOpenCodeConfigSchema.safeParse(rawConfig)
const rawConfig = parseJsonc<OmoConfig>(content)
const schemaResult = OhMyOpenCodeConfigSchema.safeParse(rawConfig)
if (!result.success) {
const errors = result.error.issues.map(
(i) => `${i.path.join(".")}: ${i.message}`
)
return { valid: false, errors }
if (!schemaResult.success) {
return {
exists: true,
path: configPath,
valid: false,
config: rawConfig,
errors: schemaResult.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`),
}
}
return { valid: true, errors: [] }
} catch (err) {
return { exists: true, path: configPath, valid: true, config: rawConfig, errors: [] }
} catch (error) {
return {
exists: true,
path: configPath,
valid: false,
errors: [err instanceof Error ? err.message : "Failed to parse config"],
config: null,
errors: [error instanceof Error ? error.message : "Failed to parse config"],
}
}
}
export function getConfigInfo(): ConfigInfo {
const configPath = findConfigPath()
function collectModelResolutionIssues(config: OmoConfig): DoctorIssue[] {
const issues: DoctorIssue[] = []
const availableModels = loadAvailableModelsFromCache()
const resolution = getModelResolutionInfoWithOverrides(config)
if (!configPath) {
return {
exists: false,
path: null,
format: null,
valid: true,
errors: [],
const invalidAgentOverrides = resolution.agents.filter(
(agent) => agent.userOverride && !agent.userOverride.includes("/")
)
const invalidCategoryOverrides = resolution.categories.filter(
(category) => category.userOverride && !category.userOverride.includes("/")
)
for (const invalidAgent of invalidAgentOverrides) {
issues.push({
title: `Invalid agent override: ${invalidAgent.name}`,
description: `Override '${invalidAgent.userOverride}' must be in provider/model format.`,
severity: "warning",
affects: [invalidAgent.name],
})
}
for (const invalidCategory of invalidCategoryOverrides) {
issues.push({
title: `Invalid category override: ${invalidCategory.name}`,
description: `Override '${invalidCategory.userOverride}' must be in provider/model format.`,
severity: "warning",
affects: [invalidCategory.name],
})
}
if (availableModels.cacheExists) {
const providerSet = new Set(availableModels.providers)
const unknownProviders = [
...resolution.agents.map((agent) => agent.userOverride),
...resolution.categories.map((category) => category.userOverride),
]
.filter((value): value is string => Boolean(value))
.map((value) => value.split("/")[0])
.filter((provider) => provider.length > 0 && !providerSet.has(provider))
if (unknownProviders.length > 0) {
const uniqueProviders = [...new Set(unknownProviders)]
issues.push({
title: "Model override uses unavailable provider",
description: `Provider(s) not found in OpenCode model cache: ${uniqueProviders.join(", ")}`,
severity: "warning",
affects: ["model resolution"],
})
}
}
if (!existsSync(configPath.path)) {
return {
exists: false,
path: configPath.path,
format: configPath.format,
valid: true,
errors: [],
}
}
const validation = validateConfig(configPath.path)
return {
exists: true,
path: configPath.path,
format: configPath.format,
valid: validation.valid,
errors: validation.errors,
}
return issues
}
export async function checkConfigValidity(): Promise<CheckResult> {
const info = getConfigInfo()
export async function checkConfig(): Promise<CheckResult> {
const validation = validateConfig()
const issues: DoctorIssue[] = []
if (!info.exists) {
if (!validation.exists) {
return {
name: CHECK_NAMES[CHECK_IDS.CONFIG_VALIDATION],
name: CHECK_NAMES[CHECK_IDS.CONFIG],
status: "pass",
message: "Using default configuration",
details: ["No custom config file found (optional)"],
message: "No custom config found; defaults are used",
details: undefined,
issues,
}
}
if (!info.valid) {
if (!validation.valid) {
issues.push(
...validation.errors.map((error) => ({
title: "Invalid configuration",
description: error,
severity: "error" as const,
affects: ["plugin startup"],
}))
)
return {
name: CHECK_NAMES[CHECK_IDS.CONFIG_VALIDATION],
name: CHECK_NAMES[CHECK_IDS.CONFIG],
status: "fail",
message: "Configuration has validation errors",
details: [
`Path: ${info.path}`,
...info.errors.map((e) => `Error: ${e}`),
],
message: `Configuration invalid (${issues.length} issue${issues.length > 1 ? "s" : ""})`,
details: validation.path ? [`Path: ${validation.path}`] : undefined,
issues,
}
}
return {
name: CHECK_NAMES[CHECK_IDS.CONFIG_VALIDATION],
status: "pass",
message: `Valid ${info.format?.toUpperCase()} config`,
details: [`Path: ${info.path}`],
if (validation.config) {
issues.push(...collectModelResolutionIssues(validation.config))
}
}
export function getConfigCheckDefinition(): CheckDefinition {
return {
id: CHECK_IDS.CONFIG_VALIDATION,
name: CHECK_NAMES[CHECK_IDS.CONFIG_VALIDATION],
category: "configuration",
check: checkConfigValidity,
critical: false,
name: CHECK_NAMES[CHECK_IDS.CONFIG],
status: issues.length > 0 ? "warn" : "pass",
message: issues.length > 0 ? `${issues.length} configuration warning(s)` : "Configuration is valid",
details: validation.path ? [`Path: ${validation.path}`] : undefined,
issues,
}
}

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"
describe("dependencies check", () => {
describe("checkAstGrepCli", () => {
it("returns dependency info", async () => {
// given
// when checking ast-grep cli
it("returns valid dependency info", async () => {
//#given ast-grep cli check
//#when checking
const info = await deps.checkAstGrepCli()
// then should return valid info
//#then should return valid DependencyInfo
expect(info.name).toBe("AST-Grep CLI")
expect(info.required).toBe(false)
expect(typeof info.installed).toBe("boolean")
expect(typeof info.version === "string" || info.version === null).toBe(true)
expect(typeof info.path === "string" || info.path === null).toBe(true)
})
})
describe("checkAstGrepNapi", () => {
it("returns dependency info", async () => {
// given
// when checking ast-grep napi
it("returns valid dependency info", async () => {
//#given ast-grep napi check
//#when checking
const info = await deps.checkAstGrepNapi()
// then should return valid info
//#then should return valid DependencyInfo
expect(info.name).toBe("AST-Grep NAPI")
expect(info.required).toBe(false)
expect(typeof info.installed).toBe("boolean")
@@ -29,124 +31,15 @@ describe("dependencies check", () => {
})
describe("checkCommentChecker", () => {
it("returns dependency info", async () => {
// given
// when checking comment checker
it("returns valid dependency info", async () => {
//#given comment checker check
//#when checking
const info = await deps.checkCommentChecker()
// then should return valid info
//#then should return valid DependencyInfo
expect(info.name).toBe("Comment Checker")
expect(info.required).toBe(false)
expect(typeof info.installed).toBe("boolean")
})
})
describe("checkDependencyAstGrepCli", () => {
let checkSpy: ReturnType<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,4 @@
import type { CheckResult, CheckDefinition, DependencyInfo } from "../types"
import { CHECK_IDS, CHECK_NAMES } from "../constants"
import type { DependencyInfo } from "../types"
async function checkBinaryExists(binary: string): Promise<{ exists: boolean; path: string | null }> {
try {
@@ -124,61 +123,3 @@ export async function checkCommentChecker(): Promise<DependencyInfo> {
}
}
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,42 @@
import type { CheckDefinition } from "../types"
import { getOpenCodeCheckDefinition } from "./opencode"
import { getPluginCheckDefinition } from "./plugin"
import { getConfigCheckDefinition } from "./config"
import { getModelResolutionCheckDefinition } from "./model-resolution"
import { getAuthCheckDefinitions } from "./auth"
import { getDependencyCheckDefinitions } from "./dependencies"
import { getGhCliCheckDefinition } from "./gh"
import { getLspCheckDefinition } from "./lsp"
import { getMcpCheckDefinitions } from "./mcp"
import { getMcpOAuthCheckDefinition } from "./mcp-oauth"
import { getVersionCheckDefinition } from "./version"
import { CHECK_IDS, CHECK_NAMES } from "../constants"
import { checkSystem, gatherSystemInfo } from "./system"
import { checkConfig } from "./config"
import { checkProviders, gatherProviderStatuses } from "./providers"
import { checkTools, gatherToolsSummary } from "./tools"
import { checkModels } from "./model-resolution"
export * from "./opencode"
export * from "./plugin"
export * from "./config"
export * from "./model-resolution"
export type { CheckDefinition }
export * from "./model-resolution-types"
export * from "./model-resolution-cache"
export * from "./model-resolution-config"
export * from "./model-resolution-effective-model"
export * from "./model-resolution-variant"
export * from "./model-resolution-details"
export * from "./auth"
export * from "./dependencies"
export * from "./gh"
export * from "./lsp"
export * from "./mcp"
export * from "./mcp-oauth"
export * from "./version"
export { gatherSystemInfo, gatherProviderStatuses, gatherToolsSummary }
export function getAllCheckDefinitions(): CheckDefinition[] {
return [
getOpenCodeCheckDefinition(),
getPluginCheckDefinition(),
getConfigCheckDefinition(),
getModelResolutionCheckDefinition(),
...getAuthCheckDefinitions(),
...getDependencyCheckDefinitions(),
getGhCliCheckDefinition(),
getLspCheckDefinition(),
...getMcpCheckDefinitions(),
getMcpOAuthCheckDefinition(),
getVersionCheckDefinition(),
{
id: CHECK_IDS.SYSTEM,
name: CHECK_NAMES[CHECK_IDS.SYSTEM],
check: checkSystem,
critical: true,
},
{
id: CHECK_IDS.CONFIG,
name: CHECK_NAMES[CHECK_IDS.CONFIG],
check: checkConfig,
},
{
id: CHECK_IDS.PROVIDERS,
name: CHECK_NAMES[CHECK_IDS.PROVIDERS],
check: checkProviders,
},
{
id: CHECK_IDS.TOOLS,
name: CHECK_NAMES[CHECK_IDS.TOOLS],
check: checkTools,
},
{
id: CHECK_IDS.MODELS,
name: CHECK_NAMES[CHECK_IDS.MODELS],
check: checkModels,
},
]
}

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

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,101 @@
import { existsSync, readFileSync } from "node:fs"
import { AGENT_MODEL_REQUIREMENTS } from "../../../shared/model-requirements"
import { getOpenCodeConfigPaths, parseJsonc } from "../../../shared"
import { AUTH_ENV_VARS, AUTH_PLUGINS, CHECK_IDS, CHECK_NAMES } from "../constants"
import type { CheckResult, DoctorIssue, ProviderStatus } from "../types"
interface OpenCodeConfigShape {
plugin?: string[]
}
function loadOpenCodePlugins(): string[] {
const configPaths = getOpenCodeConfigPaths({ binary: "opencode", version: null })
const targetPath = existsSync(configPaths.configJsonc)
? configPaths.configJsonc
: configPaths.configJson
if (!existsSync(targetPath)) return []
try {
const content = readFileSync(targetPath, "utf-8")
const parsed = parseJsonc<OpenCodeConfigShape>(content)
return parsed.plugin ?? []
} catch {
return []
}
}
function hasProviderPlugin(plugins: string[], providerId: string): boolean {
const definition = AUTH_PLUGINS[providerId]
if (!definition) return false
if (definition.plugin === "builtin") return true
return plugins.some((plugin) => plugin === definition.plugin || plugin.startsWith(`${definition.plugin}@`))
}
function hasProviderEnvVar(providerId: string): boolean {
const envVarNames = AUTH_ENV_VARS[providerId] ?? []
return envVarNames.some((envVarName) => Boolean(process.env[envVarName]))
}
function getAffectedAgents(providerId: string): string[] {
const affectedAgents: string[] = []
for (const [agentName, requirement] of Object.entries(AGENT_MODEL_REQUIREMENTS)) {
const usesProvider = requirement.fallbackChain.some((entry) => entry.providers.includes(providerId))
if (usesProvider) {
affectedAgents.push(agentName)
}
}
return affectedAgents
}
export function gatherProviderStatuses(): ProviderStatus[] {
const plugins = loadOpenCodePlugins()
return Object.entries(AUTH_PLUGINS).map(([providerId, definition]) => {
const hasPlugin = hasProviderPlugin(plugins, providerId)
const hasEnvVar = hasProviderEnvVar(providerId)
return {
id: providerId,
name: definition.name,
available: hasPlugin && hasEnvVar,
hasPlugin,
hasEnvVar,
}
})
}
export async function checkProviders(): Promise<CheckResult> {
const statuses = gatherProviderStatuses()
const issues: DoctorIssue[] = []
for (const status of statuses) {
if (status.available) continue
const missingParts: string[] = []
if (!status.hasPlugin) missingParts.push("auth plugin")
if (!status.hasEnvVar) missingParts.push("environment variable")
issues.push({
title: `${status.name} authentication missing`,
description: `Missing ${missingParts.join(" and ")} for ${status.name}.`,
fix: `Configure ${status.name} provider in OpenCode and set ${(AUTH_ENV_VARS[status.id] ?? []).join(" or ")}`,
affects: getAffectedAgents(status.id),
severity: "warning",
})
}
const status = issues.length === 0 ? "pass" : "warn"
return {
name: CHECK_NAMES[CHECK_IDS.PROVIDERS],
status,
message: issues.length === 0 ? "All provider auth checks passed" : `${issues.length} provider issue(s) detected`,
details: statuses.map(
(providerStatus) =>
`${providerStatus.name}: plugin=${providerStatus.hasPlugin ? "yes" : "no"}, env=${providerStatus.hasEnvVar ? "yes" : "no"}`
),
issues,
}
}

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,31 @@ export const STATUS_COLORS = {
} as const
export const CHECK_IDS = {
OPENCODE_INSTALLATION: "opencode-installation",
PLUGIN_REGISTRATION: "plugin-registration",
CONFIG_VALIDATION: "config-validation",
MODEL_RESOLUTION: "model-resolution",
AUTH_ANTHROPIC: "auth-anthropic",
AUTH_OPENAI: "auth-openai",
AUTH_GOOGLE: "auth-google",
DEP_AST_GREP_CLI: "dep-ast-grep-cli",
DEP_AST_GREP_NAPI: "dep-ast-grep-napi",
DEP_COMMENT_CHECKER: "dep-comment-checker",
GH_CLI: "gh-cli",
LSP_SERVERS: "lsp-servers",
MCP_BUILTIN: "mcp-builtin",
MCP_USER: "mcp-user",
MCP_OAUTH_TOKENS: "mcp-oauth-tokens",
VERSION_STATUS: "version-status",
SYSTEM: "system",
CONFIG: "config",
PROVIDERS: "providers",
TOOLS: "tools",
MODELS: "models",
} as const
export const CHECK_NAMES: Record<string, string> = {
[CHECK_IDS.OPENCODE_INSTALLATION]: "OpenCode Installation",
[CHECK_IDS.PLUGIN_REGISTRATION]: "Plugin Registration",
[CHECK_IDS.CONFIG_VALIDATION]: "Configuration Validity",
[CHECK_IDS.MODEL_RESOLUTION]: "Model Resolution",
[CHECK_IDS.AUTH_ANTHROPIC]: "Anthropic (Claude) Auth",
[CHECK_IDS.AUTH_OPENAI]: "OpenAI (ChatGPT) Auth",
[CHECK_IDS.AUTH_GOOGLE]: "Google (Gemini) Auth",
[CHECK_IDS.DEP_AST_GREP_CLI]: "AST-Grep CLI",
[CHECK_IDS.DEP_AST_GREP_NAPI]: "AST-Grep NAPI",
[CHECK_IDS.DEP_COMMENT_CHECKER]: "Comment Checker",
[CHECK_IDS.GH_CLI]: "GitHub CLI",
[CHECK_IDS.LSP_SERVERS]: "LSP Servers",
[CHECK_IDS.MCP_BUILTIN]: "Built-in MCP Servers",
[CHECK_IDS.MCP_USER]: "User MCP Configuration",
[CHECK_IDS.MCP_OAUTH_TOKENS]: "MCP OAuth Tokens",
[CHECK_IDS.VERSION_STATUS]: "Version Status",
[CHECK_IDS.SYSTEM]: "System",
[CHECK_IDS.CONFIG]: "Configuration",
[CHECK_IDS.PROVIDERS]: "Providers",
[CHECK_IDS.TOOLS]: "Tools",
[CHECK_IDS.MODELS]: "Models",
} as const
export const CATEGORY_NAMES: Record<string, string> = {
installation: "Installation",
configuration: "Configuration",
authentication: "Authentication",
dependencies: "Dependencies",
tools: "Tools & Servers",
updates: "Updates",
export const AUTH_ENV_VARS: Record<string, string[]> = {
anthropic: ["ANTHROPIC_API_KEY"],
openai: ["OPENAI_API_KEY"],
google: ["GOOGLE_API_KEY", "GEMINI_API_KEY"],
} as const
export const AUTH_PLUGINS: Record<string, { plugin: string; name: string }> = {
anthropic: { plugin: "builtin", name: "Anthropic" },
openai: { plugin: "opencode-openai-codex-auth", name: "OpenAI" },
google: { plugin: "opencode-antigravity-auth", name: "Google" },
} as const
export const EXIT_CODES = {

View File

@@ -0,0 +1,83 @@
import { describe, expect, it } from "bun:test"
import { formatDefault } from "./format-default"
import { stripAnsi } from "./format-shared"
import type { DoctorResult } from "./types"
function createBaseResult(): DoctorResult {
return {
results: [
{ name: "System", status: "pass", message: "ok", issues: [] },
{ name: "Configuration", status: "pass", message: "ok", issues: [] },
],
systemInfo: {
opencodeVersion: "1.0.200",
opencodePath: "/usr/local/bin/opencode",
pluginVersion: "3.4.0",
loadedVersion: "3.4.0",
bunVersion: "1.2.0",
configPath: "/tmp/opencode.jsonc",
configValid: true,
isLocalDev: false,
},
providers: [],
tools: {
lspInstalled: 0,
lspTotal: 0,
astGrepCli: false,
astGrepNapi: false,
commentChecker: false,
ghCli: { installed: false, authenticated: false, username: null },
mcpBuiltin: [],
mcpUser: [],
},
summary: { total: 2, passed: 2, failed: 0, warnings: 0, skipped: 0, duration: 10 },
exitCode: 0,
}
}
describe("formatDefault", () => {
it("prints a single System OK line when no issues exist", () => {
//#given
const result = createBaseResult()
//#when
const output = stripAnsi(formatDefault(result))
//#then
expect(output).toContain("System OK (opencode 1.0.200")
expect(output).not.toContain("found:")
})
it("prints numbered issue list when issues exist", () => {
//#given
const result = createBaseResult()
result.results = [
{
name: "System",
status: "fail",
message: "failed",
issues: [
{
title: "OpenCode binary not found",
description: "Install OpenCode",
fix: "Install from https://opencode.ai/docs",
severity: "error",
},
{
title: "Loaded plugin is outdated",
description: "Loaded 3.0.0, latest 3.4.0",
severity: "warning",
},
],
},
]
//#when
const output = stripAnsi(formatDefault(result))
//#then
expect(output).toContain("2 issues found:")
expect(output).toContain("1. OpenCode binary not found")
expect(output).toContain("2. Loaded plugin is outdated")
})
})

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(" oMo Doctor "))}\n`
}
export function formatIssue(issue: DoctorIssue, index: number): string {
const lines: string[] = []
const severityColor = issue.severity === "error" ? color.red : color.yellow
lines.push(`${index}. ${severityColor(issue.title)}`)
lines.push(` ${color.dim(issue.description)}`)
if (issue.fix) {
lines.push(` ${color.cyan("Fix:")} ${color.dim(issue.fix)}`)
}
if (issue.affects && issue.affects.length > 0) {
lines.push(` ${color.cyan("Affects:")} ${color.dim(issue.affects.join(", "))}`)
}
return lines.join("\n")
}

View File

@@ -0,0 +1,41 @@
import color from "picocolors"
import type { DoctorResult } from "./types"
import { formatHeader, formatStatusMark } from "./format-shared"
export function formatStatus(result: DoctorResult): string {
const lines: string[] = []
lines.push(formatHeader())
const { systemInfo, providers, tools } = result
const padding = " "
const opencodeVer = systemInfo.opencodeVersion ?? "unknown"
const pluginVer = systemInfo.pluginVersion ?? "unknown"
const bunVer = systemInfo.bunVersion ?? "unknown"
lines.push(` ${padding}System ${opencodeVer} · ${pluginVer} · Bun ${bunVer}`)
const configPath = systemInfo.configPath ?? "unknown"
const configStatus = systemInfo.configValid ? color.green("(valid)") : color.red("(invalid)")
lines.push(` ${padding}Config ${configPath} ${configStatus}`)
const providerParts = providers.map((p) => {
const mark = formatStatusMark(p.available)
return `${p.name}${mark}`
})
lines.push(` ${padding}Providers ${providerParts.join(" ")}`)
const lspText = `LSP ${tools.lspInstalled}/${tools.lspTotal}`
const astGrepMark = formatStatusMark(tools.astGrepCli)
const ghMark = formatStatusMark(tools.ghCli.installed && tools.ghCli.authenticated)
const ghUser = tools.ghCli.username ?? ""
lines.push(` ${padding}Tools ${lspText} · AST-Grep ${astGrepMark} · gh ${ghMark}${ghUser ? ` (${ghUser})` : ""}`)
const builtinCount = tools.mcpBuiltin.length
const userCount = tools.mcpUser.length
const builtinText = builtinCount > 0 ? tools.mcpBuiltin.join(" · ") : "none"
const userText = userCount > 0 ? `+ ${userCount} user` : ""
lines.push(` ${padding}MCPs ${builtinText} ${userText}`)
return lines.join("\n")
}

View File

@@ -0,0 +1,89 @@
import color from "picocolors"
import type { DoctorResult } from "./types"
import { formatHeader, formatStatusSymbol, formatIssue } from "./format-shared"
export function formatVerbose(result: DoctorResult): string {
const lines: string[] = []
lines.push(formatHeader())
const { systemInfo, providers, tools, results, summary } = result
lines.push(`${color.bold("System Information")}`)
lines.push(`${color.dim("\u2500".repeat(40))}`)
lines.push(` ${formatStatusSymbol("pass")} opencode ${systemInfo.opencodeVersion ?? "unknown"}`)
lines.push(` ${formatStatusSymbol("pass")} oh-my-opencode ${systemInfo.pluginVersion ?? "unknown"}`)
if (systemInfo.loadedVersion) {
lines.push(` ${formatStatusSymbol("pass")} loaded ${systemInfo.loadedVersion}`)
}
if (systemInfo.bunVersion) {
lines.push(` ${formatStatusSymbol("pass")} bun ${systemInfo.bunVersion}`)
}
lines.push(` ${formatStatusSymbol("pass")} path ${systemInfo.opencodePath ?? "unknown"}`)
if (systemInfo.isLocalDev) {
lines.push(` ${color.yellow("*")} ${color.dim("(local development mode)")}`)
}
lines.push("")
lines.push(`${color.bold("Configuration")}`)
lines.push(`${color.dim("\u2500".repeat(40))}`)
const configStatus = systemInfo.configValid ? color.green("valid") : color.red("invalid")
lines.push(` ${formatStatusSymbol(systemInfo.configValid ? "pass" : "fail")} ${systemInfo.configPath ?? "unknown"} (${configStatus})`)
lines.push("")
lines.push(`${color.bold("Providers")}`)
lines.push(`${color.dim("\u2500".repeat(40))}`)
for (const provider of providers) {
const availableMark = provider.available ? color.green("✓") : color.red("✗")
const pluginMark = provider.hasPlugin ? color.green("plugin") : color.dim("no plugin")
const envMark = provider.hasEnvVar ? color.green("env") : color.dim("no env")
lines.push(` ${availableMark} ${provider.name} ${pluginMark} · ${envMark}`)
}
lines.push("")
lines.push(`${color.bold("Tools")}`)
lines.push(`${color.dim("\u2500".repeat(40))}`)
lines.push(` ${formatStatusSymbol("pass")} LSP ${tools.lspInstalled}/${tools.lspTotal} installed`)
lines.push(` ${formatStatusSymbol(tools.astGrepCli ? "pass" : "fail")} ast-grep CLI ${tools.astGrepCli ? "installed" : "not found"}`)
lines.push(` ${formatStatusSymbol(tools.astGrepNapi ? "pass" : "fail")} ast-grep napi ${tools.astGrepNapi ? "installed" : "not found"}`)
lines.push(` ${formatStatusSymbol(tools.commentChecker ? "pass" : "fail")} comment-checker ${tools.commentChecker ? "installed" : "not found"}`)
lines.push(` ${formatStatusSymbol(tools.ghCli.installed && tools.ghCli.authenticated ? "pass" : "fail")} gh CLI ${tools.ghCli.installed ? "installed" : "not found"}${tools.ghCli.authenticated && tools.ghCli.username ? ` (${tools.ghCli.username})` : ""}`)
lines.push("")
lines.push(`${color.bold("MCPs")}`)
lines.push(`${color.dim("\u2500".repeat(40))}`)
if (tools.mcpBuiltin.length === 0) {
lines.push(` ${color.dim("No built-in MCPs")}`)
} else {
for (const mcp of tools.mcpBuiltin) {
lines.push(` ${formatStatusSymbol("pass")} ${mcp}`)
}
}
if (tools.mcpUser.length > 0) {
lines.push(` ${color.cyan("+")} ${tools.mcpUser.length} user MCP(s):`)
for (const mcp of tools.mcpUser) {
lines.push(` ${formatStatusSymbol("pass")} ${mcp}`)
}
}
lines.push("")
const allIssues = results.flatMap((r) => r.issues)
if (allIssues.length > 0) {
lines.push(`${color.bold("Issues")}`)
lines.push(`${color.dim("\u2500".repeat(40))}`)
allIssues.forEach((issue, index) => {
lines.push(formatIssue(issue, index + 1))
lines.push("")
})
}
lines.push(`${color.bold("Summary")}`)
lines.push(`${color.dim("\u2500".repeat(40))}`)
const passText = summary.passed > 0 ? color.green(`${summary.passed} passed`) : `${summary.passed} passed`
const failText = summary.failed > 0 ? color.red(`${summary.failed} failed`) : `${summary.failed} failed`
const warnText = summary.warnings > 0 ? color.yellow(`${summary.warnings} warnings`) : `${summary.warnings} warnings`
lines.push(` ${passText}, ${failText}, ${warnText}`)
lines.push(` ${color.dim(`Total: ${summary.total} checks in ${summary.duration}ms`)}`)
return lines.join("\n")
}

View File

@@ -1,218 +1,127 @@
import { describe, it, expect } from "bun:test"
import {
formatStatusSymbol,
formatCheckResult,
formatCategoryHeader,
formatSummary,
formatHeader,
formatFooter,
formatJsonOutput,
formatBox,
formatHelpSuggestions,
} from "./formatter"
import type { CheckResult, DoctorSummary, DoctorResult } from "./types"
import { afterEach, describe, expect, it, mock } from "bun:test"
import type { DoctorResult } from "./types"
function createDoctorResult(): DoctorResult {
return {
results: [
{ name: "System", status: "pass", message: "ok", issues: [] },
{ name: "Configuration", status: "warn", message: "warn", issues: [] },
],
systemInfo: {
opencodeVersion: "1.0.200",
opencodePath: "/usr/local/bin/opencode",
pluginVersion: "3.4.0",
loadedVersion: "3.4.0",
bunVersion: "1.2.0",
configPath: "/tmp/opencode.jsonc",
configValid: true,
isLocalDev: false,
},
providers: [{ id: "anthropic", name: "Anthropic", available: true, hasEnvVar: true, hasPlugin: true }],
tools: {
lspInstalled: 2,
lspTotal: 4,
astGrepCli: true,
astGrepNapi: false,
commentChecker: true,
ghCli: { installed: true, authenticated: true, username: "yeongyu" },
mcpBuiltin: ["context7", "grep_app"],
mcpUser: ["custom"],
},
summary: {
total: 2,
passed: 1,
failed: 0,
warnings: 1,
skipped: 0,
duration: 12,
},
exitCode: 0,
}
}
describe("formatter", () => {
describe("formatStatusSymbol", () => {
it("returns green check for pass", () => {
const symbol = formatStatusSymbol("pass")
expect(symbol).toContain("\u2713")
})
it("returns red cross for fail", () => {
const symbol = formatStatusSymbol("fail")
expect(symbol).toContain("\u2717")
})
it("returns yellow warning for warn", () => {
const symbol = formatStatusSymbol("warn")
expect(symbol).toContain("\u26A0")
})
it("returns dim circle for skip", () => {
const symbol = formatStatusSymbol("skip")
expect(symbol).toContain("\u25CB")
})
afterEach(() => {
mock.restore()
})
describe("formatCheckResult", () => {
it("includes name and message", () => {
const result: CheckResult = {
name: "Test Check",
status: "pass",
message: "All good",
}
describe("formatDoctorOutput", () => {
it("dispatches to default formatter for default mode", async () => {
//#given
const formatDefaultMock = mock(() => "default-output")
const formatStatusMock = mock(() => "status-output")
const formatVerboseMock = mock(() => "verbose-output")
mock.module("./format-default", () => ({ formatDefault: formatDefaultMock }))
mock.module("./format-status", () => ({ formatStatus: formatStatusMock }))
mock.module("./format-verbose", () => ({ formatVerbose: formatVerboseMock }))
const { formatDoctorOutput } = await import(`./formatter?default=${Date.now()}`)
const output = formatCheckResult(result, false)
//#when
const output = formatDoctorOutput(createDoctorResult(), "default")
expect(output).toContain("Test Check")
expect(output).toContain("All good")
//#then
expect(output).toBe("default-output")
expect(formatDefaultMock).toHaveBeenCalledTimes(1)
expect(formatStatusMock).toHaveBeenCalledTimes(0)
expect(formatVerboseMock).toHaveBeenCalledTimes(0)
})
it("includes details when verbose", () => {
const result: CheckResult = {
name: "Test Check",
status: "pass",
message: "OK",
details: ["Detail 1", "Detail 2"],
}
it("dispatches to status formatter for status mode", async () => {
//#given
const formatDefaultMock = mock(() => "default-output")
const formatStatusMock = mock(() => "status-output")
const formatVerboseMock = mock(() => "verbose-output")
mock.module("./format-default", () => ({ formatDefault: formatDefaultMock }))
mock.module("./format-status", () => ({ formatStatus: formatStatusMock }))
mock.module("./format-verbose", () => ({ formatVerbose: formatVerboseMock }))
const { formatDoctorOutput } = await import(`./formatter?status=${Date.now()}`)
const output = formatCheckResult(result, true)
//#when
const output = formatDoctorOutput(createDoctorResult(), "status")
expect(output).toContain("Detail 1")
expect(output).toContain("Detail 2")
//#then
expect(output).toBe("status-output")
expect(formatDefaultMock).toHaveBeenCalledTimes(0)
expect(formatStatusMock).toHaveBeenCalledTimes(1)
expect(formatVerboseMock).toHaveBeenCalledTimes(0)
})
it("hides details when not verbose", () => {
const result: CheckResult = {
name: "Test Check",
status: "pass",
message: "OK",
details: ["Detail 1"],
}
it("dispatches to verbose formatter for verbose mode", async () => {
//#given
const formatDefaultMock = mock(() => "default-output")
const formatStatusMock = mock(() => "status-output")
const formatVerboseMock = mock(() => "verbose-output")
mock.module("./format-default", () => ({ formatDefault: formatDefaultMock }))
mock.module("./format-status", () => ({ formatStatus: formatStatusMock }))
mock.module("./format-verbose", () => ({ formatVerbose: formatVerboseMock }))
const { formatDoctorOutput } = await import(`./formatter?verbose=${Date.now()}`)
const output = formatCheckResult(result, false)
//#when
const output = formatDoctorOutput(createDoctorResult(), "verbose")
expect(output).not.toContain("Detail 1")
})
})
describe("formatCategoryHeader", () => {
it("formats category name with styling", () => {
const header = formatCategoryHeader("installation")
expect(header).toContain("Installation")
})
})
describe("formatSummary", () => {
it("shows all counts", () => {
const summary: DoctorSummary = {
total: 10,
passed: 7,
failed: 1,
warnings: 2,
skipped: 0,
duration: 150,
}
const output = formatSummary(summary)
expect(output).toContain("7 passed")
expect(output).toContain("1 failed")
expect(output).toContain("2 warnings")
expect(output).toContain("10 checks")
expect(output).toContain("150ms")
})
})
describe("formatHeader", () => {
it("includes doctor branding", () => {
const header = formatHeader()
expect(header).toContain("Doctor")
})
})
describe("formatFooter", () => {
it("shows error message when failures", () => {
const summary: DoctorSummary = {
total: 5,
passed: 4,
failed: 1,
warnings: 0,
skipped: 0,
duration: 100,
}
const footer = formatFooter(summary)
expect(footer).toContain("Issues detected")
})
it("shows warning message when warnings only", () => {
const summary: DoctorSummary = {
total: 5,
passed: 4,
failed: 0,
warnings: 1,
skipped: 0,
duration: 100,
}
const footer = formatFooter(summary)
expect(footer).toContain("warnings")
})
it("shows success message when all pass", () => {
const summary: DoctorSummary = {
total: 5,
passed: 5,
failed: 0,
warnings: 0,
skipped: 0,
duration: 100,
}
const footer = formatFooter(summary)
expect(footer).toContain("operational")
//#then
expect(output).toBe("verbose-output")
expect(formatDefaultMock).toHaveBeenCalledTimes(0)
expect(formatStatusMock).toHaveBeenCalledTimes(0)
expect(formatVerboseMock).toHaveBeenCalledTimes(1)
})
})
describe("formatJsonOutput", () => {
it("returns valid JSON", () => {
const result: DoctorResult = {
results: [{ name: "Test", status: "pass", message: "OK" }],
summary: { total: 1, passed: 1, failed: 0, warnings: 0, skipped: 0, duration: 50 },
exitCode: 0,
}
it("returns valid JSON payload", async () => {
//#given
const { formatJsonOutput } = await import(`./formatter?json=${Date.now()}`)
const result = createDoctorResult()
//#when
const output = formatJsonOutput(result)
const parsed = JSON.parse(output)
const parsed = JSON.parse(output) as DoctorResult
expect(parsed.results.length).toBe(1)
expect(parsed.summary.total).toBe(1)
//#then
expect(parsed.summary.total).toBe(2)
expect(parsed.systemInfo.pluginVersion).toBe("3.4.0")
expect(parsed.tools.ghCli.username).toBe("yeongyu")
expect(parsed.exitCode).toBe(0)
})
})
describe("formatBox", () => {
it("wraps content in box", () => {
const box = formatBox("Test content")
expect(box).toContain("Test content")
expect(box).toContain("\u2500")
})
it("includes title when provided", () => {
const box = formatBox("Content", "My Title")
expect(box).toContain("My Title")
})
})
describe("formatHelpSuggestions", () => {
it("extracts suggestions from failed checks", () => {
const results: CheckResult[] = [
{ name: "Test", status: "fail", message: "Error", details: ["Run: fix-command"] },
{ name: "OK", status: "pass", message: "Good" },
]
const suggestions = formatHelpSuggestions(results)
expect(suggestions).toContain("Run: fix-command")
})
it("returns empty array when no failures", () => {
const results: CheckResult[] = [
{ name: "OK", status: "pass", message: "Good" },
]
const suggestions = formatHelpSuggestions(results)
expect(suggestions.length).toBe(0)
})
})
})

View File

@@ -1,140 +1,19 @@
import color from "picocolors"
import type { CheckResult, DoctorSummary, CheckCategory, DoctorResult } from "./types"
import { SYMBOLS, STATUS_COLORS, CATEGORY_NAMES } from "./constants"
import type { DoctorResult, DoctorMode } from "./types"
import { formatDefault } from "./format-default"
import { formatStatus } from "./format-status"
import { formatVerbose } from "./format-verbose"
export function formatStatusSymbol(status: CheckResult["status"]): string {
switch (status) {
case "pass":
return SYMBOLS.check
case "fail":
return SYMBOLS.cross
case "warn":
return SYMBOLS.warn
case "skip":
return SYMBOLS.skip
export function formatDoctorOutput(result: DoctorResult, mode: DoctorMode): string {
switch (mode) {
case "default":
return formatDefault(result)
case "status":
return formatStatus(result)
case "verbose":
return formatVerbose(result)
}
}
export function formatCheckResult(result: CheckResult, verbose: boolean): string {
const symbol = formatStatusSymbol(result.status)
const colorFn = STATUS_COLORS[result.status]
const name = colorFn(result.name)
const message = color.dim(result.message)
let line = ` ${symbol} ${name}`
if (result.message) {
line += ` ${SYMBOLS.arrow} ${message}`
}
if (verbose && result.details && result.details.length > 0) {
const detailLines = result.details.map((d) => ` ${SYMBOLS.bullet} ${color.dim(d)}`).join("\n")
line += "\n" + detailLines
}
return line
}
export function formatCategoryHeader(category: CheckCategory): string {
const name = CATEGORY_NAMES[category] || category
return `\n${color.bold(color.white(name))}\n${color.dim("\u2500".repeat(40))}`
}
export function formatSummary(summary: DoctorSummary): string {
const lines: string[] = []
lines.push(color.bold(color.white("Summary")))
lines.push(color.dim("\u2500".repeat(40)))
lines.push("")
const passText = summary.passed > 0 ? color.green(`${summary.passed} passed`) : color.dim("0 passed")
const failText = summary.failed > 0 ? color.red(`${summary.failed} failed`) : color.dim("0 failed")
const warnText = summary.warnings > 0 ? color.yellow(`${summary.warnings} warnings`) : color.dim("0 warnings")
const skipText = summary.skipped > 0 ? color.dim(`${summary.skipped} skipped`) : ""
const parts = [passText, failText, warnText]
if (skipText) parts.push(skipText)
lines.push(` ${parts.join(", ")}`)
lines.push(` ${color.dim(`Total: ${summary.total} checks in ${summary.duration}ms`)}`)
return lines.join("\n")
}
export function formatHeader(): string {
return `\n${color.bgMagenta(color.white(" oMoMoMoMo... Doctor "))}\n`
}
export function formatFooter(summary: DoctorSummary): string {
if (summary.failed > 0) {
return `\n${SYMBOLS.cross} ${color.red("Issues detected. Please review the errors above.")}\n`
}
if (summary.warnings > 0) {
return `\n${SYMBOLS.warn} ${color.yellow("All systems operational with warnings.")}\n`
}
return `\n${SYMBOLS.check} ${color.green("All systems operational!")}\n`
}
export function formatProgress(current: number, total: number, name: string): string {
const progress = color.dim(`[${current}/${total}]`)
return `${progress} Checking ${name}...`
}
export function formatJsonOutput(result: DoctorResult): string {
return JSON.stringify(result, null, 2)
}
export function formatDetails(details: string[]): string {
return details.map((d) => ` ${SYMBOLS.bullet} ${color.dim(d)}`).join("\n")
}
function stripAnsi(str: string): string {
// eslint-disable-next-line no-control-regex
return str.replace(/\x1b\[[0-9;]*m/g, "")
}
export function formatBox(content: string, title?: string): string {
const lines = content.split("\n")
const maxWidth = Math.max(...lines.map((l) => stripAnsi(l).length), title?.length ?? 0) + 4
const border = color.dim("\u2500".repeat(maxWidth))
const output: string[] = []
output.push("")
if (title) {
output.push(
color.dim("\u250C\u2500") +
color.bold(` ${title} `) +
color.dim("\u2500".repeat(maxWidth - title.length - 4)) +
color.dim("\u2510")
)
} else {
output.push(color.dim("\u250C") + border + color.dim("\u2510"))
}
for (const line of lines) {
const stripped = stripAnsi(line)
const padding = maxWidth - stripped.length
output.push(color.dim("\u2502") + ` ${line}${" ".repeat(padding - 1)}` + color.dim("\u2502"))
}
output.push(color.dim("\u2514") + border + color.dim("\u2518"))
output.push("")
return output.join("\n")
}
export function formatHelpSuggestions(results: CheckResult[]): string[] {
const suggestions: string[] = []
for (const result of results) {
if (result.status === "fail" && result.details) {
for (const detail of result.details) {
if (detail.includes("Run:") || detail.includes("Install:") || detail.includes("Visit:")) {
suggestions.push(detail)
}
}
}
}
return suggestions
}

View File

@@ -1,11 +1,11 @@
import type { DoctorOptions } from "./types"
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)
return result.exitCode
}
export * from "./types"
export { runDoctor } from "./runner"
export { formatJsonOutput } from "./formatter"
export { formatDoctorOutput, formatJsonOutput } from "./formatter"

View File

@@ -1,153 +1,253 @@
import { describe, it, expect, spyOn, afterEach } from "bun:test"
import {
runCheck,
calculateSummary,
determineExitCode,
filterChecksByCategory,
groupChecksByCategory,
} from "./runner"
import type { CheckResult, CheckDefinition, CheckCategory } from "./types"
import { afterEach, describe, expect, it, mock } from "bun:test"
import type { CheckDefinition, CheckResult, DoctorResult, ProviderStatus, SystemInfo, ToolsSummary } from "./types"
function createSystemInfo(): SystemInfo {
return {
opencodeVersion: "1.0.200",
opencodePath: "/usr/local/bin/opencode",
pluginVersion: "3.4.0",
loadedVersion: "3.4.0",
bunVersion: "1.2.0",
configPath: "/tmp/opencode.json",
configValid: true,
isLocalDev: false,
}
}
function createProviders(): ProviderStatus[] {
return [
{ id: "anthropic", name: "Anthropic", available: true, hasEnvVar: true, hasPlugin: true },
{ id: "openai", name: "OpenAI", available: false, hasEnvVar: false, hasPlugin: false },
]
}
function createTools(): ToolsSummary {
return {
lspInstalled: 1,
lspTotal: 4,
astGrepCli: true,
astGrepNapi: false,
commentChecker: true,
ghCli: { installed: true, authenticated: true, username: "yeongyu" },
mcpBuiltin: ["context7"],
mcpUser: ["custom-mcp"],
}
}
function createPassResult(name: string): CheckResult {
return { name, status: "pass", message: "ok", issues: [] }
}
function createDeferred(): {
promise: Promise<CheckResult>
resolve: (value: CheckResult) => void
} {
let resolvePromise: (value: CheckResult) => void = () => {}
const promise = new Promise<CheckResult>((resolve) => {
resolvePromise = resolve
})
return { promise, resolve: resolvePromise }
}
describe("runner", () => {
afterEach(() => {
mock.restore()
})
describe("runCheck", () => {
it("returns result from check function", async () => {
it("returns fail result with issue when check throws", async () => {
//#given
const check: CheckDefinition = {
id: "test",
name: "Test Check",
category: "installation",
check: async () => ({ name: "Test Check", status: "pass", message: "OK" }),
}
const result = await runCheck(check)
expect(result.name).toBe("Test Check")
expect(result.status).toBe("pass")
})
it("measures duration", async () => {
const check: CheckDefinition = {
id: "test",
name: "Test Check",
category: "installation",
id: "system",
name: "System",
check: async () => {
await new Promise((r) => setTimeout(r, 50))
return { name: "Test", status: "pass", message: "OK" }
},
}
const result = await runCheck(check)
expect(result.duration).toBeGreaterThanOrEqual(10)
})
it("returns fail on error", async () => {
const check: CheckDefinition = {
id: "test",
name: "Test Check",
category: "installation",
check: async () => {
throw new Error("Test error")
throw new Error("boom")
},
}
const { runCheck } = await import(`./runner?run-check-error=${Date.now()}`)
//#when
const result = await runCheck(check)
//#then
expect(result.status).toBe("fail")
expect(result.message).toContain("Test error")
expect(result.message).toBe("boom")
expect(result.issues[0]?.title).toBe("System")
expect(result.issues[0]?.severity).toBe("error")
expect(typeof result.duration).toBe("number")
})
})
describe("calculateSummary", () => {
it("counts each status correctly", () => {
it("counts statuses correctly", async () => {
//#given
const { calculateSummary } = await import(`./runner?summary=${Date.now()}`)
const results: CheckResult[] = [
{ name: "1", status: "pass", message: "" },
{ name: "2", status: "pass", message: "" },
{ name: "3", status: "fail", message: "" },
{ name: "4", status: "warn", message: "" },
{ name: "5", status: "skip", message: "" },
{ name: "1", status: "pass", message: "", issues: [] },
{ name: "2", status: "pass", message: "", issues: [] },
{ name: "3", status: "fail", message: "", issues: [] },
{ name: "4", status: "warn", message: "", issues: [] },
{ name: "5", status: "skip", message: "", issues: [] },
]
const summary = calculateSummary(results, 100)
//#when
const summary = calculateSummary(results, 19.9)
//#then
expect(summary.total).toBe(5)
expect(summary.passed).toBe(2)
expect(summary.failed).toBe(1)
expect(summary.warnings).toBe(1)
expect(summary.skipped).toBe(1)
expect(summary.duration).toBe(100)
expect(summary.duration).toBe(20)
})
})
describe("determineExitCode", () => {
it("returns 0 when all pass", () => {
it("returns zero when no failures exist", async () => {
//#given
const { determineExitCode } = await import(`./runner?exit-ok=${Date.now()}`)
const results: CheckResult[] = [
{ name: "1", status: "pass", message: "" },
{ name: "2", status: "pass", message: "" },
{ name: "1", status: "pass", message: "", issues: [] },
{ name: "2", status: "warn", message: "", issues: [] },
]
expect(determineExitCode(results)).toBe(0)
//#when
const code = determineExitCode(results)
//#then
expect(code).toBe(0)
})
it("returns 0 when only warnings", () => {
it("returns one when any failure exists", async () => {
//#given
const { determineExitCode } = await import(`./runner?exit-fail=${Date.now()}`)
const results: CheckResult[] = [
{ name: "1", status: "pass", message: "" },
{ name: "2", status: "warn", message: "" },
{ name: "1", status: "pass", message: "", issues: [] },
{ name: "2", status: "fail", message: "", issues: [] },
]
expect(determineExitCode(results)).toBe(0)
})
//#when
const code = determineExitCode(results)
it("returns 1 when any failures", () => {
const results: CheckResult[] = [
{ name: "1", status: "pass", message: "" },
{ name: "2", status: "fail", message: "" },
]
expect(determineExitCode(results)).toBe(1)
//#then
expect(code).toBe(1)
})
})
describe("filterChecksByCategory", () => {
const checks: CheckDefinition[] = [
{ id: "1", name: "Install", category: "installation", check: async () => ({ name: "", status: "pass", message: "" }) },
{ id: "2", name: "Config", category: "configuration", check: async () => ({ name: "", status: "pass", message: "" }) },
{ id: "3", name: "Auth", category: "authentication", check: async () => ({ name: "", status: "pass", message: "" }) },
]
describe("runDoctor", () => {
it("starts all checks in parallel and returns collected result", async () => {
//#given
const startedChecks: string[] = []
const deferredOne = createDeferred()
const deferredTwo = createDeferred()
const deferredThree = createDeferred()
const deferredFour = createDeferred()
const deferredFive = createDeferred()
it("returns all checks when no category", () => {
const filtered = filterChecksByCategory(checks)
const checks: CheckDefinition[] = [
{
id: "system",
name: "System",
check: async () => {
startedChecks.push("system")
return deferredOne.promise
},
},
{
id: "config",
name: "Configuration",
check: async () => {
startedChecks.push("config")
return deferredTwo.promise
},
},
{
id: "providers",
name: "Providers",
check: async () => {
startedChecks.push("providers")
return deferredThree.promise
},
},
{
id: "tools",
name: "Tools",
check: async () => {
startedChecks.push("tools")
return deferredFour.promise
},
},
{
id: "models",
name: "Models",
check: async () => {
startedChecks.push("models")
return deferredFive.promise
},
},
]
expect(filtered.length).toBe(3)
})
const expectedResult: DoctorResult = {
results: [
createPassResult("System"),
createPassResult("Configuration"),
createPassResult("Providers"),
createPassResult("Tools"),
createPassResult("Models"),
],
systemInfo: createSystemInfo(),
providers: createProviders(),
tools: createTools(),
summary: {
total: 5,
passed: 5,
failed: 0,
warnings: 0,
skipped: 0,
duration: 0,
},
exitCode: 0,
}
it("filters to specific category", () => {
const filtered = filterChecksByCategory(checks, "installation")
const formatDoctorOutputMock = mock((result: DoctorResult) => result.summary.total.toString())
const formatJsonOutputMock = mock((result: DoctorResult) => JSON.stringify(result))
expect(filtered.length).toBe(1)
expect(filtered[0].name).toBe("Install")
})
})
mock.module("./checks", () => ({
getAllCheckDefinitions: () => checks,
gatherSystemInfo: async () => expectedResult.systemInfo,
gatherProviderStatuses: () => expectedResult.providers,
gatherToolsSummary: async () => expectedResult.tools,
}))
mock.module("./formatter", () => ({
formatDoctorOutput: formatDoctorOutputMock,
formatJsonOutput: formatJsonOutputMock,
}))
describe("groupChecksByCategory", () => {
const checks: CheckDefinition[] = [
{ id: "1", name: "Install1", category: "installation", check: async () => ({ name: "", status: "pass", message: "" }) },
{ id: "2", name: "Install2", category: "installation", check: async () => ({ name: "", status: "pass", message: "" }) },
{ id: "3", name: "Config", category: "configuration", check: async () => ({ name: "", status: "pass", message: "" }) },
]
const logSpy = mock(() => {})
const originalLog = console.log
console.log = logSpy
it("groups checks by category", () => {
const groups = groupChecksByCategory(checks)
const { runDoctor } = await import(`./runner?parallel=${Date.now()}`)
const runPromise = runDoctor({ mode: "default" })
expect(groups.get("installation")?.length).toBe(2)
expect(groups.get("configuration")?.length).toBe(1)
})
//#when
await Promise.resolve()
const startedBeforeResolve = [...startedChecks]
deferredOne.resolve(createPassResult("System"))
deferredTwo.resolve(createPassResult("Configuration"))
deferredThree.resolve(createPassResult("Providers"))
deferredFour.resolve(createPassResult("Tools"))
deferredFive.resolve(createPassResult("Models"))
const result = await runPromise
it("maintains order within categories", () => {
const groups = groupChecksByCategory(checks)
const installChecks = groups.get("installation")!
expect(installChecks[0].name).toBe("Install1")
expect(installChecks[1].name).toBe("Install2")
//#then
console.log = originalLog
expect(startedBeforeResolve.sort()).toEqual(["config", "models", "providers", "system", "tools"])
expect(result.results.length).toBe(5)
expect(result.exitCode).toBe(0)
expect(formatDoctorOutputMock).toHaveBeenCalledTimes(1)
expect(formatJsonOutputMock).toHaveBeenCalledTimes(0)
})
})
})

View File

@@ -1,21 +1,7 @@
import type {
DoctorOptions,
DoctorResult,
CheckDefinition,
CheckResult,
DoctorSummary,
CheckCategory,
} from "./types"
import { getAllCheckDefinitions } from "./checks"
import { EXIT_CODES, CATEGORY_NAMES } from "./constants"
import {
formatHeader,
formatCategoryHeader,
formatCheckResult,
formatSummary,
formatFooter,
formatJsonOutput,
} from "./formatter"
import type { DoctorOptions, DoctorResult, CheckDefinition, CheckResult, DoctorSummary } from "./types"
import { getAllCheckDefinitions, gatherSystemInfo, gatherProviderStatuses, gatherToolsSummary } from "./checks"
import { EXIT_CODES } from "./constants"
import { formatDoctorOutput, formatJsonOutput } from "./formatter"
export async function runCheck(check: CheckDefinition): Promise<CheckResult> {
const start = performance.now()
@@ -28,6 +14,7 @@ export async function runCheck(check: CheckDefinition): Promise<CheckResult> {
name: check.name,
status: "fail",
message: err instanceof Error ? err.message : "Unknown error",
issues: [{ title: check.name, description: String(err), severity: "error" }],
duration: Math.round(performance.now() - start),
}
}
@@ -45,70 +32,19 @@ export function calculateSummary(results: CheckResult[], duration: number): Doct
}
export function determineExitCode(results: CheckResult[]): number {
const hasFailures = results.some((r) => r.status === "fail")
return hasFailures ? EXIT_CODES.FAILURE : EXIT_CODES.SUCCESS
return results.some((r) => r.status === "fail") ? EXIT_CODES.FAILURE : EXIT_CODES.SUCCESS
}
export function filterChecksByCategory(
checks: CheckDefinition[],
category?: CheckCategory
): CheckDefinition[] {
if (!category) return checks
return checks.filter((c) => c.category === category)
}
export function groupChecksByCategory(
checks: CheckDefinition[]
): Map<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> {
const start = performance.now()
const allChecks = getAllCheckDefinitions()
const filteredChecks = filterChecksByCategory(allChecks, options.category)
const groupedChecks = groupChecksByCategory(filteredChecks)
const results: CheckResult[] = []
if (!options.json) {
console.log(formatHeader())
}
for (const category of CATEGORY_ORDER) {
const checks = groupedChecks.get(category)
if (!checks || checks.length === 0) continue
if (!options.json) {
console.log(formatCategoryHeader(category))
}
for (const check of checks) {
const result = await runCheck(check)
results.push(result)
if (!options.json) {
console.log(formatCheckResult(result, options.verbose ?? false))
}
}
}
const [results, systemInfo, providers, tools] = await Promise.all([
Promise.all(allChecks.map(runCheck)),
gatherSystemInfo(),
Promise.resolve(gatherProviderStatuses()),
gatherToolsSummary(),
])
const duration = performance.now() - start
const summary = calculateSummary(results, duration)
@@ -116,6 +52,9 @@ export async function runDoctor(options: DoctorOptions): Promise<DoctorResult> {
const doctorResult: DoctorResult = {
results,
systemInfo,
providers,
tools,
summary,
exitCode,
}
@@ -123,9 +62,7 @@ export async function runDoctor(options: DoctorOptions): Promise<DoctorResult> {
if (options.json) {
console.log(formatJsonOutput(doctorResult))
} else {
console.log("")
console.log(formatSummary(summary))
console.log(formatFooter(summary))
console.log(formatDoctorOutput(doctorResult, options.mode))
}
return doctorResult

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 interface CheckResult {
@@ -5,31 +22,47 @@ export interface CheckResult {
status: CheckStatus
message: string
details?: string[]
issues: DoctorIssue[]
duration?: number
}
export type CheckFunction = () => Promise<CheckResult>
export type CheckCategory =
| "installation"
| "configuration"
| "authentication"
| "dependencies"
| "tools"
| "updates"
export interface CheckDefinition {
id: string
name: string
category: CheckCategory
check: CheckFunction
critical?: boolean
}
export interface DoctorOptions {
verbose?: boolean
json?: boolean
category?: CheckCategory
export interface SystemInfo {
opencodeVersion: string | null
opencodePath: string | null
pluginVersion: string | null
loadedVersion: string | null
bunVersion: string | null
configPath: string | null
configValid: boolean
isLocalDev: boolean
}
export interface ProviderStatus {
id: string
name: string
available: boolean
hasEnvVar: boolean
hasPlugin: boolean
}
export interface ToolsSummary {
lspInstalled: number
lspTotal: number
astGrepCli: boolean
astGrepNapi: boolean
commentChecker: boolean
ghCli: { installed: boolean; authenticated: boolean; username: string | null }
mcpBuiltin: string[]
mcpUser: string[]
}
export interface DoctorSummary {
@@ -43,10 +76,23 @@ export interface DoctorSummary {
export interface DoctorResult {
results: CheckResult[]
systemInfo: SystemInfo
providers: ProviderStatus[]
tools: ToolsSummary
summary: DoctorSummary
exitCode: number
}
// ===== Legacy types (used by existing checks until migration) =====
export type CheckCategory =
| "installation"
| "configuration"
| "authentication"
| "dependencies"
| "tools"
| "updates"
export interface OpenCodeInfo {
installed: boolean
version: string | null