From 7186c368b9cd1d18a1b0e078e96b1a4692b04216 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 14 Feb 2026 14:44:12 +0900 Subject: [PATCH] fix(skill-loader): discover skills from .agents/skills/ directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add discoverProjectAgentsSkills() for project-level .agents/skills/ and discoverGlobalAgentsSkills() for ~/.agents/skills/ — matching OpenCode's native skill discovery paths (https://opencode.ai/docs/skills/). Updated discoverAllSkills(), discoverSkills(), and createSkillContext() to include these new sources with correct priority ordering. Co-authored-by: dtateks Closes #1818 --- bun.lock | 28 ++++----- .../agents-skills-global.test.ts | 48 +++++++++++++++ .../opencode-skill-loader/loader.test.ts | 58 ++++++++++++++++++- src/features/opencode-skill-loader/loader.ts | 52 +++++++++++++---- src/plugin/skill-context.ts | 10 +++- 5 files changed, 167 insertions(+), 29 deletions(-) create mode 100644 src/features/opencode-skill-loader/agents-skills-global.test.ts diff --git a/bun.lock b/bun.lock index 0175f65a5..36c9e59dd 100644 --- a/bun.lock +++ b/bun.lock @@ -28,13 +28,13 @@ "typescript": "^5.7.3", }, "optionalDependencies": { - "oh-my-opencode-darwin-arm64": "3.5.2", - "oh-my-opencode-darwin-x64": "3.5.2", - "oh-my-opencode-linux-arm64": "3.5.2", - "oh-my-opencode-linux-arm64-musl": "3.5.2", - "oh-my-opencode-linux-x64": "3.5.2", - "oh-my-opencode-linux-x64-musl": "3.5.2", - "oh-my-opencode-windows-x64": "3.5.2", + "oh-my-opencode-darwin-arm64": "3.5.3", + "oh-my-opencode-darwin-x64": "3.5.3", + "oh-my-opencode-linux-arm64": "3.5.3", + "oh-my-opencode-linux-arm64-musl": "3.5.3", + "oh-my-opencode-linux-x64": "3.5.3", + "oh-my-opencode-linux-x64-musl": "3.5.3", + "oh-my-opencode-windows-x64": "3.5.3", }, }, }, @@ -226,19 +226,19 @@ "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], - "oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.5.2", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-oIS3lB2F9/N+3mF5wCKk6/EPVSz516XWN+mNdquSSeddw+xqMxGdhKY6K/XeYbHJzeN2Z8IOikNEJ6psR2/a8g=="], + "oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.5.3", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Dq0+PC2dyAqG7c3DUnQmdOkKbKmOsRHwoqgLCQNKN1lTRllF8zbWqp5B+LGKxSPxPqJIPS3mKt+wIR2KvkYJVw=="], - "oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.5.2", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-OAdXo4ZCCYO4kRWtnyz3tdmaGYPUB3WcXimXAxp+/sEZxAnh7n1RQkpLn6UxWX4AIAdRT9dfrOfRic6VoCYv2g=="], + "oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.5.3", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Ke45Bv/ygZm3YUSUumIyk647KZ2PFzw30tH597cOpG8MDPGbNVBCM6EKFezcukUPT+gPFVpE1IiGzEkn4JmgZA=="], - "oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.5.2", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-5XXNMFhp1VsyrGNRBoXcOyoaUeVkbrWkBRPDGZfpiq+kRXH3aaSWdR5G7Pl/TadOQv9Bl8/8YaxsuHRTFT1aXw=="], + "oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.5.3", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-aP5S3DngUhFkNeqYM33Ge6zccCWLzB/O3FLXLFXy/Iws03N8xugw72pnMK6lUbIia9QQBKK7IZBoYm9C79pZ3g=="], - "oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.5.2", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-/woIpqvEI85MgJvEVnz4g5FBLeiQNK7srRsueIFPBmtTahh42HFleCDaIltOl/ndjsE5nCHacQVJHkC9W9/F3Q=="], + "oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.5.3", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-UiD/hVKYZQyX4D5N5SnZT4M5Z/B2SDtJWBW4MibpYSAcPKNCEBKi/5E4hOPxAtTfFGR8tIXFmYZdQJDkVfvluw=="], - "oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.5.2", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-vTL2A+6zzGhi+m7sC8peLDq5OAp2dRR0UEb4RbZAOHtlEruF7qFEmcK3ccWxwc3+Z3G/ITfwn5VNa72ZS4pNTg=="], + "oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.5.3", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-L9kqwzElGkaQ8pgtv1ZjcHARw9LPaU4UEVjzauByTMi+/5Js/PTsNXBggxSRzZfQ8/MNBPSCiA4K10Kc0YjjvA=="], - "oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.5.2", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-bOAA55snLsK2QB00IkQy8le0Oqh/GJ7pxEHtm1oUezlQrW/nX5SS/hJ7dPHMmOd9FoiqnqyqWZxNkLmFoG463A=="], + "oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.5.3", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Z0fVVih/b2dbNeb9DK9oca5dNYCZyPySBRtxRhDXod5d7fJNgIPrvUoEd3SNfkRGORyFB3hGBZ6nqQ6N8+8DEA=="], - "oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.5.2", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-fnHiAPYglw3unPckmQBoCT6+VqjSWCE3S3J551mRo0ZFrxuEP2ZKyHZeFMMOtKwDepCvmKgd1W040+KmuVUXOA=="], + "oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.5.3", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-ocWPjRs2sJgN02PJnEIYtqdMVDex1YhEj1FzAU5XIicfzQbgxLh9nz1yhHZzfqGJq69QStU6ofpc5kQpfX1LMg=="], "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], diff --git a/src/features/opencode-skill-loader/agents-skills-global.test.ts b/src/features/opencode-skill-loader/agents-skills-global.test.ts new file mode 100644 index 000000000..290272273 --- /dev/null +++ b/src/features/opencode-skill-loader/agents-skills-global.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test" +import { mkdirSync, writeFileSync, rmSync } from "fs" +import { join } from "path" +import { tmpdir } from "os" + +const TEST_DIR = join(tmpdir(), "agents-global-skills-test-" + Date.now()) +const TEMP_HOME = join(TEST_DIR, "home") + +describe("discoverGlobalAgentsSkills", () => { + beforeEach(() => { + mkdirSync(TEST_DIR, { recursive: true }) + mkdirSync(TEMP_HOME, { recursive: true }) + }) + + afterEach(() => { + mock.restore() + rmSync(TEST_DIR, { recursive: true, force: true }) + }) + + it("#given a skill in ~/.agents/skills/ #when discoverGlobalAgentsSkills is called #then it discovers the skill", async () => { + //#given + const skillContent = `--- +name: agent-global-skill +description: A skill from global .agents/skills directory +--- +Skill body. +` + const agentsGlobalSkillsDir = join(TEMP_HOME, ".agents", "skills") + const skillDir = join(agentsGlobalSkillsDir, "agent-global-skill") + mkdirSync(skillDir, { recursive: true }) + writeFileSync(join(skillDir, "SKILL.md"), skillContent) + + mock.module("os", () => ({ + homedir: () => TEMP_HOME, + tmpdir, + })) + + //#when + const { discoverGlobalAgentsSkills } = await import("./loader") + const skills = await discoverGlobalAgentsSkills() + const skill = skills.find(s => s.name === "agent-global-skill") + + //#then + expect(skill).toBeDefined() + expect(skill?.scope).toBe("user") + expect(skill?.definition.description).toContain("A skill from global .agents/skills directory") + }) +}) diff --git a/src/features/opencode-skill-loader/loader.test.ts b/src/features/opencode-skill-loader/loader.test.ts index d77fa49d3..7aecb801f 100644 --- a/src/features/opencode-skill-loader/loader.test.ts +++ b/src/features/opencode-skill-loader/loader.test.ts @@ -552,7 +552,7 @@ Skill body. expect(names.length).toBe(uniqueNames.length) } finally { process.chdir(originalCwd) - if (originalOpenCodeConfigDir === undefined) { + if (originalOpenCodeConfigDir === undefined) { delete process.env.OPENCODE_CONFIG_DIR } else { process.env.OPENCODE_CONFIG_DIR = originalOpenCodeConfigDir @@ -560,4 +560,60 @@ Skill body. } }) }) + + describe("agents skills discovery (.agents/skills/)", () => { + it("#given a skill in .agents/skills/ #when discoverProjectAgentsSkills is called #then it discovers the skill", async () => { + //#given + const skillContent = `--- +name: agent-project-skill +description: A skill from project .agents/skills directory +--- +Skill body. +` + const agentsProjectSkillsDir = join(TEST_DIR, ".agents", "skills") + const skillDir = join(agentsProjectSkillsDir, "agent-project-skill") + mkdirSync(skillDir, { recursive: true }) + writeFileSync(join(skillDir, "SKILL.md"), skillContent) + + //#when + const { discoverProjectAgentsSkills } = await import("./loader") + const originalCwd = process.cwd() + process.chdir(TEST_DIR) + + try { + const skills = await discoverProjectAgentsSkills() + const skill = skills.find(s => s.name === "agent-project-skill") + + //#then + expect(skill).toBeDefined() + expect(skill?.scope).toBe("project") + expect(skill?.definition.description).toContain("A skill from project .agents/skills directory") + } finally { + process.chdir(originalCwd) + } + }) + + it("#given a skill in .agents/skills/ #when discoverProjectAgentsSkills is called with directory #then it discovers the skill", async () => { + //#given + const skillContent = `--- +name: agent-dir-skill +description: A skill via explicit directory param +--- +Skill body. +` + const agentsProjectSkillsDir = join(TEST_DIR, ".agents", "skills") + const skillDir = join(agentsProjectSkillsDir, "agent-dir-skill") + mkdirSync(skillDir, { recursive: true }) + writeFileSync(join(skillDir, "SKILL.md"), skillContent) + + //#when + const { discoverProjectAgentsSkills } = await import("./loader") + const skills = await discoverProjectAgentsSkills(TEST_DIR) + const skill = skills.find(s => s.name === "agent-dir-skill") + + //#then + expect(skill).toBeDefined() + expect(skill?.scope).toBe("project") + }) + }) }) diff --git a/src/features/opencode-skill-loader/loader.ts b/src/features/opencode-skill-loader/loader.ts index 81591dfa1..0a5a98aad 100644 --- a/src/features/opencode-skill-loader/loader.ts +++ b/src/features/opencode-skill-loader/loader.ts @@ -1,4 +1,5 @@ import { join } from "path" +import { homedir } from "os" import { getClaudeConfigDir } from "../../shared/claude-config-dir" import { getOpenCodeConfigDir } from "../../shared/opencode-config-dir" import type { CommandDefinition } from "../claude-code-command-loader/types" @@ -38,15 +39,25 @@ export interface DiscoverSkillsOptions { } export async function discoverAllSkills(directory?: string): Promise { - const [opencodeProjectSkills, opencodeGlobalSkills, projectSkills, userSkills] = await Promise.all([ - discoverOpencodeProjectSkills(directory), - discoverOpencodeGlobalSkills(), - discoverProjectClaudeSkills(directory), - discoverUserClaudeSkills(), - ]) + const [opencodeProjectSkills, opencodeGlobalSkills, projectSkills, userSkills, agentsProjectSkills, agentsGlobalSkills] = + await Promise.all([ + discoverOpencodeProjectSkills(directory), + discoverOpencodeGlobalSkills(), + discoverProjectClaudeSkills(directory), + discoverUserClaudeSkills(), + discoverProjectAgentsSkills(directory), + discoverGlobalAgentsSkills(), + ]) - // Priority: opencode-project > opencode > project > user - return deduplicateSkillsByName([...opencodeProjectSkills, ...opencodeGlobalSkills, ...projectSkills, ...userSkills]) + // Priority: opencode-project > opencode > project (.claude + .agents) > user (.claude + .agents) + return deduplicateSkillsByName([ + ...opencodeProjectSkills, + ...opencodeGlobalSkills, + ...projectSkills, + ...agentsProjectSkills, + ...userSkills, + ...agentsGlobalSkills, + ]) } export async function discoverSkills(options: DiscoverSkillsOptions = {}): Promise { @@ -62,13 +73,22 @@ export async function discoverSkills(options: DiscoverSkillsOptions = {}): Promi return deduplicateSkillsByName([...opencodeProjectSkills, ...opencodeGlobalSkills]) } - const [projectSkills, userSkills] = await Promise.all([ + const [projectSkills, userSkills, agentsProjectSkills, agentsGlobalSkills] = await Promise.all([ discoverProjectClaudeSkills(directory), discoverUserClaudeSkills(), + discoverProjectAgentsSkills(directory), + discoverGlobalAgentsSkills(), ]) - // Priority: opencode-project > opencode > project > user - return deduplicateSkillsByName([...opencodeProjectSkills, ...opencodeGlobalSkills, ...projectSkills, ...userSkills]) + // Priority: opencode-project > opencode > project (.claude + .agents) > user (.claude + .agents) + return deduplicateSkillsByName([ + ...opencodeProjectSkills, + ...opencodeGlobalSkills, + ...projectSkills, + ...agentsProjectSkills, + ...userSkills, + ...agentsGlobalSkills, + ]) } export async function getSkillByName(name: string, options: DiscoverSkillsOptions = {}): Promise { @@ -96,3 +116,13 @@ export async function discoverOpencodeProjectSkills(directory?: string): Promise const opencodeProjectDir = join(directory ?? process.cwd(), ".opencode", "skills") return loadSkillsFromDir({ skillsDir: opencodeProjectDir, scope: "opencode-project" }) } + +export async function discoverProjectAgentsSkills(directory?: string): Promise { + const agentsProjectDir = join(directory ?? process.cwd(), ".agents", "skills") + return loadSkillsFromDir({ skillsDir: agentsProjectDir, scope: "project" }) +} + +export async function discoverGlobalAgentsSkills(): Promise { + const agentsGlobalDir = join(homedir(), ".agents", "skills") + return loadSkillsFromDir({ skillsDir: agentsGlobalDir, scope: "user" }) +} diff --git a/src/plugin/skill-context.ts b/src/plugin/skill-context.ts index a1b89f84b..5f3bd1717 100644 --- a/src/plugin/skill-context.ts +++ b/src/plugin/skill-context.ts @@ -12,6 +12,8 @@ import { discoverProjectClaudeSkills, discoverOpencodeGlobalSkills, discoverOpencodeProjectSkills, + discoverProjectAgentsSkills, + discoverGlobalAgentsSkills, mergeSkills, } from "../features/opencode-skill-loader" import { createBuiltinSkills } from "../features/builtin-skills" @@ -55,7 +57,7 @@ export async function createSkillContext(args: { }) const includeClaudeSkills = pluginConfig.claude_code?.skills !== false - const [configSourceSkills, userSkills, globalSkills, projectSkills, opencodeProjectSkills] = + const [configSourceSkills, userSkills, globalSkills, projectSkills, opencodeProjectSkills, agentsProjectSkills, agentsGlobalSkills] = await Promise.all([ discoverConfigSourceSkills({ config: pluginConfig.skills, @@ -65,15 +67,17 @@ export async function createSkillContext(args: { discoverOpencodeGlobalSkills(), includeClaudeSkills ? discoverProjectClaudeSkills(directory) : Promise.resolve([]), discoverOpencodeProjectSkills(directory), + discoverProjectAgentsSkills(directory), + discoverGlobalAgentsSkills(), ]) const mergedSkills = mergeSkills( builtinSkills, pluginConfig.skills, configSourceSkills, - userSkills, + [...userSkills, ...agentsGlobalSkills], globalSkills, - projectSkills, + [...projectSkills, ...agentsProjectSkills], opencodeProjectSkills, { configDir: directory }, )