Files
oh-my-opencode/src/tools/slashcommand/tools.ts
T

238 lines
7.9 KiB
TypeScript
Raw Normal View History

2025-12-27 17:56:40 +08:00
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 } from "../../shared"
import { isMarkdownFile } from "../../shared/file-utils"
import { getClaudeConfigDir } from "../../shared"
import { discoverAllSkills, type LoadedSkill } from "../../features/opencode-skill-loader"
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 (!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
}
function discoverCommandsSync(): CommandInfo[] {
const { homedir } = require("os")
const userCommandsDir = join(getClaudeConfigDir(), "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]
}
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,
}
}
const availableCommands = discoverCommandsSync()
const availableSkills = discoverAllSkills()
const availableItems = [
...availableCommands,
...availableSkills.map(skillToCommandInfo),
]
const commandListForDescription = availableItems
.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(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")
}
2025-12-27 17:56:40 +08:00
export const slashcommand: ToolDefinition = 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_CONFIG_DIR/commands/ or ~/.claude/commands/ (user - Claude Code global commands)
Skills are loaded from (priority order, highest wins):
- .opencode/skill/ (opencode-project - OpenCode project-specific skills)
- ./.claude/skills/ (project - Claude Code project-specific skills)
- ~/.config/opencode/skill/ (opencode - OpenCode global skills)
- $CLAUDE_CONFIG_DIR/skills/ or ~/.claude/skills/ (user - Claude Code global skills)
Each command/skill 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()
const skills = discoverAllSkills()
const allItems = [
...commands,
...skills.map(skillToCommandInfo),
]
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)
}
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."
)
},
})