From 4400e18a529ee53057a2ba85aaa94a140e9b6c46 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 16:24:34 +0900 Subject: [PATCH] refactor(slashcommand): split tools.ts into discovery and formatting modules Extract slash command tool internals: - command-discovery.ts: command finding and listing - command-output-formatter.ts: output formatting - skill-command-converter.ts: skill-to-command conversion - slashcommand-description.ts: tool description generation - slashcommand-tool.ts: core tool definition --- src/tools/slashcommand/command-discovery.ts | 85 +++++ .../slashcommand/command-output-formatter.ts | 73 +++++ .../slashcommand/skill-command-converter.ts | 20 ++ .../slashcommand/slashcommand-description.ts | 26 ++ src/tools/slashcommand/slashcommand-tool.ts | 96 ++++++ src/tools/slashcommand/tools.ts | 296 +----------------- 6 files changed, 302 insertions(+), 294 deletions(-) create mode 100644 src/tools/slashcommand/command-discovery.ts create mode 100644 src/tools/slashcommand/command-output-formatter.ts create mode 100644 src/tools/slashcommand/skill-command-converter.ts create mode 100644 src/tools/slashcommand/slashcommand-description.ts create mode 100644 src/tools/slashcommand/slashcommand-tool.ts diff --git a/src/tools/slashcommand/command-discovery.ts b/src/tools/slashcommand/command-discovery.ts new file mode 100644 index 000000000..b44581af7 --- /dev/null +++ b/src/tools/slashcommand/command-discovery.ts @@ -0,0 +1,85 @@ +import { existsSync, readdirSync, readFileSync } from "fs" +import { basename, join } from "path" +import { parseFrontmatter, sanitizeModelField, getOpenCodeConfigDir } 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 type { CommandInfo, CommandMetadata, CommandScope } from "./types" + +function discoverCommandsFromDir(commandsDir: string, scope: CommandScope): CommandInfo[] { + if (!existsSync(commandsDir)) return [] + + const entries = readdirSync(commandsDir, { withFileTypes: true }) + const commands: CommandInfo[] = [] + + for (const entry of entries) { + if (!isMarkdownFile(entry)) continue + + const commandPath = join(commandsDir, entry.name) + const commandName = basename(entry.name, ".md") + + try { + const content = readFileSync(commandPath, "utf-8") + const { data, body } = parseFrontmatter(content) + + const isOpencodeSource = scope === "opencode" || scope === "opencode-project" + const metadata: CommandMetadata = { + name: commandName, + description: data.description || "", + argumentHint: data["argument-hint"], + model: sanitizeModelField(data.model, isOpencodeSource ? "opencode" : "claude-code"), + agent: data.agent, + subtask: Boolean(data.subtask), + } + + commands.push({ + name: commandName, + path: commandPath, + metadata, + content: body, + scope, + }) + } catch { + continue + } + } + + return commands +} + +export function discoverCommandsSync(): CommandInfo[] { + const configDir = getOpenCodeConfigDir({ binary: "opencode" }) + const userCommandsDir = join(getClaudeConfigDir(), "commands") + const projectCommandsDir = join(process.cwd(), ".claude", "commands") + const opencodeGlobalDir = join(configDir, "command") + const opencodeProjectDir = join(process.cwd(), ".opencode", "command") + + const userCommands = discoverCommandsFromDir(userCommandsDir, "user") + const opencodeGlobalCommands = discoverCommandsFromDir(opencodeGlobalDir, "opencode") + const projectCommands = discoverCommandsFromDir(projectCommandsDir, "project") + const opencodeProjectCommands = discoverCommandsFromDir(opencodeProjectDir, "opencode-project") + + const builtinCommandsMap = loadBuiltinCommands() + const builtinCommands: CommandInfo[] = Object.values(builtinCommandsMap).map((command) => ({ + name: command.name, + metadata: { + name: command.name, + description: command.description || "", + argumentHint: command.argumentHint, + model: command.model, + agent: command.agent, + subtask: command.subtask, + }, + content: command.template, + scope: "builtin", + })) + + return [ + ...builtinCommands, + ...opencodeProjectCommands, + ...projectCommands, + ...opencodeGlobalCommands, + ...userCommands, + ] +} diff --git a/src/tools/slashcommand/command-output-formatter.ts b/src/tools/slashcommand/command-output-formatter.ts new file mode 100644 index 000000000..36c714c68 --- /dev/null +++ b/src/tools/slashcommand/command-output-formatter.ts @@ -0,0 +1,73 @@ +import { dirname } from "path" +import { resolveCommandsInText, resolveFileReferencesInText } from "../../shared" +import type { CommandInfo } from "./types" + +export async function formatLoadedCommand( + command: CommandInfo, + userMessage?: string +): Promise { + const sections: string[] = [] + + sections.push(`# /${command.name} Command\n`) + + if (command.metadata.description) { + sections.push(`**Description**: ${command.metadata.description}\n`) + } + + if (command.metadata.argumentHint) { + sections.push(`**Usage**: /${command.name} ${command.metadata.argumentHint}\n`) + } + + if (userMessage) { + sections.push(`**Arguments**: ${userMessage}\n`) + } + + if (command.metadata.model) { + sections.push(`**Model**: ${command.metadata.model}\n`) + } + + if (command.metadata.agent) { + sections.push(`**Agent**: ${command.metadata.agent}\n`) + } + + if (command.metadata.subtask) { + sections.push("**Subtask**: true\n") + } + + sections.push(`**Scope**: ${command.scope}\n`) + sections.push("---\n") + sections.push("## Command Instructions\n") + + let content = command.content || "" + if (!content && command.lazyContentLoader) { + content = await command.lazyContentLoader.load() + } + + const commandDir = command.path ? dirname(command.path) : process.cwd() + const withFileReferences = await resolveFileReferencesInText(content, commandDir) + const resolvedContent = await resolveCommandsInText(withFileReferences) + + let finalContent = resolvedContent.trim() + if (userMessage) { + finalContent = finalContent.replace(/\$\{user_message\}/g, userMessage) + } + + sections.push(finalContent) + return sections.join("\n") +} + +export function formatCommandList(items: CommandInfo[]): string { + if (items.length === 0) return "No commands or skills found." + + const lines = ["# Available Commands & Skills\n"] + + for (const command of items) { + const hint = command.metadata.argumentHint ? ` ${command.metadata.argumentHint}` : "" + lines.push( + `- **/${command.name}${hint}**: ${command.metadata.description || "(no description)"} (${command.scope})` + ) + } + + lines.push(`\n**Total**: ${items.length} items`) + return lines.join("\n") +} diff --git a/src/tools/slashcommand/skill-command-converter.ts b/src/tools/slashcommand/skill-command-converter.ts new file mode 100644 index 000000000..166b8ad99 --- /dev/null +++ b/src/tools/slashcommand/skill-command-converter.ts @@ -0,0 +1,20 @@ +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/slashcommand-description.ts b/src/tools/slashcommand/slashcommand-description.ts new file mode 100644 index 000000000..00717e913 --- /dev/null +++ b/src/tools/slashcommand/slashcommand-description.ts @@ -0,0 +1,26 @@ +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 new file mode 100644 index 000000000..fb1227f3c --- /dev/null +++ b/src/tools/slashcommand/slashcommand-tool.ts @@ -0,0 +1,96 @@ +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" + +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 allItems = await getAllItems() + cachedDescription = buildDescriptionFromItems(allItems) + return cachedDescription + } + + if (options.commands !== undefined && options.skills !== undefined) { + const allItems = [...options.commands, ...options.skills.map(skillToCommandInfo)] + cachedDescription = buildDescriptionFromItems(allItems) + } 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) { + 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 `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.ts b/src/tools/slashcommand/tools.ts index 8cf3ff3b4..d4bbe4cbc 100644 --- a/src/tools/slashcommand/tools.ts +++ b/src/tools/slashcommand/tools.ts @@ -1,294 +1,2 @@ -import { tool, type ToolDefinition } from "@opencode-ai/plugin" -import { existsSync, readdirSync, readFileSync } from "fs" -import { join, basename, dirname } from "path" -import { parseFrontmatter, resolveCommandsInText, resolveFileReferencesInText, sanitizeModelField, getOpenCodeConfigDir } from "../../shared" -import type { CommandFrontmatter } from "../../features/claude-code-command-loader/types" -import { isMarkdownFile } from "../../shared/file-utils" -import { getClaudeConfigDir } from "../../shared" -import { discoverAllSkills, type LoadedSkill } from "../../features/opencode-skill-loader" -import { loadBuiltinCommands } from "../../features/builtin-commands" -import type { CommandScope, CommandMetadata, CommandInfo, SlashcommandToolOptions } from "./types" - -function discoverCommandsFromDir(commandsDir: string, scope: CommandScope): CommandInfo[] { - if (!existsSync(commandsDir)) { - return [] - } - - const entries = readdirSync(commandsDir, { withFileTypes: true }) - const commands: CommandInfo[] = [] - - for (const entry of entries) { - if (!isMarkdownFile(entry)) continue - - const commandPath = join(commandsDir, entry.name) - const commandName = basename(entry.name, ".md") - - try { - const content = readFileSync(commandPath, "utf-8") - const { data, body } = parseFrontmatter(content) - - const isOpencodeSource = scope === "opencode" || scope === "opencode-project" - const metadata: CommandMetadata = { - name: commandName, - description: data.description || "", - argumentHint: data["argument-hint"], - model: sanitizeModelField(data.model, isOpencodeSource ? "opencode" : "claude-code"), - agent: data.agent, - subtask: Boolean(data.subtask), - } - - commands.push({ - name: commandName, - path: commandPath, - metadata, - content: body, - scope, - }) - } catch { - continue - } - } - - return commands -} - -export function discoverCommandsSync(): CommandInfo[] { - const configDir = getOpenCodeConfigDir({ binary: "opencode" }) - const userCommandsDir = join(getClaudeConfigDir(), "commands") - const projectCommandsDir = join(process.cwd(), ".claude", "commands") - const opencodeGlobalDir = join(configDir, "command") - const opencodeProjectDir = join(process.cwd(), ".opencode", "command") - - const userCommands = discoverCommandsFromDir(userCommandsDir, "user") - const opencodeGlobalCommands = discoverCommandsFromDir(opencodeGlobalDir, "opencode") - const projectCommands = discoverCommandsFromDir(projectCommandsDir, "project") - const opencodeProjectCommands = discoverCommandsFromDir(opencodeProjectDir, "opencode-project") - - const builtinCommandsMap = loadBuiltinCommands() - const builtinCommands: CommandInfo[] = Object.values(builtinCommandsMap).map(cmd => ({ - name: cmd.name, - metadata: { - name: cmd.name, - description: cmd.description || "", - argumentHint: cmd.argumentHint, - model: cmd.model, - agent: cmd.agent, - subtask: cmd.subtask - }, - content: cmd.template, - scope: "builtin" - })) - - return [...builtinCommands, ...opencodeProjectCommands, ...projectCommands, ...opencodeGlobalCommands, ...userCommands] -} - -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, - } -} - -async function formatLoadedCommand(cmd: CommandInfo, userMessage?: string): Promise { - const sections: string[] = [] - - sections.push(`# /${cmd.name} Command\n`) - - if (cmd.metadata.description) { - sections.push(`**Description**: ${cmd.metadata.description}\n`) - } - - if (cmd.metadata.argumentHint) { - sections.push(`**Usage**: /${cmd.name} ${cmd.metadata.argumentHint}\n`) - } - - if (userMessage) { - sections.push(`**Arguments**: ${userMessage}\n`) - } - - if (cmd.metadata.model) { - sections.push(`**Model**: ${cmd.metadata.model}\n`) - } - - if (cmd.metadata.agent) { - sections.push(`**Agent**: ${cmd.metadata.agent}\n`) - } - - if (cmd.metadata.subtask) { - sections.push(`**Subtask**: true\n`) - } - - sections.push(`**Scope**: ${cmd.scope}\n`) - sections.push("---\n") - sections.push("## Command Instructions\n") - - let content = cmd.content || "" - if (!content && cmd.lazyContentLoader) { - content = await cmd.lazyContentLoader.load() - } - - const commandDir = cmd.path ? dirname(cmd.path) : process.cwd() - const withFileRefs = await resolveFileReferencesInText(content, commandDir) - const resolvedContent = await resolveCommandsInText(withFileRefs) - - // Substitute user_message into content if provided - let finalContent = resolvedContent.trim() - if (userMessage) { - finalContent = finalContent.replace(/\$\{user_message\}/g, userMessage) - } - - sections.push(finalContent) - - return sections.join("\n") -} - -function formatCommandList(items: CommandInfo[]): string { - if (items.length === 0) { - return "No commands or skills found." - } - - const lines = ["# Available Commands & Skills\n"] - - for (const cmd of items) { - const hint = cmd.metadata.argumentHint ? ` ${cmd.metadata.argumentHint}` : "" - lines.push( - `- **/${cmd.name}${hint}**: ${cmd.metadata.description || "(no description)"} (${cmd.scope})` - ) - } - - lines.push(`\n**Total**: ${items.length} items`) - return lines.join("\n") -} - -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. -` - -function buildDescriptionFromItems(items: CommandInfo[]): string { - const commandListForDescription = items - .map((cmd) => { - const hint = cmd.metadata.argumentHint ? ` ${cmd.metadata.argumentHint}` : "" - return `- /${cmd.name}${hint}: ${cmd.metadata.description} (${cmd.scope})` - }) - .join("\n") - - return `${TOOL_DESCRIPTION_PREFIX} - -${commandListForDescription} -` -} - -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 allItems = await getAllItems() - cachedDescription = buildDescriptionFromItems(allItems) - return cachedDescription - } - - if (options.commands !== undefined && options.skills !== undefined) { - const allItems = [...options.commands, ...options.skills.map(skillToCommandInfo)] - cachedDescription = buildDescriptionFromItems(allItems) - } else { - 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 cmdName = args.command.replace(/^\//, "") - - const exactMatch = allItems.find( - (cmd) => cmd.name.toLowerCase() === cmdName.toLowerCase() - ) - - if (exactMatch) { - return await formatLoadedCommand(exactMatch, args.user_message) - } - - const partialMatches = allItems.filter((cmd) => - cmd.name.toLowerCase().includes(cmdName.toLowerCase()) - ) - - if (partialMatches.length > 0) { - const matchList = partialMatches.map((cmd) => `/${cmd.name}`).join(", ") - return ( - `No exact match for "/${cmdName}". Did you mean: ${matchList}?\n\n` + - formatCommandList(allItems) - ) - } - - return ( - `Command or skill "/${cmdName}" not found.\n\n` + - formatCommandList(allItems) + - "\n\nTry a different name." - ) - }, - }) -} - -// Default instance for backward compatibility (lazy loading) -export const slashcommand: ToolDefinition = createSlashcommandTool() +export { discoverCommandsSync } from "./command-discovery" +export { createSlashcommandTool, slashcommand } from "./slashcommand-tool"