Просмотр исходного кода

feat: add support for reading skills from .agents/skills directories (#11842)

Co-authored-by: Filip <[email protected]>
Dax 2 недель назад
Родитель
Сommit
17e62b050f

+ 2 - 0
packages/opencode/src/flag/flag.ts

@@ -23,6 +23,8 @@ export namespace Flag {
     OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT")
   export const OPENCODE_DISABLE_CLAUDE_CODE_SKILLS =
     OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_SKILLS")
+  export const OPENCODE_DISABLE_EXTERNAL_SKILLS =
+    OPENCODE_DISABLE_CLAUDE_CODE_SKILLS || truthy("OPENCODE_DISABLE_EXTERNAL_SKILLS")
   export declare const OPENCODE_DISABLE_PROJECT_CONFIG: boolean
   export const OPENCODE_FAKE_VCS = process.env["OPENCODE_FAKE_VCS"]
   export declare const OPENCODE_CLIENT: string

+ 33 - 30
packages/opencode/src/skill/skill.ts

@@ -40,8 +40,12 @@ export namespace Skill {
     }),
   )
 
+  // External skill directories to search for (project-level and global)
+  // These follow the directory layout used by Claude Code and other agents.
+  const EXTERNAL_DIRS = [".claude", ".agents"]
+  const EXTERNAL_SKILL_GLOB = new Bun.Glob("skills/**/SKILL.md")
+
   const OPENCODE_SKILL_GLOB = new Bun.Glob("{skill,skills}/**/SKILL.md")
-  const CLAUDE_SKILL_GLOB = new Bun.Glob("skills/**/SKILL.md")
   const SKILL_GLOB = new Bun.Glob("**/SKILL.md")
 
   export const state = Instance.state(async () => {
@@ -79,38 +83,37 @@ export namespace Skill {
       }
     }
 
-    // Scan .claude/skills/ directories (project-level)
-    const claudeDirs = await Array.fromAsync(
-      Filesystem.up({
-        targets: [".claude"],
-        start: Instance.directory,
-        stop: Instance.worktree,
-      }),
-    )
-    // Also include global ~/.claude/skills/
-    const globalClaude = `${Global.Path.home}/.claude`
-    if (await Filesystem.isDir(globalClaude)) {
-      claudeDirs.push(globalClaude)
+    const scanExternal = async (root: string, scope: "global" | "project") => {
+      return Array.fromAsync(
+        EXTERNAL_SKILL_GLOB.scan({
+          cwd: root,
+          absolute: true,
+          onlyFiles: true,
+          followSymlinks: true,
+          dot: true,
+        }),
+      )
+        .then((matches) => Promise.all(matches.map(addSkill)))
+        .catch((error) => {
+          log.error(`failed to scan ${scope} skills`, { dir: root, error })
+        })
     }
 
-    if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_SKILLS) {
-      for (const dir of claudeDirs) {
-        const matches = await Array.fromAsync(
-          CLAUDE_SKILL_GLOB.scan({
-            cwd: dir,
-            absolute: true,
-            onlyFiles: true,
-            followSymlinks: true,
-            dot: true,
-          }),
-        ).catch((error) => {
-          log.error("failed .claude directory scan for skills", { dir, error })
-          return []
-        })
+    // Scan external skill directories (.claude/skills/, .agents/skills/, etc.)
+    // Load global (home) first, then project-level (so project-level overwrites)
+    if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
+      for (const dir of EXTERNAL_DIRS) {
+        const root = path.join(Global.Path.home, dir)
+        if (!(await Filesystem.isDir(root))) continue
+        await scanExternal(root, "global")
+      }
 
-        for (const match of matches) {
-          await addSkill(match)
-        }
+      for await (const root of Filesystem.up({
+        targets: EXTERNAL_DIRS,
+        start: Instance.directory,
+        stop: Instance.worktree,
+      })) {
+        await scanExternal(root, "project")
       }
     }
 

+ 107 - 0
packages/opencode/test/skill/skill.test.ts

@@ -219,3 +219,110 @@ test("returns empty array when no skills exist", async () => {
     },
   })
 })
+
+test("discovers skills from .agents/skills/ directory", async () => {
+  await using tmp = await tmpdir({
+    git: true,
+    init: async (dir) => {
+      const skillDir = path.join(dir, ".agents", "skills", "agent-skill")
+      await Bun.write(
+        path.join(skillDir, "SKILL.md"),
+        `---
+name: agent-skill
+description: A skill in the .agents/skills directory.
+---
+
+# Agent Skill
+`,
+      )
+    },
+  })
+
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const skills = await Skill.all()
+      expect(skills.length).toBe(1)
+      const agentSkill = skills.find((s) => s.name === "agent-skill")
+      expect(agentSkill).toBeDefined()
+      expect(agentSkill!.location).toContain(".agents/skills/agent-skill/SKILL.md")
+    },
+  })
+})
+
+test("discovers global skills from ~/.agents/skills/ directory", async () => {
+  await using tmp = await tmpdir({ git: true })
+
+  const originalHome = process.env.OPENCODE_TEST_HOME
+  process.env.OPENCODE_TEST_HOME = tmp.path
+
+  try {
+    const skillDir = path.join(tmp.path, ".agents", "skills", "global-agent-skill")
+    await fs.mkdir(skillDir, { recursive: true })
+    await Bun.write(
+      path.join(skillDir, "SKILL.md"),
+      `---
+name: global-agent-skill
+description: A global skill from ~/.agents/skills for testing.
+---
+
+# Global Agent Skill
+
+This skill is loaded from the global home directory.
+`,
+    )
+
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        const skills = await Skill.all()
+        expect(skills.length).toBe(1)
+        expect(skills[0].name).toBe("global-agent-skill")
+        expect(skills[0].description).toBe("A global skill from ~/.agents/skills for testing.")
+        expect(skills[0].location).toContain(".agents/skills/global-agent-skill/SKILL.md")
+      },
+    })
+  } finally {
+    process.env.OPENCODE_TEST_HOME = originalHome
+  }
+})
+
+test("discovers skills from both .claude/skills/ and .agents/skills/", async () => {
+  await using tmp = await tmpdir({
+    git: true,
+    init: async (dir) => {
+      const claudeDir = path.join(dir, ".claude", "skills", "claude-skill")
+      const agentDir = path.join(dir, ".agents", "skills", "agent-skill")
+      await Bun.write(
+        path.join(claudeDir, "SKILL.md"),
+        `---
+name: claude-skill
+description: A skill in the .claude/skills directory.
+---
+
+# Claude Skill
+`,
+      )
+      await Bun.write(
+        path.join(agentDir, "SKILL.md"),
+        `---
+name: agent-skill
+description: A skill in the .agents/skills directory.
+---
+
+# Agent Skill
+`,
+      )
+    },
+  })
+
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const skills = await Skill.all()
+      expect(skills.length).toBe(2)
+      expect(skills.find((s) => s.name === "claude-skill")).toBeDefined()
+      expect(skills.find((s) => s.name === "agent-skill")).toBeDefined()
+    },
+  })
+})