Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions src/agents/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -51,7 +52,8 @@ function isFactory(source: AgentSource): source is AgentFactory {
export function buildAgent(
source: AgentSource,
model?: string,
categories?: CategoriesConfig
categories?: CategoriesConfig,
pluginSkills?: Map<string, LoadedSkill>
): AgentConfig {
const base = isFactory(source) ? source(model) : source
const categoryConfigs: Record<string, CategoryConfig> = categories
Expand All @@ -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 : "")
Expand Down Expand Up @@ -130,7 +132,8 @@ export function createBuiltinAgents(
agentOverrides: AgentOverrides = {},
directory?: string,
systemDefaultModel?: string,
categories?: CategoriesConfig
categories?: CategoriesConfig,
pluginSkills?: Map<string, LoadedSkill>
): Record<string, AgentConfig> {
const result: Record<string, AgentConfig> = {}
const availableAgents: AvailableAgent[] = []
Expand All @@ -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()
Expand Down
36 changes: 26 additions & 10 deletions src/features/opencode-skill-loader/merger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@ import { sanitizeModelField } from "../../shared/model-sanitizer"
import { deepMerge } from "../../shared/deep-merge"

const SCOPE_PRIORITY: Record<SkillScope, number> = {
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 {
Expand Down Expand Up @@ -181,7 +182,8 @@ function mergeSkillDefinitions(base: LoadedSkill, patch: SkillDefinition): Loade
}

export interface MergeSkillsOptions {
configDir?: string
configDir?: string
pluginSkills?: LoadedSkill[]
}

export function mergeSkills(
Expand Down Expand Up @@ -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)
Expand Down
150 changes: 150 additions & 0 deletions src/features/opencode-skill-loader/skill-content.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, LoadedSkill> = 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", () => {
Expand Down Expand Up @@ -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<string, LoadedSkill> = 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."
)
})
})
})
18 changes: 18 additions & 0 deletions src/features/opencode-skill-loader/skill-content.ts
Original file line number Diff line number Diff line change
@@ -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<string, LoadedSkill>
}

function injectGitMasterConfig(template: string, config?: GitMasterConfig): string {
Expand All @@ -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
Expand All @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion src/features/opencode-skill-loader/types.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
29 changes: 28 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, LoadedSkill> | 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();
Expand All @@ -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() || "";
Expand Down
Loading