diff --git a/src/features/claude-code-command-loader/loader.test.ts b/src/features/claude-code-command-loader/loader.test.ts index dde2096c0..be7928d3f 100644 --- a/src/features/claude-code-command-loader/loader.test.ts +++ b/src/features/claude-code-command-loader/loader.test.ts @@ -1,3 +1,4 @@ +import { execFileSync } from "node:child_process" import { afterEach, beforeEach, describe, expect, it } from "bun:test" import { mkdirSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" @@ -98,4 +99,27 @@ describe("claude-code command loader", () => { // then expect(commands["duplicate-global"]?.description).toBe("(opencode) Profile global command") }) + + it("#given nested project opencode commands in a worktree #when loadOpencodeProjectCommands is called #then it preserves slash names and stops at the worktree root", async () => { + // given + const repositoryDir = join(TEST_DIR, "repo") + const nestedDirectory = join(repositoryDir, "packages", "app", "src") + mkdirSync(nestedDirectory, { recursive: true }) + execFileSync("git", ["init"], { + cwd: repositoryDir, + stdio: ["ignore", "ignore", "ignore"], + }) + writeCommand(join(repositoryDir, ".opencode", "commands", "deploy"), "staging", "Deploy staging") + writeCommand(join(repositoryDir, ".opencode", "command"), "release", "Release command") + writeCommand(join(TEST_DIR, ".opencode", "commands"), "outside", "Outside command") + + // when + const commands = await loadOpencodeProjectCommands(nestedDirectory) + + // then + expect(commands["deploy/staging"]?.description).toBe("(opencode-project) Deploy staging") + expect(commands.release?.description).toBe("(opencode-project) Release command") + expect(commands.outside).toBeUndefined() + expect(commands["deploy:staging"]).toBeUndefined() + }) }) diff --git a/src/features/claude-code-command-loader/loader.ts b/src/features/claude-code-command-loader/loader.ts index aee1f6e59..b052f56bd 100644 --- a/src/features/claude-code-command-loader/loader.ts +++ b/src/features/claude-code-command-loader/loader.ts @@ -7,7 +7,6 @@ import { findProjectOpencodeCommandDirs, getClaudeConfigDir, getOpenCodeCommandDirs, - getOpenCodeConfigDir, } from "../../shared" import { log } from "../../shared/logger" import type { CommandScope, CommandDefinition, CommandFrontmatter, LoadedCommand } from "./types" @@ -51,7 +50,7 @@ async function loadCommandsFromDir( if (entry.isDirectory()) { if (entry.name.startsWith(".")) continue const subDirPath = join(commandsDir, entry.name) - const subPrefix = prefix ? `${prefix}:${entry.name}` : entry.name + const subPrefix = prefix ? `${prefix}/${entry.name}` : entry.name const subCommands = await loadCommandsFromDir(subDirPath, scope, visited, subPrefix) commands.push(...subCommands) continue @@ -61,7 +60,7 @@ async function loadCommandsFromDir( const commandPath = join(commandsDir, entry.name) const baseCommandName = basename(entry.name, ".md") - const commandName = prefix ? `${prefix}:${baseCommandName}` : baseCommandName + const commandName = prefix ? `${prefix}/${baseCommandName}` : baseCommandName try { const content = await fs.readFile(commandPath, "utf-8") diff --git a/src/features/opencode-skill-loader/project-skill-discovery.test.ts b/src/features/opencode-skill-loader/project-skill-discovery.test.ts new file mode 100644 index 000000000..0d34da8ea --- /dev/null +++ b/src/features/opencode-skill-loader/project-skill-discovery.test.ts @@ -0,0 +1,86 @@ +import { execFileSync } from "node:child_process" +import { afterEach, beforeEach, describe, expect, it } from "bun:test" +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { + discoverOpencodeProjectSkills, + discoverProjectAgentsSkills, + discoverProjectClaudeSkills, +} from "./loader" + +function writeSkill(directory: string, name: string, description: string): void { + mkdirSync(directory, { recursive: true }) + writeFileSync( + join(directory, "SKILL.md"), + `---\nname: ${name}\ndescription: ${description}\n---\nBody\n`, + ) +} + +describe("project skill discovery", () => { + let tempDir = "" + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "omo-project-skill-discovery-")) + }) + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }) + }) + + it("discovers ancestor project skill directories up to the worktree root", async () => { + // given + const repositoryDir = join(tempDir, "repo") + const nestedDirectory = join(repositoryDir, "packages", "app", "src") + + mkdirSync(nestedDirectory, { recursive: true }) + execFileSync("git", ["init"], { + cwd: repositoryDir, + stdio: ["ignore", "ignore", "ignore"], + }) + + writeSkill( + join(repositoryDir, ".claude", "skills", "repo-claude"), + "repo-claude", + "Discovered from the repository root", + ) + writeSkill( + join(repositoryDir, ".agents", "skills", "repo-agents"), + "repo-agents", + "Discovered from the repository root", + ) + writeSkill( + join(repositoryDir, ".opencode", "skill", "repo-opencode"), + "repo-opencode", + "Discovered from the repository root", + ) + + writeSkill( + join(tempDir, ".claude", "skills", "outside-claude"), + "outside-claude", + "Should stay outside the worktree", + ) + writeSkill( + join(tempDir, ".agents", "skills", "outside-agents"), + "outside-agents", + "Should stay outside the worktree", + ) + writeSkill( + join(tempDir, ".opencode", "skills", "outside-opencode"), + "outside-opencode", + "Should stay outside the worktree", + ) + + // when + const [claudeSkills, agentSkills, opencodeSkills] = await Promise.all([ + discoverProjectClaudeSkills(nestedDirectory), + discoverProjectAgentsSkills(nestedDirectory), + discoverOpencodeProjectSkills(nestedDirectory), + ]) + + // then + expect(claudeSkills.map(skill => skill.name)).toEqual(["repo-claude"]) + expect(agentSkills.map(skill => skill.name)).toEqual(["repo-agents"]) + expect(opencodeSkills.map(skill => skill.name)).toEqual(["repo-opencode"]) + }) +}) diff --git a/src/plugin-handlers/agent-config-handler-agents-skills.test.ts b/src/plugin-handlers/agent-config-handler-agents-skills.test.ts new file mode 100644 index 000000000..593f22d9e --- /dev/null +++ b/src/plugin-handlers/agent-config-handler-agents-skills.test.ts @@ -0,0 +1,125 @@ +import type { AgentConfig } from "@opencode-ai/sdk" +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" +import * as agents from "../agents" +import * as shared from "../shared" +import * as sisyphusJunior from "../agents/sisyphus-junior" +import type { OhMyOpenCodeConfig } from "../config" +import * as skillLoader from "../features/opencode-skill-loader" +import { applyAgentConfig } from "./agent-config-handler" +import type { PluginComponents } from "./plugin-components-loader" + +function createPluginComponents(): PluginComponents { + return { + commands: {}, + skills: {}, + agents: {}, + mcpServers: {}, + hooksConfigs: [], + plugins: [], + errors: [], + } +} + +function createPluginConfig(): OhMyOpenCodeConfig { + return { + sisyphus_agent: { + planner_enabled: false, + }, + } +} + +describe("applyAgentConfig .agents skills", () => { + let createBuiltinAgentsSpy: ReturnType + let createSisyphusJuniorAgentSpy: ReturnType + let discoverConfigSourceSkillsSpy: ReturnType + let discoverUserClaudeSkillsSpy: ReturnType + let discoverProjectClaudeSkillsSpy: ReturnType + let discoverOpencodeGlobalSkillsSpy: ReturnType + let discoverOpencodeProjectSkillsSpy: ReturnType + let discoverProjectAgentsSkillsSpy: ReturnType + let discoverGlobalAgentsSkillsSpy: ReturnType + let logSpy: ReturnType + + beforeEach(() => { + createBuiltinAgentsSpy = spyOn(agents, "createBuiltinAgents").mockResolvedValue({ + sisyphus: { name: "sisyphus", prompt: "builtin", mode: "primary" } satisfies AgentConfig, + }) + createSisyphusJuniorAgentSpy = spyOn( + sisyphusJunior, + "createSisyphusJuniorAgentWithOverrides", + ).mockReturnValue({ + name: "sisyphus-junior", + prompt: "junior", + mode: "all", + } satisfies AgentConfig) + discoverConfigSourceSkillsSpy = spyOn(skillLoader, "discoverConfigSourceSkills").mockResolvedValue([]) + discoverUserClaudeSkillsSpy = spyOn(skillLoader, "discoverUserClaudeSkills").mockResolvedValue([]) + discoverProjectClaudeSkillsSpy = spyOn(skillLoader, "discoverProjectClaudeSkills").mockResolvedValue([]) + discoverOpencodeGlobalSkillsSpy = spyOn(skillLoader, "discoverOpencodeGlobalSkills").mockResolvedValue([]) + discoverOpencodeProjectSkillsSpy = spyOn(skillLoader, "discoverOpencodeProjectSkills").mockResolvedValue([]) + discoverProjectAgentsSkillsSpy = spyOn(skillLoader, "discoverProjectAgentsSkills").mockResolvedValue([]) + discoverGlobalAgentsSkillsSpy = spyOn(skillLoader, "discoverGlobalAgentsSkills").mockResolvedValue([]) + logSpy = spyOn(shared, "log").mockImplementation(() => {}) + }) + + afterEach(() => { + createBuiltinAgentsSpy.mockRestore() + createSisyphusJuniorAgentSpy.mockRestore() + discoverConfigSourceSkillsSpy.mockRestore() + discoverUserClaudeSkillsSpy.mockRestore() + discoverProjectClaudeSkillsSpy.mockRestore() + discoverOpencodeGlobalSkillsSpy.mockRestore() + discoverOpencodeProjectSkillsSpy.mockRestore() + discoverProjectAgentsSkillsSpy.mockRestore() + discoverGlobalAgentsSkillsSpy.mockRestore() + logSpy.mockRestore() + }) + + test("calls .agents skill discovery during agent configuration", async () => { + // given + const directory = "/tmp/project" + + // when + await applyAgentConfig({ + config: { model: "anthropic/claude-opus-4-6", agent: {} }, + pluginConfig: createPluginConfig(), + ctx: { directory }, + pluginComponents: createPluginComponents(), + }) + + // then + expect(discoverProjectAgentsSkillsSpy).toHaveBeenCalledWith(directory) + expect(discoverGlobalAgentsSkillsSpy).toHaveBeenCalled() + }) + + test("passes discovered .agents skills to builtin agent creation", async () => { + // given + discoverProjectAgentsSkillsSpy.mockResolvedValue([ + { + name: "project-agent-skill", + definition: { name: "project-agent-skill", template: "project-template" }, + scope: "project", + }, + ]) + discoverGlobalAgentsSkillsSpy.mockResolvedValue([ + { + name: "global-agent-skill", + definition: { name: "global-agent-skill", template: "global-template" }, + scope: "user", + }, + ]) + + // when + await applyAgentConfig({ + config: { model: "anthropic/claude-opus-4-6", agent: {} }, + pluginConfig: createPluginConfig(), + ctx: { directory: "/tmp/project" }, + pluginComponents: createPluginComponents(), + }) + + // then + const discoveredSkills = createBuiltinAgentsSpy.mock.calls[0]?.[6] as Array<{ name: string }> + expect(discoveredSkills.map(skill => skill.name)).toContain("project-agent-skill") + expect(discoveredSkills.map(skill => skill.name)).toContain("global-agent-skill") + }) +}) diff --git a/src/shared/index.ts b/src/shared/index.ts index 5d2615d70..ee690e816 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -62,11 +62,11 @@ export * from "./truncate-description" export * from "./opencode-storage-paths" export * from "./opencode-message-dir" export * from "./opencode-command-dirs" +export * from "./project-discovery-dirs" export * from "./normalize-sdk-response" 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/opencode-command-dirs.test.ts b/src/shared/opencode-command-dirs.test.ts index 4b2ac48f0..e75b0284c 100644 --- a/src/shared/opencode-command-dirs.test.ts +++ b/src/shared/opencode-command-dirs.test.ts @@ -27,8 +27,8 @@ describe("opencode-command-dirs", () => { 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/skill") + expect(dirs).toContain("/home/user/.config/opencode/skills") expect(dirs).toHaveLength(4) }) }) diff --git a/src/shared/opencode-command-dirs.ts b/src/shared/opencode-command-dirs.ts index 4431370ad..34f2ecce1 100644 --- a/src/shared/opencode-command-dirs.ts +++ b/src/shared/opencode-command-dirs.ts @@ -14,7 +14,6 @@ function getParentOpencodeConfigDir(configDir: string): string | null { export function getOpenCodeCommandDirs(options: OpenCodeConfigDirOptions): string[] { const configDir = getOpenCodeConfigDir(options) const parentConfigDir = getParentOpencodeConfigDir(configDir) - return Array.from( new Set([ join(configDir, "commands"), @@ -27,7 +26,6 @@ export function getOpenCodeCommandDirs(options: OpenCodeConfigDirOptions): strin export function getOpenCodeSkillDirs(options: OpenCodeConfigDirOptions): string[] { const configDir = getOpenCodeConfigDir(options) const parentConfigDir = getParentOpencodeConfigDir(configDir) - return Array.from( new Set([ join(configDir, "skills"), diff --git a/src/tools/slashcommand/opencode-project-command-discovery.test.ts b/src/tools/slashcommand/opencode-project-command-discovery.test.ts new file mode 100644 index 000000000..f845d9b93 --- /dev/null +++ b/src/tools/slashcommand/opencode-project-command-discovery.test.ts @@ -0,0 +1,60 @@ +import { execFileSync } from "node:child_process" +import { afterEach, beforeEach, describe, expect, it } from "bun:test" +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { discoverCommandsSync } from "./command-discovery" + +function writeCommand(path: string, description: string, body: string): void { + mkdirSync(join(path, ".."), { recursive: true }) + writeFileSync(path, `---\ndescription: ${description}\n---\n${body}\n`) +} + +describe("opencode project command discovery", () => { + let tempDir = "" + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "omo-opencode-project-command-discovery-")) + }) + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }) + }) + + it("discovers ancestor opencode commands with slash-separated nested names and worktree boundaries", () => { + // given + const repositoryDir = join(tempDir, "repo") + const nestedDirectory = join(repositoryDir, "packages", "app", "src") + + mkdirSync(nestedDirectory, { recursive: true }) + execFileSync("git", ["init"], { + cwd: repositoryDir, + stdio: ["ignore", "ignore", "ignore"], + }) + + writeCommand( + join(repositoryDir, ".opencode", "commands", "deploy", "staging.md"), + "Deploy to staging", + "Run the staged deploy.", + ) + writeCommand( + join(repositoryDir, ".opencode", "command", "release.md"), + "Release command", + "Run the release.", + ) + writeCommand( + join(tempDir, ".opencode", "commands", "outside.md"), + "Outside command", + "Should not be discovered.", + ) + + // when + const names = discoverCommandsSync(nestedDirectory).map(command => command.name) + + // then + expect(names).toContain("deploy/staging") + expect(names).toContain("release") + expect(names).not.toContain("deploy:staging") + expect(names).not.toContain("outside") + }) +})