diff --git a/src/features/opencode-skill-loader/loader.ts b/src/features/opencode-skill-loader/loader.ts index e577809fe..6f0c44c3b 100644 --- a/src/features/opencode-skill-loader/loader.ts +++ b/src/features/opencode-skill-loader/loader.ts @@ -48,6 +48,20 @@ export async function loadOpencodeProjectSkills(directory?: string): Promise> { + const agentsProjectSkillDirs = findProjectAgentsSkillDirs(directory ?? process.cwd()) + const allSkills = await Promise.all( + agentsProjectSkillDirs.map((skillsDir) => loadSkillsFromDir({ skillsDir, scope: "project" })), + ) + return skillsToCommandDefinitionRecord(deduplicateSkillsByName(allSkills.flat())) +} + +export async function loadGlobalAgentsSkills(): Promise> { + const agentsGlobalDir = join(homedir(), ".agents", "skills") + const skills = await loadSkillsFromDir({ skillsDir: agentsGlobalDir, scope: "user" }) + return skillsToCommandDefinitionRecord(skills) +} + export interface DiscoverSkillsOptions { includeClaudeCodePaths?: boolean directory?: string diff --git a/src/plugin-handlers/agent-config-handler.test.ts b/src/plugin-handlers/agent-config-handler.test.ts index bacc3fa24..6cb7514ed 100644 --- a/src/plugin-handlers/agent-config-handler.test.ts +++ b/src/plugin-handlers/agent-config-handler.test.ts @@ -8,6 +8,7 @@ import * as sisyphusJunior from "../agents/sisyphus-junior" import type { OhMyOpenCodeConfig } from "../config" import * as agentLoader from "../features/claude-code-agent-loader" import * as skillLoader from "../features/opencode-skill-loader" +import type { LoadedSkill } from "../features/opencode-skill-loader" import { getAgentDisplayName } from "../shared/agent-display-names" import { applyAgentConfig } from "./agent-config-handler" import type { PluginComponents } from "./plugin-components-loader" @@ -51,6 +52,8 @@ describe("applyAgentConfig builtin override protection", () => { let discoverProjectClaudeSkillsSpy: ReturnType let discoverOpencodeGlobalSkillsSpy: ReturnType let discoverOpencodeProjectSkillsSpy: ReturnType + let discoverProjectAgentsSkillsSpy: ReturnType + let discoverGlobalAgentsSkillsSpy: ReturnType let loadUserAgentsSpy: ReturnType let loadProjectAgentsSpy: ReturnType let migrateAgentConfigSpy: ReturnType @@ -121,6 +124,14 @@ describe("applyAgentConfig builtin override protection", () => { skillLoader, "discoverOpencodeProjectSkills", ).mockResolvedValue([]) + discoverProjectAgentsSkillsSpy = spyOn( + skillLoader, + "discoverProjectAgentsSkills", + ).mockResolvedValue([]) + discoverGlobalAgentsSkillsSpy = spyOn( + skillLoader, + "discoverGlobalAgentsSkills", + ).mockResolvedValue([]) loadUserAgentsSpy = spyOn(agentLoader, "loadUserAgents").mockReturnValue({}) loadProjectAgentsSpy = spyOn(agentLoader, "loadProjectAgents").mockReturnValue({}) @@ -139,6 +150,8 @@ describe("applyAgentConfig builtin override protection", () => { discoverProjectClaudeSkillsSpy.mockRestore() discoverOpencodeGlobalSkillsSpy.mockRestore() discoverOpencodeProjectSkillsSpy.mockRestore() + discoverProjectAgentsSkillsSpy.mockRestore() + discoverGlobalAgentsSkillsSpy.mockRestore() loadUserAgentsSpy.mockRestore() loadProjectAgentsSpy.mockRestore() migrateAgentConfigSpy.mockRestore() @@ -279,4 +292,45 @@ describe("applyAgentConfig builtin override protection", () => { // then expect(createSisyphusJuniorAgentSpy).toHaveBeenCalledWith(undefined, "openai/gpt-5.4", false) }) + + test("includes project and global .agents skills in builtin agent awareness", async () => { + // given + const projectAgentsSkill = { + name: "project-agent-skill", + definition: { + name: "project-agent-skill", + description: "Project agent skill", + template: "template", + }, + scope: "project", + } satisfies LoadedSkill + const globalAgentsSkill = { + name: "global-agent-skill", + definition: { + name: "global-agent-skill", + description: "Global agent skill", + template: "template", + }, + scope: "user", + } satisfies LoadedSkill + discoverProjectAgentsSkillsSpy.mockResolvedValue([projectAgentsSkill]) + discoverGlobalAgentsSkillsSpy.mockResolvedValue([globalAgentsSkill]) + + // when + await applyAgentConfig({ + config: createBaseConfig(), + pluginConfig: createPluginConfig(), + ctx: { directory: "/tmp" }, + pluginComponents: createPluginComponents(), + }) + + // then + const discoveredSkills = createBuiltinAgentsSpy.mock.calls[0]?.[6] + expect(discoveredSkills).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "project-agent-skill" }), + expect.objectContaining({ name: "global-agent-skill" }), + ]), + ) + }) }) diff --git a/src/plugin-handlers/agent-config-handler.ts b/src/plugin-handlers/agent-config-handler.ts index 33f15f233..539fa6bb0 100644 --- a/src/plugin-handlers/agent-config-handler.ts +++ b/src/plugin-handlers/agent-config-handler.ts @@ -6,8 +6,10 @@ import { AGENT_NAME_MAP } from "../shared/migration"; import { getAgentDisplayName } from "../shared/agent-display-names"; import { discoverConfigSourceSkills, + discoverGlobalAgentsSkills, discoverOpencodeGlobalSkills, discoverOpencodeProjectSkills, + discoverProjectAgentsSkills, discoverProjectClaudeSkills, discoverUserClaudeSkills, } from "../features/opencode-skill-loader"; @@ -52,8 +54,10 @@ export async function applyAgentConfig(params: { discoveredConfigSourceSkills, discoveredUserSkills, discoveredProjectSkills, + discoveredProjectAgentsSkills, discoveredOpencodeGlobalSkills, discoveredOpencodeProjectSkills, + discoveredGlobalAgentsSkills, ] = await Promise.all([ discoverConfigSourceSkills({ config: params.pluginConfig.skills, @@ -63,16 +67,22 @@ export async function applyAgentConfig(params: { includeClaudeSkillsForAwareness ? discoverProjectClaudeSkills(params.ctx.directory) : Promise.resolve([]), + includeClaudeSkillsForAwareness + ? discoverProjectAgentsSkills(params.ctx.directory) + : Promise.resolve([]), discoverOpencodeGlobalSkills(), discoverOpencodeProjectSkills(params.ctx.directory), + includeClaudeSkillsForAwareness ? discoverGlobalAgentsSkills() : Promise.resolve([]), ]); const allDiscoveredSkills = [ ...discoveredConfigSourceSkills, ...discoveredOpencodeProjectSkills, ...discoveredProjectSkills, + ...discoveredProjectAgentsSkills, ...discoveredOpencodeGlobalSkills, ...discoveredUserSkills, + ...discoveredGlobalAgentsSkills, ]; const browserProvider = diff --git a/src/plugin-handlers/command-config-handler.test.ts b/src/plugin-handlers/command-config-handler.test.ts new file mode 100644 index 000000000..7767c6639 --- /dev/null +++ b/src/plugin-handlers/command-config-handler.test.ts @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import * as builtinCommands from "../features/builtin-commands"; +import * as commandLoader from "../features/claude-code-command-loader"; +import * as skillLoader from "../features/opencode-skill-loader"; +import type { OhMyOpenCodeConfig } from "../config"; +import type { PluginComponents } from "./plugin-components-loader"; +import { applyCommandConfig } from "./command-config-handler"; + +function createPluginComponents(): PluginComponents { + return { + commands: {}, + skills: {}, + agents: {}, + mcpServers: {}, + hooksConfigs: [], + plugins: [], + errors: [], + }; +} + +function createPluginConfig(): OhMyOpenCodeConfig { + return {}; +} + +describe("applyCommandConfig", () => { + let loadBuiltinCommandsSpy: ReturnType; + let loadUserCommandsSpy: ReturnType; + let loadProjectCommandsSpy: ReturnType; + let loadOpencodeGlobalCommandsSpy: ReturnType; + let loadOpencodeProjectCommandsSpy: ReturnType; + let discoverConfigSourceSkillsSpy: ReturnType; + let loadUserSkillsSpy: ReturnType; + let loadProjectSkillsSpy: ReturnType; + let loadOpencodeGlobalSkillsSpy: ReturnType; + let loadOpencodeProjectSkillsSpy: ReturnType; + let loadProjectAgentsSkillsSpy: ReturnType; + let loadGlobalAgentsSkillsSpy: ReturnType; + + beforeEach(() => { + loadBuiltinCommandsSpy = spyOn(builtinCommands, "loadBuiltinCommands").mockReturnValue({}); + loadUserCommandsSpy = spyOn(commandLoader, "loadUserCommands").mockResolvedValue({}); + loadProjectCommandsSpy = spyOn(commandLoader, "loadProjectCommands").mockResolvedValue({}); + loadOpencodeGlobalCommandsSpy = spyOn(commandLoader, "loadOpencodeGlobalCommands").mockResolvedValue({}); + loadOpencodeProjectCommandsSpy = spyOn(commandLoader, "loadOpencodeProjectCommands").mockResolvedValue({}); + discoverConfigSourceSkillsSpy = spyOn(skillLoader, "discoverConfigSourceSkills").mockResolvedValue([]); + loadUserSkillsSpy = spyOn(skillLoader, "loadUserSkills").mockResolvedValue({}); + loadProjectSkillsSpy = spyOn(skillLoader, "loadProjectSkills").mockResolvedValue({}); + loadOpencodeGlobalSkillsSpy = spyOn(skillLoader, "loadOpencodeGlobalSkills").mockResolvedValue({}); + loadOpencodeProjectSkillsSpy = spyOn(skillLoader, "loadOpencodeProjectSkills").mockResolvedValue({}); + loadProjectAgentsSkillsSpy = spyOn(skillLoader, "loadProjectAgentsSkills").mockResolvedValue({}); + loadGlobalAgentsSkillsSpy = spyOn(skillLoader, "loadGlobalAgentsSkills").mockResolvedValue({}); + }); + + afterEach(() => { + loadBuiltinCommandsSpy.mockRestore(); + loadUserCommandsSpy.mockRestore(); + loadProjectCommandsSpy.mockRestore(); + loadOpencodeGlobalCommandsSpy.mockRestore(); + loadOpencodeProjectCommandsSpy.mockRestore(); + discoverConfigSourceSkillsSpy.mockRestore(); + loadUserSkillsSpy.mockRestore(); + loadProjectSkillsSpy.mockRestore(); + loadOpencodeGlobalSkillsSpy.mockRestore(); + loadOpencodeProjectSkillsSpy.mockRestore(); + loadProjectAgentsSkillsSpy.mockRestore(); + loadGlobalAgentsSkillsSpy.mockRestore(); + }); + + test("includes .agents skills in command config", async () => { + // given + loadProjectAgentsSkillsSpy.mockResolvedValue({ + "agents-project-skill": { + description: "(project - Skill) Agents project skill", + template: "template", + }, + }); + loadGlobalAgentsSkillsSpy.mockResolvedValue({ + "agents-global-skill": { + description: "(user - Skill) Agents global skill", + template: "template", + }, + }); + const config: Record = { command: {} }; + + // when + await applyCommandConfig({ + config, + pluginConfig: createPluginConfig(), + ctx: { directory: "/tmp" }, + pluginComponents: createPluginComponents(), + }); + + // then + const commandConfig = config.command as Record; + expect(commandConfig["agents-project-skill"]?.description).toContain("Agents project skill"); + expect(commandConfig["agents-global-skill"]?.description).toContain("Agents global skill"); + }); +}); diff --git a/src/plugin-handlers/command-config-handler.ts b/src/plugin-handlers/command-config-handler.ts index a5cb0e946..7afd1e416 100644 --- a/src/plugin-handlers/command-config-handler.ts +++ b/src/plugin-handlers/command-config-handler.ts @@ -9,6 +9,8 @@ import { import { loadBuiltinCommands } from "../features/builtin-commands"; import { discoverConfigSourceSkills, + loadGlobalAgentsSkills, + loadProjectAgentsSkills, loadUserSkills, loadProjectSkills, loadOpencodeGlobalSkills, @@ -36,7 +38,9 @@ export async function applyCommandConfig(params: { opencodeGlobalCommands, opencodeProjectCommands, userSkills, + globalAgentsSkills, projectSkills, + projectAgentsSkills, opencodeGlobalSkills, opencodeProjectSkills, ] = await Promise.all([ @@ -49,7 +53,9 @@ export async function applyCommandConfig(params: { loadOpencodeGlobalCommands(), loadOpencodeProjectCommands(params.ctx.directory), includeClaudeSkills ? loadUserSkills() : Promise.resolve({}), + includeClaudeSkills ? loadGlobalAgentsSkills() : Promise.resolve({}), includeClaudeSkills ? loadProjectSkills(params.ctx.directory) : Promise.resolve({}), + includeClaudeSkills ? loadProjectAgentsSkills(params.ctx.directory) : Promise.resolve({}), loadOpencodeGlobalSkills(), loadOpencodeProjectSkills(params.ctx.directory), ]); @@ -59,11 +65,13 @@ export async function applyCommandConfig(params: { ...skillsToCommandDefinitionRecord(configSourceSkills), ...userCommands, ...userSkills, + ...globalAgentsSkills, ...opencodeGlobalCommands, ...opencodeGlobalSkills, ...systemCommands, ...projectCommands, ...projectSkills, + ...projectAgentsSkills, ...opencodeProjectCommands, ...opencodeProjectSkills, ...params.pluginComponents.commands, diff --git a/src/shared/project-discovery-dirs.test.ts b/src/shared/project-discovery-dirs.test.ts index 13dcc8a71..39ba5dc13 100644 --- a/src/shared/project-discovery-dirs.test.ts +++ b/src/shared/project-discovery-dirs.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test" -import { mkdirSync, rmSync } from "node:fs" +import { mkdirSync, realpathSync, rmSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" import { @@ -11,6 +11,10 @@ import { const TEST_DIR = join(tmpdir(), `project-discovery-dirs-${Date.now()}`) +function canonicalPath(path: string): string { + return realpathSync(path) +} + describe("project-discovery-dirs", () => { beforeEach(() => { mkdirSync(TEST_DIR, { recursive: true }) @@ -33,9 +37,9 @@ describe("project-discovery-dirs", () => { // then expect(directories).toEqual([ - join(projectDir, ".opencode", "skills"), - join(projectDir, ".opencode", "skill"), - join(TEST_DIR, ".opencode", "skills"), + canonicalPath(join(projectDir, ".opencode", "skills")), + canonicalPath(join(projectDir, ".opencode", "skill")), + canonicalPath(join(TEST_DIR, ".opencode", "skills")), ]) }) @@ -51,8 +55,8 @@ describe("project-discovery-dirs", () => { // then expect(directories).toEqual([ - join(projectDir, ".opencode", "commands"), - join(TEST_DIR, ".opencode", "command"), + canonicalPath(join(projectDir, ".opencode", "commands")), + canonicalPath(join(TEST_DIR, ".opencode", "command")), ]) }) @@ -68,7 +72,21 @@ describe("project-discovery-dirs", () => { const agentsDirectories = findProjectAgentsSkillDirs(childDir) // then - expect(claudeDirectories).toEqual([join(projectDir, ".claude", "skills")]) - expect(agentsDirectories).toEqual([join(TEST_DIR, ".agents", "skills")]) + expect(claudeDirectories).toEqual([canonicalPath(join(projectDir, ".claude", "skills"))]) + expect(agentsDirectories).toEqual([canonicalPath(join(TEST_DIR, ".agents", "skills"))]) + }) + + it("#given a stop directory #when finding ancestor dirs #then it does not scan beyond the stop boundary", () => { + // given + const projectDir = join(TEST_DIR, "project") + const childDir = join(projectDir, "apps", "cli") + mkdirSync(join(projectDir, ".opencode", "skills"), { recursive: true }) + mkdirSync(join(TEST_DIR, ".opencode", "skills"), { recursive: true }) + + // when + const directories = findProjectOpencodeSkillDirs(childDir, projectDir) + + // then + expect(directories).toEqual([canonicalPath(join(projectDir, ".opencode", "skills"))]) }) }) diff --git a/src/shared/project-discovery-dirs.ts b/src/shared/project-discovery-dirs.ts index 007c3c16b..4e22b66f6 100644 --- a/src/shared/project-discovery-dirs.ts +++ b/src/shared/project-discovery-dirs.ts @@ -1,13 +1,29 @@ -import { existsSync } from "node:fs" +import { execFileSync } from "node:child_process" +import { existsSync, realpathSync } from "node:fs" import { dirname, join, resolve } from "node:path" +function normalizePath(path: string): string { + const resolvedPath = resolve(path) + if (!existsSync(resolvedPath)) { + return resolvedPath + } + + try { + return realpathSync(resolvedPath) + } catch { + return resolvedPath + } +} + function findAncestorDirectories( startDirectory: string, targetPaths: ReadonlyArray>, + stopDirectory?: string, ): string[] { const directories: string[] = [] const seen = new Set() - let currentDirectory = resolve(startDirectory) + let currentDirectory = normalizePath(startDirectory) + const resolvedStopDirectory = stopDirectory ? normalizePath(stopDirectory) : undefined while (true) { for (const targetPath of targetPaths) { @@ -20,33 +36,66 @@ function findAncestorDirectories( directories.push(candidateDirectory) } + if (resolvedStopDirectory === currentDirectory) { + return directories + } + const parentDirectory = dirname(currentDirectory) if (parentDirectory === currentDirectory) { return directories } - currentDirectory = parentDirectory + currentDirectory = normalizePath(parentDirectory) } } -export function findProjectClaudeSkillDirs(startDirectory: string): string[] { - return findAncestorDirectories(startDirectory, [[".claude", "skills"]]) +function detectWorktreePath(directory: string): string | undefined { + try { + return execFileSync("git", ["rev-parse", "--show-toplevel"], { + cwd: directory, + encoding: "utf-8", + timeout: 5000, + stdio: ["pipe", "pipe", "pipe"], + }).trim() + } catch { + return undefined + } } -export function findProjectAgentsSkillDirs(startDirectory: string): string[] { - return findAncestorDirectories(startDirectory, [[".agents", "skills"]]) +export function findProjectClaudeSkillDirs(startDirectory: string, stopDirectory?: string): string[] { + return findAncestorDirectories( + startDirectory, + [[".claude", "skills"]], + stopDirectory ?? detectWorktreePath(startDirectory), + ) } -export function findProjectOpencodeSkillDirs(startDirectory: string): string[] { - return findAncestorDirectories(startDirectory, [ - [".opencode", "skills"], - [".opencode", "skill"], - ]) +export function findProjectAgentsSkillDirs(startDirectory: string, stopDirectory?: string): string[] { + return findAncestorDirectories( + startDirectory, + [[".agents", "skills"]], + stopDirectory ?? detectWorktreePath(startDirectory), + ) } -export function findProjectOpencodeCommandDirs(startDirectory: string): string[] { - return findAncestorDirectories(startDirectory, [ - [".opencode", "commands"], - [".opencode", "command"], - ]) +export function findProjectOpencodeSkillDirs(startDirectory: string, stopDirectory?: string): string[] { + return findAncestorDirectories( + startDirectory, + [ + [".opencode", "skills"], + [".opencode", "skill"], + ], + stopDirectory ?? detectWorktreePath(startDirectory), + ) +} + +export function findProjectOpencodeCommandDirs(startDirectory: string, stopDirectory?: string): string[] { + return findAncestorDirectories( + startDirectory, + [ + [".opencode", "commands"], + [".opencode", "command"], + ], + stopDirectory ?? detectWorktreePath(startDirectory), + ) } diff --git a/src/tools/slashcommand/command-discovery.test.ts b/src/tools/slashcommand/command-discovery.test.ts index 232515a33..b0b3c2b5a 100644 --- a/src/tools/slashcommand/command-discovery.test.ts +++ b/src/tools/slashcommand/command-discovery.test.ts @@ -235,4 +235,24 @@ Use plural command. expect(duplicates).toHaveLength(1) expect(duplicates[0]?.content).toContain("Use plural command.") }) + + it("discovers nested opencode project commands", () => { + const commandsDir = join(projectDir, ".opencode", "commands", "refactor") + + mkdirSync(commandsDir, { recursive: true }) + writeFileSync( + join(commandsDir, "code.md"), + `--- +description: Nested command +--- +Use nested command. +`, + ) + + const commands = discoverCommandsSync(projectDir) + const nestedCommand = commands.find((command) => command.name === "refactor/code") + + expect(nestedCommand?.content).toContain("Use nested command.") + expect(nestedCommand?.scope).toBe("opencode-project") + }) }) diff --git a/src/tools/slashcommand/command-discovery.ts b/src/tools/slashcommand/command-discovery.ts index 7567e74dd..dc8922381 100644 --- a/src/tools/slashcommand/command-discovery.ts +++ b/src/tools/slashcommand/command-discovery.ts @@ -18,17 +18,37 @@ export interface CommandDiscoveryOptions { enabledPluginsOverride?: Record } -function discoverCommandsFromDir(commandsDir: string, scope: CommandScope): CommandInfo[] { +const NESTED_COMMAND_SEPARATOR = "/" + +function discoverCommandsFromDir( + commandsDir: string, + scope: CommandScope, + prefix = "", +): CommandInfo[] { if (!existsSync(commandsDir)) return [] const entries = readdirSync(commandsDir, { withFileTypes: true }) const commands: CommandInfo[] = [] for (const entry of entries) { + if (entry.isDirectory()) { + if (entry.name.startsWith(".")) continue + const nestedPrefix = prefix + ? `${prefix}${NESTED_COMMAND_SEPARATOR}${entry.name}` + : entry.name + commands.push( + ...discoverCommandsFromDir(join(commandsDir, entry.name), scope, nestedPrefix), + ) + continue + } + if (!isMarkdownFile(entry)) continue const commandPath = join(commandsDir, entry.name) - const commandName = basename(entry.name, ".md") + const baseCommandName = basename(entry.name, ".md") + const commandName = prefix + ? `${prefix}${NESTED_COMMAND_SEPARATOR}${baseCommandName}` + : baseCommandName try { const content = readFileSync(commandPath, "utf-8")