From 787ce99edab35e2f56e694accf40f14d22d6cfd9 Mon Sep 17 00:00:00 2001 From: User Date: Fri, 27 Mar 2026 13:21:08 +0100 Subject: [PATCH 1/3] 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` +} From 885d3a24620c295a3a13c07e9c6939ce0f0348c3 Mon Sep 17 00:00:00 2001 From: codivedev Date: Fri, 27 Mar 2026 13:26:19 +0100 Subject: [PATCH 2/3] fix: detect and warn about opencode-skills conflict --- src/plugin-handlers/command-config-handler.ts | 11 +++ src/shared/external-plugin-detector.ts | 68 ------------------- 2 files changed, 11 insertions(+), 68 deletions(-) diff --git a/src/plugin-handlers/command-config-handler.ts b/src/plugin-handlers/command-config-handler.ts index 7afd1e416..b5d91120c 100644 --- a/src/plugin-handlers/command-config-handler.ts +++ b/src/plugin-handlers/command-config-handler.ts @@ -17,6 +17,11 @@ import { loadOpencodeProjectSkills, skillsToCommandDefinitionRecord, } from "../features/opencode-skill-loader"; +import { + detectExternalSkillPlugin, + getSkillPluginConflictWarning, + log, +} from "../shared"; import type { PluginComponents } from "./plugin-components-loader"; export async function applyCommandConfig(params: { @@ -31,6 +36,12 @@ export async function applyCommandConfig(params: { const includeClaudeCommands = params.pluginConfig.claude_code?.commands ?? true; const includeClaudeSkills = params.pluginConfig.claude_code?.skills ?? true; + // Detect conflicting skill plugins + const externalSkillPlugin = detectExternalSkillPlugin(params.ctx.directory); + if (externalSkillPlugin.detected) { + log(getSkillPluginConflictWarning(externalSkillPlugin.pluginName!)); + } + const [ configSourceSkills, userCommands, diff --git a/src/shared/external-plugin-detector.ts b/src/shared/external-plugin-detector.ts index 843aaed06..a73149d68 100644 --- a/src/shared/external-plugin-detector.ts +++ b/src/shared/external-plugin-detector.ts @@ -120,28 +120,6 @@ function matchesSkillPlugin(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 -} - export interface ExternalNotifierResult { detected: boolean pluginName: string | null @@ -154,12 +132,6 @@ export interface ExternalSkillPluginResult { 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. @@ -212,32 +184,6 @@ export function detectExternalSkillPlugin(directory: string): ExternalSkillPlugi } } -/** - * 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. */ @@ -268,17 +214,3 @@ Both oh-my-opencode and ${pluginName} scan ~/.config/opencode/skills/ and regist 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` -} From 38347a396e2f8c44200852190b3c0cd6b9e4a58b Mon Sep 17 00:00:00 2001 From: codivedev Date: Fri, 27 Mar 2026 13:49:58 +0100 Subject: [PATCH 3/3] fix: address review comments - remove duplicates and respect skills config --- src/plugin-handlers/command-config-handler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugin-handlers/command-config-handler.ts b/src/plugin-handlers/command-config-handler.ts index b5d91120c..e4d10ec1a 100644 --- a/src/plugin-handlers/command-config-handler.ts +++ b/src/plugin-handlers/command-config-handler.ts @@ -38,7 +38,7 @@ export async function applyCommandConfig(params: { // Detect conflicting skill plugins const externalSkillPlugin = detectExternalSkillPlugin(params.ctx.directory); - if (externalSkillPlugin.detected) { + if (includeClaudeSkills && externalSkillPlugin.detected) { log(getSkillPluginConflictWarning(externalSkillPlugin.pluginName!)); }