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