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:
@@ -51,6 +51,7 @@ export function createHooks(args: {
|
||||
|
||||
const skill = createSkillHooks({
|
||||
ctx,
|
||||
pluginConfig,
|
||||
isHookEnabled,
|
||||
safeHookEnabled,
|
||||
mergedSkills,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -102,6 +102,19 @@ After`
|
||||
expect(result?.args).toBe("project")
|
||||
})
|
||||
|
||||
it("should parse namespaced marketplace commands", () => {
|
||||
// given a namespaced command
|
||||
const text = "/daplug:run-prompt build bridge"
|
||||
|
||||
// when parsing
|
||||
const result = parseSlashCommand(text)
|
||||
|
||||
// then should keep full namespaced command
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.command).toBe("daplug:run-prompt")
|
||||
expect(result?.args).toBe("build bridge")
|
||||
})
|
||||
|
||||
it("should return null for non-slash text", () => {
|
||||
// given text without slash
|
||||
const text = "regular text"
|
||||
|
||||
168
src/hooks/auto-slash-command/executor.test.ts
Normal file
168
src/hooks/auto-slash-command/executor.test.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
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 "./executor"
|
||||
|
||||
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 })
|
||||
|
||||
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.
|
||||
`,
|
||||
)
|
||||
|
||||
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("auto-slash command executor plugin dispatch", () => {
|
||||
let tempDir = ""
|
||||
let envSnapshot: EnvSnapshot
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "omo-executor-plugin-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 })
|
||||
})
|
||||
|
||||
it("resolves marketplace plugin commands when plugin loading is enabled", async () => {
|
||||
const result = await executeSlashCommand(
|
||||
{
|
||||
command: "daplug:run-prompt",
|
||||
args: "ship it",
|
||||
raw: "/daplug:run-prompt ship it",
|
||||
},
|
||||
{
|
||||
skills: [],
|
||||
pluginsEnabled: true,
|
||||
},
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.replacementText).toContain("# /daplug:run-prompt Command")
|
||||
expect(result.replacementText).toContain("**Scope**: plugin")
|
||||
})
|
||||
|
||||
it("excludes marketplace commands when plugins are disabled via config toggle", async () => {
|
||||
const result = await executeSlashCommand(
|
||||
{
|
||||
command: "daplug:run-prompt",
|
||||
args: "",
|
||||
raw: "/daplug:run-prompt",
|
||||
},
|
||||
{
|
||||
skills: [],
|
||||
pluginsEnabled: false,
|
||||
},
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe(
|
||||
'Command "/daplug:run-prompt" not found. Use the skill tool to list available skills and commands.',
|
||||
)
|
||||
})
|
||||
|
||||
it("returns standard not-found for unknown namespaced commands", async () => {
|
||||
const result = await executeSlashCommand(
|
||||
{
|
||||
command: "daplug:missing",
|
||||
args: "",
|
||||
raw: "/daplug:missing",
|
||||
},
|
||||
{
|
||||
skills: [],
|
||||
pluginsEnabled: true,
|
||||
},
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe(
|
||||
'Command "/daplug:missing" not found. Use the skill tool to list available skills and commands.',
|
||||
)
|
||||
expect(result.error).not.toContain("Marketplace plugin commands")
|
||||
})
|
||||
})
|
||||
@@ -12,10 +12,15 @@ 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 {
|
||||
type: "user" | "project" | "opencode" | "opencode-project" | "skill" | "builtin"
|
||||
type: "user" | "project" | "opencode" | "opencode-project" | "skill" | "builtin" | "plugin"
|
||||
}
|
||||
|
||||
interface CommandMetadata {
|
||||
@@ -99,6 +104,36 @@ function skillToCommandInfo(skill: LoadedSkill): CommandInfo {
|
||||
|
||||
export interface ExecutorOptions {
|
||||
skills?: LoadedSkill[]
|
||||
pluginsEnabled?: boolean
|
||||
enabledPluginsOverride?: Record<string, boolean>
|
||||
}
|
||||
|
||||
function discoverPluginCommands(options?: ExecutorOptions): 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",
|
||||
}))
|
||||
}
|
||||
|
||||
async function discoverAllCommands(options?: ExecutorOptions): Promise<CommandInfo[]> {
|
||||
@@ -128,6 +163,7 @@ async function discoverAllCommands(options?: ExecutorOptions): Promise<CommandIn
|
||||
|
||||
const skills = options?.skills ?? await discoverAllSkills()
|
||||
const skillCommands = skills.map(skillToCommandInfo)
|
||||
const pluginCommands = discoverPluginCommands(options)
|
||||
|
||||
return [
|
||||
...builtinCommands,
|
||||
@@ -136,6 +172,7 @@ async function discoverAllCommands(options?: ExecutorOptions): Promise<CommandIn
|
||||
...opencodeGlobalCommands,
|
||||
...userCommands,
|
||||
...skillCommands,
|
||||
...pluginCommands,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -202,9 +239,7 @@ export async function executeSlashCommand(parsed: ParsedSlashCommand, options?:
|
||||
if (!command) {
|
||||
return {
|
||||
success: false,
|
||||
error: parsed.command.includes(":")
|
||||
? `Marketplace plugin commands like "/${parsed.command}" are not supported. Use .claude/commands/ for custom commands.`
|
||||
: `Command "/${parsed.command}" not found. Use the skill tool to list available skills and commands.`,
|
||||
error: `Command "/${parsed.command}" not found. Use the skill tool to list available skills and commands.`,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,11 +22,15 @@ const sessionProcessedCommandExecutions = new Set<string>()
|
||||
|
||||
export interface AutoSlashCommandHookOptions {
|
||||
skills?: LoadedSkill[]
|
||||
pluginsEnabled?: boolean
|
||||
enabledPluginsOverride?: Record<string, boolean>
|
||||
}
|
||||
|
||||
export function createAutoSlashCommandHook(options?: AutoSlashCommandHookOptions) {
|
||||
const executorOptions: ExecutorOptions = {
|
||||
skills: options?.skills,
|
||||
pluginsEnabled: options?.pluginsEnabled,
|
||||
enabledPluginsOverride: options?.enabledPluginsOverride,
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AvailableSkill } from "../../agents/dynamic-agent-prompt-builder"
|
||||
import type { HookName } from "../../config"
|
||||
import type { HookName, OhMyOpenCodeConfig } from "../../config"
|
||||
import type { LoadedSkill } from "../../features/opencode-skill-loader/types"
|
||||
import type { PluginContext } from "../types"
|
||||
|
||||
@@ -13,12 +13,20 @@ export type SkillHooks = {
|
||||
|
||||
export function createSkillHooks(args: {
|
||||
ctx: PluginContext
|
||||
pluginConfig: OhMyOpenCodeConfig
|
||||
isHookEnabled: (hookName: HookName) => boolean
|
||||
safeHookEnabled: boolean
|
||||
mergedSkills: LoadedSkill[]
|
||||
availableSkills: AvailableSkill[]
|
||||
}): SkillHooks {
|
||||
const { ctx, isHookEnabled, safeHookEnabled, mergedSkills, availableSkills } = args
|
||||
const {
|
||||
ctx,
|
||||
pluginConfig,
|
||||
isHookEnabled,
|
||||
safeHookEnabled,
|
||||
mergedSkills,
|
||||
availableSkills,
|
||||
} = args
|
||||
|
||||
const safeHook = <T>(hookName: HookName, factory: () => T): T | null =>
|
||||
safeCreateHook(hookName, factory, { enabled: safeHookEnabled })
|
||||
@@ -30,7 +38,11 @@ export function createSkillHooks(args: {
|
||||
|
||||
const autoSlashCommand = isHookEnabled("auto-slash-command")
|
||||
? safeHook("auto-slash-command", () =>
|
||||
createAutoSlashCommandHook({ skills: mergedSkills }))
|
||||
createAutoSlashCommandHook({
|
||||
skills: mergedSkills,
|
||||
pluginsEnabled: pluginConfig.claude_code?.plugins ?? true,
|
||||
enabledPluginsOverride: pluginConfig.claude_code?.plugins_override,
|
||||
}))
|
||||
: null
|
||||
|
||||
return { categorySkillReminder, autoSlashCommand }
|
||||
|
||||
@@ -95,7 +95,10 @@ export function createToolRegistry(args: {
|
||||
getSessionID: getSessionIDForMcp,
|
||||
})
|
||||
|
||||
const commands = discoverCommandsSync(ctx.directory)
|
||||
const commands = discoverCommandsSync(ctx.directory, {
|
||||
pluginsEnabled: pluginConfig.claude_code?.plugins ?? true,
|
||||
enabledPluginsOverride: pluginConfig.claude_code?.plugins_override,
|
||||
})
|
||||
const skillTool = createSkillTool({
|
||||
commands,
|
||||
skills: skillContext.mergedSkills,
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
160
src/tools/slashcommand/command-discovery.test.ts
Normal file
160
src/tools/slashcommand/command-discovery.test.ts
Normal file
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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,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
|
||||
|
||||
Reference in New Issue
Block a user