feat: add support for reading skills from .agents/skills directories (#11842)
Co-authored-by: Filip <34747899+neriousy@users.noreply.github.com>
This commit is contained in:
@@ -23,6 +23,8 @@ export namespace Flag {
|
|||||||
OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT")
|
OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT")
|
||||||
export const OPENCODE_DISABLE_CLAUDE_CODE_SKILLS =
|
export const OPENCODE_DISABLE_CLAUDE_CODE_SKILLS =
|
||||||
OPENCODE_DISABLE_CLAUDE_CODE || truthy("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 declare const OPENCODE_DISABLE_PROJECT_CONFIG: boolean
|
||||||
export const OPENCODE_FAKE_VCS = process.env["OPENCODE_FAKE_VCS"]
|
export const OPENCODE_FAKE_VCS = process.env["OPENCODE_FAKE_VCS"]
|
||||||
export declare const OPENCODE_CLIENT: string
|
export declare const OPENCODE_CLIENT: string
|
||||||
|
|||||||
@@ -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 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")
|
const SKILL_GLOB = new Bun.Glob("**/SKILL.md")
|
||||||
|
|
||||||
export const state = Instance.state(async () => {
|
export const state = Instance.state(async () => {
|
||||||
@@ -79,38 +83,37 @@ export namespace Skill {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scan .claude/skills/ directories (project-level)
|
const scanExternal = async (root: string, scope: "global" | "project") => {
|
||||||
const claudeDirs = await Array.fromAsync(
|
return Array.fromAsync(
|
||||||
Filesystem.up({
|
EXTERNAL_SKILL_GLOB.scan({
|
||||||
targets: [".claude"],
|
cwd: root,
|
||||||
start: Instance.directory,
|
absolute: true,
|
||||||
stop: Instance.worktree,
|
onlyFiles: true,
|
||||||
}),
|
followSymlinks: true,
|
||||||
)
|
dot: true,
|
||||||
// Also include global ~/.claude/skills/
|
}),
|
||||||
const globalClaude = `${Global.Path.home}/.claude`
|
)
|
||||||
if (await Filesystem.isDir(globalClaude)) {
|
.then((matches) => Promise.all(matches.map(addSkill)))
|
||||||
claudeDirs.push(globalClaude)
|
.catch((error) => {
|
||||||
|
log.error(`failed to scan ${scope} skills`, { dir: root, error })
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_SKILLS) {
|
// Scan external skill directories (.claude/skills/, .agents/skills/, etc.)
|
||||||
for (const dir of claudeDirs) {
|
// Load global (home) first, then project-level (so project-level overwrites)
|
||||||
const matches = await Array.fromAsync(
|
if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
|
||||||
CLAUDE_SKILL_GLOB.scan({
|
for (const dir of EXTERNAL_DIRS) {
|
||||||
cwd: dir,
|
const root = path.join(Global.Path.home, dir)
|
||||||
absolute: true,
|
if (!(await Filesystem.isDir(root))) continue
|
||||||
onlyFiles: true,
|
await scanExternal(root, "global")
|
||||||
followSymlinks: true,
|
}
|
||||||
dot: true,
|
|
||||||
}),
|
|
||||||
).catch((error) => {
|
|
||||||
log.error("failed .claude directory scan for skills", { dir, error })
|
|
||||||
return []
|
|
||||||
})
|
|
||||||
|
|
||||||
for (const match of matches) {
|
for await (const root of Filesystem.up({
|
||||||
await addSkill(match)
|
targets: EXTERNAL_DIRS,
|
||||||
}
|
start: Instance.directory,
|
||||||
|
stop: Instance.worktree,
|
||||||
|
})) {
|
||||||
|
await scanExternal(root, "project")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user