diff --git a/src/tools/slashcommand/index.ts b/src/tools/slashcommand/index.ts new file mode 100644 index 0000000..0071c5f --- /dev/null +++ b/src/tools/slashcommand/index.ts @@ -0,0 +1,2 @@ +export * from "./types" +export { slashcommand } from "./tools" diff --git a/src/tools/slashcommand/tools.ts b/src/tools/slashcommand/tools.ts new file mode 100644 index 0000000..21f9544 --- /dev/null +++ b/src/tools/slashcommand/tools.ts @@ -0,0 +1,202 @@ +import { tool } from "@opencode-ai/plugin" +import { existsSync, readdirSync, readFileSync } from "fs" +import { homedir } from "os" +import { join, basename, dirname } from "path" +import { parseFrontmatter, resolveCommandsInText, resolveFileReferencesInText, sanitizeModelField } from "../../shared" +import type { CommandScope, CommandMetadata, CommandInfo } 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 (entry.name.startsWith(".")) continue + if (!entry.name.endsWith(".md")) continue + if (!entry.isFile()) 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 metadata: CommandMetadata = { + name: commandName, + description: data.description || "", + argumentHint: data["argument-hint"], + model: sanitizeModelField(data.model), + agent: data.agent, + subtask: Boolean(data.subtask), + } + + commands.push({ + name: commandName, + path: commandPath, + metadata, + content: body, + scope, + }) + } catch { + continue + } + } + + return commands +} + +function discoverCommandsSync(): CommandInfo[] { + const userCommandsDir = join(homedir(), ".claude", "commands") + const projectCommandsDir = join(process.cwd(), ".claude", "commands") + const opencodeGlobalDir = join(homedir(), ".config", "opencode", "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") + + return [...opencodeProjectCommands, ...projectCommands, ...opencodeGlobalCommands, ...userCommands] +} + +const availableCommands = discoverCommandsSync() +const commandListForDescription = availableCommands + .map((cmd) => { + const hint = cmd.metadata.argumentHint ? ` ${cmd.metadata.argumentHint}` : "" + return `- /${cmd.name}${hint}: ${cmd.metadata.description} (${cmd.scope})` + }) + .join("\n") + +async function formatLoadedCommand(cmd: CommandInfo): 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 (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") + + const commandDir = dirname(cmd.path) + const withFileRefs = await resolveFileReferencesInText(cmd.content, commandDir) + const resolvedContent = await resolveCommandsInText(withFileRefs) + sections.push(resolvedContent.trim()) + + return sections.join("\n") +} + +function formatCommandList(commands: CommandInfo[]): string { + if (commands.length === 0) { + return "No commands found." + } + + const lines = ["# Available Commands\n"] + + for (const cmd of commands) { + const hint = cmd.metadata.argumentHint ? ` ${cmd.metadata.argumentHint}` : "" + lines.push( + `- **/${cmd.name}${hint}**: ${cmd.metadata.description || "(no description)"} (${cmd.scope})` + ) + } + + lines.push(`\n**Total**: ${commands.length} commands`) + return lines.join("\n") +} + +export const slashcommand = tool({ + description: `Execute a slash command within the main conversation. + +When you use this tool, the slash command gets expanded to a full prompt that provides detailed instructions on how to complete the task. + +How slash commands work: +- Invoke commands using this tool with the command name (without arguments) +- The command's prompt will expand and provide detailed instructions +- Arguments from user input should be passed separately + +Important: +- Only use commands listed in Available Commands below +- Do not invoke a command that is already running +- **CRITICAL**: When user's message starts with '/' (e.g., "/commit", "/plan"), you MUST immediately invoke this tool with that command. Do NOT attempt to handle the command manually. + +Commands are loaded from (priority order, highest wins): +- .opencode/command/ (opencode-project - OpenCode project-specific commands) +- ./.claude/commands/ (project - Claude Code project-specific commands) +- ~/.config/opencode/command/ (opencode - OpenCode global commands) +- ~/.claude/commands/ (user - Claude Code global commands) + +Each command is a markdown file with: +- YAML frontmatter: description, argument-hint, model, agent, subtask (optional) +- Markdown body: The command instructions/prompt +- File references: @path/to/file (relative to command file location) +- Shell injection: \`!\`command\`\` (executes and injects output) + +Available Commands: +${commandListForDescription}`, + + args: { + command: tool.schema + .string() + .describe( + "The slash command to execute (without the leading slash). E.g., 'commit', 'plan', 'execute'." + ), + }, + + async execute(args) { + const commands = discoverCommandsSync() + + if (!args.command) { + return formatCommandList(commands) + "\n\nProvide a command name to execute." + } + + const cmdName = args.command.replace(/^\//, "") + + const exactMatch = commands.find( + (cmd) => cmd.name.toLowerCase() === cmdName.toLowerCase() + ) + + if (exactMatch) { + return await formatLoadedCommand(exactMatch) + } + + const partialMatches = commands.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(commands) + ) + } + + return ( + `Command "/${cmdName}" not found.\n\n` + + formatCommandList(commands) + + "\n\nTry a different command name." + ) + }, +}) diff --git a/src/tools/slashcommand/types.ts b/src/tools/slashcommand/types.ts new file mode 100644 index 0000000..41142d0 --- /dev/null +++ b/src/tools/slashcommand/types.ts @@ -0,0 +1,18 @@ +export type CommandScope = "user" | "project" | "opencode" | "opencode-project" + +export interface CommandMetadata { + name: string + description: string + argumentHint?: string + model?: string + agent?: string + subtask?: boolean +} + +export interface CommandInfo { + name: string + path: string + metadata: CommandMetadata + content: string + scope: CommandScope +}