Merge pull request #2842 from code-yeongyu/fix/opencode-skill-override-gaps
fix: align path discovery with upstream opencode
This commit is contained in:
101
src/features/claude-code-command-loader/loader.test.ts
Normal file
101
src/features/claude-code-command-loader/loader.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { loadOpencodeGlobalCommands, loadOpencodeProjectCommands } from "./loader"
|
||||
|
||||
const TEST_DIR = join(tmpdir(), `claude-code-command-loader-${Date.now()}`)
|
||||
|
||||
function writeCommand(directory: string, name: string, description: string): void {
|
||||
mkdirSync(directory, { recursive: true })
|
||||
writeFileSync(
|
||||
join(directory, `${name}.md`),
|
||||
`---\ndescription: ${description}\n---\nRun ${name}.\n`,
|
||||
)
|
||||
}
|
||||
|
||||
describe("claude-code command loader", () => {
|
||||
let originalOpencodeConfigDir: string | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
mkdirSync(TEST_DIR, { recursive: true })
|
||||
originalOpencodeConfigDir = process.env.OPENCODE_CONFIG_DIR
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (originalOpencodeConfigDir === undefined) {
|
||||
delete process.env.OPENCODE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.OPENCODE_CONFIG_DIR = originalOpencodeConfigDir
|
||||
}
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it("#given a parent .opencode/commands directory #when loadOpencodeProjectCommands is called from child directory #then it loads the ancestor command", async () => {
|
||||
// given
|
||||
const projectDir = join(TEST_DIR, "project")
|
||||
const childDir = join(projectDir, "apps", "desktop")
|
||||
writeCommand(join(projectDir, ".opencode", "commands"), "ancestor", "Ancestor command")
|
||||
|
||||
// when
|
||||
const commands = await loadOpencodeProjectCommands(childDir)
|
||||
|
||||
// then
|
||||
expect(commands.ancestor?.description).toBe("(opencode-project) Ancestor command")
|
||||
})
|
||||
|
||||
it("#given a .opencode/command directory #when loadOpencodeProjectCommands is called #then it loads the singular alias directory", async () => {
|
||||
// given
|
||||
writeCommand(join(TEST_DIR, ".opencode", "command"), "singular", "Singular command")
|
||||
|
||||
// when
|
||||
const commands = await loadOpencodeProjectCommands(TEST_DIR)
|
||||
|
||||
// then
|
||||
expect(commands.singular?.description).toBe("(opencode-project) Singular command")
|
||||
})
|
||||
|
||||
it("#given duplicate project command names across ancestors #when loadOpencodeProjectCommands is called #then the nearest directory wins", async () => {
|
||||
// given
|
||||
const projectRoot = join(TEST_DIR, "project")
|
||||
const childDir = join(projectRoot, "apps", "desktop")
|
||||
const ancestorDir = join(TEST_DIR, ".opencode", "commands")
|
||||
const projectDir = join(projectRoot, ".opencode", "commands")
|
||||
writeCommand(ancestorDir, "duplicate", "Ancestor command")
|
||||
writeCommand(projectDir, "duplicate", "Nearest command")
|
||||
|
||||
// when
|
||||
const commands = await loadOpencodeProjectCommands(childDir)
|
||||
|
||||
// then
|
||||
expect(commands.duplicate?.description).toBe("(opencode-project) Nearest command")
|
||||
})
|
||||
|
||||
it("#given a global .opencode/commands directory #when loadOpencodeGlobalCommands is called #then it loads the plural alias directory", async () => {
|
||||
// given
|
||||
const opencodeConfigDir = join(TEST_DIR, "opencode-config")
|
||||
process.env.OPENCODE_CONFIG_DIR = opencodeConfigDir
|
||||
writeCommand(join(opencodeConfigDir, "commands"), "global-plural", "Global plural command")
|
||||
|
||||
// when
|
||||
const commands = await loadOpencodeGlobalCommands()
|
||||
|
||||
// then
|
||||
expect(commands["global-plural"]?.description).toBe("(opencode) Global plural command")
|
||||
})
|
||||
|
||||
it("#given duplicate global command names across profile and parent dirs #when loadOpencodeGlobalCommands is called #then the profile dir wins", async () => {
|
||||
// given
|
||||
const opencodeRootDir = join(TEST_DIR, "opencode-root")
|
||||
const profileConfigDir = join(opencodeRootDir, "profiles", "codex")
|
||||
process.env.OPENCODE_CONFIG_DIR = profileConfigDir
|
||||
writeCommand(join(opencodeRootDir, "commands"), "duplicate-global", "Parent global command")
|
||||
writeCommand(join(profileConfigDir, "commands"), "duplicate-global", "Profile global command")
|
||||
|
||||
// when
|
||||
const commands = await loadOpencodeGlobalCommands()
|
||||
|
||||
// then
|
||||
expect(commands["duplicate-global"]?.description).toBe("(opencode) Profile global command")
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,12 @@ import { join, basename } from "path"
|
||||
import { parseFrontmatter } from "../../shared/frontmatter"
|
||||
import { sanitizeModelField } from "../../shared/model-sanitizer"
|
||||
import { isMarkdownFile } from "../../shared/file-utils"
|
||||
import { getClaudeConfigDir, getOpenCodeConfigDir } from "../../shared"
|
||||
import {
|
||||
findProjectOpencodeCommandDirs,
|
||||
getClaudeConfigDir,
|
||||
getOpenCodeCommandDirs,
|
||||
getOpenCodeConfigDir,
|
||||
} from "../../shared"
|
||||
import { log } from "../../shared/logger"
|
||||
import type { CommandScope, CommandDefinition, CommandFrontmatter, LoadedCommand } from "./types"
|
||||
|
||||
@@ -99,9 +104,25 @@ $ARGUMENTS
|
||||
return commands
|
||||
}
|
||||
|
||||
function deduplicateLoadedCommandsByName(commands: LoadedCommand[]): LoadedCommand[] {
|
||||
const seen = new Set<string>()
|
||||
const deduplicatedCommands: LoadedCommand[] = []
|
||||
|
||||
for (const command of commands) {
|
||||
if (seen.has(command.name)) {
|
||||
continue
|
||||
}
|
||||
|
||||
seen.add(command.name)
|
||||
deduplicatedCommands.push(command)
|
||||
}
|
||||
|
||||
return deduplicatedCommands
|
||||
}
|
||||
|
||||
function commandsToRecord(commands: LoadedCommand[]): Record<string, CommandDefinition> {
|
||||
const result: Record<string, CommandDefinition> = {}
|
||||
for (const cmd of commands) {
|
||||
for (const cmd of deduplicateLoadedCommandsByName(commands)) {
|
||||
const { name: _name, argumentHint: _argumentHint, ...openCodeCompatible } = cmd.definition
|
||||
result[cmd.name] = openCodeCompatible as CommandDefinition
|
||||
}
|
||||
@@ -121,16 +142,21 @@ export async function loadProjectCommands(directory?: string): Promise<Record<st
|
||||
}
|
||||
|
||||
export async function loadOpencodeGlobalCommands(): Promise<Record<string, CommandDefinition>> {
|
||||
const configDir = getOpenCodeConfigDir({ binary: "opencode" })
|
||||
const opencodeCommandsDir = join(configDir, "command")
|
||||
const commands = await loadCommandsFromDir(opencodeCommandsDir, "opencode")
|
||||
return commandsToRecord(commands)
|
||||
const opencodeCommandDirs = getOpenCodeCommandDirs({ binary: "opencode" })
|
||||
const allCommands = await Promise.all(
|
||||
opencodeCommandDirs.map((commandsDir) => loadCommandsFromDir(commandsDir, "opencode")),
|
||||
)
|
||||
return commandsToRecord(allCommands.flat())
|
||||
}
|
||||
|
||||
export async function loadOpencodeProjectCommands(directory?: string): Promise<Record<string, CommandDefinition>> {
|
||||
const opencodeProjectDir = join(directory ?? process.cwd(), ".opencode", "command")
|
||||
const commands = await loadCommandsFromDir(opencodeProjectDir, "opencode-project")
|
||||
return commandsToRecord(commands)
|
||||
const opencodeProjectDirs = findProjectOpencodeCommandDirs(directory ?? process.cwd())
|
||||
const allCommands = await Promise.all(
|
||||
opencodeProjectDirs.map((commandsDir) =>
|
||||
loadCommandsFromDir(commandsDir, "opencode-project"),
|
||||
),
|
||||
)
|
||||
return commandsToRecord(allCommands.flat())
|
||||
}
|
||||
|
||||
export async function loadAllCommands(directory?: string): Promise<Record<string, CommandDefinition>> {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
|
||||
import { mkdirSync, rmSync, writeFileSync } from "fs"
|
||||
import { join } from "path"
|
||||
import { tmpdir } from "os"
|
||||
import { homedir, tmpdir } from "os"
|
||||
import { SkillsConfigSchema } from "../../config/schema/skills"
|
||||
import { discoverConfigSourceSkills, normalizePathForGlob } from "./config-source-discovery"
|
||||
|
||||
@@ -69,6 +69,28 @@ describe("config source discovery", () => {
|
||||
expect(names).not.toContain("skip/skipped-skill")
|
||||
})
|
||||
|
||||
it("loads skills from ~/ sources path", async () => {
|
||||
// given
|
||||
const homeSkillsDir = join(homedir(), `.omo-config-source-${Date.now()}`)
|
||||
writeSkill(join(homeSkillsDir, "tilde-skill"), "tilde-skill", "Loaded from tilde path")
|
||||
const config = SkillsConfigSchema.parse({
|
||||
sources: [{ path: `~/${homeSkillsDir.split(homedir())[1]?.replace(/^\//, "")}`, recursive: true }],
|
||||
})
|
||||
|
||||
try {
|
||||
// when
|
||||
const skills = await discoverConfigSourceSkills({
|
||||
config,
|
||||
configDir: join(TEST_DIR, "config"),
|
||||
})
|
||||
|
||||
// then
|
||||
expect(skills.some((skill) => skill.name === "tilde-skill")).toBe(true)
|
||||
} finally {
|
||||
rmSync(homeSkillsDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("normalizes windows separators before glob matching", () => {
|
||||
// given
|
||||
const windowsPath = "keep\\nested\\SKILL.md"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { promises as fs } from "fs"
|
||||
import { homedir } from "os"
|
||||
import { dirname, extname, isAbsolute, join, relative } from "path"
|
||||
import picomatch from "picomatch"
|
||||
import type { SkillsConfig } from "../../config/schema"
|
||||
@@ -15,6 +16,14 @@ function isHttpUrl(path: string): boolean {
|
||||
}
|
||||
|
||||
function toAbsolutePath(path: string, configDir: string): string {
|
||||
if (path === "~") {
|
||||
return homedir()
|
||||
}
|
||||
|
||||
if (path.startsWith("~/")) {
|
||||
return join(homedir(), path.slice(2))
|
||||
}
|
||||
|
||||
if (isAbsolute(path)) {
|
||||
return path
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<Record<string, CommandDefinition
|
||||
}
|
||||
|
||||
export async function loadProjectSkills(directory?: string): Promise<Record<string, CommandDefinition>> {
|
||||
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<Record<string, CommandDefinition>> {
|
||||
@@ -30,9 +37,15 @@ export async function loadOpencodeGlobalSkills(): Promise<Record<string, Command
|
||||
}
|
||||
|
||||
export async function loadOpencodeProjectSkills(directory?: string): Promise<Record<string, CommandDefinition>> {
|
||||
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<LoadedSkill[]> {
|
||||
}
|
||||
|
||||
export async function discoverProjectClaudeSkills(directory?: string): Promise<LoadedSkill[]> {
|
||||
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<LoadedSkill[]> {
|
||||
@@ -117,13 +133,23 @@ export async function discoverOpencodeGlobalSkills(): Promise<LoadedSkill[]> {
|
||||
}
|
||||
|
||||
export async function discoverOpencodeProjectSkills(directory?: string): Promise<LoadedSkill[]> {
|
||||
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<LoadedSkill[]> {
|
||||
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<LoadedSkill[]> {
|
||||
|
||||
@@ -67,5 +67,6 @@ export * from "./session-directory-resolver"
|
||||
export * from "./prompt-tools"
|
||||
export * from "./internal-initiator-marker"
|
||||
export * from "./plugin-command-discovery"
|
||||
export * from "./project-discovery-dirs"
|
||||
export { SessionCategoryRegistry } from "./session-category-registry"
|
||||
export * from "./plugin-identity"
|
||||
|
||||
@@ -26,8 +26,10 @@ describe("opencode-command-dirs", () => {
|
||||
const dirs = getOpenCodeSkillDirs({ binary: "opencode" })
|
||||
|
||||
expect(dirs).toContain("/home/user/.config/opencode/profiles/opus/skills")
|
||||
expect(dirs).toContain("/home/user/.config/opencode/profiles/opus/skill")
|
||||
expect(dirs).toContain("/home/user/.config/opencode/skills")
|
||||
expect(dirs).toHaveLength(2)
|
||||
expect(dirs).toContain("/home/user/.config/opencode/skill")
|
||||
expect(dirs).toHaveLength(4)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -41,7 +43,8 @@ describe("opencode-command-dirs", () => {
|
||||
const dirs = getOpenCodeSkillDirs({ binary: "opencode" })
|
||||
|
||||
expect(dirs).toContain("/home/user/.config/opencode/skills")
|
||||
expect(dirs).toHaveLength(1)
|
||||
expect(dirs).toContain("/home/user/.config/opencode/skill")
|
||||
expect(dirs).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -56,9 +59,11 @@ describe("opencode-command-dirs", () => {
|
||||
const { getOpenCodeCommandDirs } = await import("./opencode-command-dirs")
|
||||
const dirs = getOpenCodeCommandDirs({ binary: "opencode" })
|
||||
|
||||
expect(dirs).toContain("/home/user/.config/opencode/profiles/opus/commands")
|
||||
expect(dirs).toContain("/home/user/.config/opencode/profiles/opus/command")
|
||||
expect(dirs).toContain("/home/user/.config/opencode/commands")
|
||||
expect(dirs).toContain("/home/user/.config/opencode/command")
|
||||
expect(dirs).toHaveLength(2)
|
||||
expect(dirs).toHaveLength(4)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,8 +17,9 @@ export function getOpenCodeCommandDirs(options: OpenCodeConfigDirOptions): strin
|
||||
|
||||
return Array.from(
|
||||
new Set([
|
||||
join(configDir, "commands"),
|
||||
join(configDir, "command"),
|
||||
...(parentConfigDir ? [join(parentConfigDir, "command")] : []),
|
||||
...(parentConfigDir ? [join(parentConfigDir, "commands"), join(parentConfigDir, "command")] : []),
|
||||
])
|
||||
)
|
||||
}
|
||||
@@ -30,7 +31,8 @@ export function getOpenCodeSkillDirs(options: OpenCodeConfigDirOptions): string[
|
||||
return Array.from(
|
||||
new Set([
|
||||
join(configDir, "skills"),
|
||||
...(parentConfigDir ? [join(parentConfigDir, "skills")] : []),
|
||||
join(configDir, "skill"),
|
||||
...(parentConfigDir ? [join(parentConfigDir, "skills"), join(parentConfigDir, "skill")] : []),
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
74
src/shared/project-discovery-dirs.test.ts
Normal file
74
src/shared/project-discovery-dirs.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
|
||||
import { mkdirSync, rmSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import {
|
||||
findProjectAgentsSkillDirs,
|
||||
findProjectClaudeSkillDirs,
|
||||
findProjectOpencodeCommandDirs,
|
||||
findProjectOpencodeSkillDirs,
|
||||
} from "./project-discovery-dirs"
|
||||
|
||||
const TEST_DIR = join(tmpdir(), `project-discovery-dirs-${Date.now()}`)
|
||||
|
||||
describe("project-discovery-dirs", () => {
|
||||
beforeEach(() => {
|
||||
mkdirSync(TEST_DIR, { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it("#given nested .opencode skill directories #when finding project opencode skill dirs #then returns nearest-first with aliases", () => {
|
||||
// given
|
||||
const projectDir = join(TEST_DIR, "project")
|
||||
const childDir = join(projectDir, "apps", "cli")
|
||||
mkdirSync(join(projectDir, ".opencode", "skill"), { recursive: true })
|
||||
mkdirSync(join(projectDir, ".opencode", "skills"), { recursive: true })
|
||||
mkdirSync(join(TEST_DIR, ".opencode", "skills"), { recursive: true })
|
||||
|
||||
// when
|
||||
const directories = findProjectOpencodeSkillDirs(childDir)
|
||||
|
||||
// then
|
||||
expect(directories).toEqual([
|
||||
join(projectDir, ".opencode", "skills"),
|
||||
join(projectDir, ".opencode", "skill"),
|
||||
join(TEST_DIR, ".opencode", "skills"),
|
||||
])
|
||||
})
|
||||
|
||||
it("#given nested .opencode command directories #when finding project opencode command dirs #then returns nearest-first with aliases", () => {
|
||||
// given
|
||||
const projectDir = join(TEST_DIR, "project")
|
||||
const childDir = join(projectDir, "packages", "tool")
|
||||
mkdirSync(join(projectDir, ".opencode", "commands"), { recursive: true })
|
||||
mkdirSync(join(TEST_DIR, ".opencode", "command"), { recursive: true })
|
||||
|
||||
// when
|
||||
const directories = findProjectOpencodeCommandDirs(childDir)
|
||||
|
||||
// then
|
||||
expect(directories).toEqual([
|
||||
join(projectDir, ".opencode", "commands"),
|
||||
join(TEST_DIR, ".opencode", "command"),
|
||||
])
|
||||
})
|
||||
|
||||
it("#given ancestor claude and agents skill directories #when finding project compatibility dirs #then discovers both scopes", () => {
|
||||
// given
|
||||
const projectDir = join(TEST_DIR, "project")
|
||||
const childDir = join(projectDir, "src", "nested")
|
||||
mkdirSync(join(projectDir, ".claude", "skills"), { recursive: true })
|
||||
mkdirSync(join(TEST_DIR, ".agents", "skills"), { recursive: true })
|
||||
|
||||
// when
|
||||
const claudeDirectories = findProjectClaudeSkillDirs(childDir)
|
||||
const agentsDirectories = findProjectAgentsSkillDirs(childDir)
|
||||
|
||||
// then
|
||||
expect(claudeDirectories).toEqual([join(projectDir, ".claude", "skills")])
|
||||
expect(agentsDirectories).toEqual([join(TEST_DIR, ".agents", "skills")])
|
||||
})
|
||||
})
|
||||
52
src/shared/project-discovery-dirs.ts
Normal file
52
src/shared/project-discovery-dirs.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { existsSync } from "node:fs"
|
||||
import { dirname, join, resolve } from "node:path"
|
||||
|
||||
function findAncestorDirectories(
|
||||
startDirectory: string,
|
||||
targetPaths: ReadonlyArray<ReadonlyArray<string>>,
|
||||
): string[] {
|
||||
const directories: string[] = []
|
||||
const seen = new Set<string>()
|
||||
let currentDirectory = resolve(startDirectory)
|
||||
|
||||
while (true) {
|
||||
for (const targetPath of targetPaths) {
|
||||
const candidateDirectory = join(currentDirectory, ...targetPath)
|
||||
if (!existsSync(candidateDirectory) || seen.has(candidateDirectory)) {
|
||||
continue
|
||||
}
|
||||
|
||||
seen.add(candidateDirectory)
|
||||
directories.push(candidateDirectory)
|
||||
}
|
||||
|
||||
const parentDirectory = dirname(currentDirectory)
|
||||
if (parentDirectory === currentDirectory) {
|
||||
return directories
|
||||
}
|
||||
|
||||
currentDirectory = parentDirectory
|
||||
}
|
||||
}
|
||||
|
||||
export function findProjectClaudeSkillDirs(startDirectory: string): string[] {
|
||||
return findAncestorDirectories(startDirectory, [[".claude", "skills"]])
|
||||
}
|
||||
|
||||
export function findProjectAgentsSkillDirs(startDirectory: string): string[] {
|
||||
return findAncestorDirectories(startDirectory, [[".agents", "skills"]])
|
||||
}
|
||||
|
||||
export function findProjectOpencodeSkillDirs(startDirectory: string): string[] {
|
||||
return findAncestorDirectories(startDirectory, [
|
||||
[".opencode", "skills"],
|
||||
[".opencode", "skill"],
|
||||
])
|
||||
}
|
||||
|
||||
export function findProjectOpencodeCommandDirs(startDirectory: string): string[] {
|
||||
return findAncestorDirectories(startDirectory, [
|
||||
[".opencode", "commands"],
|
||||
[".opencode", "command"],
|
||||
])
|
||||
}
|
||||
@@ -181,4 +181,58 @@ Use parent opencode commit command.
|
||||
expect(commitCommand?.scope).toBe("opencode")
|
||||
expect(commitCommand?.content).toContain("Use parent opencode commit command.")
|
||||
})
|
||||
|
||||
it("discovers ancestor project opencode commands from plural commands directory", () => {
|
||||
const projectRoot = join(projectDir, "workspace")
|
||||
const childDir = join(projectRoot, "apps", "cli")
|
||||
const commandsDir = join(projectRoot, ".opencode", "commands")
|
||||
|
||||
mkdirSync(childDir, { recursive: true })
|
||||
mkdirSync(commandsDir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(commandsDir, "ancestor.md"),
|
||||
`---
|
||||
description: Discover command from ancestor plural directory
|
||||
---
|
||||
Use ancestor command.
|
||||
`,
|
||||
)
|
||||
|
||||
const commands = discoverCommandsSync(childDir)
|
||||
const ancestorCommand = commands.find((command) => command.name === "ancestor")
|
||||
|
||||
expect(ancestorCommand?.scope).toBe("opencode-project")
|
||||
expect(ancestorCommand?.content).toContain("Use ancestor command.")
|
||||
})
|
||||
|
||||
it("deduplicates same-named opencode commands while keeping the higher-priority alias", () => {
|
||||
const commandsRoot = join(projectDir, ".opencode")
|
||||
const singularDir = join(commandsRoot, "command")
|
||||
const pluralDir = join(commandsRoot, "commands")
|
||||
|
||||
mkdirSync(singularDir, { recursive: true })
|
||||
mkdirSync(pluralDir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(singularDir, "duplicate.md"),
|
||||
`---
|
||||
description: Singular duplicate command
|
||||
---
|
||||
Use singular command.
|
||||
`,
|
||||
)
|
||||
writeFileSync(
|
||||
join(pluralDir, "duplicate.md"),
|
||||
`---
|
||||
description: Plural duplicate command
|
||||
---
|
||||
Use plural command.
|
||||
`,
|
||||
)
|
||||
|
||||
const commands = discoverCommandsSync(projectDir)
|
||||
const duplicates = commands.filter((command) => command.name === "duplicate")
|
||||
|
||||
expect(duplicates).toHaveLength(1)
|
||||
expect(duplicates[0]?.content).toContain("Use plural command.")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { basename, join } from "path"
|
||||
import {
|
||||
parseFrontmatter,
|
||||
sanitizeModelField,
|
||||
findProjectOpencodeCommandDirs,
|
||||
getOpenCodeCommandDirs,
|
||||
discoverPluginCommandDefinitions,
|
||||
} from "../../shared"
|
||||
@@ -75,6 +76,22 @@ function discoverPluginCommands(options?: CommandDiscoveryOptions): CommandInfo[
|
||||
}))
|
||||
}
|
||||
|
||||
function deduplicateCommandInfosByName(commands: CommandInfo[]): CommandInfo[] {
|
||||
const seen = new Set<string>()
|
||||
const deduplicatedCommands: CommandInfo[] = []
|
||||
|
||||
for (const command of commands) {
|
||||
if (seen.has(command.name)) {
|
||||
continue
|
||||
}
|
||||
|
||||
seen.add(command.name)
|
||||
deduplicatedCommands.push(command)
|
||||
}
|
||||
|
||||
return deduplicatedCommands
|
||||
}
|
||||
|
||||
export function discoverCommandsSync(
|
||||
directory?: string,
|
||||
options?: CommandDiscoveryOptions,
|
||||
@@ -82,14 +99,16 @@ export function discoverCommandsSync(
|
||||
const userCommandsDir = join(getClaudeConfigDir(), "commands")
|
||||
const projectCommandsDir = join(directory ?? process.cwd(), ".claude", "commands")
|
||||
const opencodeGlobalDirs = getOpenCodeCommandDirs({ binary: "opencode" })
|
||||
const opencodeProjectDir = join(directory ?? process.cwd(), ".opencode", "command")
|
||||
const opencodeProjectDirs = findProjectOpencodeCommandDirs(directory ?? process.cwd())
|
||||
|
||||
const userCommands = discoverCommandsFromDir(userCommandsDir, "user")
|
||||
const opencodeGlobalCommands = opencodeGlobalDirs.flatMap((commandsDir) =>
|
||||
discoverCommandsFromDir(commandsDir, "opencode")
|
||||
)
|
||||
const projectCommands = discoverCommandsFromDir(projectCommandsDir, "project")
|
||||
const opencodeProjectCommands = discoverCommandsFromDir(opencodeProjectDir, "opencode-project")
|
||||
const opencodeProjectCommands = opencodeProjectDirs.flatMap((commandsDir) =>
|
||||
discoverCommandsFromDir(commandsDir, "opencode-project"),
|
||||
)
|
||||
const pluginCommands = discoverPluginCommands(options)
|
||||
|
||||
const builtinCommandsMap = loadBuiltinCommands()
|
||||
@@ -107,12 +126,12 @@ export function discoverCommandsSync(
|
||||
scope: "builtin",
|
||||
}))
|
||||
|
||||
return [
|
||||
return deduplicateCommandInfosByName([
|
||||
...projectCommands,
|
||||
...userCommands,
|
||||
...opencodeProjectCommands,
|
||||
...opencodeGlobalCommands,
|
||||
...builtinCommands,
|
||||
...pluginCommands,
|
||||
]
|
||||
])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user