2025-12-09 15:48:29 +09:00
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"
2025-12-13 14:23:04 +09:00
import { isMarkdownFile } from "../../shared/file-utils"
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 )
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."
)
} ,
} )