Merge pull request #4048 from PeterPonyu/feat/doctor-check-tui-plugin

feat(doctor): warn when oh-my-openagent/tui is missing from tui.json
This commit is contained in:
YeonGyu-Kim
2026-05-21 12:58:39 +09:00
committed by GitHub
4 changed files with 348 additions and 0 deletions
+6
View File
@@ -5,6 +5,7 @@ import { checkConfig } from "./config"
import { checkTools, gatherToolsSummary } from "./tools"
import { checkModels } from "./model-resolution"
import { checkTeamMode } from "./team-mode"
import { checkTuiPluginConfig } from "./tui-plugin-config"
export type { CheckDefinition }
export * from "./model-resolution-types"
@@ -23,6 +24,11 @@ export function getAllCheckDefinitions(): CheckDefinition[] {
name: CHECK_NAMES[CHECK_IDS.CONFIG],
check: checkConfig,
},
{
id: CHECK_IDS.TUI_PLUGIN,
name: CHECK_NAMES[CHECK_IDS.TUI_PLUGIN],
check: checkTuiPluginConfig,
},
{
id: CHECK_IDS.TOOLS,
name: CHECK_NAMES[CHECK_IDS.TOOLS],
@@ -0,0 +1,159 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { PLUGIN_NAME } from "../../../shared"
import { checkTuiPluginConfig } from "./tui-plugin-config"
let testConfigDir: string
let originalConfigDir: string | undefined
function writeOpenCodeConfig(plugins: string[]): void {
writeFileSync(
join(testConfigDir, "opencode.json"),
JSON.stringify({ plugin: plugins }, null, 2) + "\n",
"utf-8",
)
}
function writeTuiConfig(plugins: string[]): void {
writeFileSync(
join(testConfigDir, "tui.json"),
JSON.stringify({ plugin: plugins }, null, 2) + "\n",
"utf-8",
)
}
function writeFilePluginPackage(dir: string, packageName: string): string {
mkdirSync(dir, { recursive: true })
writeFileSync(
join(dir, "package.json"),
JSON.stringify({ name: packageName }, null, 2) + "\n",
"utf-8",
)
return `file:${dir}`
}
describe("tui-plugin-config check", () => {
beforeEach(() => {
originalConfigDir = process.env.OPENCODE_CONFIG_DIR
testConfigDir = join(
tmpdir(),
`omo-doctor-tui-${Date.now()}-${Math.random().toString(36).slice(2)}`,
)
mkdirSync(testConfigDir, { recursive: true })
process.env.OPENCODE_CONFIG_DIR = testConfigDir
})
afterEach(() => {
rmSync(testConfigDir, { recursive: true, force: true })
if (originalConfigDir === undefined) {
delete process.env.OPENCODE_CONFIG_DIR
} else {
process.env.OPENCODE_CONFIG_DIR = originalConfigDir
}
})
it("passes when both server and TUI entries are registered", async () => {
//#given opencode.json has the server entry and tui.json has the TUI entry
writeOpenCodeConfig([PLUGIN_NAME])
writeTuiConfig([`${PLUGIN_NAME}/tui`])
//#when running the check
const result = await checkTuiPluginConfig()
//#then both are detected and status is pass
expect(result.status).toBe("pass")
expect(result.issues).toHaveLength(0)
expect(result.name).toBe("TUI Plugin")
})
it("warns when server is registered but TUI entry is missing", async () => {
//#given opencode.json has the server entry but tui.json does not
writeOpenCodeConfig([PLUGIN_NAME])
writeTuiConfig(["some-other-tui-plugin"])
//#when running the check
const result = await checkTuiPluginConfig()
//#then status is warn with a single warning issue
expect(result.status).toBe("warn")
expect(result.issues).toHaveLength(1)
expect(result.issues[0].severity).toBe("warning")
expect(result.issues[0].title).toContain("TUI plugin entry missing")
expect(result.issues[0].fix).toBeDefined()
})
it("warns when server is registered but tui.json does not exist", async () => {
//#given opencode.json has the server entry and tui.json is absent
writeOpenCodeConfig([PLUGIN_NAME])
//#when running the check
const result = await checkTuiPluginConfig()
//#then status is warn — missing file means missing entry
expect(result.status).toBe("warn")
expect(result.issues).toHaveLength(1)
expect(result.issues[0].severity).toBe("warning")
})
it("passes when tui.json has a file: entry pointing at our package", async () => {
//#given opencode.json has the server entry and tui.json uses a file: URL
//# pointing at a local checkout of our package
writeOpenCodeConfig([PLUGIN_NAME])
const localPkgDir = join(testConfigDir, "local-checkout")
const fileEntry = writeFilePluginPackage(localPkgDir, "oh-my-opencode")
writeTuiConfig([fileEntry])
//#when running the check
const result = await checkTuiPluginConfig()
//#then file: entry satisfies registration and status is pass
expect(result.status).toBe("pass")
expect(result.issues).toHaveLength(0)
})
it("warns when tui.json has our entry but server plugin is missing from opencode.json", async () => {
//#given tui.json has the TUI entry but opencode.json does not have the server entry
writeOpenCodeConfig(["some-other-plugin"])
writeTuiConfig([`${PLUGIN_NAME}/tui`])
//#when running the check
const result = await checkTuiPluginConfig()
//#then status is warn — TUI-only registration can't function without the server side
expect(result.status).toBe("warn")
expect(result.issues).toHaveLength(1)
expect(result.issues[0].severity).toBe("warning")
expect(result.issues[0].title).toContain("Server plugin entry missing")
expect(result.issues[0].fix).toBeDefined()
})
it("skips when neither config registers the plugin", async () => {
//#given an opencode.json and tui.json with no oh-my-openagent entries
writeOpenCodeConfig(["some-other-plugin"])
writeTuiConfig(["some-other-tui-plugin"])
//#when running the check
const result = await checkTuiPluginConfig()
//#then status is skip — plugin not installed at all
expect(result.status).toBe("skip")
expect(result.issues).toHaveLength(0)
expect(result.message).toContain("not registered")
})
it("passes when legacy server entry is paired with legacy TUI entry", async () => {
//#given legacy package names in both configs
writeOpenCodeConfig(["oh-my-opencode"])
writeTuiConfig(["oh-my-opencode/tui"])
//#when running the check
const result = await checkTuiPluginConfig()
//#then legacy aliases are accepted
expect(result.status).toBe("pass")
expect(result.issues).toHaveLength(0)
})
})
+181
View File
@@ -0,0 +1,181 @@
import { existsSync, readFileSync } from "node:fs"
import { join } from "node:path"
import {
ACCEPTED_PACKAGE_NAMES,
LEGACY_PLUGIN_NAME,
PLUGIN_NAME,
getOpenCodeConfigDir,
getOpenCodeConfigPaths,
parseJsonc,
} from "../../../shared"
import { CHECK_IDS, CHECK_NAMES } from "../constants"
import type { CheckResult, DoctorIssue } from "../types"
const TUI_SUBPATH = "tui"
interface OpenCodeConfigShape {
plugin?: string[]
}
interface TuiConfigShape {
plugin?: string[]
}
interface ServerPluginInfo {
registered: boolean
configPath: string | null
}
interface TuiPluginInfo {
registered: boolean
configPath: string | null
exists: boolean
}
// Returns true if `entry` is a file:-URL pointing at a directory whose
// package.json declares one of our accepted package names. opencode-tui loads
// such entries via the `./tui` subpath export, so a `file:` entry already
// satisfies the TUI plugin registration even without an explicit
// `oh-my-openagent/tui` entry. Mirrors the helper used in
// add-tui-plugin-to-tui-config.ts during installation.
function isOurFilePluginEntry(entry: string): boolean {
if (!entry.startsWith("file:")) return false
let path = entry.slice("file:".length)
if (path.startsWith("//")) path = path.slice(2)
try {
const pkgJsonPath = join(path, "package.json")
if (!existsSync(pkgJsonPath)) return false
const parsed = JSON.parse(readFileSync(pkgJsonPath, "utf-8")) as { name?: unknown }
return typeof parsed.name === "string"
&& (ACCEPTED_PACKAGE_NAMES as readonly string[]).includes(parsed.name)
} catch {
return false
}
}
function isServerPluginEntry(entry: string): boolean {
if (entry === PLUGIN_NAME || entry.startsWith(`${PLUGIN_NAME}@`)) return true
if (entry === LEGACY_PLUGIN_NAME || entry.startsWith(`${LEGACY_PLUGIN_NAME}@`)) return true
if (entry.startsWith("file:") && isOurFilePluginEntry(entry)) return true
return false
}
function isTuiPluginEntry(entry: string): boolean {
const canonicalPrefix = `${PLUGIN_NAME}/${TUI_SUBPATH}`
const legacyPrefix = `${LEGACY_PLUGIN_NAME}/${TUI_SUBPATH}`
if (entry === canonicalPrefix || entry.startsWith(`${canonicalPrefix}@`)) return true
if (entry === legacyPrefix || entry.startsWith(`${legacyPrefix}@`)) return true
// file: entries pointing at our package already expose the ./tui subpath via
// package.json `exports`, so the TUI plugin loads without a separate entry.
if (entry.startsWith("file:") && isOurFilePluginEntry(entry)) return true
return false
}
export function detectServerPluginRegistration(): ServerPluginInfo {
const paths = getOpenCodeConfigPaths({ binary: "opencode", version: null })
const configPath = existsSync(paths.configJsonc)
? paths.configJsonc
: existsSync(paths.configJson)
? paths.configJson
: null
if (!configPath) {
return { registered: false, configPath: null }
}
try {
const parsed = parseJsonc<OpenCodeConfigShape>(readFileSync(configPath, "utf-8"))
const plugins = parsed.plugin ?? []
return { registered: plugins.some(isServerPluginEntry), configPath }
} catch {
return { registered: false, configPath }
}
}
export function detectTuiPluginRegistration(): TuiPluginInfo {
const tuiJsonPath = join(getOpenCodeConfigDir({ binary: "opencode" }), "tui.json")
if (!existsSync(tuiJsonPath)) {
return { registered: false, configPath: tuiJsonPath, exists: false }
}
try {
const parsed = parseJsonc<TuiConfigShape>(readFileSync(tuiJsonPath, "utf-8"))
const plugins = parsed.plugin ?? []
return { registered: plugins.some(isTuiPluginEntry), configPath: tuiJsonPath, exists: true }
} catch {
return { registered: false, configPath: tuiJsonPath, exists: true }
}
}
export async function checkTuiPluginConfig(): Promise<CheckResult> {
const name = CHECK_NAMES[CHECK_IDS.TUI_PLUGIN]
const server = detectServerPluginRegistration()
const tui = detectTuiPluginRegistration()
const issues: DoctorIssue[] = []
const details: string[] = []
if (server.configPath) details.push(`opencode.json: ${server.configPath}`)
if (tui.configPath) details.push(`tui.json: ${tui.configPath}`)
if (!server.registered && !tui.registered) {
return {
name,
status: "skip",
message: "Plugin not registered (server or TUI)",
details: details.length > 0 ? details : undefined,
issues,
}
}
if (server.registered && !tui.registered) {
issues.push({
title: "TUI plugin entry missing from tui.json",
description:
"The server plugin is registered in opencode.json, but the TUI plugin entry "
+ `("${PLUGIN_NAME}/${TUI_SUBPATH}") is missing from tui.json. The Roles · `
+ "Models sidebar section and TUI-only commands will not appear.",
fix: "Re-run the installer (`npx oh-my-openagent install`) to auto-write tui.json, "
+ `or add "${PLUGIN_NAME}/${TUI_SUBPATH}" to the "plugin" array in ${tui.configPath}.`,
affects: ["TUI sidebar", "TUI commands"],
severity: "warning",
})
return {
name,
status: "warn",
message: "TUI plugin entry missing from tui.json",
details: details.length > 0 ? details : undefined,
issues,
}
}
if (!server.registered && tui.registered) {
issues.push({
title: "Server plugin entry missing from opencode.json",
description:
`The TUI plugin entry ("${PLUGIN_NAME}/${TUI_SUBPATH}") is registered in tui.json, `
+ "but the server plugin (oh-my-openagent) is missing from opencode.json. "
+ "The plugin cannot function correctly without both halves — the server side "
+ "handles tool dispatch, hook execution, and SDK integration.",
fix: "Re-run the installer (`npx oh-my-openagent install`) to auto-write opencode.json, "
+ `or add "${PLUGIN_NAME}" to the "plugin" array in ${server.configPath ?? "opencode.json"}.`,
affects: ["tool dispatch", "hook execution", "SDK integration"],
severity: "warning",
})
return {
name,
status: "warn",
message: "Server plugin entry missing from opencode.json",
details: details.length > 0 ? details : undefined,
issues,
}
}
return {
name,
status: "pass",
message: "Server and TUI plugin entries are both registered",
details: details.length > 0 ? details : undefined,
issues,
}
}
+2
View File
@@ -21,6 +21,7 @@ export const STATUS_COLORS = {
export const CHECK_IDS = {
SYSTEM: "system",
CONFIG: "config",
TUI_PLUGIN: "tui-plugin",
TOOLS: "tools",
MODELS: "models",
TEAM_MODE: "team-mode",
@@ -29,6 +30,7 @@ export const CHECK_IDS = {
export const CHECK_NAMES: Record<string, string> = {
[CHECK_IDS.SYSTEM]: "System",
[CHECK_IDS.CONFIG]: "Configuration",
[CHECK_IDS.TUI_PLUGIN]: "TUI Plugin",
[CHECK_IDS.TOOLS]: "Tools",
[CHECK_IDS.MODELS]: "Models",
[CHECK_IDS.TEAM_MODE]: "Team Mode",