From 787ce99edab35e2f56e694accf40f14d22d6cfd9 Mon Sep 17 00:00:00 2001 From: User Date: Fri, 27 Mar 2026 13:21:08 +0100 Subject: [PATCH] fix: detect and warn about opencode-skills conflict When opencode-skills plugin is registered alongside oh-my-openagent, all user skills are loaded twice, causing 'Duplicate tool names detected' warnings and HTTP 400 errors. This fix: 1. Detects if opencode-skills plugin is loaded in opencode.json 2. Emits a startup warning explaining the conflict 3. Suggests fixes: either remove opencode-skills or disable skills in oh-my-openagent Fixes #2881 --- src/index.ts | 7 + src/shared/external-plugin-detector.test.ts | 146 ++++++++++++++++++- src/shared/external-plugin-detector.ts | 147 ++++++++++++++++++++ 3 files changed, 299 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 506e65c35..69fbf60b7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,7 @@ import { loadPluginConfig } from "./plugin-config" import { createModelCacheState } from "./plugin-state" import { createFirstMessageVariantGate } from "./shared/first-message-variant" import { injectServerAuthIntoClient, log, logLegacyPluginStartupWarning } from "./shared" +import { detectExternalSkillPlugin, getSkillPluginConflictWarning } from "./shared/external-plugin-detector" import { startTmuxCheck } from "./tools" let activePluginDispose: PluginDispose | null = null @@ -25,6 +26,12 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { }) logLegacyPluginStartupWarning() + // Detect conflicting skill plugins (e.g., opencode-skills) + const skillPluginCheck = detectExternalSkillPlugin(ctx.directory) + if (skillPluginCheck.detected && skillPluginCheck.pluginName) { + console.warn(getSkillPluginConflictWarning(skillPluginCheck.pluginName)) + } + injectServerAuthIntoClient(ctx.client) startTmuxCheck() await activePluginDispose?.() diff --git a/src/shared/external-plugin-detector.test.ts b/src/shared/external-plugin-detector.test.ts index 73f4a4bf2..03ecfd5a8 100644 --- a/src/shared/external-plugin-detector.test.ts +++ b/src/shared/external-plugin-detector.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test, beforeEach, afterEach } from "bun:test" -import { detectExternalNotificationPlugin, getNotificationConflictWarning } from "./external-plugin-detector" +import { detectExternalNotificationPlugin, getNotificationConflictWarning, detectExternalSkillPlugin, getSkillPluginConflictWarning } from "./external-plugin-detector" import * as fs from "node:fs" import * as path from "node:path" import * as os from "node:os" @@ -285,4 +285,148 @@ describe("external-plugin-detector", () => { expect(warning).toContain("force_enable") }) }) + + describe("detectExternalSkillPlugin", () => { + test("should return detected=false when no plugins configured", () => { + // given - empty directory + // when + const result = detectExternalSkillPlugin(tempDir) + // then + expect(result.detected).toBe(false) + expect(result.pluginName).toBeNull() + }) + + test("should return detected=false when only oh-my-opencode is configured", () => { + // given - opencode.json with only oh-my-opencode + const opencodeDir = path.join(tempDir, ".opencode") + fs.mkdirSync(opencodeDir, { recursive: true }) + fs.writeFileSync( + path.join(opencodeDir, "opencode.json"), + JSON.stringify({ plugin: ["oh-my-opencode"] }) + ) + + // when + const result = detectExternalSkillPlugin(tempDir) + + // then + expect(result.detected).toBe(false) + expect(result.pluginName).toBeNull() + expect(result.allPlugins).toContain("oh-my-opencode") + }) + + test("should detect opencode-skills plugin", () => { + // given - opencode.json with opencode-skills + const opencodeDir = path.join(tempDir, ".opencode") + fs.mkdirSync(opencodeDir, { recursive: true }) + fs.writeFileSync( + path.join(opencodeDir, "opencode.json"), + JSON.stringify({ plugin: ["oh-my-opencode", "opencode-skills"] }) + ) + + // when + const result = detectExternalSkillPlugin(tempDir) + + // then + expect(result.detected).toBe(true) + expect(result.pluginName).toBe("opencode-skills") + }) + + test("should detect opencode-skills with version suffix", () => { + // given - opencode.json with versioned opencode-skills + const opencodeDir = path.join(tempDir, ".opencode") + fs.mkdirSync(opencodeDir, { recursive: true }) + fs.writeFileSync( + path.join(opencodeDir, "opencode.json"), + JSON.stringify({ plugin: ["oh-my-opencode", "opencode-skills@1.2.3"] }) + ) + + // when + const result = detectExternalSkillPlugin(tempDir) + + // then + expect(result.detected).toBe(true) + expect(result.pluginName).toBe("opencode-skills") + }) + + test("should detect @opencode/skills scoped package", () => { + // given - opencode.json with scoped package name + const opencodeDir = path.join(tempDir, ".opencode") + fs.mkdirSync(opencodeDir, { recursive: true }) + fs.writeFileSync( + path.join(opencodeDir, "opencode.json"), + JSON.stringify({ plugin: ["oh-my-opencode", "@opencode/skills"] }) + ) + + // when + const result = detectExternalSkillPlugin(tempDir) + + // then + expect(result.detected).toBe(true) + expect(result.pluginName).toBe("@opencode/skills") + }) + + test("should detect npm:opencode-skills", () => { + // given - npm prefix + const opencodeDir = path.join(tempDir, ".opencode") + fs.mkdirSync(opencodeDir, { recursive: true }) + fs.writeFileSync( + path.join(opencodeDir, "opencode.json"), + JSON.stringify({ plugin: ["npm:opencode-skills"] }) + ) + + // when + const result = detectExternalSkillPlugin(tempDir) + + // then + expect(result.detected).toBe(true) + expect(result.pluginName).toBe("opencode-skills") + }) + + test("should detect file:///path/to/opencode-skills", () => { + // given - file path + const opencodeDir = path.join(tempDir, ".opencode") + fs.mkdirSync(opencodeDir, { recursive: true }) + fs.writeFileSync( + path.join(opencodeDir, "opencode.json"), + JSON.stringify({ plugin: ["file:///home/user/plugins/opencode-skills"] }) + ) + + // when + const result = detectExternalSkillPlugin(tempDir) + + // then + expect(result.detected).toBe(true) + expect(result.pluginName).toBe("opencode-skills") + }) + + test("should NOT match opencode-skills-extra (suffix variation)", () => { + // given - plugin with similar name but different suffix + const opencodeDir = path.join(tempDir, ".opencode") + fs.mkdirSync(opencodeDir, { recursive: true }) + fs.writeFileSync( + path.join(opencodeDir, "opencode.json"), + JSON.stringify({ plugin: ["opencode-skills-extra"] }) + ) + + // when + const result = detectExternalSkillPlugin(tempDir) + + // then + expect(result.detected).toBe(false) + expect(result.pluginName).toBeNull() + }) + }) + + describe("getSkillPluginConflictWarning", () => { + test("should generate warning message with plugin name", () => { + // when + const warning = getSkillPluginConflictWarning("opencode-skills") + + // then + expect(warning).toContain("opencode-skills") + expect(warning).toContain("Duplicate tool names detected") + expect(warning).toContain("claude_code") + expect(warning).toContain("skills") + }) + }) }) diff --git a/src/shared/external-plugin-detector.ts b/src/shared/external-plugin-detector.ts index 5cc69a534..843aaed06 100644 --- a/src/shared/external-plugin-detector.ts +++ b/src/shared/external-plugin-detector.ts @@ -24,6 +24,16 @@ const KNOWN_NOTIFICATION_PLUGINS = [ "mohak34/opencode-notifier", ] +/** + * Known skill plugins that conflict with oh-my-opencode's skill loading. + * Both plugins scan ~/.config/opencode/skills/ and register tools independently, + * causing "Duplicate tool names detected" warnings and HTTP 400 errors. + */ +const KNOWN_SKILL_PLUGINS = [ + "opencode-skills", + "@opencode/skills", +] + function getWindowsAppdataDir(): string | null { return process.env.APPDATA || null } @@ -88,12 +98,68 @@ function matchesNotificationPlugin(entry: string): string | null { return null } +/** + * Check if a plugin entry matches a known skill plugin. + * Handles various formats: "name", "name@version", "npm:name", "file://path/name" + */ +function matchesSkillPlugin(entry: string): string | null { + const normalized = entry.toLowerCase() + for (const known of KNOWN_SKILL_PLUGINS) { + // Exact match + if (normalized === known) return known + // Version suffix: "opencode-skills@1.2.3" + if (normalized.startsWith(`${known}@`)) return known + // npm: prefix + if (normalized === `npm:${known}` || normalized.startsWith(`npm:${known}@`)) return known + // file:// path ending exactly with package name + if (normalized.startsWith("file://") && ( + normalized.endsWith(`/${known}`) || + normalized.endsWith(`\\${known}`) + )) return known + } + return null +} + +/** + * Check if a plugin entry matches a known skill plugin. + * Handles various formats: "name", "name@version", "npm:name", "file://path/name" + */ +function matchesSkillPlugin(entry: string): string | null { + const normalized = entry.toLowerCase() + for (const known of KNOWN_SKILL_PLUGINS) { + // Exact match + if (normalized === known) return known + // Version suffix: "opencode-skills@1.2.3" + if (normalized.startsWith(`${known}@`)) return known + // npm: prefix + if (normalized === `npm:${known}` || normalized.startsWith(`npm:${known}@`)) return known + // file:// path ending exactly with package name + if (normalized.startsWith("file://") && ( + normalized.endsWith(`/${known}`) || + normalized.endsWith(`\\${known}`) + )) return known + } + return null +} + export interface ExternalNotifierResult { detected: boolean pluginName: string | null allPlugins: string[] } +export interface ExternalSkillPluginResult { + detected: boolean + pluginName: string | null + allPlugins: string[] +} + +export interface ExternalSkillPluginResult { + detected: boolean + pluginName: string | null + allPlugins: string[] +} + /** * Detect if any external notification plugin is configured. * Returns information about detected plugins for logging/warning. @@ -120,6 +186,58 @@ export function detectExternalNotificationPlugin(directory: string): ExternalNot } } +/** + * Detect if any external skill plugin is configured. + * Returns information about detected plugins for logging/warning. + */ +export function detectExternalSkillPlugin(directory: string): ExternalSkillPluginResult { + const plugins = loadOpencodePlugins(directory) + + for (const plugin of plugins) { + const match = matchesSkillPlugin(plugin) + if (match) { + log(`Detected external skill plugin: ${plugin}`) + return { + detected: true, + pluginName: match, + allPlugins: plugins, + } + } + } + + return { + detected: false, + pluginName: null, + allPlugins: plugins, + } +} + +/** + * Detect if any external skill plugin is configured. + * Returns information about detected plugins for logging/warning. + */ +export function detectExternalSkillPlugin(directory: string): ExternalSkillPluginResult { + const plugins = loadOpencodePlugins(directory) + + for (const plugin of plugins) { + const match = matchesSkillPlugin(plugin) + if (match) { + log(`Detected external skill plugin: ${plugin}`) + return { + detected: true, + pluginName: match, + allPlugins: plugins, + } + } + } + + return { + detected: false, + pluginName: null, + allPlugins: plugins, + } +} + /** * Generate a warning message for users with conflicting notification plugins. */ @@ -135,3 +253,32 @@ Both oh-my-opencode and ${pluginName} listen to session.idle events. 1. Remove ${pluginName} from your opencode.json plugins 2. Or set "notification": { "force_enable": true } in oh-my-opencode.json` } + +/** + * Generate a warning message for users with conflicting skill plugins. + */ +export function getSkillPluginConflictWarning(pluginName: string): string { + return `[oh-my-opencode] External skill plugin detected: ${pluginName} + +Both oh-my-opencode and ${pluginName} scan ~/.config/opencode/skills/ and register tools independently. + Running both simultaneously causes "Duplicate tool names detected" warnings and HTTP 400 errors. + + Consider either: + 1. Remove ${pluginName} from your opencode.json plugins to use oh-my-opencode's skill loading + 2. Or disable oh-my-opencode's skill loading by setting "claude_code.skills": false in oh-my-opencode.json + 3. Or uninstall oh-my-opencode if you prefer ${pluginName}'s skill management` +} + +/** + * Generate a warning message for users with conflicting skill plugins. + */ +export function getSkillPluginConflictWarning(pluginName: string): string { + return `[oh-my-openagent] WARNING: External skill plugin detected: ${pluginName} + +Both oh-my-openagent and ${pluginName} scan ~/.config/opencode/skills/ and register tools. +Running both simultaneously causes "Duplicate tool names detected" errors. + +To fix this issue, either: +1. Remove ${pluginName} from your opencode.json plugins +2. Or disable skills in oh-my-openagent by setting "claude_code": { "skills": false } in oh-my-openagent.json` +}