feat(cli): add doctor command for installation health checks (#334)

Implements a comprehensive 'doctor' command that diagnoses oh-my-opencode
installation health with a beautiful TUI output.

Checks performed:
- OpenCode installation (version, path, binary)
- Plugin registration in opencode.json
- Configuration file validity (oh-my-opencode.json)
- Auth providers (Anthropic, OpenAI, Google)
- Dependencies (ast-grep CLI/NAPI, comment-checker)
- LSP servers availability
- MCP servers (builtin and user)
- Version status and updates

Features:
- Beautiful TUI with symbols and colors
- --verbose flag for detailed output
- --json flag for machine-readable output
- --category flag for running specific checks
- Exit code 1 on failures for CI integration

Closes #333

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
This commit is contained in:
Sisyphus
2025-12-30 15:06:41 +09:00
committed by GitHub
parent b92cd6ab68
commit b8efd3c771
25 changed files with 2936 additions and 0 deletions

View File

@@ -0,0 +1,114 @@
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

@@ -0,0 +1,115 @@
import { existsSync, readFileSync } from "node:fs"
import { homedir } from "node:os"
import { join } from "node:path"
import type { CheckResult, CheckDefinition, AuthProviderInfo, AuthProviderId } from "../types"
import { CHECK_IDS, CHECK_NAMES } from "../constants"
import { parseJsonc } from "../../../shared"
const OPENCODE_CONFIG_DIR = join(homedir(), ".config", "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

@@ -0,0 +1,103 @@
import { describe, it, expect, spyOn, afterEach } 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")
// #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()
})
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: [],
})
// #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)
})
})
})

View File

