Преглед изворни кода

tweak: read global claude skills too (#6420)

Aiden Cline пре 3 месеци
родитељ
комит
abc7eed92b

+ 5 - 2
packages/opencode/src/global/index.ts

@@ -12,14 +12,17 @@ const state = path.join(xdgState!, app)
 
 export namespace Global {
   export const Path = {
-    home: os.homedir(),
+    // Allow override via OPENCODE_TEST_HOME for test isolation
+    get home() {
+      return process.env.OPENCODE_TEST_HOME || os.homedir()
+    },
     data,
     bin: path.join(data, "bin"),
     log: path.join(data, "log"),
     cache,
     config,
     state,
-  } as const
+  }
 }
 
 await Promise.all([

+ 31 - 12
packages/opencode/src/skill/skill.ts

@@ -4,6 +4,9 @@ import { Instance } from "../project/instance"
 import { NamedError } from "@opencode-ai/util/error"
 import { ConfigMarkdown } from "../config/markdown"
 import { Log } from "../util/log"
+import { Global } from "@/global"
+import { Filesystem } from "@/util/filesystem"
+import { exists } from "fs/promises"
 
 export namespace Skill {
   const log = Log.create({ service: "skill" })
@@ -33,10 +36,9 @@ export namespace Skill {
   )
 
   const OPENCODE_SKILL_GLOB = new Bun.Glob("skill/**/SKILL.md")
-  const CLAUDE_SKILL_GLOB = new Bun.Glob(".claude/skills/**/SKILL.md")
+  const CLAUDE_SKILL_GLOB = new Bun.Glob("skills/**/SKILL.md")
 
   export const state = Instance.state(async () => {
-    const directories = await Config.directories()
     const skills: Record<string, Info> = {}
 
     const addSkill = async (match: string) => {
@@ -64,25 +66,42 @@ export namespace Skill {
       }
     }
 
-    for (const dir of directories) {
-      for await (const match of OPENCODE_SKILL_GLOB.scan({
+    // 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 exists(globalClaude)) {
+      claudeDirs.push(globalClaude)
+    }
+
+    for (const dir of claudeDirs) {
+      for await (const match of CLAUDE_SKILL_GLOB.scan({
         cwd: dir,
         absolute: true,
         onlyFiles: true,
         followSymlinks: true,
+        dot: true,
       })) {
         await addSkill(match)
       }
     }
 
-    for await (const match of CLAUDE_SKILL_GLOB.scan({
-      cwd: Instance.worktree,
-      absolute: true,
-      onlyFiles: true,
-      followSymlinks: true,
-      dot: true,
-    })) {
-      await addSkill(match)
+    // Scan .opencode/skill/ directories
+    for (const dir of await Config.directories()) {
+      for await (const match of OPENCODE_SKILL_GLOB.scan({
+        cwd: dir,
+        absolute: true,
+        onlyFiles: true,
+        followSymlinks: true,
+      })) {
+        await addSkill(match)
+      }
     }
 
     return skills

+ 6 - 0
packages/opencode/test/preload.ts

@@ -11,6 +11,12 @@ await fs.mkdir(dir, { recursive: true })
 afterAll(() => {
   fsSync.rmSync(dir, { recursive: true, force: true })
 })
+// Set test home directory to isolate tests from user's actual home directory
+// This prevents tests from picking up real user configs/skills from ~/.claude/skills
+const testHome = path.join(dir, "home")
+await fs.mkdir(testHome, { recursive: true })
+process.env["OPENCODE_TEST_HOME"] = testHome
+
 process.env["XDG_DATA_HOME"] = path.join(dir, "share")
 process.env["XDG_CACHE_HOME"] = path.join(dir, "cache")
 process.env["XDG_CONFIG_HOME"] = path.join(dir, "config")

+ 79 - 25
packages/opencode/test/skill/skill.test.ts

@@ -1,9 +1,26 @@
 import { test, expect } from "bun:test"
 import { Skill } from "../../src/skill"
-import { SystemPrompt } from "../../src/session/system"
 import { Instance } from "../../src/project/instance"
 import { tmpdir } from "../fixture/fixture"
 import path from "path"
+import fs from "fs/promises"
+
+async function createGlobalSkill(homeDir: string) {
+  const skillDir = path.join(homeDir, ".claude", "skills", "global-test-skill")
+  await fs.mkdir(skillDir, { recursive: true })
+  await Bun.write(
+    path.join(skillDir, "SKILL.md"),
+    `---
+name: global-test-skill
+description: A global skill from ~/.claude/skills for testing.
+---
+
+# Global Test Skill
+
+This skill is loaded from the global home directory.
+`,
+  )
+}
 
 test("discovers skills from .opencode/skill/ directory", async () => {
   await using tmp = await tmpdir({
@@ -30,9 +47,10 @@ Instructions here.
     fn: async () => {
       const skills = await Skill.all()
       expect(skills.length).toBe(1)
-      expect(skills[0].name).toBe("test-skill")
-      expect(skills[0].description).toBe("A test skill for verification.")
-      expect(skills[0].location).toContain("skill/test-skill/SKILL.md")
+      const testSkill = skills.find((s) => s.name === "test-skill")
+      expect(testSkill).toBeDefined()
+      expect(testSkill!.description).toBe("A test skill for verification.")
+      expect(testSkill!.location).toContain("skill/test-skill/SKILL.md")
     },
   })
 })
@@ -41,15 +59,26 @@ test("discovers multiple skills from .opencode/skill/ directory", async () => {
   await using tmp = await tmpdir({
     git: true,
     init: async (dir) => {
-      const skillDir = path.join(dir, ".opencode", "skill", "my-skill")
+      const skillDir1 = path.join(dir, ".opencode", "skill", "skill-one")
+      const skillDir2 = path.join(dir, ".opencode", "skill", "skill-two")
       await Bun.write(
-        path.join(skillDir, "SKILL.md"),
+        path.join(skillDir1, "SKILL.md"),
         `---
-name: my-skill
-description: Another test skill.
+name: skill-one
+description: First test skill.
 ---
 
-# My Skill
+# Skill One
+`,
+      )
+      await Bun.write(
+        path.join(skillDir2, "SKILL.md"),
+        `---
+name: skill-two
+description: Second test skill.
+---
+
+# Skill Two
 `,
       )
     },
@@ -59,8 +88,9 @@ description: Another test skill.
     directory: tmp.path,
     fn: async () => {
       const skills = await Skill.all()
-      expect(skills.length).toBe(1)
-      expect(skills[0].name).toBe("my-skill")
+      expect(skills.length).toBe(2)
+      expect(skills.find((s) => s.name === "skill-one")).toBeDefined()
+      expect(skills.find((s) => s.name === "skill-two")).toBeDefined()
     },
   })
 })
@@ -89,18 +119,6 @@ Just some content without YAML frontmatter.
   })
 })
 
-test("returns empty array when no skills exist", async () => {
-  await using tmp = await tmpdir({ git: true })
-
-  await Instance.provide({
-    directory: tmp.path,
-    fn: async () => {
-      const skills = await Skill.all()
-      expect(skills).toEqual([])
-    },
-  })
-})
-
 test("discovers skills from .claude/skills/ directory", async () => {
   await using tmp = await tmpdir({
     git: true,
@@ -124,8 +142,44 @@ description: A skill in the .claude/skills directory.
     fn: async () => {
       const skills = await Skill.all()
       expect(skills.length).toBe(1)
-      expect(skills[0].name).toBe("claude-skill")
-      expect(skills[0].location).toContain(".claude/skills/claude-skill/SKILL.md")
+      const claudeSkill = skills.find((s) => s.name === "claude-skill")
+      expect(claudeSkill).toBeDefined()
+      expect(claudeSkill!.location).toContain(".claude/skills/claude-skill/SKILL.md")
+    },
+  })
+})
+
+test("discovers global skills from ~/.claude/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 {
+    await createGlobalSkill(tmp.path)
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        const skills = await Skill.all()
+        expect(skills.length).toBe(1)
+        expect(skills[0].name).toBe("global-test-skill")
+        expect(skills[0].description).toBe("A global skill from ~/.claude/skills for testing.")
+        expect(skills[0].location).toContain(".claude/skills/global-test-skill/SKILL.md")
+      },
+    })
+  } finally {
+    process.env.OPENCODE_TEST_HOME = originalHome
+  }
+})
+
+test("returns empty array when no skills exist", async () => {
+  await using tmp = await tmpdir({ git: true })
+
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const skills = await Skill.all()
+      expect(skills).toEqual([])
     },
   })
 })