Merge pull request #2842 from code-yeongyu/fix/opencode-skill-override-gaps
fix: align path discovery with upstream opencode
This commit is contained in:
@@ -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 { parseFrontmatter } from "../../shared/frontmatter"
|
||||||
import { sanitizeModelField } from "../../shared/model-sanitizer"
|
import { sanitizeModelField } from "../../shared/model-sanitizer"
|
||||||
import { isMarkdownFile } from "../../shared/file-utils"
|
import { isMarkdownFile } from "../../shared/file-utils"
|
||||||
import { getClaudeConfigDir, getOpenCodeConfigDir } from "../../shared"
|
import {
|
||||||
|
findProjectOpencodeCommandDirs,
|
||||||
|
getClaudeConfigDir,
|
||||||
|
getOpenCodeCommandDirs,
|
||||||
|
getOpenCodeConfigDir,
|
||||||
|
} from "../../shared"
|
||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
import type { CommandScope, CommandDefinition, CommandFrontmatter, LoadedCommand } from "./types"
|
import type { CommandScope, CommandDefinition, CommandFrontmatter, LoadedCommand } from "./types"
|
||||||
|
|
||||||
@@ -99,9 +104,25 @@ $ARGUMENTS
|
|||||||
return commands
|
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> {
|
function commandsToRecord(commands: LoadedCommand[]): Record<string, CommandDefinition> {
|
||||||
const result: 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
|
const { name: _name, argumentHint: _argumentHint, ...openCodeCompatible } = cmd.definition
|
||||||
result[cmd.name] = openCodeCompatible as CommandDefinition
|
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>> {
|
export async function loadOpencodeGlobalCommands(): Promise<Record<string, CommandDefinition>> {
|
||||||
const configDir = getOpenCodeConfigDir({ binary: "opencode" })
|
const opencodeCommandDirs = getOpenCodeCommandDirs({ binary: "opencode" })
|
||||||
const opencodeCommandsDir = join(configDir, "command")
|
const allCommands = await Promise.all(
|
||||||
const commands = await loadCommandsFromDir(opencodeCommandsDir, "opencode")
|
opencodeCommandDirs.map((commandsDir) => loadCommandsFromDir(commandsDir, "opencode")),
|
||||||
return commandsToRecord(commands)
|
)
|
||||||
|
return commandsToRecord(allCommands.flat())
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadOpencodeProjectCommands(directory?: string): Promise<Record<string, CommandDefinition>> {
|
export async function loadOpencodeProjectCommands(directory?: string): Promise<Record<string, CommandDefinition>> {
|
||||||
const opencodeProjectDir = join(directory ?? process.cwd(), ".opencode", "command")
|
const opencodeProjectDirs = findProjectOpencodeCommandDirs(directory ?? process.cwd())
|
||||||
const commands = await loadCommandsFromDir(opencodeProjectDir, "opencode-project")
|
const allCommands = await Promise.all(
|
||||||
return commandsToRecord(commands)
|
opencodeProjectDirs.map((commandsDir) =>
|
||||||
|
loadCommandsFromDir(commandsDir, "opencode-project"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return commandsToRecord(allCommands.flat())
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadAllCommands(directory?: string): Promise<Record<string, CommandDefinition>> {
|
export async function loadAllCommands(directory?: string): Promise<Record<string, CommandDefinition>> {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
|
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
|
||||||
import { mkdirSync, rmSync, writeFileSync } from "fs"
|
import { mkdirSync, rmSync, writeFileSync } from "fs"
|
||||||
import { join } from "path"
|
import { join } from "path"
|
||||||
import { tmpdir } from "os"
|
import { homedir, tmpdir } from "os"
|
||||||
import { SkillsConfigSchema } from "../../config/schema/skills"
|
import { SkillsConfigSchema } from "../../config/schema/skills"
|
||||||
import { discoverConfigSourceSkills, normalizePathForGlob } from "./config-source-discovery"
|
import { discoverConfigSourceSkills, normalizePathForGlob } from "./config-source-discovery"
|
||||||
|
|
||||||
@@ -69,6 +69,28 @@ describe("config source discovery", () => {
|
|||||||
expect(names).not.toContain("skip/skipped-skill")
|
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", () => {
|
it("normalizes windows separators before glob matching", () => {
|
||||||
// given
|
// given
|
||||||
const windowsPath = "keep\\nested\\SKILL.md"
|
const windowsPath = "keep\\nested\\SKILL.md"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { promises as fs } from "fs"
|
import { promises as fs } from "fs"
|
||||||
|
import { homedir } from "os"
|
||||||
import { dirname, extname, isAbsolute, join, relative } from "path"
|
import { dirname, extname, isAbsolute, join, relative } from "path"
|
||||||
import picomatch from "picomatch"
|
import picomatch from "picomatch"
|
||||||
import type { SkillsConfig } from "../../config/schema"
|
import type { SkillsConfig } from "../../config/schema"
|
||||||
@@ -15,6 +16,14 @@ function isHttpUrl(path: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toAbsolutePath(path: string, configDir: string): string {
|
function toAbsolutePath(path: string, configDir: string): string {
|
||||||
|
if (path === "~") {
|
||||||
|
return homedir()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.startsWith("~/")) {
|
||||||
|
return join(homedir(), path.slice(2))
|
||||||
|
}
|
||||||
|
|
||||||
if (isAbsolute(path)) {
|
if (isAbsolute(path)) {
|
||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -615,5 +615,92 @@ Skill body.
|
|||||||
expect(skill).toBeDefined()
|
expect(skill).toBeDefined()
|
||||||
expect(skill?.scope).toBe("project")
|
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 { getClaudeConfigDir } from "../../shared/claude-config-dir"
|
||||||
import { getOpenCodeConfigDir } from "../../shared/opencode-config-dir"
|
import { getOpenCodeConfigDir } from "../../shared/opencode-config-dir"
|
||||||
import { getOpenCodeSkillDirs } from "../../shared/opencode-command-dirs"
|
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 { CommandDefinition } from "../claude-code-command-loader/types"
|
||||||
import type { LoadedSkill } from "./types"
|
import type { LoadedSkill } from "./types"
|
||||||
import { skillsToCommandDefinitionRecord } from "./skill-definition-record"
|
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>> {
|
export async function loadProjectSkills(directory?: string): Promise<Record<string, CommandDefinition>> {
|
||||||
const projectSkillsDir = join(directory ?? process.cwd(), ".claude", "skills")
|
const projectSkillDirs = findProjectClaudeSkillDirs(directory ?? process.cwd())
|
||||||
const skills = await loadSkillsFromDir({ skillsDir: projectSkillsDir, scope: "project" })
|
const allSkills = await Promise.all(
|
||||||
return skillsToCommandDefinitionRecord(skills)
|
projectSkillDirs.map((skillsDir) => loadSkillsFromDir({ skillsDir, scope: "project" })),
|
||||||
|
)
|
||||||
|
return skillsToCommandDefinitionRecord(deduplicateSkillsByName(allSkills.flat()))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadOpencodeGlobalSkills(): Promise<Record<string, CommandDefinition>> {
|
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>> {
|
export async function loadOpencodeProjectSkills(directory?: string): Promise<Record<string, CommandDefinition>> {
|
||||||
const opencodeProjectDir = join(directory ?? process.cwd(), ".opencode", "skills")
|
const opencodeProjectSkillDirs = findProjectOpencodeSkillDirs(
|
||||||
const skills = await loadSkillsFromDir({ skillsDir: opencodeProjectDir, scope: "opencode-project" })
|
directory ?? process.cwd(),
|
||||||
return skillsToCommandDefinitionRecord(skills)
|
)
|
||||||
|
const allSkills = await Promise.all(
|
||||||
|
opencodeProjectSkillDirs.map((skillsDir) =>
|
||||||
|
loadSkillsFromDir({ skillsDir, scope: "opencode-project" }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return skillsToCommandDefinitionRecord(deduplicateSkillsByName(allSkills.flat()))
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DiscoverSkillsOptions {
|
export interface DiscoverSkillsOptions {
|
||||||
@@ -104,8 +117,11 @@ export async function discoverUserClaudeSkills(): Promise<LoadedSkill[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function discoverProjectClaudeSkills(directory?: string): Promise<LoadedSkill[]> {
|
export async function discoverProjectClaudeSkills(directory?: string): Promise<LoadedSkill[]> {
|
||||||
const projectSkillsDir = join(directory ?? process.cwd(), ".claude", "skills")
|
const projectSkillDirs = findProjectClaudeSkillDirs(directory ?? process.cwd())
|
||||||
return loadSkillsFromDir({ skillsDir: projectSkillsDir, scope: "project" })
|
const allSkills = await Promise.all(
|
||||||
|
projectSkillDirs.map((skillsDir) => loadSkillsFromDir({ skillsDir, scope: "project" })),
|
||||||
|
)
|
||||||
|
return deduplicateSkillsByName(allSkills.flat())
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function discoverOpencodeGlobalSkills(): Promise<LoadedSkill[]> {
|
export async function discoverOpencodeGlobalSkills(): Promise<LoadedSkill[]> {
|
||||||
@@ -117,13 +133,23 @@ export async function discoverOpencodeGlobalSkills(): Promise<LoadedSkill[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function discoverOpencodeProjectSkills(directory?: string): Promise<LoadedSkill[]> {
|
export async function discoverOpencodeProjectSkills(directory?: string): Promise<LoadedSkill[]> {
|
||||||
const opencodeProjectDir = join(directory ?? process.cwd(), ".opencode", "skills")
|
const opencodeProjectSkillDirs = findProjectOpencodeSkillDirs(
|
||||||
return loadSkillsFromDir({ skillsDir: opencodeProjectDir, scope: "opencode-project" })
|
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[]> {
|
export async function discoverProjectAgentsSkills(directory?: string): Promise<LoadedSkill[]> {
|
||||||
const agentsProjectDir = join(directory ?? process.cwd(), ".agents", "skills")
|
const agentsProjectSkillDirs = findProjectAgentsSkillDirs(directory ?? process.cwd())
|
||||||
return loadSkillsFromDir({ skillsDir: agentsProjectDir, scope: "project" })
|
const allSkills = await Promise.all(
|
||||||
|
agentsProjectSkillDirs.map((skillsDir) => loadSkillsFromDir({ skillsDir, scope: "project" })),
|
||||||
|
)
|
||||||
|
return deduplicateSkillsByName(allSkills.flat())
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function discoverGlobalAgentsSkills(): Promise<LoadedSkill[]> {
|
export async function discoverGlobalAgentsSkills(): Promise<LoadedSkill[]> {
|
||||||
|
|||||||
@@ -67,5 +67,6 @@ export * from "./session-directory-resolver"
|
|||||||
export * from "./prompt-tools"
|
export * from "./prompt-tools"
|
||||||
export * from "./internal-initiator-marker"
|
export * from "./internal-initiator-marker"
|
||||||
export * from "./plugin-command-discovery"
|
export * from "./plugin-command-discovery"
|
||||||
|
export * from "./project-discovery-dirs"
|
||||||
export { SessionCategoryRegistry } from "./session-category-registry"
|
export { SessionCategoryRegistry } from "./session-category-registry"
|
||||||
export * from "./plugin-identity"
|
export * from "./plugin-identity"
|
||||||
|
|||||||
@@ -26,8 +26,10 @@ describe("opencode-command-dirs", () => {
|
|||||||
const dirs = getOpenCodeSkillDirs({ binary: "opencode" })
|
const dirs = getOpenCodeSkillDirs({ binary: "opencode" })
|
||||||
|
|
||||||
expect(dirs).toContain("/home/user/.config/opencode/profiles/opus/skills")
|
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).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" })
|
const dirs = getOpenCodeSkillDirs({ binary: "opencode" })
|
||||||
|
|
||||||
expect(dirs).toContain("/home/user/.config/opencode/skills")
|
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 { getOpenCodeCommandDirs } = await import("./opencode-command-dirs")
|
||||||
const dirs = getOpenCodeCommandDirs({ binary: "opencode" })
|
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/profiles/opus/command")
|
||||||
|
expect(dirs).toContain("/home/user/.config/opencode/commands")
|
||||||
expect(dirs).toContain("/home/user/.config/opencode/command")
|
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(
|
return Array.from(
|
||||||
new Set([
|
new Set([
|
||||||
|
join(configDir, "commands"),
|
||||||
join(configDir, "command"),
|
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(
|
return Array.from(
|
||||||
new Set([
|
new Set([
|
||||||
join(configDir, "skills"),
|
join(configDir, "skills"),
|
||||||
...(parentConfigDir ? [join(parentConfigDir, "skills")] : []),
|
join(configDir, "skill"),
|
||||||
|
...(parentConfigDir ? [join(parentConfigDir, "skills"), join(parentConfigDir, "skill")] : []),
|
||||||
])
|
])
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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?.scope).toBe("opencode")
|
||||||
expect(commitCommand?.content).toContain("Use parent opencode commit command.")
|
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 {
|
import {
|
||||||
parseFrontmatter,
|
parseFrontmatter,
|
||||||
sanitizeModelField,
|
sanitizeModelField,
|
||||||
|
findProjectOpencodeCommandDirs,
|
||||||
getOpenCodeCommandDirs,
|
getOpenCodeCommandDirs,
|
||||||
discoverPluginCommandDefinitions,
|
discoverPluginCommandDefinitions,
|
||||||
} from "../../shared"
|
} 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(
|
export function discoverCommandsSync(
|
||||||
directory?: string,
|
directory?: string,
|
||||||
options?: CommandDiscoveryOptions,
|
options?: CommandDiscoveryOptions,
|
||||||
@@ -82,14 +99,16 @@ export function discoverCommandsSync(
|
|||||||
const userCommandsDir = join(getClaudeConfigDir(), "commands")
|
const userCommandsDir = join(getClaudeConfigDir(), "commands")
|
||||||
const projectCommandsDir = join(directory ?? process.cwd(), ".claude", "commands")
|
const projectCommandsDir = join(directory ?? process.cwd(), ".claude", "commands")
|
||||||
const opencodeGlobalDirs = getOpenCodeCommandDirs({ binary: "opencode" })
|
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 userCommands = discoverCommandsFromDir(userCommandsDir, "user")
|
||||||
const opencodeGlobalCommands = opencodeGlobalDirs.flatMap((commandsDir) =>
|
const opencodeGlobalCommands = opencodeGlobalDirs.flatMap((commandsDir) =>
|
||||||
discoverCommandsFromDir(commandsDir, "opencode")
|
discoverCommandsFromDir(commandsDir, "opencode")
|
||||||
)
|
)
|
||||||
const projectCommands = discoverCommandsFromDir(projectCommandsDir, "project")
|
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 pluginCommands = discoverPluginCommands(options)
|
||||||
|
|
||||||
const builtinCommandsMap = loadBuiltinCommands()
|
const builtinCommandsMap = loadBuiltinCommands()
|
||||||
@@ -107,12 +126,12 @@ export function discoverCommandsSync(
|
|||||||
scope: "builtin",
|
scope: "builtin",
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return [
|
return deduplicateCommandInfosByName([
|
||||||
...projectCommands,
|
...projectCommands,
|
||||||
...userCommands,
|
...userCommands,
|
||||||
...opencodeProjectCommands,
|
...opencodeProjectCommands,
|
||||||
...opencodeGlobalCommands,
|
...opencodeGlobalCommands,
|
||||||
...builtinCommands,
|
...builtinCommands,
|
||||||
...pluginCommands,
|
...pluginCommands,
|
||||||
]
|
])
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user