diff --git a/src/features/opencode-skill-loader/loader.test.ts b/src/features/opencode-skill-loader/loader.test.ts index 7aecb801f..c5042e0b1 100644 --- a/src/features/opencode-skill-loader/loader.test.ts +++ b/src/features/opencode-skill-loader/loader.test.ts @@ -615,5 +615,92 @@ Skill body. expect(skill).toBeDefined() expect(skill?.scope).toBe("project") }) + + it("#given a skill in ancestor .agents/skills/ #when discoverProjectAgentsSkills is called from child directory #then it discovers the ancestor skill", async () => { + // given + const skillContent = `--- +name: ancestor-agent-skill +description: A skill from ancestor .agents/skills directory +--- +Skill body. +` + const projectDir = join(TEST_DIR, "project") + const childDir = join(projectDir, "apps", "worker") + const agentsProjectSkillsDir = join(projectDir, ".agents", "skills") + const skillDir = join(agentsProjectSkillsDir, "ancestor-agent-skill") + mkdirSync(childDir, { recursive: true }) + mkdirSync(skillDir, { recursive: true }) + writeFileSync(join(skillDir, "SKILL.md"), skillContent) + + // when + const { discoverProjectAgentsSkills } = await import("./loader") + const skills = await discoverProjectAgentsSkills(childDir) + const skill = skills.find((candidate) => candidate.name === "ancestor-agent-skill") + + // then + expect(skill).toBeDefined() + expect(skill?.scope).toBe("project") + }) + }) + + describe("opencode project skill discovery", () => { + it("#given a skill in ancestor .opencode/skills/ #when discoverOpencodeProjectSkills is called from child directory #then it discovers the ancestor skill", async () => { + // given + const skillContent = `--- +name: ancestor-opencode-skill +description: A skill from ancestor .opencode/skills directory +--- +Skill body. +` + const projectDir = join(TEST_DIR, "project") + const childDir = join(projectDir, "packages", "cli") + const skillsDir = join(projectDir, ".opencode", "skills", "ancestor-opencode-skill") + mkdirSync(childDir, { recursive: true }) + mkdirSync(skillsDir, { recursive: true }) + writeFileSync(join(skillsDir, "SKILL.md"), skillContent) + + // when + const { discoverOpencodeProjectSkills } = await import("./loader") + const skills = await discoverOpencodeProjectSkills(childDir) + const skill = skills.find((candidate) => candidate.name === "ancestor-opencode-skill") + + // then + expect(skill).toBeDefined() + expect(skill?.scope).toBe("opencode-project") + }) + + it("#given a skill in .opencode/skill/ #when discoverOpencodeProjectSkills is called #then it discovers the singular alias directory", async () => { + // given + const skillContent = `--- +name: singular-opencode-skill +description: A skill from .opencode/skill directory +--- +Skill body. +` + const singularSkillDir = join( + TEST_DIR, + ".opencode", + "skill", + "singular-opencode-skill", + ) + mkdirSync(singularSkillDir, { recursive: true }) + writeFileSync(join(singularSkillDir, "SKILL.md"), skillContent) + + // when + const { discoverOpencodeProjectSkills } = await import("./loader") + const originalCwd = process.cwd() + process.chdir(TEST_DIR) + + try { + const skills = await discoverOpencodeProjectSkills() + const skill = skills.find((candidate) => candidate.name === "singular-opencode-skill") + + // then + expect(skill).toBeDefined() + expect(skill?.scope).toBe("opencode-project") + } finally { + process.chdir(originalCwd) + } + }) }) }) diff --git a/src/features/opencode-skill-loader/loader.ts b/src/features/opencode-skill-loader/loader.ts index 205267e3e..e577809fe 100644 --- a/src/features/opencode-skill-loader/loader.ts +++ b/src/features/opencode-skill-loader/loader.ts @@ -3,6 +3,11 @@ import { homedir } from "os" import { getClaudeConfigDir } from "../../shared/claude-config-dir" import { getOpenCodeConfigDir } from "../../shared/opencode-config-dir" import { getOpenCodeSkillDirs } from "../../shared/opencode-command-dirs" +import { + findProjectAgentsSkillDirs, + findProjectClaudeSkillDirs, + findProjectOpencodeSkillDirs, +} from "../../shared/project-discovery-dirs" import type { CommandDefinition } from "../claude-code-command-loader/types" import type { LoadedSkill } from "./types" import { skillsToCommandDefinitionRecord } from "./skill-definition-record" @@ -16,9 +21,11 @@ export async function loadUserSkills(): Promise> { - const projectSkillsDir = join(directory ?? process.cwd(), ".claude", "skills") - const skills = await loadSkillsFromDir({ skillsDir: projectSkillsDir, scope: "project" }) - return skillsToCommandDefinitionRecord(skills) + const projectSkillDirs = findProjectClaudeSkillDirs(directory ?? process.cwd()) + const allSkills = await Promise.all( + projectSkillDirs.map((skillsDir) => loadSkillsFromDir({ skillsDir, scope: "project" })), + ) + return skillsToCommandDefinitionRecord(deduplicateSkillsByName(allSkills.flat())) } export async function loadOpencodeGlobalSkills(): Promise> { @@ -30,9 +37,15 @@ export async function loadOpencodeGlobalSkills(): Promise> { - const opencodeProjectDir = join(directory ?? process.cwd(), ".opencode", "skills") - const skills = await loadSkillsFromDir({ skillsDir: opencodeProjectDir, scope: "opencode-project" }) - return skillsToCommandDefinitionRecord(skills) + const opencodeProjectSkillDirs = findProjectOpencodeSkillDirs( + directory ?? process.cwd(), + ) + const allSkills = await Promise.all( + opencodeProjectSkillDirs.map((skillsDir) => + loadSkillsFromDir({ skillsDir, scope: "opencode-project" }), + ), + ) + return skillsToCommandDefinitionRecord(deduplicateSkillsByName(allSkills.flat())) } export interface DiscoverSkillsOptions { @@ -104,8 +117,11 @@ export async function discoverUserClaudeSkills(): Promise { } export async function discoverProjectClaudeSkills(directory?: string): Promise { - const projectSkillsDir = join(directory ?? process.cwd(), ".claude", "skills") - return loadSkillsFromDir({ skillsDir: projectSkillsDir, scope: "project" }) + const projectSkillDirs = findProjectClaudeSkillDirs(directory ?? process.cwd()) + const allSkills = await Promise.all( + projectSkillDirs.map((skillsDir) => loadSkillsFromDir({ skillsDir, scope: "project" })), + ) + return deduplicateSkillsByName(allSkills.flat()) } export async function discoverOpencodeGlobalSkills(): Promise { @@ -117,13 +133,23 @@ export async function discoverOpencodeGlobalSkills(): Promise { } export async function discoverOpencodeProjectSkills(directory?: string): Promise { - const opencodeProjectDir = join(directory ?? process.cwd(), ".opencode", "skills") - return loadSkillsFromDir({ skillsDir: opencodeProjectDir, scope: "opencode-project" }) + const opencodeProjectSkillDirs = findProjectOpencodeSkillDirs( + directory ?? process.cwd(), + ) + const allSkills = await Promise.all( + opencodeProjectSkillDirs.map((skillsDir) => + loadSkillsFromDir({ skillsDir, scope: "opencode-project" }), + ), + ) + return deduplicateSkillsByName(allSkills.flat()) } export async function discoverProjectAgentsSkills(directory?: string): Promise { - const agentsProjectDir = join(directory ?? process.cwd(), ".agents", "skills") - return loadSkillsFromDir({ skillsDir: agentsProjectDir, scope: "project" }) + const agentsProjectSkillDirs = findProjectAgentsSkillDirs(directory ?? process.cwd()) + const allSkills = await Promise.all( + agentsProjectSkillDirs.map((skillsDir) => loadSkillsFromDir({ skillsDir, scope: "project" })), + ) + return deduplicateSkillsByName(allSkills.flat()) } export async function discoverGlobalAgentsSkills(): Promise {