Merge pull request #2021 from cruzanstx/feat/marketplace-plugin-dispatch

feat(dispatch): wire marketplace plugin commands into slash command dispatch
This commit is contained in:
YeonGyu-Kim
2026-03-02 23:28:20 +09:00
committed by GitHub
14 changed files with 461 additions and 14 deletions
+1 -1
View File
@@ -464,7 +464,7 @@ describe("skill tool - ordering and priority", () => {
const tool = createSkillTool({ skills, commands })
//#then: should include priority info
expect(tool.description).toContain("Priority: project > user > opencode > builtin")
expect(tool.description).toContain("Priority: project > user > opencode > builtin/plugin")
expect(tool.description).toContain("Skills listed before commands")
})
+6 -2
View File
@@ -16,6 +16,7 @@ const scopePriority: Record<string, number> = {
user: 3,
opencode: 2,
"opencode-project": 2,
plugin: 1,
config: 1,
builtin: 1,
}
@@ -89,7 +90,7 @@ function formatCombinedDescription(skills: SkillInfo[], commands: CommandInfo[])
}
if (allItems.length > 0) {
lines.push(`\n<available_items>\nPriority: project > user > opencode > builtin | Skills listed before commands\nInvoke via: skill(name="item-name") — omit leading slash for commands.\n${allItems.join("\n")}\n</available_items>`)
lines.push(`\n<available_items>\nPriority: project > user > opencode > builtin/plugin | Skills listed before commands\nInvoke via: skill(name="item-name") — omit leading slash for commands.\n${allItems.join("\n")}\n</available_items>`)
}
return TOOL_DESCRIPTION_PREFIX + lines.join("")
@@ -195,7 +196,10 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
const getCommands = (): CommandInfo[] => {
if (cachedCommands) return cachedCommands
cachedCommands = discoverCommandsSync()
cachedCommands = discoverCommandsSync(undefined, {
pluginsEnabled: options.pluginsEnabled,
enabledPluginsOverride: options.enabledPluginsOverride,
})
return cachedCommands
}
+4
View File
@@ -33,4 +33,8 @@ export interface SkillLoadOptions {
/** Git master configuration for watermark/co-author settings */
gitMasterConfig?: GitMasterConfig
disabledSkills?: Set<string>
/** Include Claude marketplace plugin commands in discovery (default: true) */
pluginsEnabled?: boolean
/** Override plugin enablement from Claude settings by plugin key */
enabledPluginsOverride?: Record<string, boolean>
}
@@ -0,0 +1,160 @@
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 { discoverCommandsSync } from "./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): { projectDir: string } {
const projectDir = join(baseDir, "project")
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(projectDir, { recursive: true })
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
return { projectDir }
}
describe("slashcommand command discovery plugin integration", () => {
let tempDir = ""
let projectDir = ""
let envSnapshot: EnvSnapshot
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "omo-command-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,
}
const setup = writePluginFixture(tempDir)
projectDir = setup.projectDir
})
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 })
})
it("discovers marketplace plugin commands and skills as command items", () => {
const commands = discoverCommandsSync(projectDir, { pluginsEnabled: true })
const names = commands.map(command => command.name)
expect(names).toContain("daplug:run-prompt")
expect(names).toContain("daplug:plugin-plan")
const pluginCommand = commands.find(command => command.name === "daplug:run-prompt")
const pluginSkill = commands.find(command => command.name === "daplug:plugin-plan")
expect(pluginCommand?.scope).toBe("plugin")
expect(pluginSkill?.scope).toBe("plugin")
})
it("omits marketplace plugin commands when plugins are disabled", () => {
const commands = discoverCommandsSync(projectDir, { pluginsEnabled: false })
const names = commands.map(command => command.name)
expect(names).not.toContain("daplug:run-prompt")
expect(names).not.toContain("daplug:plugin-plan")
})
it("honors plugins_override by disabling overridden plugin keys", () => {
const commands = discoverCommandsSync(projectDir, {
pluginsEnabled: true,
enabledPluginsOverride: { "daplug@1.0.0": false },
})
const names = commands.map(command => command.name)
expect(names).not.toContain("daplug:run-prompt")
expect(names).not.toContain("daplug:plugin-plan")
})
})
+44 -1
View File
@@ -5,8 +5,18 @@ import type { CommandFrontmatter } from "../../features/claude-code-command-load
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 {
pluginsEnabled?: boolean
enabledPluginsOverride?: Record<string, boolean>
}
function discoverCommandsFromDir(commandsDir: string, scope: CommandScope): CommandInfo[] {
if (!existsSync(commandsDir)) return []
@@ -48,7 +58,38 @@ function discoverCommandsFromDir(commandsDir: string, scope: CommandScope): Comm
return commands
}
export function discoverCommandsSync(directory?: string): CommandInfo[] {
function discoverPluginCommands(options?: CommandDiscoveryOptions): CommandInfo[] {
if (options?.pluginsEnabled === false) {
return []
}
const { plugins } = discoverInstalledPlugins({
enabledPluginsOverride: options?.enabledPluginsOverride,
})
const pluginDefinitions = {
...loadPluginCommands(plugins),
...loadPluginSkillsAsCommands(plugins),
}
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",
}))
}
export function discoverCommandsSync(
directory?: string,
options?: CommandDiscoveryOptions,
): CommandInfo[] {
const configDir = getOpenCodeConfigDir({ binary: "opencode" })
const userCommandsDir = join(getClaudeConfigDir(), "commands")
const projectCommandsDir = join(directory ?? process.cwd(), ".claude", "commands")
@@ -59,6 +100,7 @@ export function discoverCommandsSync(directory?: string): CommandInfo[] {
const opencodeGlobalCommands = discoverCommandsFromDir(opencodeGlobalDir, "opencode")
const projectCommands = discoverCommandsFromDir(projectCommandsDir, "project")
const opencodeProjectCommands = discoverCommandsFromDir(opencodeProjectDir, "opencode-project")
const pluginCommands = discoverPluginCommands(options)
const builtinCommandsMap = loadBuiltinCommands()
const builtinCommands: CommandInfo[] = Object.values(builtinCommandsMap).map((command) => ({
@@ -81,5 +123,6 @@ export function discoverCommandsSync(directory?: string): CommandInfo[] {
...opencodeProjectCommands,
...opencodeGlobalCommands,
...builtinCommands,
...pluginCommands,
]
}
+1 -1
View File
@@ -1,6 +1,6 @@
import type { LazyContentLoader } from "../../features/opencode-skill-loader"
export type CommandScope = "builtin" | "config" | "user" | "project" | "opencode" | "opencode-project"
export type CommandScope = "builtin" | "config" | "user" | "project" | "opencode" | "opencode-project" | "plugin"
export interface CommandMetadata {
name: string