Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions src/features/opencode-skill-loader/loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from "bun:test"
import { mkdirSync, writeFileSync, rmSync } from "fs"
import { join } from "path"
import { tmpdir } from "os"
import { mock } from "bun:test"

const TEST_DIR = join(tmpdir(), "skill-loader-test-" + Date.now())
const SKILLS_DIR = join(TEST_DIR, ".opencode", "skills")
Expand Down Expand Up @@ -558,6 +559,120 @@ Skill body.
process.env.OPENCODE_CONFIG_DIR = originalOpenCodeConfigDir
}
}
})
})

describe("agents skills discovery (.agents/skills/)", () => {
it("discoverProjectAgentsSkills discovers skills from .agents/skills/ directory", 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("discoverGlobalAgentsSkills discovers skills from home ~/.agents/skills/ directory", async () => {
// given
const tempHome = join(TEST_DIR, "home")
const agentsGlobalSkillsDir = join(tempHome, ".agents", "skills")
const skillDir = join(agentsGlobalSkillsDir, "agent-global-skill")
mkdirSync(skillDir, { recursive: true })
const skillContent = `---
name: agent-global-skill
description: A skill from global .agents/skills directory
---
Skill body.
`
writeFileSync(join(skillDir, "SKILL.md"), skillContent)

// given: mock homedir to return tempHome
mock.module("os", () => ({
homedir: () => tempHome,
tmpdir,
}))

// when
const { discoverGlobalAgentsSkills } = await import("./loader")

try {
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")
} finally {
mock.restore()
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: mock.restore() does not undo mock.module() overrides in Bun, so the mocked os module remains in the module cache after this test. This can leak the fake homedir() into subsequent tests and cause flakiness. Restore the module mock explicitly (e.g., re-mock os with the original exports) instead of relying on mock.restore().

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/features/opencode-skill-loader/loader.test.ts, line 629:

<comment>`mock.restore()` does not undo `mock.module()` overrides in Bun, so the mocked `os` module remains in the module cache after this test. This can leak the fake `homedir()` into subsequent tests and cause flakiness. Restore the module mock explicitly (e.g., re-mock `os` with the original exports) instead of relying on `mock.restore()`.</comment>

<file context>
@@ -558,6 +559,120 @@ Skill body.
+        expect(skill?.scope).toBe("user")
+        expect(skill?.definition.description).toContain("A skill from global .agents/skills directory")
+      } finally {
+        mock.restore()
+      }
+    })
</file context>
Fix with Cubic

}
})

it("discoverSkills includes .agents/skills/ when includeClaudeCodePaths is true", async () => {
// given
const originalCwd = process.cwd()
const skillContentOpencode = `---
name: mixed-skill
description: From .opencode/skills
---
Opencode skill body.
`
const skillContentAgents = `---
name: agents-only-skill
description: From .agents/skills
---
Agents skill body.
`
const opencodeSkillsDir = join(TEST_DIR, ".opencode", "skills")
const agentsSkillsDir = join(TEST_DIR, ".agents", "skills")

const opencodeSkillDir = join(opencodeSkillsDir, "mixed-skill")
mkdirSync(opencodeSkillDir, { recursive: true })
writeFileSync(join(opencodeSkillDir, "SKILL.md"), skillContentOpencode)

const agentsSkillDir = join(agentsSkillsDir, "agents-only-skill")
mkdirSync(agentsSkillDir, { recursive: true })
writeFileSync(join(agentsSkillDir, "SKILL.md"), skillContentAgents)

// when
const { discoverSkills } = await import("./loader")
process.chdir(TEST_DIR)

try {
const skills = await discoverSkills({ includeClaudeCodePaths: true })
const opencodeSkill = skills.find(s => s.name === "mixed-skill")
const agentsSkill = skills.find(s => s.name === "agents-only-skill")

// then
expect(opencodeSkill).toBeDefined()
expect(opencodeSkill?.scope).toBe("opencode-project")
expect(agentsSkill).toBeDefined()
expect(agentsSkill?.scope).toBe("project")
} finally {
process.chdir(originalCwd)
}
})
})
})
41 changes: 35 additions & 6 deletions src/features/opencode-skill-loader/loader.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -37,15 +38,24 @@ export interface DiscoverSkillsOptions {
}

