feat(tools): add slashcommand tool for executing slash commands
Provides 'slashcommand' tool that executes commands loaded by command-loader. Handles shell injection and file reference resolution. 🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
2
src/tools/slashcommand/index.ts
Normal file
2
src/tools/slashcommand/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./types"
|
||||||
|
export { slashcommand } from "./tools"
|
||||||
202
src/tools/slashcommand/tools.ts
Normal file
202
src/tools/slashcommand/tools.ts
Normal file
@@ -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<string> {
|
||||||
|
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."
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
18
src/tools/slashcommand/types.ts
Normal file
18
src/tools/slashcommand/types.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user