From da3e80464d2a93faeb679e4d914bb04bd53ec164 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 26 Mar 2026 11:22:00 +0900 Subject: [PATCH 1/8] fix(shared): add ancestor project discovery helpers Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/shared/index.ts | 1 + src/shared/project-discovery-dirs.test.ts | 74 +++++++++++++++++++++++ src/shared/project-discovery-dirs.ts | 52 ++++++++++++++++ 3 files changed, 127 insertions(+) create mode 100644 src/shared/project-discovery-dirs.test.ts create mode 100644 src/shared/project-discovery-dirs.ts diff --git a/src/shared/index.ts b/src/shared/index.ts index 726b55fa5..5d2615d70 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -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" diff --git a/src/shared/project-discovery-dirs.test.ts b/src/shared/project-discovery-dirs.test.ts new file mode 100644 index 000000000..13dcc8a71 --- /dev/null +++ b/src/shared/project-discovery-dirs.test.ts @@ -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")]) + }) +}) diff --git a/src/shared/project-discovery-dirs.ts b/src/shared/project-discovery-dirs.ts new file mode 100644 index 000000000..007c3c16b --- /dev/null +++ b/src/shared/project-discovery-dirs.ts @@ -0,0 +1,52 @@ +import { existsSync } from "node:fs" +import { dirname, join, resolve } from "node:path" + +function findAncestorDirectories( + startDirectory: string, + targetPaths: ReadonlyArray>, +): string[] { + const directories: string[] = [] + const seen = new Set() + 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"], + ]) +} From 6d688ac0ae13686913c74ee2283be875deeed030 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 26 Mar 2026 11:22:00 +0900 Subject: [PATCH 2/8] fix(shared): support opencode directory aliases Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/shared/opencode-command-dirs.test.ts | 11 ++++++++--- src/shared/opencode-command-dirs.ts | 6 ++++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/shared/opencode-command-dirs.test.ts b/src/shared/opencode-command-dirs.test.ts index 02ffb871f..4b2ac48f0 100644 --- a/src/shared/opencode-command-dirs.test.ts +++ b/src/shared/opencode-command-dirs.test.ts @@ -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) }) }) }) diff --git a/src/shared/opencode-command-dirs.ts b/src/shared/opencode-command-dirs.ts index 456085730..4431370ad 100644 --- a/src/shared/opencode-command-dirs.ts +++ b/src/shared/opencode-command-dirs.ts @@ -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")] : []), ]) ) } From 82425008561fedb32485217ea9559794737eb7ba Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 26 Mar 2026 11:22:00 +0900 Subject: [PATCH 3/8] fix(skills): expand tilde config source paths Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../config-source-discovery.test.ts | 24 ++++++++++++++++++- .../config-source-discovery.ts | 9 +++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/features/opencode-skill-loader/config-source-discovery.test.ts b/src/features/opencode-skill-loader/config-source-discovery.test.ts index d10303ce0..091118ce6 100644 --- a/src/features/opencode-skill-loader/config-source-discovery.test.ts +++ b/src/features/opencode-skill-loader/config-source-discovery.test.ts @@ -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" diff --git a/src/features/opencode-skill-loader/config-source-discovery.ts b/src/features/opencode-skill-loader/config-source-discovery.ts index df3ee653e..b290c8b30 100644 --- a/src/features/opencode-skill-loader/config-source-discovery.ts +++ b/src/features/opencode-skill-loader/config-source-discovery.ts @@ -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 } From b5cb50b561ddf08da68dddba6157c11c936f9423 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 26 Mar 2026 11:22:00 +0900 Subject: [PATCH 4/8] fix(skills): discover ancestor project skill directories Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../opencode-skill-loader/loader.test.ts | 87 +++++++++++++++++++ src/features/opencode-skill-loader/loader.ts | 50 ++++++++--- 2 files changed, 125 insertions(+), 12 deletions(-) diff --git a/src/features/opencode-skill-loader/loader.test.ts b/src/features/opencode-skill-loader/loader.test.ts index 7aecb801f..c5042e0b1 100644 --- a/src/features/opencode-skill-loader/loader.test.ts +++ b/src/features/opencode-skill-loader/loader.test.ts @@ -615,5 +615,92 @@ Skill body. expect(skill).toBeDefined() expect(skill?.scope).toBe("project") }) + + it("#given a skill in ancestor .agents/skills/ #when discoverProjectAgentsSkills is called from child directory #then it discovers the ancestor skill", async () => { + // given + const skillContent = `--- +name: ancestor-agent-skill +description: A skill from ancestor .agents/skills directory +--- +Skill body. +` + const projectDir = join(TEST_DIR, "project") + const childDir = join(projectDir, "apps", "worker") + const agentsProjectSkillsDir = join(projectDir, ".agents", "skills") + const skillDir = join(agentsProjectSkillsDir, "ancestor-agent-skill") + mkdirSync(childDir, { recursive: true }) + mkdirSync(skillDir, { recursive: true }) + writeFileSync(join(skillDir, "SKILL.md"), skillContent) + + // when + const { discoverProjectAgentsSkills } = await import("./loader") + const skills = await discoverProjectAgentsSkills(childDir) + const skill = skills.find((candidate) => candidate.name === "ancestor-agent-skill") + + // then + expect(skill).toBeDefined() + expect(skill?.scope).toBe("project") + }) + }) + + describe("opencode project skill discovery", () => { + it("#given a skill in ancestor .opencode/skills/ #when discoverOpencodeProjectSkills is called from child directory #then it discovers the ancestor skill", async () => { + // given + const skillContent = `--- +name: ancestor-opencode-skill +description: A skill from ancestor .opencode/skills directory +--- +Skill body. +` + const projectDir = join(TEST_DIR, "project") + const childDir = join(projectDir, "packages", "cli") + const skillsDir = join(projectDir, ".opencode", "skills", "ancestor-opencode-skill") + mkdirSync(childDir, { recursive: true }) + mkdirSync(skillsDir, { recursive: true }) + writeFileSync(join(skillsDir, "SKILL.md"), skillContent) + + // when + const { discoverOpencodeProjectSkills } = await import("./loader") + const skills = await discoverOpencodeProjectSkills(childDir) + const skill = skills.find((candidate) => candidate.name === "ancestor-opencode-skill") + + // then + expect(skill).toBeDefined() + expect(skill?.scope).toBe("opencode-project") + }) + + it("#given a skill in .opencode/skill/ #when discoverOpencodeProjectSkills is called #then it discovers the singular alias directory", async () => { + // given + const skillContent = `--- +name: singular-opencode-skill +description: A skill from .opencode/skill directory +--- +Skill body. +` + const singularSkillDir = join( + TEST_DIR, + ".opencode", + "skill", + "singular-opencode-skill", + ) + mkdirSync(singularSkillDir, { recursive: true }) + writeFileSync(join(singularSkillDir, "SKILL.md"), skillContent) + + // when + const { discoverOpencodeProjectSkills } = await import("./loader") + const originalCwd = process.cwd() + process.chdir(TEST_DIR) + + try { + const skills = await discoverOpencodeProjectSkills() + const skill = skills.find((candidate) => candidate.name === "singular-opencode-skill") + + // then + expect(skill).toBeDefined() + expect(skill?.scope).toBe("opencode-project") + } finally { + process.chdir(originalCwd) + } + }) }) }) diff --git a/src/features/opencode-skill-loader/loader.ts b/src/features/opencode-skill-loader/loader.ts index 205267e3e..e577809fe 100644 --- a/src/features/opencode-skill-loader/loader.ts +++ b/src/features/opencode-skill-loader/loader.ts @@ -3,6 +3,11 @@ import { homedir } from "os" import { getClaudeConfigDir } from "../../shared/claude-config-dir" import { getOpenCodeConfigDir } from "../../shared/opencode-config-dir" import { getOpenCodeSkillDirs } from "../../shared/opencode-command-dirs" +import { + findProjectAgentsSkillDirs, + findProjectClaudeSkillDirs, + findProjectOpencodeSkillDirs, +} from "../../shared/project-discovery-dirs" import type { CommandDefinition } from "../claude-code-command-loader/types" import type { LoadedSkill } from "./types" import { skillsToCommandDefinitionRecord } from "./skill-definition-record" @@ -16,9 +21,11 @@ export async function loadUserSkills(): Promise> { - const projectSkillsDir = join(directory ?? process.cwd(), ".claude", "skills") - const skills = await loadSkillsFromDir({ skillsDir: projectSkillsDir, scope: "project" }) - return skillsToCommandDefinitionRecord(skills) + const projectSkillDirs = findProjectClaudeSkillDirs(directory ?? process.cwd()) + const allSkills = await Promise.all( + projectSkillDirs.map((skillsDir) => loadSkillsFromDir({ skillsDir, scope: "project" })), + ) + return skillsToCommandDefinitionRecord(deduplicateSkillsByName(allSkills.flat())) } export async function loadOpencodeGlobalSkills(): Promise> { @@ -30,9 +37,15 @@ export async function loadOpencodeGlobalSkills(): Promise> { - const opencodeProjectDir = join(directory ?? process.cwd(), ".opencode", "skills") - const skills = await loadSkillsFromDir({ skillsDir: opencodeProjectDir, scope: "opencode-project" }) - return skillsToCommandDefinitionRecord(skills) + const opencodeProjectSkillDirs = findProjectOpencodeSkillDirs( + directory ?? process.cwd(), + ) + const allSkills = await Promise.all( + opencodeProjectSkillDirs.map((skillsDir) => + loadSkillsFromDir({ skillsDir, scope: "opencode-project" }), + ), + ) + return skillsToCommandDefinitionRecord(deduplicateSkillsByName(allSkills.flat())) } export interface DiscoverSkillsOptions { @@ -104,8 +117,11 @@ export async function discoverUserClaudeSkills(): Promise { } export async function discoverProjectClaudeSkills(directory?: string): Promise { - const projectSkillsDir = join(directory ?? process.cwd(), ".claude", "skills") - return loadSkillsFromDir({ skillsDir: projectSkillsDir, scope: "project" }) + const projectSkillDirs = findProjectClaudeSkillDirs(directory ?? process.cwd()) + const allSkills = await Promise.all( + projectSkillDirs.map((skillsDir) => loadSkillsFromDir({ skillsDir, scope: "project" })), + ) + return deduplicateSkillsByName(allSkills.flat()) } export async function discoverOpencodeGlobalSkills(): Promise { @@ -117,13 +133,23 @@ export async function discoverOpencodeGlobalSkills(): Promise { } export async function discoverOpencodeProjectSkills(directory?: string): Promise { - const opencodeProjectDir = join(directory ?? process.cwd(), ".opencode", "skills") - return loadSkillsFromDir({ skillsDir: opencodeProjectDir, scope: "opencode-project" }) + const opencodeProjectSkillDirs = findProjectOpencodeSkillDirs( + directory ?? process.cwd(), + ) + const allSkills = await Promise.all( + opencodeProjectSkillDirs.map((skillsDir) => + loadSkillsFromDir({ skillsDir, scope: "opencode-project" }), + ), + ) + return deduplicateSkillsByName(allSkills.flat()) } export async function discoverProjectAgentsSkills(directory?: string): Promise { - const agentsProjectDir = join(directory ?? process.cwd(), ".agents", "skills") - return loadSkillsFromDir({ skillsDir: agentsProjectDir, scope: "project" }) + const agentsProjectSkillDirs = findProjectAgentsSkillDirs(directory ?? process.cwd()) + const allSkills = await Promise.all( + agentsProjectSkillDirs.map((skillsDir) => loadSkillsFromDir({ skillsDir, scope: "project" })), + ) + return deduplicateSkillsByName(allSkills.flat()) } export async function discoverGlobalAgentsSkills(): Promise { From 28bcab066e9d22bf4371052ea2d36bda4d2d4376 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 26 Mar 2026 11:22:00 +0900 Subject: [PATCH 5/8] fix(commands): load opencode command dirs from aliases Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../claude-code-command-loader/loader.test.ts | 70 +++++++++++++++++++ .../claude-code-command-loader/loader.ts | 26 ++++--- 2 files changed, 88 insertions(+), 8 deletions(-) create mode 100644 src/features/claude-code-command-loader/loader.test.ts diff --git a/src/features/claude-code-command-loader/loader.test.ts b/src/features/claude-code-command-loader/loader.test.ts new file mode 100644 index 000000000..490a31f4a --- /dev/null +++ b/src/features/claude-code-command-loader/loader.test.ts @@ -0,0 +1,70 @@ +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 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") + }) +}) diff --git a/src/features/claude-code-command-loader/loader.ts b/src/features/claude-code-command-loader/loader.ts index adf2cc4c1..152bf21f9 100644 --- a/src/features/claude-code-command-loader/loader.ts +++ b/src/features/claude-code-command-loader/loader.ts @@ -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" @@ -121,16 +126,21 @@ export async function loadProjectCommands(directory?: string): Promise> { - 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> { - 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> { From b6ee7f09b1a35a219dd8b7782fda49e1896b5d0b Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 26 Mar 2026 11:22:00 +0900 Subject: [PATCH 6/8] fix(slashcommand): discover ancestor opencode commands Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../slashcommand/command-discovery.test.ts | 23 +++++++++++++++++++ src/tools/slashcommand/command-discovery.ts | 7 ++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/tools/slashcommand/command-discovery.test.ts b/src/tools/slashcommand/command-discovery.test.ts index 36e59ba0c..1ebf65c97 100644 --- a/src/tools/slashcommand/command-discovery.test.ts +++ b/src/tools/slashcommand/command-discovery.test.ts @@ -181,4 +181,27 @@ 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.") + }) }) diff --git a/src/tools/slashcommand/command-discovery.ts b/src/tools/slashcommand/command-discovery.ts index 7fb96c322..ad8e23130 100644 --- a/src/tools/slashcommand/command-discovery.ts +++ b/src/tools/slashcommand/command-discovery.ts @@ -3,6 +3,7 @@ import { basename, join } from "path" import { parseFrontmatter, sanitizeModelField, + findProjectOpencodeCommandDirs, getOpenCodeCommandDirs, discoverPluginCommandDefinitions, } from "../../shared" @@ -82,14 +83,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() From 9fde3708389ff4e5fbd5fa7d99313b9d32107385 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 26 Mar 2026 11:36:59 +0900 Subject: [PATCH 7/8] fix(commands): preserve nearest opencode command precedence Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../claude-code-command-loader/loader.test.ts | 31 +++++++++++++++++++ .../claude-code-command-loader/loader.ts | 18 ++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/features/claude-code-command-loader/loader.test.ts b/src/features/claude-code-command-loader/loader.test.ts index 490a31f4a..dde2096c0 100644 --- a/src/features/claude-code-command-loader/loader.test.ts +++ b/src/features/claude-code-command-loader/loader.test.ts @@ -55,6 +55,22 @@ describe("claude-code command loader", () => { 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") @@ -67,4 +83,19 @@ describe("claude-code command loader", () => { // 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") + }) }) diff --git a/src/features/claude-code-command-loader/loader.ts b/src/features/claude-code-command-loader/loader.ts index 152bf21f9..aee1f6e59 100644 --- a/src/features/claude-code-command-loader/loader.ts +++ b/src/features/claude-code-command-loader/loader.ts @@ -104,9 +104,25 @@ $ARGUMENTS return commands } +function deduplicateLoadedCommandsByName(commands: LoadedCommand[]): LoadedCommand[] { + const seen = new Set() + 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 { const result: Record = {} - 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 } From 94b4a4f850ef5ae9ff7a069b44d2f01989218924 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 26 Mar 2026 11:36:59 +0900 Subject: [PATCH 8/8] fix(slashcommand): deduplicate opencode command aliases Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../slashcommand/command-discovery.test.ts | 31 +++++++++++++++++++ src/tools/slashcommand/command-discovery.ts | 20 ++++++++++-- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/tools/slashcommand/command-discovery.test.ts b/src/tools/slashcommand/command-discovery.test.ts index 1ebf65c97..232515a33 100644 --- a/src/tools/slashcommand/command-discovery.test.ts +++ b/src/tools/slashcommand/command-discovery.test.ts @@ -204,4 +204,35 @@ Use ancestor command. 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.") + }) }) diff --git a/src/tools/slashcommand/command-discovery.ts b/src/tools/slashcommand/command-discovery.ts index ad8e23130..7567e74dd 100644 --- a/src/tools/slashcommand/command-discovery.ts +++ b/src/tools/slashcommand/command-discovery.ts @@ -76,6 +76,22 @@ function discoverPluginCommands(options?: CommandDiscoveryOptions): CommandInfo[ })) } +function deduplicateCommandInfosByName(commands: CommandInfo[]): CommandInfo[] { + const seen = new Set() + 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, @@ -110,12 +126,12 @@ export function discoverCommandsSync( scope: "builtin", })) - return [ + return deduplicateCommandInfosByName([ ...projectCommands, ...userCommands, ...opencodeProjectCommands, ...opencodeGlobalCommands, ...builtinCommands, ...pluginCommands, - ] + ]) }