fix(dispatch): resolve plugin namespace parsing, template substitution, and discovery duplication

This commit is contained in:
YeonGyu-Kim
2026-03-03 00:14:01 +09:00
parent f383d7abb5
commit c084cc3f26
10 changed files with 256 additions and 38 deletions

View File

@@ -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")
})
})
})

View File

@@ -3,7 +3,7 @@ export const HOOK_NAME = "auto-slash-command" as const
export const AUTO_SLASH_COMMAND_TAG_OPEN = "<auto-slash-command>"
export const AUTO_SLASH_COMMAND_TAG_CLOSE = "</auto-slash-command>"
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",

View File

@@ -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}")
})
})

View File

@@ -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<st
const commandDir = cmd.path ? dirname(cmd.path) : process.cwd()
const withFileRefs = await resolveFileReferencesInText(content, commandDir)
const resolvedContent = await resolveCommandsInText(withFileRefs)
sections.push(resolvedContent.trim())
const resolvedArguments = args
const substitutedContent = resolvedContent
.replace(/\$\{user_message\}/g, resolvedArguments)
.replace(/\$ARGUMENTS/g, resolvedArguments)
sections.push(substitutedContent.trim())
if (args) {
sections.push("\n\n---\n")

View File

@@ -60,4 +60,5 @@ 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 { SessionCategoryRegistry } from "./session-category-registry"

View File

@@ -0,0 +1,135 @@
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 { discoverPluginCommandDefinitions } from "./plugin-command-discovery"
const ENV_KEYS = [
"CLAUDE_CONFIG_DIR",
"CLAUDE_PLUGINS_HOME",
"CLAUDE_SETTINGS_PATH",
"OPENCODE_CONFIG_DIR",
] as const
type EnvKey = (typeof ENV_KEYS)[number]
type EnvSnapshot = Record<EnvKey, string | undefined>
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")
})
})
})

View File

@@ -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<string, boolean>
}
export function discoverPluginCommandDefinitions(
options?: PluginCommandDiscoveryOptions,
): Record<string, CommandDefinition> {
if (options?.pluginsEnabled === false) {
return {}
}
const { plugins } = discoverInstalledPlugins({
enabledPluginsOverride: options?.enabledPluginsOverride,
})
return {
...loadPluginCommands(plugins),
...loadPluginSkillsAsCommands(plugins),
}
}

View File

@@ -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,

View File

@@ -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}")
})
})
})

View File

@@ -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)