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:
Dax
2026-02-03 15:51:54 -05:00
committed by GitHub
parent ee84eb44ee
commit 17e62b050f
3 changed files with 142 additions and 30 deletions

View File

@@ -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

View File

@@ -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")
}
}

View File

@@ -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()
},
})
})