import { promises as fs, type Dirent } from "fs" import { join, basename } from "path" import { parseFrontmatter } from "../../shared/frontmatter" import { sanitizeModelField } from "../../shared/model-sanitizer" import { isMarkdownFile } from "../../shared/file-utils" import { EXCLUDED_DIRS, findProjectOpencodeCommandDirs, getClaudeConfigDir, getOpenCodeCommandDirs, } from "../../shared" import { log } from "../../shared/logger" import { clearCommandLoaderCache, deleteCachedCommands, getCachedCommands, getCommandLoaderCacheKey, setCachedCommands, } from "./loader-cache" import type { CommandScope, CommandDefinition, CommandFrontmatter, LoadedCommand } from "./types" export { clearCommandLoaderCache } async function loadCommandsFromDir( commandsDir: string, scope: CommandScope, visited: Set = new Set(), prefix: string = "" ): Promise { try { await fs.access(commandsDir) } catch { return [] } let realPath: string try { realPath = await fs.realpath(commandsDir) } catch (error) { log(`Failed to resolve command directory: ${commandsDir}`, error) return [] } if (visited.has(realPath)) { return [] } visited.add(realPath) let entries: Dirent[] try { entries = await fs.readdir(commandsDir, { withFileTypes: true }) } catch (error) { log(`Failed to read command directory: ${commandsDir}`, error) return [] } const commands: LoadedCommand[] = [] for (const entry of entries) { if (entry.isDirectory()) { if (EXCLUDED_DIRS.has(entry.name)) continue if (entry.name.startsWith(".")) continue const subDirPath = join(commandsDir, entry.name) const subPrefix = prefix ? `${prefix}/${entry.name}` : entry.name const subCommands = await loadCommandsFromDir(subDirPath, scope, visited, subPrefix) commands.push(...subCommands) continue } if (!isMarkdownFile(entry)) continue const commandPath = join(commandsDir, entry.name) const baseCommandName = basename(entry.name, ".md") const commandName = prefix ? `${prefix}/${baseCommandName}` : baseCommandName try { const content = await fs.readFile(commandPath, "utf-8") const { data, body } = parseFrontmatter(content) const wrappedTemplate = ` ${body.trim()} $ARGUMENTS ` const formattedDescription = `(${scope}) ${data.description || ""}` const isOpencodeSource = scope === "opencode" || scope === "opencode-project" const definition: CommandDefinition = { name: commandName, description: formattedDescription, template: wrappedTemplate, agent: data.agent, model: sanitizeModelField(data.model, isOpencodeSource ? "opencode" : "claude-code"), subtask: data.subtask, argumentHint: data["argument-hint"], handoffs: data.handoffs, } commands.push({ name: commandName, path: commandPath, definition, scope, }) } catch (error) { log(`Failed to parse command: ${commandPath}`, error) continue } } return commands } function deduplicateLoadedCommandsByName(commands: LoadedCommand[]): LoadedCommand[] { const seen = new Set() const deduplicatedCommands: LoadedCommand[] = [] for (const command of commands) { if (seen.has(command.name)) { continue } seen.add(command.name) deduplicatedCommands.push(command) } return deduplicatedCommands } function commandsToRecord(commands: LoadedCommand[]): Record { const result: Record = {} for (const cmd of deduplicateLoadedCommandsByName(commands)) { const { name: _name, argumentHint: _argumentHint, ...openCodeCompatible } = cmd.definition result[cmd.name] = openCodeCompatible as CommandDefinition } return result } export async function loadUserCommands(): Promise> { const userCommandsDir = join(getClaudeConfigDir(), "commands") const commands = await loadCommandsFromDir(userCommandsDir, "user") return commandsToRecord(commands) } export async function loadProjectCommands(directory?: string): Promise> { const projectCommandsDir = join(directory ?? process.cwd(), ".claude", "commands") const commands = await loadCommandsFromDir(projectCommandsDir, "project") return commandsToRecord(commands) } export async function loadOpencodeGlobalCommands(): Promise> { const opencodeCommandDirs = getOpenCodeCommandDirs({ binary: "opencode" }) const allCommands = await Promise.all( opencodeCommandDirs.map((commandsDir) => loadCommandsFromDir(commandsDir, "opencode")), ) return commandsToRecord(allCommands.flat()) } export async function loadOpencodeProjectCommands(directory?: string): Promise> { const opencodeProjectDirs = findProjectOpencodeCommandDirs(directory ?? process.cwd()) const allCommands = await Promise.all( opencodeProjectDirs.map((commandsDir) => loadCommandsFromDir(commandsDir, "opencode-project"), ), ) return commandsToRecord(allCommands.flat()) } export async function loadAllCommands(directory?: string): Promise> { const cacheKey = await getCommandLoaderCacheKey(directory) const cachedCommands = getCachedCommands(cacheKey) if (cachedCommands) { return cachedCommands } const loadCommandsPromise = Promise.all([ loadUserCommands(), loadProjectCommands(directory), loadOpencodeGlobalCommands(), loadOpencodeProjectCommands(directory), ]) .then(([user, project, global, projectOpencode]) => { return { ...projectOpencode, ...global, ...project, ...user } }) .catch((error) => { deleteCachedCommands(cacheKey) throw error }) setCachedCommands(cacheKey, loadCommandsPromise) return loadCommandsPromise }