@@ -0,0 +1,123 @@
import { existsSync, readFileSync } from "node:fs"
import { homedir } from "node:os"
import { join } from "node:path"
import type { CheckResult, CheckDefinition, ConfigInfo } from "../types"
import { CHECK_IDS, CHECK_NAMES, PACKAGE_NAME } from "../constants"
import { parseJsonc, detectConfigFile } from "../../../shared"
import { OhMyOpenCodeConfigSchema } from "../../../config"
const USER_CONFIG_DIR = join(homedir(), ".config", "opencode")
const USER_CONFIG_BASE = join(USER_CONFIG_DIR, `${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" }
}
const userDetected = detectConfigFile(USER_CONFIG_BASE)
if (userDetected.format !== "none") {
return { path: userDetected.path, format: userDetected.format as "json" | "jsonc" }
}
return null
}
export function validateConfig(configPath: string): { valid: boolean; errors: string[] } {
try {
const content = readFileSync(configPath, "utf-8")
const rawConfig = parseJsonc<Record<string, unknown>>(content)
const result = OhMyOpenCodeConfigSchema.safeParse(rawConfig)
if (!result.success) {
const errors = result.error.issues.map(
(i) => `${i.path.join(".")}: ${i.message}`
)
return { valid: false, errors }
}
return { valid: true, errors: [] }
} catch (err) {
return {
valid: false,
errors: [err instanceof Error ? err.message : "Failed to parse config"],
}
}
}
export function getConfigInfo(): ConfigInfo {
const configPath = findConfigPath()
if (!configPath) {
return {
exists: false,
path: null,
format: null,
valid: true,
errors: [],
}
}
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,
}
}
export async function checkConfigValidity(): Promise<CheckResult> {
const info = getConfigInfo()
if (!info.exists) {
return {
name: CHECK_NAMES[CHECK_IDS.CONFIG_VALIDATION],
status: "pass",
message: "Using default configuration",
details: ["No custom config file found (optional)"],
}
}
if (!info.valid) {
return {
name: CHECK_NAMES[CHECK_IDS.CONFIG_VALIDATION],
status: "fail",
message: "Configuration has validation errors",
details: [
`Path: ${info.path}`,
...info.errors.map((e) => `Error: ${e}`),
],
}
}
return {
name: CHECK_NAMES[CHECK_IDS.CONFIG_VALIDATION],
status: "pass",
message: `Valid ${info.format?.toUpperCase()} config`,
details: [`Path: ${info.path}`],
}
}
export function getConfigCheckDefinition(): CheckDefinition {
return {
id: CHECK_IDS.CONFIG_VALIDATION,
name: CHECK_NAMES[CHECK_IDS.CONFIG_VALIDATION],
category: "configuration",
check: checkConfigValidity,
critical: false,
}
}

View File

@@ -0,0 +1,152 @@
import { describe, it, expect, spyOn, afterEach } from "bun:test"
import * as deps from "./dependencies"
describe("dependencies check", () => {
describe("checkAstGrepCli", () => {
it("returns dependency info", async () => {
// #given
// #when checking ast-grep cli
const info = await deps.checkAstGrepCli()
// #then should return valid info
expect(info.name).toBe("AST-Grep CLI")
expect(info.required).toBe(false)
expect(typeof info.installed).toBe("boolean")
})
})
describe("checkAstGrepNapi", () => {
it("returns dependency info", () => {
// #given
// #when checking ast-grep napi
const info = deps.checkAstGrepNapi()
// #then should return valid info
expect(info.name).toBe("AST-Grep NAPI")
expect(info.required).toBe(false)
expect(typeof info.installed).toBe("boolean")
})
})
describe("checkCommentChecker", () => {
it("returns dependency info", async () => {
// #given
// #when checking comment checker
const info = await deps.checkCommentChecker()
// #then should return valid info
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").mockReturnValue({
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

@@ -0,0 +1,163 @@
import type { CheckResult, CheckDefinition, DependencyInfo } from "../types"
import { CHECK_IDS, CHECK_NAMES } from "../constants"
async function checkBinaryExists(binary: string): Promise<{ exists: boolean; path: string | null }> {
try {
const proc = Bun.spawn(["which", 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 getBinaryVersion(binary: string): Promise<string | null> {
try {
const proc = Bun.spawn([binary, "--version"], { stdout: "pipe", stderr: "pipe" })
const output = await new Response(proc.stdout).text()
await proc.exited
if (proc.exitCode === 0) {
return output.trim().split("\n")[0]
}
} catch {
// intentionally empty - version unavailable
}
return null
}
export async function checkAstGrepCli(): Promise<DependencyInfo> {
const binaryCheck = await checkBinaryExists("sg")
const altBinaryCheck = !binaryCheck.exists ? await checkBinaryExists("ast-grep") : null
const binary = binaryCheck.exists ? binaryCheck : altBinaryCheck
if (!binary || !binary.exists) {
return {
name: "AST-Grep CLI",
required: false,
installed: false,
version: null,
path: null,
installHint: "Install: npm install -g @ast-grep/cli",
}
}
const version = await getBinaryVersion(binary.path!)
return {
name: "AST-Grep CLI",
required: false,
installed: true,
version,
path: binary.path,
}
}
export function checkAstGrepNapi(): DependencyInfo {
try {
require.resolve("@ast-grep/napi")
return {
name: "AST-Grep NAPI",
required: false,
installed: true,
version: null,
path: null,
}
} catch {
return {
name: "AST-Grep NAPI",
required: false,
installed: false,
version: null,
path: null,
installHint: "Will use CLI fallback if available",
}
}
}
export async function checkCommentChecker(): Promise<DependencyInfo> {
const binaryCheck = await checkBinaryExists("comment-checker")
if (!binaryCheck.exists) {
return {
name: "Comment Checker",
required: false,
installed: false,
version: null,
path: null,
installHint: "Hook will be disabled if not available",
}
}
const version = await getBinaryVersion("comment-checker")
return {
name: "Comment Checker",
required: false,
installed: true,
version,
path: binaryCheck.path,
}
}
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 = 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

@@ -0,0 +1,31 @@
import type { CheckDefinition } from "../types"
import { getOpenCodeCheckDefinition } from "./opencode"
import { getPluginCheckDefinition } from "./plugin"
import { getConfigCheckDefinition } from "./config"
import { getAuthCheckDefinitions } from "./auth"
import { getDependencyCheckDefinitions } from "./dependencies"
import { getLspCheckDefinition } from "./lsp"
import { getMcpCheckDefinitions } from "./mcp"
import { getVersionCheckDefinition } from "./version"
export * from "./opencode"
export * from "./plugin"
export * from "./config"
export * from "./auth"
export * from "./dependencies"
export * from "./lsp"
export * from "./mcp"
export * from "./version"
export function getAllCheckDefinitions(): CheckDefinition[] {
return [
getOpenCodeCheckDefinition(),
getPluginCheckDefinition(),
getConfigCheckDefinition(),
...getAuthCheckDefinitions(),
...getDependencyCheckDefinitions(),
getLspCheckDefinition(),
...getMcpCheckDefinitions(),
getVersionCheckDefinition(),
]
}

View File

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

@@ -0,0 +1,85 @@
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"] },
]
async function checkBinaryExists(binary: string): Promise<boolean> {
try {
const proc = Bun.spawn(["which", binary], { stdout: "pipe", stderr: "pipe" })
await proc.exited
return proc.exitCode === 0
} catch {
return false
}
}
export async function getLspServersInfo(): Promise<LspServerInfo[]> {
const servers: LspServerInfo[] = []
for (const server of DEFAULT_LSP_SERVERS) {
const installed = await checkBinaryExists(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

@@ -0,0 +1,117 @@
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(3)
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("websearch_exa")
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("3")
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("websearch_exa"))).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

@@ -0,0 +1,128 @@
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", "websearch_exa", "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

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

View File

@@ -0,0 +1,118 @@
import type { CheckResult, CheckDefinition, OpenCodeInfo } from "../types"
import { CHECK_IDS, CHECK_NAMES, MIN_OPENCODE_VERSION, OPENCODE_BINARIES } from "../constants"
export async function findOpenCodeBinary(): Promise<{ binary: string; path: string } | null> {
for (const binary of OPENCODE_BINARIES) {
try {
const proc = Bun.spawn(["which", binary], { stdout: "pipe", stderr: "pipe" })
const output = await new Response(proc.stdout).text()
await proc.exited
if (proc.exitCode === 0) {
return { binary, path: output.trim() }
}
} catch {
continue
}
}
return null
}
export async function getOpenCodeVersion(binary: string): Promise<string | null> {
try {
const proc = Bun.spawn([binary, "--version"], { 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.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

@@ -0,0 +1,109 @@
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

@@ -0,0 +1,127 @@
import { existsSync, readFileSync } from "node:fs"
import { homedir } from "node:os"
import { join } from "node:path"
import type { CheckResult, CheckDefinition, PluginInfo } from "../types"
import { CHECK_IDS, CHECK_NAMES, PACKAGE_NAME } from "../constants"
import { parseJsonc } from "../../../shared"
const OPENCODE_CONFIG_DIR = join(homedir(), ".config", "opencode")
const OPENCODE_JSON = join(OPENCODE_CONFIG_DIR, "opencode.json")
const OPENCODE_JSONC = join(OPENCODE_CONFIG_DIR, "opencode.jsonc")
function detectConfigPath(): { path: string; format: "json" | "jsonc" } | null {
if (existsSync(OPENCODE_JSONC)) {
return { path: OPENCODE_JSONC, format: "jsonc" }
}
if (existsSync(OPENCODE_JSON)) {
return { path: OPENCODE_JSON, 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 }
}
}
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) {
return {
name: CHECK_NAMES[CHECK_IDS.PLUGIN_REGISTRATION],
status: "fail",
message: "OpenCode config file not found",
details: [
"Run: bunx oh-my-opencode install",
`Expected: ${OPENCODE_JSON} or ${OPENCODE_JSONC}`,
],
}
}
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,148 @@
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

@@ -0,0 +1,177 @@
import { existsSync, readFileSync } from "node:fs"
import { homedir } from "node:os"
import { join } from "node:path"
import type { CheckResult, CheckDefinition, VersionCheckInfo } from "../types"
import { CHECK_IDS, CHECK_NAMES, PACKAGE_NAME } from "../constants"
import { parseJsonc } from "../../../shared"
const OPENCODE_CONFIG_DIR = join(homedir(), ".config", "opencode")
const OPENCODE_PACKAGE_JSON = join(OPENCODE_CONFIG_DIR, "package.json")
const OPENCODE_JSON = join(OPENCODE_CONFIG_DIR, "opencode.json")
const OPENCODE_JSONC = join(OPENCODE_CONFIG_DIR, "opencode.jsonc")
async function fetchLatestVersion(): Promise<string | null> {
try {
const res = await fetch(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, {
signal: AbortSignal.timeout(5000),
})
if (!res.ok) return null
const data = (await res.json()) as { version: string }
return data.version
} catch {
return null
}
}
function getCurrentVersion(): {
version: string | null
isLocalDev: boolean
isPinned: boolean
pinnedVersion: string | null
} {
const configPath = existsSync(OPENCODE_JSONC) ? OPENCODE_JSONC : OPENCODE_JSON
if (!existsSync(configPath)) {
return { version: null, isLocalDev: false, isPinned: false, pinnedVersion: null }
}
try {
const content = readFileSync(configPath, "utf-8")
const config = parseJsonc<{ plugin?: string[] }>(content)
const plugins = config.plugin ?? []
for (const plugin of plugins) {
if (plugin.startsWith("file:") && plugin.includes(PACKAGE_NAME)) {
return { version: "local-dev", isLocalDev: true, isPinned: false, pinnedVersion: null }
}
if (plugin.startsWith(`${PACKAGE_NAME}@`)) {
const pinnedVersion = plugin.split("@")[1]
return { version: pinnedVersion, isLocalDev: false, isPinned: true, pinnedVersion }
}
if (plugin === PACKAGE_NAME) {
if (existsSync(OPENCODE_PACKAGE_JSON)) {
try {
const pkgContent = readFileSync(OPENCODE_PACKAGE_JSON, "utf-8")
const pkg = JSON.parse(pkgContent) as { dependencies?: Record<string, string> }
const depVersion = pkg.dependencies?.[PACKAGE_NAME]
if (depVersion) {
const cleanVersion = depVersion.replace(/^[\^~]/, "")
return { version: cleanVersion, isLocalDev: false, isPinned: false, pinnedVersion: null }
}
} catch {
// intentionally empty - parse errors ignored
}
}
return { version: null, isLocalDev: false, isPinned: false, pinnedVersion: null }
}
}
return { version: null, isLocalDev: false, isPinned: false, pinnedVersion: null }
} catch {
return { version: null, isLocalDev: false, isPinned: false, pinnedVersion: null }
}
}
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 current = getCurrentVersion()
const latestVersion = await fetchLatestVersion()
const isUpToDate =
current.isLocalDev ||
current.isPinned ||
!current.version ||
!latestVersion ||
compareVersions(current.version, latestVersion)
return {
currentVersion: current.version,
latestVersion,
isUpToDate,
isLocalDev: current.isLocalDev,
isPinned: current.isPinned,
}
}
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

@@ -0,0 +1,70 @@
import color from "picocolors"
export const SYMBOLS = {
check: color.green("\u2713"),
cross: color.red("\u2717"),
warn: color.yellow("\u26A0"),
info: color.blue("\u2139"),
arrow: color.cyan("\u2192"),
bullet: color.dim("\u2022"),
skip: color.dim("\u25CB"),
} as const
export const STATUS_COLORS = {
pass: color.green,
fail: color.red,
warn: color.yellow,
skip: color.dim,
} as const
export const CHECK_IDS = {
OPENCODE_INSTALLATION: "opencode-installation",
PLUGIN_REGISTRATION: "plugin-registration",
CONFIG_VALIDATION: "config-validation",
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",
LSP_SERVERS: "lsp-servers",
MCP_BUILTIN: "mcp-builtin",
MCP_USER: "mcp-user",
VERSION_STATUS: "version-status",
} 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.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.LSP_SERVERS]: "LSP Servers",
[CHECK_IDS.MCP_BUILTIN]: "Built-in MCP Servers",
[CHECK_IDS.MCP_USER]: "User MCP Configuration",
[CHECK_IDS.VERSION_STATUS]: "Version Status",
} as const
export const CATEGORY_NAMES: Record<string, string> = {
installation: "Installation",
configuration: "Configuration",
authentication: "Authentication",
dependencies: "Dependencies",
tools: "Tools & Servers",
updates: "Updates",
} as const
export const EXIT_CODES = {
SUCCESS: 0,
FAILURE: 1,
} as const
export const MIN_OPENCODE_VERSION = "1.0.150"
export const PACKAGE_NAME = "oh-my-opencode"
export const OPENCODE_BINARIES = ["opencode", "opencode-desktop"] as const

View File

@@ -0,0 +1,218 @@
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"
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")
})
})
describe("formatCheckResult", () => {
it("includes name and message", () => {
const result: CheckResult = {
name: "Test Check",
status: "pass",
message: "All good",
}
const output = formatCheckResult(result, false)
expect(output).toContain("Test Check")
expect(output).toContain("All good")
})
it("includes details when verbose", () => {
const result: CheckResult = {
name: "Test Check",
status: "pass",
message: "OK",
details: ["Detail 1", "Detail 2"],
}
const output = formatCheckResult(result, true)
expect(output).toContain("Detail 1")
expect(output).toContain("Detail 2")
})
it("hides details when not verbose", () => {
const result: CheckResult = {
name: "Test Check",
status: "pass",
message: "OK",
details: ["Detail 1"],
}
const output = formatCheckResult(result, false)
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")
})
})
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,
}
const output = formatJsonOutput(result)
const parsed = JSON.parse(output)
expect(parsed.results.length).toBe(1)
expect(parsed.summary.total).toBe(1)
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)
})
})
})

140
src/cli/doctor/formatter.ts Normal file
View File

@@ -0,0 +1,140 @@
import color from "picocolors"
import type { CheckResult, DoctorSummary, CheckCategory, DoctorResult } from "./types"
import { SYMBOLS, STATUS_COLORS, CATEGORY_NAMES } from "./constants"
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 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
}

11
src/cli/doctor/index.ts Normal file
View File

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

View File

@@ -0,0 +1,153 @@
import { describe, it, expect, spyOn, afterEach } from "bun:test"
import {
runCheck,
calculateSummary,
determineExitCode,
filterChecksByCategory,
groupChecksByCategory,
} from "./runner"
import type { CheckResult, CheckDefinition, CheckCategory } from "./types"
describe("runner", () => {
describe("runCheck", () => {
it("returns result from check function", async () => {
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",
check: async () => {
await new Promise((r) => setTimeout(r, 10))
return { name: "Test", status: "pass", message: "OK" }
},
}
const result = await runCheck(check)
expect(result.duration).toBeGreaterThanOrEqual(10)
})
it("returns fail on error", async () => {
const check: CheckDefinition = {
id: "test",
name: "Test Check",
category: "installation",
check: async () => {
throw new Error("Test error")
},
}
const result = await runCheck(check)
expect(result.status).toBe("fail")
expect(result.message).toContain("Test error")
})
})
describe("calculateSummary", () => {
it("counts each status correctly", () => {
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: "" },
]
const summary = calculateSummary(results, 100)
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)
})
})
describe("determineExitCode", () => {
it("returns 0 when all pass", () => {
const results: CheckResult[] = [
{ name: "1", status: "pass", message: "" },
{ name: "2", status: "pass", message: "" },
]
expect(determineExitCode(results)).toBe(0)
})
it("returns 0 when only warnings", () => {
const results: CheckResult[] = [
{ name: "1", status: "pass", message: "" },
{ name: "2", status: "warn", message: "" },
]
expect(determineExitCode(results)).toBe(0)
})
it("returns 1 when any failures", () => {
const results: CheckResult[] = [
{ name: "1", status: "pass", message: "" },
{ name: "2", status: "fail", message: "" },
]
expect(determineExitCode(results)).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: "" }) },
]
it("returns all checks when no category", () => {
const filtered = filterChecksByCategory(checks)
expect(filtered.length).toBe(3)
})
it("filters to specific category", () => {
const filtered = filterChecksByCategory(checks, "installation")
expect(filtered.length).toBe(1)
expect(filtered[0].name).toBe("Install")
})
})
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: "" }) },
]
it("groups checks by category", () => {
const groups = groupChecksByCategory(checks)
expect(groups.get("installation")?.length).toBe(2)
expect(groups.get("configuration")?.length).toBe(1)
})
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")
})
})
})

132
src/cli/doctor/runner.ts Normal file
View File

@@ -0,0 +1,132 @@
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"
export async function runCheck(check: CheckDefinition): Promise<CheckResult> {
const start = performance.now()
try {
const result = await check.check()
result.duration = Math.round(performance.now() - start)
return result
} catch (err) {
return {
name: check.name,
status: "fail",
message: err instanceof Error ? err.message : "Unknown error",
duration: Math.round(performance.now() - start),
}
}
}
export function calculateSummary(results: CheckResult[], duration: number): DoctorSummary {
return {
total: results.length,
passed: results.filter((r) => r.status === "pass").length,
failed: results.filter((r) => r.status === "fail").length,
warnings: results.filter((r) => r.status === "warn").length,
skipped: results.filter((r) => r.status === "skip").length,
duration: Math.round(duration),
}
}
export function determineExitCode(results: CheckResult[]): number {
const hasFailures = results.some((r) => r.status === "fail")
return hasFailures ? EXIT_CODES.FAILURE : EXIT_CODES.SUCCESS
}
export function filterChecksByCategory(
checks: CheckDefinition[],
category?: CheckCategory
): CheckDefinition[] {
if (!category) return checks
return checks.filter((c) => c.category === category)
}
export function groupChecksByCategory(
checks: CheckDefinition[]
): Map<CheckCategory, CheckDefinition[]> {
const groups = new Map<CheckCategory, CheckDefinition[]>()
for (const check of checks) {
const existing = groups.get(check.category) ?? []
existing.push(check)
groups.set(check.category, existing)
}
return groups
}
const CATEGORY_ORDER: CheckCategory[] = [
"installation",
"configuration",
"authentication",
"dependencies",
"tools",
"updates",
]
export async function runDoctor(options: DoctorOptions): Promise<DoctorResult> {
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 duration = performance.now() - start
const summary = calculateSummary(results, duration)
const exitCode = determineExitCode(results)
const doctorResult: DoctorResult = {
results,
summary,
exitCode,
}
if (options.json) {
console.log(formatJsonOutput(doctorResult))
} else {
console.log("")
console.log(formatSummary(summary))
console.log(formatFooter(summary))
}
return doctorResult
}

113
src/cli/doctor/types.ts Normal file
View File

@@ -0,0 +1,113 @@
export type CheckStatus = "pass" | "fail" | "warn" | "skip"
export interface CheckResult {
name: string
status: CheckStatus
message: string
details?: string[]
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 DoctorSummary {
total: number
passed: number
failed: number
warnings: number
skipped: number
duration: number
}
export interface DoctorResult {
results: CheckResult[]
summary: DoctorSummary
exitCode: number
}
export interface OpenCodeInfo {
installed: boolean
version: string | null
path: string | null
binary: "opencode" | "opencode-desktop" | null
}
export interface PluginInfo {
registered: boolean
configPath: string | null
entry: string | null
isPinned: boolean
pinnedVersion: string | null
}
export interface ConfigInfo {
exists: boolean
path: string | null
format: "json" | "jsonc" | null
valid: boolean
errors: string[]
}
export type AuthProviderId = "anthropic" | "openai" | "google"
export interface AuthProviderInfo {
id: AuthProviderId
name: string
pluginInstalled: boolean
configured: boolean
error?: string
}
export interface DependencyInfo {
name: string
required: boolean
installed: boolean
version: string | null
path: string | null
installHint?: string
}
export interface LspServerInfo {
id: string
installed: boolean
extensions: string[]
source: "builtin" | "config" | "plugin"
}
export interface McpServerInfo {
id: string
type: "builtin" | "user"
enabled: boolean
valid: boolean
error?: string
}
export interface VersionCheckInfo {
currentVersion: string | null
latestVersion: string | null
isUpToDate: boolean
isLocalDev: boolean
isPinned: boolean
}

View File

@@ -3,9 +3,11 @@ import { Command } from "commander"
import { install } from "./install"
import { run } from "./run"
import { getLocalVersion } from "./get-local-version"
import { doctor } from "./doctor"
import type { InstallArgs } from "./types"
import type { RunOptions } from "./run"
import type { GetLocalVersionOptions } from "./get-local-version/types"
import type { DoctorOptions } from "./doctor"
const packageJson = await import("../../package.json")
const VERSION = packageJson.version
@@ -101,6 +103,37 @@ This command shows:
process.exit(exitCode)
})
program
.command("doctor")
.description("Check oh-my-opencode installation health and diagnose issues")
.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
`)
.action(async (options) => {
const doctorOptions: DoctorOptions = {
verbose: options.verbose ?? false,
json: options.json ?? false,
category: options.category,
}
const exitCode = await doctor(doctorOptions)
process.exit(exitCode)
})
program
.command("version")
.description("Show version information")