248 lines
7.2 KiB
TypeScript

import { existsSync, readdirSync, readFileSync } from "fs"
import { join, basename, dirname } from "path"
import {
parseFrontmatter,
resolveCommandsInText,
resolveFileReferencesInText,
sanitizeModelField,
getClaudeConfigDir,
getOpenCodeConfigDir,
discoverPluginCommandDefinitions,
} from "../../shared"
import { loadBuiltinCommands } from "../../features/builtin-commands"
import type { CommandFrontmatter } from "../../features/claude-code-command-loader/types"
import { isMarkdownFile } from "../../shared/file-utils"
import { discoverAllSkills, type LoadedSkill, type LazyContentLoader } from "../../features/opencode-skill-loader"
import type { ParsedSlashCommand } from "./types"
interface CommandScope {
type: "user" | "project" | "opencode" | "opencode-project" | "skill" | "builtin" | "plugin"
}
interface CommandMetadata {
name: string
description: string
argumentHint?: string
model?: string
agent?: string
subtask?: boolean
}
interface CommandInfo {
name: string
path?: string
metadata: CommandMetadata
content?: string
scope: CommandScope["type"]
lazyContentLoader?: LazyContentLoader
}
function discoverCommandsFromDir(commandsDir: string, scope: CommandScope["type"]): 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<CommandFrontmatter>(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 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",
lazyContentLoader: skill.lazyContent,
}
}
export interface ExecutorOptions {
skills?: LoadedSkill[]
pluginsEnabled?: boolean
enabledPluginsOverride?: Record<string, boolean>
}
function discoverPluginCommands(options?: ExecutorOptions): CommandInfo[] {
const pluginDefinitions = discoverPluginCommandDefinitions(options)
return Object.entries(pluginDefinitions).map(([name, definition]) => ({
name,
metadata: {
name,
description: definition.description || "",
model: definition.model,
agent: definition.agent,
subtask: definition.subtask,
},
content: definition.template,
scope: "plugin",
}))
}
async function discoverAllCommands(options?: ExecutorOptions): Promise<CommandInfo[]> {
const configDir = getOpenCodeConfigDir({ binary: "opencode" })
const userCommandsDir = join(getClaudeConfigDir(), "commands")
const projectCommandsDir = join(process.cwd(), ".claude", "commands")
const opencodeGlobalDir = join(configDir, "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")
const builtinCommandsMap = loadBuiltinCommands()
const builtinCommands: CommandInfo[] = Object.values(builtinCommandsMap).map(cmd => ({
name: cmd.name,
metadata: {
name: cmd.name,
description: cmd.description || "",
model: cmd.model,
agent: cmd.agent,
subtask: cmd.subtask,
},
content: cmd.template,
scope: "builtin",
}))
const skills = options?.skills ?? await discoverAllSkills()
const skillCommands = skills.map(skillToCommandInfo)
const pluginCommands = discoverPluginCommands(options)
return [
...builtinCommands,
...opencodeProjectCommands,
...projectCommands,
...opencodeGlobalCommands,
...userCommands,
...skillCommands,
...pluginCommands,
]
}
async function findCommand(commandName: string, options?: ExecutorOptions): Promise<CommandInfo | null> {
const allCommands = await discoverAllCommands(options)
return allCommands.find(
(cmd) => cmd.name.toLowerCase() === commandName.toLowerCase()
) ?? null
}
async function formatCommandTemplate(cmd: CommandInfo, args: string): Promise<string> {
const sections: string[] = []
sections.push(`# /${cmd.name} Command\n`)
if (cmd.metadata.description) {
sections.push(`**Description**: ${cmd.metadata.description}\n`)
}
if (args) {
sections.push(`**User Arguments**: ${args}\n`)
}
if (cmd.metadata.model) {
sections.push(`**Model**: ${cmd.metadata.model}\n`)
}
if (cmd.metadata.agent) {
sections.push(`**Agent**: ${cmd.metadata.agent}\n`)
}
sections.push(`**Scope**: ${cmd.scope}\n`)
sections.push("---\n")
sections.push("## Command Instructions\n")
let content = cmd.content || ""
if (!content && cmd.lazyContentLoader) {
content = await cmd.lazyContentLoader.load()
}
const commandDir = cmd.path ? dirname(cmd.path) : process.cwd()
const withFileRefs = await resolveFileReferencesInText(content, commandDir)
const resolvedContent = await resolveCommandsInText(withFileRefs)
const resolvedArguments = args
const substitutedContent = resolvedContent
.replace(/\$\{user_message\}/g, resolvedArguments)
.replace(/\$ARGUMENTS/g, resolvedArguments)
sections.push(substitutedContent.trim())
if (args) {
sections.push("\n\n---\n")
sections.push("## User Request\n")
sections.push(args)
}
return sections.join("\n")
}
export interface ExecuteResult {
success: boolean
replacementText?: string
error?: string
}
export async function executeSlashCommand(parsed: ParsedSlashCommand, options?: ExecutorOptions): Promise<ExecuteResult> {
const command = await findCommand(parsed.command, options)
if (!command) {
return {
success: false,
error: `Command "/${parsed.command}" not found. Use the skill tool to list available skills and commands.`,
}
}
try {
const template = await formatCommandTemplate(command, parsed.args)
return {
success: true,
replacementText: template,
}
} catch (err) {
return {
success: false,
error: `Failed to load command "/${parsed.command}": ${err instanceof Error ? err.message : String(err)}`,
}
}
}