Merge pull request #2842 from code-yeongyu/fix/opencode-skill-override-gaps

fix: align path discovery with upstream opencode
This commit is contained in:
YeonGyu-Kim
2026-03-26 11:54:08 +09:00
committed by GitHub
13 changed files with 509 additions and 31 deletions

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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[]> {

View File

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

View File

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

View File

@@ -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")] : []),
])
)
}

View 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")])
})
})

View 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"],
])
}

View File

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

View File

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