diff --git a/src/hooks/auto-slash-command/executor.ts b/src/hooks/auto-slash-command/executor.ts index cd06ee78d..8a7536ad9 100644 --- a/src/hooks/auto-slash-command/executor.ts +++ b/src/hooks/auto-slash-command/executor.ts @@ -1,86 +1,25 @@ -import { existsSync, readdirSync, readFileSync } from "fs" -import { join, basename, dirname } from "path" +import { dirname } from "path" import { - parseFrontmatter, resolveCommandsInText, resolveFileReferencesInText, - sanitizeModelField, - getClaudeConfigDir, - getOpenCodeConfigDir, - discoverPluginCommandDefinitions, } from "../../shared" -import { loadBuiltinCommands } from "../../features/builtin-commands" -import type { CommandFrontmatter } from "../../features/claude-code-command-loader/types" -import { isMarkdownFile } from "../../shared/file-utils" import { discoverAllSkills, type LoadedSkill, type LazyContentLoader } from "../../features/opencode-skill-loader" +import { discoverCommandsSync } from "../../tools/slashcommand" +import type { CommandInfo as DiscoveredCommandInfo, CommandMetadata } from "../../tools/slashcommand/types" import type { ParsedSlashCommand } from "./types" -interface CommandScope { - type: "user" | "project" | "opencode" | "opencode-project" | "skill" | "builtin" | "plugin" -} - -interface CommandMetadata { - name: string - description: string - argumentHint?: string - model?: string - agent?: string - subtask?: boolean -} - -interface CommandInfo { +interface SkillCommandInfo { name: string path?: string metadata: CommandMetadata content?: string - scope: CommandScope["type"] + scope: "skill" lazyContentLoader?: LazyContentLoader } -function discoverCommandsFromDir(commandsDir: string, scope: CommandScope["type"]): CommandInfo[] { - if (!existsSync(commandsDir)) { - return [] - } +type CommandInfo = DiscoveredCommandInfo | SkillCommandInfo - const entries = readdirSync(commandsDir, { withFileTypes: true }) - const commands: CommandInfo[] = [] - - for (const entry of entries) { - if (!isMarkdownFile(entry)) continue - - const commandPath = join(commandsDir, entry.name) - const commandName = basename(entry.name, ".md") - - try { - const content = readFileSync(commandPath, "utf-8") - const { data, body } = parseFrontmatter(content) - - const isOpencodeSource = scope === "opencode" || scope === "opencode-project" - const metadata: CommandMetadata = { - name: commandName, - description: data.description || "", - argumentHint: data["argument-hint"], - model: sanitizeModelField(data.model, isOpencodeSource ? "opencode" : "claude-code"), - agent: data.agent, - subtask: Boolean(data.subtask), - } - - commands.push({ - name: commandName, - path: commandPath, - metadata, - content: body, - scope, - }) - } catch { - continue - } - } - - return commands -} - -function skillToCommandInfo(skill: LoadedSkill): CommandInfo { +function skillToCommandInfo(skill: LoadedSkill): SkillCommandInfo { return { name: skill.name, path: skill.path, @@ -104,60 +43,30 @@ export interface ExecutorOptions { enabledPluginsOverride?: Record } -function discoverPluginCommands(options?: ExecutorOptions): CommandInfo[] { - const pluginDefinitions = discoverPluginCommandDefinitions(options) - - return Object.entries(pluginDefinitions).map(([name, definition]) => ({ - name, - metadata: { - name, - description: definition.description || "", - model: definition.model, - agent: definition.agent, - subtask: definition.subtask, - }, - content: definition.template, - scope: "plugin", - })) +function filterDiscoveredCommandsByScope( + commands: DiscoveredCommandInfo[], + scope: DiscoveredCommandInfo["scope"], +): DiscoveredCommandInfo[] { + return commands.filter(command => command.scope === scope) } async function discoverAllCommands(options?: ExecutorOptions): Promise { - const configDir = getOpenCodeConfigDir({ binary: "opencode" }) - const userCommandsDir = join(getClaudeConfigDir(), "commands") - const projectCommandsDir = join(process.cwd(), ".claude", "commands") - const opencodeGlobalDir = join(configDir, "command") - const opencodeProjectDir = join(process.cwd(), ".opencode", "command") - - const userCommands = discoverCommandsFromDir(userCommandsDir, "user") - const opencodeGlobalCommands = discoverCommandsFromDir(opencodeGlobalDir, "opencode") - const projectCommands = discoverCommandsFromDir(projectCommandsDir, "project") - const opencodeProjectCommands = discoverCommandsFromDir(opencodeProjectDir, "opencode-project") - const builtinCommandsMap = loadBuiltinCommands() - const builtinCommands: CommandInfo[] = Object.values(builtinCommandsMap).map(cmd => ({ - name: cmd.name, - metadata: { - name: cmd.name, - description: cmd.description || "", - model: cmd.model, - agent: cmd.agent, - subtask: cmd.subtask, - }, - content: cmd.template, - scope: "builtin", - })) + const discoveredCommands = discoverCommandsSync(process.cwd(), { + pluginsEnabled: options?.pluginsEnabled, + enabledPluginsOverride: options?.enabledPluginsOverride, + }) const skills = options?.skills ?? await discoverAllSkills() const skillCommands = skills.map(skillToCommandInfo) - const pluginCommands = discoverPluginCommands(options) return [ - ...builtinCommands, - ...opencodeProjectCommands, - ...projectCommands, - ...opencodeGlobalCommands, - ...userCommands, + ...filterDiscoveredCommandsByScope(discoveredCommands, "builtin"), + ...filterDiscoveredCommandsByScope(discoveredCommands, "opencode-project"), + ...filterDiscoveredCommandsByScope(discoveredCommands, "project"), + ...filterDiscoveredCommandsByScope(discoveredCommands, "opencode"), + ...filterDiscoveredCommandsByScope(discoveredCommands, "user"), ...skillCommands, - ...pluginCommands, + ...filterDiscoveredCommandsByScope(discoveredCommands, "plugin"), ] } diff --git a/src/tools/slashcommand/execution-compatibility.test.ts b/src/tools/slashcommand/execution-compatibility.test.ts new file mode 100644 index 000000000..92ef26216 --- /dev/null +++ b/src/tools/slashcommand/execution-compatibility.test.ts @@ -0,0 +1,63 @@ +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 { executeSlashCommand } from "../../hooks/auto-slash-command/executor" +import { discoverCommandsSync } from "./command-discovery" + +describe("slashcommand discovery and execution compatibility", () => { + let tempDir = "" + let originalWorkingDirectory = "" + let originalOpencodeConfigDir: string | undefined + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "omo-slashcommand-compat-test-")) + originalWorkingDirectory = process.cwd() + originalOpencodeConfigDir = process.env.OPENCODE_CONFIG_DIR + }) + + afterEach(() => { + process.chdir(originalWorkingDirectory) + + if (originalOpencodeConfigDir === undefined) { + delete process.env.OPENCODE_CONFIG_DIR + } else { + process.env.OPENCODE_CONFIG_DIR = originalOpencodeConfigDir + } + + rmSync(tempDir, { recursive: true, force: true }) + }) + + it("executes commands discovered from a parent opencode config dir", async () => { + // given + const projectDir = join(tempDir, "project") + const opencodeRootDir = join(tempDir, "opencode-root") + const profileConfigDir = join(opencodeRootDir, "profiles", "codex") + const parentCommandDir = join(opencodeRootDir, "command") + const commandName = "parent-only-command" + + mkdirSync(projectDir, { recursive: true }) + mkdirSync(profileConfigDir, { recursive: true }) + mkdirSync(parentCommandDir, { recursive: true }) + writeFileSync( + join(parentCommandDir, `${commandName}.md`), + `---\ndescription: Parent config command\n---\nExecute from parent config.\n`, + ) + process.env.OPENCODE_CONFIG_DIR = profileConfigDir + process.chdir(projectDir) + + expect(discoverCommandsSync(projectDir).some(command => command.name === commandName)).toBe(true) + + // when + const result = await executeSlashCommand({ + command: commandName, + args: "", + raw: `/${commandName}`, + }, { skills: [] }) + + // then + expect(result.success).toBe(true) + expect(result.replacementText).toContain("Execute from parent config.") + expect(result.replacementText).toContain("**Scope**: opencode") + }) +})