diff --git a/src/cli/doctor/checks/index.ts b/src/cli/doctor/checks/index.ts index 55e908b32..428deb4ec 100644 --- a/src/cli/doctor/checks/index.ts +++ b/src/cli/doctor/checks/index.ts @@ -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], diff --git a/src/cli/doctor/checks/tui-plugin-config.test.ts b/src/cli/doctor/checks/tui-plugin-config.test.ts new file mode 100644 index 000000000..86f47def5 --- /dev/null +++ b/src/cli/doctor/checks/tui-plugin-config.test.ts @@ -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) + }) +}) diff --git a/src/cli/doctor/checks/tui-plugin-config.ts b/src/cli/doctor/checks/tui-plugin-config.ts new file mode 100644 index 000000000..8fe07024e --- /dev/null +++ b/src/cli/doctor/checks/tui-plugin-config.ts @@ -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(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(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 { + 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, + } +} diff --git a/src/cli/doctor/constants.ts b/src/cli/doctor/constants.ts index dad93f8e8..f209d5901 100644 --- a/src/cli/doctor/constants.ts +++ b/src/cli/doctor/constants.ts @@ -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 = { [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",