2025-12-27 17:56:40 +08:00
import { tool , type ToolDefinition } from "@opencode-ai/plugin"
2025-12-09 15:48:29 +09:00
import { existsSync , readdirSync , readFileSync } from "fs"
import { join , basename , dirname } from "path"
import { parseFrontmatter , resolveCommandsInText , resolveFileReferencesInText , sanitizeModelField } from "../../shared"
2025-12-13 14:23:04 +09:00
import { isMarkdownFile } from "../../shared/file-utils"
2025-12-26 23:28:33 +09:00
import { getClaudeConfigDir } from "../../shared"
2025-12-30 10:42:05 +09:00
import { discoverAllSkills , type LoadedSkill } from "../../features/opencode-skill-loader"
2025-12-09 15:48:29 +09:00
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 ) {
2025-12-13 14:23:04 +09:00
if ( ! isMarkdownFile ( entry ) ) continue
2025-12-09 15:48:29 +09:00
const commandPath = join ( commandsDir , entry . name )
const commandName = basename ( entry . name , ".md" )
try {
const content = readFileSync ( commandPath , "utf-8" )
const { data , body } = parseFrontmatter ( content )
2025-12-14 12:24:59 +09:00
const isOpencodeSource = scope === "opencode" || scope === "opencode-project"
2025-12-09 15:48:29 +09:00
const metadata : CommandMetadata = {
name : commandName ,
description : data.description || "" ,
argumentHint : data [ "argument-hint" ] ,
2025-12-14 12:24:59 +09:00
model : sanitizeModelField ( data . model , isOpencodeSource ? "opencode" : "claude-code" ) ,
2025-12-09 15:48:29 +09:00
agent : data.agent ,
subtask : Boolean ( data . subtask ) ,
}
commands . push ( {
name : commandName ,
path : commandPath ,
metadata ,
content : body ,
scope ,
} )
} catch {
continue
}
}
return commands
}
function discoverCommandsSync ( ) : CommandInfo [ ] {
2025-12-26 23:28:33 +09:00
const { homedir } = require ( "os" )
const userCommandsDir = join ( getClaudeConfigDir ( ) , "commands" )
2025-12-09 15:48:29 +09:00
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 ]
}
2025-12-30 10:42:05 +09:00
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 ,
}
}
2025-12-09 15:48:29 +09:00
const availableCommands = discoverCommandsSync ( )
2025-12-30 10:42:05 +09:00
const availableSkills = discoverAllSkills ( )
const availableItems = [
. . . availableCommands ,
. . . availableSkills . map ( skillToCommandInfo ) ,
]
const commandListForDescription = availableItems
2025-12-09 15:48:29 +09:00
. 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" )
}
2025-12-30 10:42:05 +09:00
function formatCommandList ( items : CommandInfo [ ] ) : string {
if ( items . length === 0 ) {
return "No commands or skills found."
2025-12-09 15:48:29 +09:00
}
2025-12-30 10:42:05 +09:00
const lines = [ "# Available Commands & Skills\n" ]
2025-12-09 15:48:29 +09:00
2025-12-30 10:42:05 +09:00
for ( const cmd of items ) {
2025-12-09 15:48:29 +09:00
const hint = cmd . metadata . argumentHint ? ` ${ cmd . metadata . argumentHint } ` : ""
lines . push (
` - **/ ${ cmd . name } ${ hint } **: ${ cmd . metadata . description || "(no description)" } ( ${ cmd . scope } ) `
)
}
2025-12-30 10:42:05 +09:00
lines . push ( ` \ n**Total**: ${ items . length } items ` )
2025-12-09 15:48:29 +09:00
return lines . join ( "\n" )
}
2025-12-27 17:56:40 +08:00
export const slashcommand : ToolDefinition = tool ( {
2025-12-09 15:48:29 +09:00
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)
2025-12-26 23:28:33 +09:00
- $ CLAUDE_CONFIG_DIR/commands/ or ~/.claude/commands/ (user - Claude Code global commands)
2025-12-09 15:48:29 +09:00
2025-12-30 10:42:05 +09:00
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:
2025-12-09 15:48:29 +09:00
- 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 ( )
2025-12-30 10:42:05 +09:00
const skills = discoverAllSkills ( )
const allItems = [
. . . commands ,
. . . skills . map ( skillToCommandInfo ) ,
]
2025-12-09 15:48:29 +09:00
if ( ! args . command ) {
2025-12-30 10:42:05 +09:00
return formatCommandList ( allItems ) + "\n\nProvide a command or skill name to execute."
2025-12-09 15:48:29 +09:00
}
const cmdName = args . command . replace ( /^\// , "" )
2025-12-30 10:42:05 +09:00
const exactMatch = allItems . find (
2025-12-09 15:48:29 +09:00
( cmd ) = > cmd . name . toLowerCase ( ) === cmdName . toLowerCase ( )
)
if ( exactMatch ) {
return await formatLoadedCommand ( exactMatch )
}
2025-12-30 10:42:05 +09:00
const partialMatches = allItems . filter ( ( cmd ) = >
2025-12-09 15:48:29 +09:00
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 ` +
2025-12-30 10:42:05 +09:00
formatCommandList ( allItems )
2025-12-09 15:48:29 +09:00
)
}
return (
2025-12-30 10:42:05 +09:00
` Command or skill "/ ${ cmdName } " not found. \ n \ n ` +
formatCommandList ( allItems ) +
"\n\nTry a different name."
2025-12-09 15:48:29 +09:00
)
} ,
} )