export async function discoverAllSkills(): Promise<LoadedSkill[]> {
const [opencodeProjectSkills, opencodeGlobalSkills, projectSkills, userSkills] = await Promise.all([
const [opencodeProjectSkills, opencodeGlobalSkills, projectSkills, userSkills, agentsProjectSkills, agentsGlobalSkills] = await Promise.all([
discoverOpencodeProjectSkills(),
discoverOpencodeGlobalSkills(),
discoverProjectClaudeSkills(),
discoverUserClaudeSkills(),
discoverProjectAgentsSkills(),
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<LoadedSkill[]> {
Expand All @@ -61,13 +71,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(),
discoverUserClaudeSkills(),
discoverProjectAgentsSkills(),
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<LoadedSkill | undefined> {
Expand Down Expand Up @@ -95,3 +114,13 @@ export async function discoverOpencodeProjectSkills(): Promise<LoadedSkill[]> {
const opencodeProjectDir = join(process.cwd(), ".opencode", "skills")
return loadSkillsFromDir({ skillsDir: opencodeProjectDir, scope: "opencode-project" })
}

export async function discoverProjectAgentsSkills(): Promise<LoadedSkill[]> {
const agentsProjectDir = join(process.cwd(), ".agents", "skills")
return loadSkillsFromDir({ skillsDir: agentsProjectDir, scope: "project" })
}

export async function discoverGlobalAgentsSkills(): Promise<LoadedSkill[]> {
const agentsGlobalDir = join(homedir(), ".agents", "skills")
return loadSkillsFromDir({ skillsDir: agentsGlobalDir, scope: "user" })
}
16 changes: 10 additions & 6 deletions src/plugin/skill-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
discoverProjectClaudeSkills,
discoverOpencodeGlobalSkills,
discoverOpencodeProjectSkills,
discoverProjectAgentsSkills,
discoverGlobalAgentsSkills,
mergeSkills,
} from "../features/opencode-skill-loader"
import { createBuiltinSkills } from "../features/builtin-skills"
Expand Down Expand Up @@ -53,21 +55,23 @@ export async function createSkillContext(args: {
return true
})

const includeClaudeSkills = pluginConfig.claude_code?.skills !== false
const [userSkills, globalSkills, projectSkills, opencodeProjectSkills] =
const includeExternalSkills = pluginConfig.claude_code?.skills !== false
const [userSkills, globalSkills, projectSkills, opencodeProjectSkills, agentsProjectSkills, agentsGlobalSkills] =
await Promise.all([
includeClaudeSkills ? discoverUserClaudeSkills() : Promise.resolve([]),
includeExternalSkills ? discoverUserClaudeSkills() : Promise.resolve([]),
discoverOpencodeGlobalSkills(),
includeClaudeSkills ? discoverProjectClaudeSkills() : Promise.resolve([]),
includeExternalSkills ? discoverProjectClaudeSkills() : Promise.resolve([]),
discoverOpencodeProjectSkills(),
includeExternalSkills ? discoverProjectAgentsSkills() : Promise.resolve([]),
includeExternalSkills ? discoverGlobalAgentsSkills() : Promise.resolve([]),
])

const mergedSkills = mergeSkills(
builtinSkills,
pluginConfig.skills,
userSkills,
[...userSkills, ...agentsGlobalSkills],
globalSkills,
projectSkills,
[...projectSkills, ...agentsProjectSkills],
opencodeProjectSkills,
{ configDir: directory },
)
Expand Down
Loading