diff --git a/src/agents/utils.ts b/src/agents/utils.ts index 808a6ef364..a0122bdc45 100644 --- a/src/agents/utils.ts +++ b/src/agents/utils.ts @@ -15,6 +15,7 @@ import type { AvailableAgent } from "./sisyphus-prompt-builder" import { deepMerge } from "../shared" import { DEFAULT_CATEGORIES } from "../tools/sisyphus-task/constants" import { resolveMultipleSkills } from "../features/opencode-skill-loader/skill-content" +import type { LoadedSkill } from "../features/opencode-skill-loader/types" type AgentSource = AgentFactory | AgentConfig @@ -51,7 +52,8 @@ function isFactory(source: AgentSource): source is AgentFactory { export function buildAgent( source: AgentSource, model?: string, - categories?: CategoriesConfig + categories?: CategoriesConfig, + pluginSkills?: Map ): AgentConfig { const base = isFactory(source) ? source(model) : source const categoryConfigs: Record = categories @@ -75,7 +77,7 @@ export function buildAgent( } if (agentWithCategory.skills?.length) { - const { resolved } = resolveMultipleSkills(agentWithCategory.skills) + const { resolved } = resolveMultipleSkills(agentWithCategory.skills, { pluginSkills }) if (resolved.size > 0) { const skillContent = Array.from(resolved.values()).join("\n\n") base.prompt = skillContent + (base.prompt ? "\n\n" + base.prompt : "") @@ -130,7 +132,8 @@ export function createBuiltinAgents( agentOverrides: AgentOverrides = {}, directory?: string, systemDefaultModel?: string, - categories?: CategoriesConfig + categories?: CategoriesConfig, + pluginSkills?: Map ): Record { const result: Record = {} const availableAgents: AvailableAgent[] = [] @@ -149,7 +152,7 @@ export function createBuiltinAgents( const override = agentOverrides[agentName] const model = override?.model - let config = buildAgent(source, model, mergedCategories) + let config = buildAgent(source, model, mergedCategories, pluginSkills) if (agentName === "librarian" && directory && config.prompt) { const envContext = createEnvContext() diff --git a/src/features/opencode-skill-loader/merger.ts b/src/features/opencode-skill-loader/merger.ts index 07755d71cc..29320b55c8 100644 --- a/src/features/opencode-skill-loader/merger.ts +++ b/src/features/opencode-skill-loader/merger.ts @@ -10,12 +10,13 @@ import { sanitizeModelField } from "../../shared/model-sanitizer" import { deepMerge } from "../../shared/deep-merge" const SCOPE_PRIORITY: Record = { - builtin: 1, - config: 2, - user: 3, - opencode: 4, - project: 5, - "opencode-project": 6, + builtin: 1, + config: 2, + user: 3, + opencode: 4, + project: 5, + "opencode-project": 6, + plugin: 7, } function builtinToLoaded(builtin: BuiltinSkill): LoadedSkill { @@ -181,7 +182,8 @@ function mergeSkillDefinitions(base: LoadedSkill, patch: SkillDefinition): Loade } export interface MergeSkillsOptions { - configDir?: string + configDir?: string + pluginSkills?: LoadedSkill[] } export function mergeSkills( @@ -250,9 +252,23 @@ export function mergeSkills( } } - for (const name of normalizedConfig.disable) { - skillMap.delete(name) - } + const disabledNames = new Set(normalizedConfig.disable) + for (const [name, entry] of Object.entries(normalizedConfig.entries)) { + if (entry === false || (entry !== true && entry.disable)) { + disabledNames.add(name) + } + } + + if (options.pluginSkills) { + for (const skill of options.pluginSkills) { + if (disabledNames.has(skill.name)) continue + + const existing = skillMap.get(skill.name) + if (!existing || SCOPE_PRIORITY[skill.scope] > SCOPE_PRIORITY[existing.scope]) { + skillMap.set(skill.name, skill) + } + } + } if (normalizedConfig.enable.length > 0) { const enableSet = new Set(normalizedConfig.enable) diff --git a/src/features/opencode-skill-loader/skill-content.test.ts b/src/features/opencode-skill-loader/skill-content.test.ts index 66b432b6df..c25ab88138 100644 --- a/src/features/opencode-skill-loader/skill-content.test.ts +++ b/src/features/opencode-skill-loader/skill-content.test.ts @@ -1,5 +1,33 @@ import { describe, it, expect } from "bun:test" import { resolveSkillContent, resolveMultipleSkills } from "./skill-content" +import type { LoadedSkill, SkillScope } from "./types" + +const mockPluginSkills: Map = new Map([ + [ + "test-plugin:test-skill", + { + name: "test-plugin:test-skill", + definition: { + name: "test-plugin:test-skill", + description: "A test plugin skill", + template: "This is the test plugin skill template content.", + }, + scope: "plugin" as SkillScope, + }, + ], + [ + "another-plugin:another-skill", + { + name: "another-plugin:another-skill", + definition: { + name: "another-plugin:another-skill", + description: "Another test plugin skill", + template: "Another plugin skill template for testing.", + }, + scope: "plugin" as SkillScope, + }, + ], +]) describe("resolveSkillContent", () => { it("should return template for existing skill", () => { @@ -109,3 +137,125 @@ describe("resolveMultipleSkills", () => { expect(result.resolved.size).toBe(2) }) }) + +describe("plugin skills", () => { + describe("resolveSkillContent with plugin skills", () => { + it("should return template for plugin skill when provided in options", () => { + // #given: plugin skills map with 'test-plugin:test-skill' + // #when: resolving content for plugin skill with pluginSkills option + const result = resolveSkillContent("test-plugin:test-skill", { + pluginSkills: mockPluginSkills, + }) + + // #then: returns plugin skill template + expect(result).not.toBeNull() + expect(result).toBe("This is the test plugin skill template content.") + }) + + it("should return null for plugin skill when pluginSkills not provided", () => { + // #given: no pluginSkills in options + // #when: resolving content for plugin skill without pluginSkills + const result = resolveSkillContent("test-plugin:test-skill") + + // #then: returns null (plugin skill not found in builtins) + expect(result).toBeNull() + }) + + it("should prioritize plugin skill over builtin if name conflicts", () => { + // #given: plugin skill with same name as builtin (hypothetical) + const conflictingPluginSkills: Map = new Map([ + [ + "frontend-ui-ux", + { + name: "frontend-ui-ux", + definition: { + name: "frontend-ui-ux", + description: "Plugin override", + template: "PLUGIN OVERRIDE CONTENT", + }, + scope: "plugin" as SkillScope, + }, + ], + ]) + + // #when: resolving with conflicting plugin skill + const result = resolveSkillContent("frontend-ui-ux", { + pluginSkills: conflictingPluginSkills, + }) + + // #then: plugin skill takes priority + expect(result).toBe("PLUGIN OVERRIDE CONTENT") + }) + }) + + describe("resolveMultipleSkills with plugin skills", () => { + it("should resolve plugin skills when provided in options", () => { + // #given: list with plugin skill names + const skillNames = ["test-plugin:test-skill"] + + // #when: resolving with pluginSkills option + const result = resolveMultipleSkills(skillNames, { + pluginSkills: mockPluginSkills, + }) + + // #then: plugin skill resolved + expect(result.resolved.size).toBe(1) + expect(result.notFound).toEqual([]) + expect(result.resolved.get("test-plugin:test-skill")).toBe( + "This is the test plugin skill template content." + ) + }) + + it("should resolve mixed builtin and plugin skills", () => { + // #given: list with both builtin and plugin skills + const skillNames = ["playwright", "test-plugin:test-skill", "frontend-ui-ux"] + + // #when: resolving with pluginSkills option + const result = resolveMultipleSkills(skillNames, { + pluginSkills: mockPluginSkills, + }) + + // #then: all skills resolved + expect(result.resolved.size).toBe(3) + expect(result.notFound).toEqual([]) + expect(result.resolved.get("playwright")).toContain("Playwright Browser Automation") + expect(result.resolved.get("test-plugin:test-skill")).toBe( + "This is the test plugin skill template content." + ) + expect(result.resolved.get("frontend-ui-ux")).toContain("Designer-Turned-Developer") + }) + + it("should report plugin skill as not found when pluginSkills not provided", () => { + // #given: plugin skill name without pluginSkills option + const skillNames = ["test-plugin:test-skill", "playwright"] + + // #when: resolving without pluginSkills + const result = resolveMultipleSkills(skillNames) + + // #then: plugin skill in notFound, builtin resolved + expect(result.resolved.size).toBe(1) + expect(result.notFound).toEqual(["test-plugin:test-skill"]) + expect(result.resolved.get("playwright")).toContain("Playwright Browser Automation") + }) + + it("should resolve multiple plugin skills", () => { + // #given: list with multiple plugin skills + const skillNames = ["test-plugin:test-skill", "another-plugin:another-skill"] + + // #when: resolving with pluginSkills option + const result = resolveMultipleSkills(skillNames, { + pluginSkills: mockPluginSkills, + }) + + // #then: all plugin skills resolved + expect(result.resolved.size).toBe(2) + expect(result.notFound).toEqual([]) + expect(result.resolved.get("test-plugin:test-skill")).toBe( + "This is the test plugin skill template content." + ) + expect(result.resolved.get("another-plugin:another-skill")).toBe( + "Another plugin skill template for testing." + ) + }) + }) +}) diff --git a/src/features/opencode-skill-loader/skill-content.ts b/src/features/opencode-skill-loader/skill-content.ts index 6929ec3200..d641cba97c 100644 --- a/src/features/opencode-skill-loader/skill-content.ts +++ b/src/features/opencode-skill-loader/skill-content.ts @@ -1,8 +1,10 @@ import { createBuiltinSkills } from "../builtin-skills/skills" import type { GitMasterConfig } from "../../config/schema" +import type { LoadedSkill } from "./types" export interface SkillResolutionOptions { gitMasterConfig?: GitMasterConfig + pluginSkills?: Map } function injectGitMasterConfig(template: string, config?: GitMasterConfig): string { @@ -24,6 +26,11 @@ function injectGitMasterConfig(template: string, config?: GitMasterConfig): stri } export function resolveSkillContent(skillName: string, options?: SkillResolutionOptions): string | null { + if (options?.pluginSkills?.has(skillName)) { + const pluginSkill = options.pluginSkills.get(skillName)! + return pluginSkill.definition.template ?? null + } + const skills = createBuiltinSkills() const skill = skills.find((s) => s.name === skillName) if (!skill) return null @@ -46,6 +53,17 @@ export function resolveMultipleSkills(skillNames: string[], options?: SkillResol const notFound: string[] = [] for (const name of skillNames) { + if (options?.pluginSkills?.has(name)) { + const pluginSkill = options.pluginSkills.get(name)! + const template = pluginSkill.definition.template + if (template) { + resolved.set(name, template) + } else { + notFound.push(name) + } + continue + } + const template = skillMap.get(name) if (template) { if (name === "git-master" && options?.gitMasterConfig) { diff --git a/src/features/opencode-skill-loader/types.ts b/src/features/opencode-skill-loader/types.ts index 18d9bc3d86..05e7d970bf 100644 --- a/src/features/opencode-skill-loader/types.ts +++ b/src/features/opencode-skill-loader/types.ts @@ -1,7 +1,7 @@ import type { CommandDefinition } from "../claude-code-command-loader/types" import type { SkillMcpConfig } from "../skill-mcp-manager/types" -export type SkillScope = "builtin" | "config" | "user" | "project" | "opencode" | "opencode-project" +export type SkillScope = "builtin" | "config" | "user" | "project" | "opencode" | "opencode-project" | "plugin" export interface SkillMetadata { name?: string diff --git a/src/index.ts b/src/index.ts index f17807bd70..e9c272416d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -45,7 +45,13 @@ import { discoverOpencodeProjectSkills, mergeSkills, } from "./features/opencode-skill-loader"; +import type { LoadedSkill } from "./features/opencode-skill-loader/types"; +import type { CommandDefinition } from "./features/claude-code-command-loader/types"; import { createBuiltinSkills } from "./features/builtin-skills"; +import { + loadPluginSkillsAsCommands, + discoverInstalledPlugins, +} from "./features/claude-code-plugin-loader"; import { getSystemMcpServerNames } from "./features/claude-code-mcp-loader"; import { setMainSession, @@ -237,12 +243,31 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { const callOmoAgent = createCallOmoAgent(ctx, backgroundManager); const lookAt = createLookAt(ctx); + + // Load plugin skills (needed for sisyphusTask and mergeSkills) + // Note: config-handler.ts also loads plugin skills for createBuiltinAgents + let pluginSkillsArray: LoadedSkill[] | undefined; + let pluginSkillsMap: Map | undefined; + try { + const pluginLoadResult = await discoverInstalledPlugins(); + const pluginSkillCommands = loadPluginSkillsAsCommands(pluginLoadResult.plugins); + pluginSkillsArray = Object.entries(pluginSkillCommands).map(([name, definition]) => ({ + name, + definition, + scope: "plugin" as const, + })); + pluginSkillsMap = new Map(pluginSkillsArray.map((s) => [s.name, s])); + } catch (error) { + console.error("Failed to load plugin skills:", error); + } + const sisyphusTask = createSisyphusTask({ manager: backgroundManager, client: ctx.client, directory: ctx.directory, userCategories: pluginConfig.categories, gitMasterConfig: pluginConfig.git_master, + pluginSkills: pluginSkillsMap, }); const disabledSkills = new Set(pluginConfig.disabled_skills ?? []); const systemMcpNames = getSystemMcpServerNames(); @@ -262,13 +287,15 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { includeClaudeSkills ? discoverProjectClaudeSkills() : Promise.resolve([]), discoverOpencodeProjectSkills(), ]); + const mergedSkills = mergeSkills( builtinSkills, pluginConfig.skills, userSkills, globalSkills, projectSkills, - opencodeProjectSkills + opencodeProjectSkills, + { pluginSkills: pluginSkillsArray } ); const skillMcpManager = new SkillMcpManager(); const getSessionIDForMcp = () => getMainSessionID() || ""; diff --git a/src/plugin-handlers/config-handler.ts b/src/plugin-handlers/config-handler.ts index 96ff156f64..33cee993b2 100644 --- a/src/plugin-handlers/config-handler.ts +++ b/src/plugin-handlers/config-handler.ts @@ -1,4 +1,5 @@ import { createBuiltinAgents } from "../agents"; +import type { LoadedSkill } from "../features/opencode-skill-loader/types"; import { createSisyphusJuniorAgentWithOverrides } from "../agents/sisyphus-junior"; import { loadUserCommands, @@ -99,12 +100,21 @@ export function createConfigHandler(deps: ConfigHandlerDeps) { log(`Plugin load errors`, { errors: pluginComponents.errors }); } + // Convert plugin commands to LoadedSkill format + const pluginSkills: LoadedSkill[] = Object.entries(pluginComponents.skills).map(([name, definition]) => ({ + name, + definition, + scope: "plugin" as const, + })); + const pluginSkillsMap = new Map(pluginSkills.map((s) => [s.name, s])); + const builtinAgents = createBuiltinAgents( pluginConfig.disabled_agents, pluginConfig.agents, ctx.directory, config.model as string | undefined, - pluginConfig.categories + pluginConfig.categories, + pluginSkillsMap ); // Claude Code agents: Do NOT apply permission migration diff --git a/src/tools/sisyphus-task/tools.ts b/src/tools/sisyphus-task/tools.ts index d4b7207930..4eba76c023 100644 --- a/src/tools/sisyphus-task/tools.ts +++ b/src/tools/sisyphus-task/tools.ts @@ -7,6 +7,7 @@ import type { CategoryConfig, CategoriesConfig, GitMasterConfig } from "../../co import { SISYPHUS_TASK_DESCRIPTION, DEFAULT_CATEGORIES, CATEGORY_PROMPT_APPENDS } from "./constants" import { findNearestMessageWithFields, findFirstMessageWithAgent, MESSAGE_STORAGE } from "../../features/hook-message-injector" import { resolveMultipleSkills } from "../../features/opencode-skill-loader/skill-content" +import type { LoadedSkill } from "../../features/opencode-skill-loader/types" import { createBuiltinSkills } from "../../features/builtin-skills/skills" import { getTaskToastManager } from "../../features/task-toast-manager" import { subagentSessions, getSessionAgent } from "../../features/claude-code-session-state" @@ -87,11 +88,12 @@ function resolveCategoryConfig( } export interface SisyphusTaskToolOptions { - manager: BackgroundManager - client: OpencodeClient - directory: string - userCategories?: CategoriesConfig - gitMasterConfig?: GitMasterConfig + manager: BackgroundManager + client: OpencodeClient + directory: string + userCategories?: CategoriesConfig + gitMasterConfig?: GitMasterConfig + pluginSkills?: Map } export interface BuildSystemContentInput { @@ -114,7 +116,7 @@ export function buildSystemContent(input: BuildSystemContentInput): string | und } export function createSisyphusTask(options: SisyphusTaskToolOptions): ToolDefinition { - const { manager, client, directory, userCategories, gitMasterConfig } = options + const { manager, client, directory, userCategories, gitMasterConfig, pluginSkills } = options return tool({ description: SISYPHUS_TASK_DESCRIPTION, @@ -139,9 +141,11 @@ export function createSisyphusTask(options: SisyphusTaskToolOptions): ToolDefini let skillContent: string | undefined if (args.skills.length > 0) { - const { resolved, notFound } = resolveMultipleSkills(args.skills, { gitMasterConfig }) + const { resolved, notFound } = resolveMultipleSkills(args.skills, { gitMasterConfig, pluginSkills }) if (notFound.length > 0) { - const available = createBuiltinSkills().map(s => s.name).join(", ") + const builtinNames = createBuiltinSkills().map(s => s.name) + const pluginNames = pluginSkills ? Array.from(pluginSkills.keys()) : [] + const available = [...builtinNames, ...pluginNames].join(", ") return `❌ Skills not found: ${notFound.join(", ")}. Available: ${available}` } skillContent = Array.from(resolved.values()).join("\n\n") diff --git a/src/tools/slashcommand/types.ts b/src/tools/slashcommand/types.ts index 2cacdd014c..e060b0c7b6 100644 --- a/src/tools/slashcommand/types.ts +++ b/src/tools/slashcommand/types.ts @@ -1,6 +1,6 @@ import type { LoadedSkill, LazyContentLoader } from "../../features/opencode-skill-loader" -export type CommandScope = "builtin" | "config" | "user" | "project" | "opencode" | "opencode-project" +export type CommandScope = "builtin" | "config" | "user" | "project" | "opencode" | "opencode-project" | "plugin" export interface CommandMetadata { name: string