diff --git a/src/hooks/auto-slash-command/constants.test.ts b/src/hooks/auto-slash-command/constants.test.ts new file mode 100644 index 000000000..6fd99647a --- /dev/null +++ b/src/hooks/auto-slash-command/constants.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "bun:test" +import { parseSlashCommand } from "./detector" + +describe("slash command parsing pattern", () => { + describe("#given plugin namespace includes dot", () => { + it("#then parses command name with dot and colon", () => { + // given + const text = "/my.plugin:run ship" + + // when + const parsed = parseSlashCommand(text) + + // then + expect(parsed).not.toBeNull() + expect(parsed?.command).toBe("my.plugin:run") + expect(parsed?.args).toBe("ship") + }) + }) +}) diff --git a/src/hooks/auto-slash-command/constants.ts b/src/hooks/auto-slash-command/constants.ts index a8bdac19e..a5df43e9b 100644 --- a/src/hooks/auto-slash-command/constants.ts +++ b/src/hooks/auto-slash-command/constants.ts @@ -3,7 +3,7 @@ export const HOOK_NAME = "auto-slash-command" as const export const AUTO_SLASH_COMMAND_TAG_OPEN = "" export const AUTO_SLASH_COMMAND_TAG_CLOSE = "" -export const SLASH_COMMAND_PATTERN = /^\/([a-zA-Z@][\w:@/-]*)\s*(.*)/ +export const SLASH_COMMAND_PATTERN = /^\/([a-zA-Z@][\w.:@/-]*)\s*(.*)/ export const EXCLUDED_COMMANDS = new Set([ "ralph-loop", diff --git a/src/hooks/auto-slash-command/executor.test.ts b/src/hooks/auto-slash-command/executor.test.ts index 979215fae..9f96e7a83 100644 --- a/src/hooks/auto-slash-command/executor.test.ts +++ b/src/hooks/auto-slash-command/executor.test.ts @@ -35,6 +35,14 @@ function writePluginFixture(baseDir: string): void { description: Run prompt from daplug --- Execute daplug prompt flow. +`, + ) + writeFileSync( + join(pluginInstallPath, "commands", "templated.md"), + `--- +description: Templated prompt from daplug +--- +Echo $ARGUMENTS and \${user_message}. `, ) @@ -165,4 +173,23 @@ describe("auto-slash command executor plugin dispatch", () => { ) expect(result.error).not.toContain("Marketplace plugin commands") }) + + it("replaces $ARGUMENTS placeholders in plugin command templates", async () => { + const result = await executeSlashCommand( + { + command: "daplug:templated", + args: "ship it", + raw: "/daplug:templated ship it", + }, + { + skills: [], + pluginsEnabled: true, + }, + ) + + expect(result.success).toBe(true) + expect(result.replacementText).toContain("Echo ship it and ship it.") + expect(result.replacementText).not.toContain("$ARGUMENTS") + expect(result.replacementText).not.toContain("${user_message}") + }) }) diff --git a/src/hooks/auto-slash-command/executor.ts b/src/hooks/auto-slash-command/executor.ts index f7c906e20..cd06ee78d 100644 --- a/src/hooks/auto-slash-command/executor.ts +++ b/src/hooks/auto-slash-command/executor.ts @@ -7,16 +7,12 @@ import { 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 { - discoverInstalledPlugins, - loadPluginCommands, - loadPluginSkillsAsCommands, -} from "../../features/claude-code-plugin-loader" import type { ParsedSlashCommand } from "./types" interface CommandScope { @@ -109,18 +105,7 @@ export interface ExecutorOptions { } function discoverPluginCommands(options?: ExecutorOptions): CommandInfo[] { - if (options?.pluginsEnabled === false) { - return [] - } - - const { plugins } = discoverInstalledPlugins({ - enabledPluginsOverride: options?.enabledPluginsOverride, - }) - - const pluginDefinitions = { - ...loadPluginCommands(plugins), - ...loadPluginSkillsAsCommands(plugins), - } + const pluginDefinitions = discoverPluginCommandDefinitions(options) return Object.entries(pluginDefinitions).map(([name, definition]) => ({ name, @@ -216,7 +201,11 @@ async function formatCommandTemplate(cmd: CommandInfo, args: string): Promise + +function writePluginFixture(baseDir: string): void { + const claudeConfigDir = join(baseDir, "claude-config") + const pluginsHome = join(claudeConfigDir, "plugins") + const settingsPath = join(claudeConfigDir, "settings.json") + const opencodeConfigDir = join(baseDir, "opencode-config") + const pluginInstallPath = join(baseDir, "installed-plugins", "daplug") + const pluginKey = "daplug@1.0.0" + + mkdirSync(join(pluginInstallPath, ".claude-plugin"), { recursive: true }) + mkdirSync(join(pluginInstallPath, "commands"), { recursive: true }) + mkdirSync(join(pluginInstallPath, "skills", "plugin-plan"), { recursive: true }) + + writeFileSync( + join(pluginInstallPath, ".claude-plugin", "plugin.json"), + JSON.stringify({ name: "daplug", version: "1.0.0" }, null, 2), + ) + writeFileSync( + join(pluginInstallPath, "commands", "run-prompt.md"), + `--- +description: Run prompt from daplug +--- +Execute daplug prompt flow. +`, + ) + writeFileSync( + join(pluginInstallPath, "skills", "plugin-plan", "SKILL.md"), + `--- +name: plugin-plan +description: Plan work from daplug skill +--- +Build a plan from plugin skill context. +`, + ) + + mkdirSync(pluginsHome, { recursive: true }) + writeFileSync( + join(pluginsHome, "installed_plugins.json"), + JSON.stringify( + { + version: 2, + plugins: { + [pluginKey]: [ + { + scope: "user", + installPath: pluginInstallPath, + version: "1.0.0", + installedAt: "2026-01-01T00:00:00.000Z", + lastUpdated: "2026-01-01T00:00:00.000Z", + }, + ], + }, + }, + null, + 2, + ), + ) + + mkdirSync(claudeConfigDir, { recursive: true }) + writeFileSync( + settingsPath, + JSON.stringify( + { + enabledPlugins: { + [pluginKey]: true, + }, + }, + null, + 2, + ), + ) + mkdirSync(opencodeConfigDir, { recursive: true }) + + process.env.CLAUDE_CONFIG_DIR = claudeConfigDir + process.env.CLAUDE_PLUGINS_HOME = pluginsHome + process.env.CLAUDE_SETTINGS_PATH = settingsPath + process.env.OPENCODE_CONFIG_DIR = opencodeConfigDir +} + +describe("plugin command discovery utility", () => { + let tempDir = "" + let envSnapshot: EnvSnapshot + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "omo-shared-plugin-discovery-test-")) + envSnapshot = { + CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR, + CLAUDE_PLUGINS_HOME: process.env.CLAUDE_PLUGINS_HOME, + CLAUDE_SETTINGS_PATH: process.env.CLAUDE_SETTINGS_PATH, + OPENCODE_CONFIG_DIR: process.env.OPENCODE_CONFIG_DIR, + } + writePluginFixture(tempDir) + }) + + afterEach(() => { + for (const key of ENV_KEYS) { + const previousValue = envSnapshot[key] + if (previousValue === undefined) { + delete process.env[key] + } else { + process.env[key] = previousValue + } + } + rmSync(tempDir, { recursive: true, force: true }) + }) + + describe("#given plugin loading is enabled", () => { + it("#then returns plugin command and skill definitions", () => { + // given + const options = { pluginsEnabled: true } + + // when + const definitions = discoverPluginCommandDefinitions(options) + + // then + expect(Object.keys(definitions)).toContain("daplug:run-prompt") + expect(Object.keys(definitions)).toContain("daplug:plugin-plan") + }) + }) +}) diff --git a/src/shared/plugin-command-discovery.ts b/src/shared/plugin-command-discovery.ts new file mode 100644 index 000000000..615bf0b52 --- /dev/null +++ b/src/shared/plugin-command-discovery.ts @@ -0,0 +1,28 @@ +import { + discoverInstalledPlugins, + loadPluginCommands, + loadPluginSkillsAsCommands, +} from "../features/claude-code-plugin-loader" +import type { CommandDefinition } from "../features/claude-code-command-loader/types" + +export interface PluginCommandDiscoveryOptions { + pluginsEnabled?: boolean + enabledPluginsOverride?: Record +} + +export function discoverPluginCommandDefinitions( + options?: PluginCommandDiscoveryOptions, +): Record { + if (options?.pluginsEnabled === false) { + return {} + } + + const { plugins } = discoverInstalledPlugins({ + enabledPluginsOverride: options?.enabledPluginsOverride, + }) + + return { + ...loadPluginCommands(plugins), + ...loadPluginSkillsAsCommands(plugins), + } +} diff --git a/src/tools/slashcommand/command-discovery.ts b/src/tools/slashcommand/command-discovery.ts index 57182f020..078380526 100644 --- a/src/tools/slashcommand/command-discovery.ts +++ b/src/tools/slashcommand/command-discovery.ts @@ -1,15 +1,15 @@ import { existsSync, readdirSync, readFileSync } from "fs" import { basename, join } from "path" -import { parseFrontmatter, sanitizeModelField, getOpenCodeConfigDir } from "../../shared" +import { + parseFrontmatter, + sanitizeModelField, + getOpenCodeConfigDir, + discoverPluginCommandDefinitions, +} from "../../shared" import type { CommandFrontmatter } from "../../features/claude-code-command-loader/types" import { isMarkdownFile } from "../../shared/file-utils" import { getClaudeConfigDir } from "../../shared" import { loadBuiltinCommands } from "../../features/builtin-commands" -import { - discoverInstalledPlugins, - loadPluginCommands, - loadPluginSkillsAsCommands, -} from "../../features/claude-code-plugin-loader" import type { CommandInfo, CommandMetadata, CommandScope } from "./types" export interface CommandDiscoveryOptions { @@ -59,18 +59,7 @@ function discoverCommandsFromDir(commandsDir: string, scope: CommandScope): Comm } function discoverPluginCommands(options?: CommandDiscoveryOptions): CommandInfo[] { - if (options?.pluginsEnabled === false) { - return [] - } - - const { plugins } = discoverInstalledPlugins({ - enabledPluginsOverride: options?.enabledPluginsOverride, - }) - - const pluginDefinitions = { - ...loadPluginCommands(plugins), - ...loadPluginSkillsAsCommands(plugins), - } + const pluginDefinitions = discoverPluginCommandDefinitions(options) return Object.entries(pluginDefinitions).map(([name, definition]) => ({ name, diff --git a/src/tools/slashcommand/command-output-formatter.test.ts b/src/tools/slashcommand/command-output-formatter.test.ts new file mode 100644 index 000000000..dd2b7c169 --- /dev/null +++ b/src/tools/slashcommand/command-output-formatter.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "bun:test" +import { formatLoadedCommand } from "./command-output-formatter" +import type { CommandInfo } from "./types" + +describe("command output formatter", () => { + describe("#given command template includes argument placeholders", () => { + it("#then replaces both placeholder forms", async () => { + // given + const command: CommandInfo = { + name: "daplug:templated", + metadata: { + name: "daplug:templated", + description: "Templated plugin command", + }, + content: "Echo $ARGUMENTS and ${user_message}.", + scope: "plugin", + } + + // when + const output = await formatLoadedCommand(command, "ship it") + + // then + expect(output).toContain("Echo ship it and ship it.") + expect(output).not.toContain("$ARGUMENTS") + expect(output).not.toContain("${user_message}") + }) + }) +}) diff --git a/src/tools/slashcommand/command-output-formatter.ts b/src/tools/slashcommand/command-output-formatter.ts index 36c714c68..cbfe8a417 100644 --- a/src/tools/slashcommand/command-output-formatter.ts +++ b/src/tools/slashcommand/command-output-formatter.ts @@ -49,7 +49,9 @@ export async function formatLoadedCommand( let finalContent = resolvedContent.trim() if (userMessage) { - finalContent = finalContent.replace(/\$\{user_message\}/g, userMessage) + finalContent = finalContent + .replace(/\$\{user_message\}/g, userMessage) + .replace(/\$ARGUMENTS/g, userMessage) } sections.push(finalContent)