From ee5df1683e311b35d5538aa52e88eba9fa718208 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 21 Feb 2026 14:38:18 +0900 Subject: [PATCH] refactor: remove slashcommand tool implementation --- src/tools/slashcommand/index.test.ts | 17 +++ src/tools/slashcommand/index.ts | 2 +- .../slashcommand/skill-command-converter.ts | 20 --- src/tools/slashcommand/skill-formatter.ts | 131 ------------------ .../slashcommand/slashcommand-description.ts | 26 ---- src/tools/slashcommand/slashcommand-tool.ts | 110 --------------- src/tools/slashcommand/tools.test.ts | 88 ------------ src/tools/slashcommand/tools.ts | 2 - src/tools/slashcommand/types.ts | 17 +-- 9 files changed, 19 insertions(+), 394 deletions(-) create mode 100644 src/tools/slashcommand/index.test.ts delete mode 100644 src/tools/slashcommand/skill-command-converter.ts delete mode 100644 src/tools/slashcommand/skill-formatter.ts delete mode 100644 src/tools/slashcommand/slashcommand-description.ts delete mode 100644 src/tools/slashcommand/slashcommand-tool.ts delete mode 100644 src/tools/slashcommand/tools.test.ts delete mode 100644 src/tools/slashcommand/tools.ts diff --git a/src/tools/slashcommand/index.test.ts b/src/tools/slashcommand/index.test.ts new file mode 100644 index 000000000..3555bd93f --- /dev/null +++ b/src/tools/slashcommand/index.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "bun:test" +import * as slashcommand from "./index" + +describe("slashcommand module exports", () => { + it("exports discovery API only", () => { + // given + const moduleExports = slashcommand as Record + + // when + const exportNames = Object.keys(moduleExports) + + // then + expect(exportNames).toContain("discoverCommandsSync") + expect(exportNames).not.toContain("createSlashcommandTool") + expect(exportNames).not.toContain("slashcommand") + }) +}) diff --git a/src/tools/slashcommand/index.ts b/src/tools/slashcommand/index.ts index d3092023d..ab769cd68 100644 --- a/src/tools/slashcommand/index.ts +++ b/src/tools/slashcommand/index.ts @@ -1,2 +1,2 @@ export * from "./types" -export { slashcommand, createSlashcommandTool, discoverCommandsSync } from "./tools" +export { discoverCommandsSync } from "./command-discovery" diff --git a/src/tools/slashcommand/skill-command-converter.ts b/src/tools/slashcommand/skill-command-converter.ts deleted file mode 100644 index 166b8ad99..000000000 --- a/src/tools/slashcommand/skill-command-converter.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { LoadedSkill } from "../../features/opencode-skill-loader" -import type { CommandInfo } from "./types" - -export function skillToCommandInfo(skill: LoadedSkill): CommandInfo { - return { - name: skill.name, - path: skill.path, - metadata: { - name: skill.name, - description: skill.definition.description || "", - argumentHint: skill.definition.argumentHint, - model: skill.definition.model, - agent: skill.definition.agent, - subtask: skill.definition.subtask, - }, - content: skill.definition.template, - scope: skill.scope, - lazyContentLoader: skill.lazyContent, - } -} diff --git a/src/tools/slashcommand/skill-formatter.ts b/src/tools/slashcommand/skill-formatter.ts deleted file mode 100644 index 9396c99c4..000000000 --- a/src/tools/slashcommand/skill-formatter.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { dirname } from "node:path" -import type { LoadedSkill } from "../../features/opencode-skill-loader" -import { extractSkillTemplate } from "../../features/opencode-skill-loader/skill-content" -import { injectGitMasterConfig as injectGitMasterConfigOriginal } from "../../features/opencode-skill-loader/skill-content" -import type { SkillMcpManager, SkillMcpClientInfo, SkillMcpServerContext } from "../../features/skill-mcp-manager" -import type { Tool, Resource, Prompt } from "@modelcontextprotocol/sdk/types.js" -import type { GitMasterConfig } from "../../config/schema/git-master" - -export async function extractSkillBody(skill: LoadedSkill): Promise { - if (skill.lazyContent) { - const fullTemplate = await skill.lazyContent.load() - const templateMatch = fullTemplate.match(/([\s\S]*?)<\/skill-instruction>/) - return templateMatch ? templateMatch[1].trim() : fullTemplate - } - - if (skill.path) { - return extractSkillTemplate(skill) - } - - const templateMatch = skill.definition.template?.match(/([\s\S]*?)<\/skill-instruction>/) - return templateMatch ? templateMatch[1].trim() : skill.definition.template || "" -} - -export async function formatMcpCapabilities( - skill: LoadedSkill, - manager: SkillMcpManager, - sessionID: string -): Promise { - if (!skill.mcpConfig || Object.keys(skill.mcpConfig).length === 0) { - return null - } - - const sections: string[] = ["", "## Available MCP Servers", ""] - - for (const [serverName, config] of Object.entries(skill.mcpConfig)) { - const info: SkillMcpClientInfo = { - serverName, - skillName: skill.name, - sessionID, - } - const context: SkillMcpServerContext = { - config, - skillName: skill.name, - } - - sections.push(`### ${serverName}`) - sections.push("") - - try { - const [tools, resources, prompts] = await Promise.all([ - manager.listTools(info, context).catch(() => []), - manager.listResources(info, context).catch(() => []), - manager.listPrompts(info, context).catch(() => []), - ]) - - if (tools.length > 0) { - sections.push("**Tools:**") - sections.push("") - for (const t of tools as Tool[]) { - sections.push(`#### \`${t.name}\``) - if (t.description) { - sections.push(t.description) - } - sections.push("") - sections.push("**inputSchema:**") - sections.push("```json") - sections.push(JSON.stringify(t.inputSchema, null, 2)) - sections.push("```") - sections.push("") - } - } - if (resources.length > 0) { - sections.push(`**Resources**: ${resources.map((r: Resource) => r.uri).join(", ")}`) - } - if (prompts.length > 0) { - sections.push(`**Prompts**: ${prompts.map((p: Prompt) => p.name).join(", ")}`) - } - - if (tools.length === 0 && resources.length === 0 && prompts.length === 0) { - sections.push("*No capabilities discovered*") - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - sections.push(`*Failed to connect: ${errorMessage.split("\n")[0]}*`) - } - - sections.push("") - sections.push(`Use \`skill_mcp\` tool with \`mcp_name="${serverName}"\` to invoke.`) - sections.push("") - } - - return sections.join("\n") -} - -export { injectGitMasterConfigOriginal as injectGitMasterConfig } - -export async function formatSkillOutput( - skill: LoadedSkill, - mcpManager?: SkillMcpManager, - getSessionID?: () => string, - gitMasterConfig?: GitMasterConfig -): Promise { - let body = await extractSkillBody(skill) - - if (skill.name === "git-master") { - body = injectGitMasterConfigOriginal(body, gitMasterConfig) - } - - const dir = skill.path ? dirname(skill.path) : skill.resolvedPath || process.cwd() - - const output = [ - `## Skill: ${skill.name}`, - "", - `**Base directory**: ${dir}`, - "", - body, - ] - - if (mcpManager && getSessionID && skill.mcpConfig) { - const mcpInfo = await formatMcpCapabilities( - skill, - mcpManager, - getSessionID() - ) - if (mcpInfo) { - output.push(mcpInfo) - } - } - - return output.join("\n") -} diff --git a/src/tools/slashcommand/slashcommand-description.ts b/src/tools/slashcommand/slashcommand-description.ts deleted file mode 100644 index 00717e913..000000000 --- a/src/tools/slashcommand/slashcommand-description.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { CommandInfo } from "./types" - -export const TOOL_DESCRIPTION_PREFIX = `Load a skill or execute a command to get detailed instructions for a specific task. - -Skills and commands provide specialized knowledge and step-by-step guidance. -Use this when a task matches an available skill's or command's description. - -**How to use:** -- Call with command name only: command='publish' -- Call with command and arguments: command='publish' user_message='patch' -- The tool will return detailed instructions for the command with your arguments substituted. -` - -export function buildDescriptionFromItems(items: CommandInfo[]): string { - const commandListForDescription = items - .map((command) => { - const hint = command.metadata.argumentHint ? ` ${command.metadata.argumentHint}` : "" - return `- /${command.name}${hint}: ${command.metadata.description} (${command.scope})` - }) - .join("\n") - - return `${TOOL_DESCRIPTION_PREFIX} - -${commandListForDescription} -` -} diff --git a/src/tools/slashcommand/slashcommand-tool.ts b/src/tools/slashcommand/slashcommand-tool.ts deleted file mode 100644 index 798c22816..000000000 --- a/src/tools/slashcommand/slashcommand-tool.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { tool, type ToolDefinition } from "@opencode-ai/plugin" -import { discoverAllSkills, type LoadedSkill } from "../../features/opencode-skill-loader" -import type { CommandInfo, SlashcommandToolOptions } from "./types" -import { discoverCommandsSync } from "./command-discovery" -import { buildDescriptionFromItems, TOOL_DESCRIPTION_PREFIX } from "./slashcommand-description" -import { formatCommandList, formatLoadedCommand } from "./command-output-formatter" -import { skillToCommandInfo } from "./skill-command-converter" -import { formatSkillOutput } from "./skill-formatter" - -export function createSlashcommandTool(options: SlashcommandToolOptions = {}): ToolDefinition { - let cachedCommands: CommandInfo[] | null = options.commands ?? null - let cachedSkills: LoadedSkill[] | null = options.skills ?? null - let cachedDescription: string | null = null - - const getCommands = (): CommandInfo[] => { - if (cachedCommands) return cachedCommands - cachedCommands = discoverCommandsSync() - return cachedCommands - } - - const getSkills = async (): Promise => { - if (cachedSkills) return cachedSkills - cachedSkills = await discoverAllSkills() - return cachedSkills - } - - const getAllItems = async (): Promise => { - const commands = getCommands() - const skills = await getSkills() - return [...commands, ...skills.map(skillToCommandInfo)] - } - - const buildDescription = async (): Promise => { - if (cachedDescription) return cachedDescription - const commands = getCommands() - cachedDescription = buildDescriptionFromItems(commands) - return cachedDescription - } - - if (options.commands !== undefined) { - cachedDescription = buildDescriptionFromItems(options.commands) - } else { - void buildDescription() - } - - return tool({ - get description() { - return cachedDescription ?? TOOL_DESCRIPTION_PREFIX - }, - - args: { - command: tool.schema - .string() - .describe( - "The slash command name (without leading slash). E.g., 'publish', 'commit', 'plan'" - ), - user_message: tool.schema - .string() - .optional() - .describe( - "Optional arguments or context to pass to the command. E.g., for '/publish patch', command='publish' user_message='patch'" - ), - }, - - async execute(args) { - const allItems = await getAllItems() - - if (!args.command) { - return formatCommandList(allItems) + "\n\nProvide a command or skill name to execute." - } - - const commandName = args.command.replace(/^\//, "") - - const exactMatch = allItems.find( - (command) => command.name.toLowerCase() === commandName.toLowerCase() - ) - - if (exactMatch) { - const skills = await getSkills() - const matchedSkill = skills.find(s => s.name === exactMatch.name) - - if (matchedSkill) { - return await formatSkillOutput( - matchedSkill, - options.mcpManager, - options.getSessionID, - options.gitMasterConfig - ) - } - - return await formatLoadedCommand(exactMatch, args.user_message) - } - - const partialMatches = allItems.filter((command) => - command.name.toLowerCase().includes(commandName.toLowerCase()) - ) - - if (partialMatches.length > 0) { - const matchList = partialMatches.map((command) => `/${command.name}`).join(", ") - return `No exact match for "/${commandName}". Did you mean: ${matchList}?\n\n${formatCommandList(allItems)}` - } - - return commandName.includes(":") - ? `Marketplace plugin commands like "/${commandName}" are not supported. Use .claude/commands/ for custom commands.\n\n${formatCommandList(allItems)}` - : `Command or skill "/${commandName}" not found.\n\n${formatCommandList(allItems)}\n\nTry a different name.` - }, - }) -} - -export const slashcommand: ToolDefinition = createSlashcommandTool() diff --git a/src/tools/slashcommand/tools.test.ts b/src/tools/slashcommand/tools.test.ts deleted file mode 100644 index aec487b58..000000000 --- a/src/tools/slashcommand/tools.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { describe, it, expect } from "bun:test" -import { createSlashcommandTool } from "./tools" -import type { CommandInfo } from "./types" -import type { LoadedSkill } from "../../features/opencode-skill-loader" - -function createMockCommand(name: string, description = ""): CommandInfo { - return { - name, - metadata: { - name, - description: description || `Test command ${name}`, - }, - scope: "builtin", - } -} - -function createMockSkill(name: string, description = ""): LoadedSkill { - return { - name, - path: `/test/skills/${name}/SKILL.md`, - resolvedPath: `/test/skills/${name}`, - definition: { - name, - description: description || `Test skill ${name}`, - template: "Test template", - }, - scope: "opencode-project", - } -} - -describe("slashcommand tool - synchronous description", () => { - it("includes only commands in description, not skills", () => { - // given - const commands = [createMockCommand("commit", "Create a git commit")] - const skills = [createMockSkill("playwright", "Browser automation via Playwright MCP")] - - // when - const tool = createSlashcommandTool({ commands, skills }) - - // then - expect(tool.description).toContain("commit") - expect(tool.description).not.toContain("playwright") - }) - - it("lists all commands but excludes skills from description", () => { - // given - const commands = [ - createMockCommand("commit", "Git commit"), - createMockCommand("plan", "Create plan"), - ] - const skills = [ - createMockSkill("playwright", "Browser automation"), - createMockSkill("frontend-ui-ux", "Frontend design"), - createMockSkill("git-master", "Git operations"), - ] - - // when - const tool = createSlashcommandTool({ commands, skills }) - - // then - expect(tool.description).toContain("commit") - expect(tool.description).toContain("plan") - expect(tool.description).not.toContain("playwright") - expect(tool.description).not.toContain("frontend-ui-ux") - expect(tool.description).not.toContain("git-master") - }) - - it("shows prefix-only description when both commands and skills are empty", () => { - // given / #when - const tool = createSlashcommandTool({ commands: [], skills: [] }) - - // then - even with no items, description should be built synchronously (not just prefix) - expect(tool.description).toContain("Load a skill") - }) - - it("includes user_message parameter documentation in description", () => { - // given - const commands = [createMockCommand("publish", "Publish package")] - const skills: LoadedSkill[] = [] - - // when - const tool = createSlashcommandTool({ commands, skills }) - - // then - expect(tool.description).toContain("user_message") - expect(tool.description).toContain("command='publish' user_message='patch'") - }) -}) diff --git a/src/tools/slashcommand/tools.ts b/src/tools/slashcommand/tools.ts deleted file mode 100644 index d4bbe4cbc..000000000 --- a/src/tools/slashcommand/tools.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { discoverCommandsSync } from "./command-discovery" -export { createSlashcommandTool, slashcommand } from "./slashcommand-tool" diff --git a/src/tools/slashcommand/types.ts b/src/tools/slashcommand/types.ts index 172b282c8..090e12178 100644 --- a/src/tools/slashcommand/types.ts +++ b/src/tools/slashcommand/types.ts @@ -1,6 +1,4 @@ -import type { LoadedSkill, LazyContentLoader } from "../../features/opencode-skill-loader" -import type { SkillMcpManager } from "../../features/skill-mcp-manager" -import type { GitMasterConfig } from "../../config/schema/git-master" +import type { LazyContentLoader } from "../../features/opencode-skill-loader" export type CommandScope = "builtin" | "config" | "user" | "project" | "opencode" | "opencode-project" @@ -21,16 +19,3 @@ export interface CommandInfo { scope: CommandScope lazyContentLoader?: LazyContentLoader } - -export interface SlashcommandToolOptions { - /** Pre-loaded commands (skip discovery if provided) */ - commands?: CommandInfo[] - /** Pre-loaded skills (skip discovery if provided) */ - skills?: LoadedSkill[] - /** MCP manager for skill MCP capabilities */ - mcpManager?: SkillMcpManager - /** Function to get current session ID */ - getSessionID?: () => string - /** Git master configuration */ - gitMasterConfig?: GitMasterConfig -}