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
+122 -80
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,
}
}