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)