From 08959d634ec39403d88092a6db7e6d65f3359c76 Mon Sep 17 00:00:00 2001 From: PeterPonyu Date: Fri, 15 May 2026 06:47:27 -0400 Subject: [PATCH 1/2] feat(doctor): warn when oh-my-openagent/tui is missing from tui.json The plugin ships two module entries: the server plugin (registered in opencode.json) and the TUI plugin (./tui subpath, registered in tui.json). opencode's TUI loader reads tui.json, not opencode.json, so a fresh install that only writes opencode.json leaves the Roles - Models sidebar section and TUI-only commands unloaded. Commit 19e8cab717 fixed the install flow, but existing users who installed before that won't have tui.json populated and have no signal that anything is wrong. This adds a doctor check that detects the mismatch and emits a clear warning with a one-line fix suggestion (re-run the installer or add the entry manually). The check is a soft warning, not a fatal blocker, and is pure (no side effects, no auto-write). It accepts: - canonical and legacy package names in either config - file: URLs pointing at a local checkout of our package (opencode-tui loads the ./tui subpath via package.json exports for those entries) Status matrix: - both registered -> pass - server registered, TUI missing -> warn - TUI provided via file: entry -> pass - neither registered -> skip (plugin not installed at all) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cli/doctor/checks/index.ts | 6 + .../doctor/checks/tui-plugin-config.test.ts | 143 ++++++++++++++++ src/cli/doctor/checks/tui-plugin-config.ts | 159 ++++++++++++++++++ src/cli/doctor/constants.ts | 2 + 4 files changed, 310 insertions(+) create mode 100644 src/cli/doctor/checks/tui-plugin-config.test.ts create mode 100644 src/cli/doctor/checks/tui-plugin-config.ts 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..2211c6440 --- /dev/null +++ b/src/cli/doctor/checks/tui-plugin-config.test.ts @@ -0,0 +1,143 @@ +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("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..83eb84a59 --- /dev/null +++ b/src/cli/doctor/checks/tui-plugin-config.ts @@ -0,0 +1,159 @@ +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, + } + } + + 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", From cc88bedd9c78942c8d6caa3f626a634b459f6e69 Mon Sep 17 00:00:00 2001 From: PeterPonyu Date: Fri, 15 May 2026 10:30:45 -0400 Subject: [PATCH 2/2] fix(doctor): emit warning when tui.json is registered but opencode.json is not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the tui-plugin-config check returned PASS when the server plugin (oh-my-openagent in opencode.json) was missing but the TUI plugin entry was present in tui.json. The plugin can't function with only half of the registration — the server side handles tool dispatch, hook execution, and SDK integration; the TUI side only ships the sidebar. Now we emit a clear warning with the fix suggestion. Addresses cubic-dev-ai's review on PR #4048 (severity 3/10). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../doctor/checks/tui-plugin-config.test.ts | 16 ++++++++++++++ src/cli/doctor/checks/tui-plugin-config.ts | 22 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/src/cli/doctor/checks/tui-plugin-config.test.ts b/src/cli/doctor/checks/tui-plugin-config.test.ts index 2211c6440..86f47def5 100644 --- a/src/cli/doctor/checks/tui-plugin-config.test.ts +++ b/src/cli/doctor/checks/tui-plugin-config.test.ts @@ -114,6 +114,22 @@ describe("tui-plugin-config check", () => { 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"]) diff --git a/src/cli/doctor/checks/tui-plugin-config.ts b/src/cli/doctor/checks/tui-plugin-config.ts index 83eb84a59..8fe07024e 100644 --- a/src/cli/doctor/checks/tui-plugin-config.ts +++ b/src/cli/doctor/checks/tui-plugin-config.ts @@ -149,6 +149,28 @@ export async function checkTuiPluginConfig(): Promise { } } + 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",