diff --git a/src/features/command-loader/index.ts b/src/features/command-loader/index.ts new file mode 100644 index 0000000..644158c --- /dev/null +++ b/src/features/command-loader/index.ts @@ -0,0 +1,2 @@ +export * from "./types" +export * from "./loader" diff --git a/src/features/command-loader/loader.ts b/src/features/command-loader/loader.ts new file mode 100644 index 0000000..97e3f81 --- /dev/null +++ b/src/features/command-loader/loader.ts @@ -0,0 +1,94 @@ +import { existsSync, readdirSync, readFileSync } from "fs" +import { homedir } from "os" +import { join, basename } from "path" +import { parseFrontmatter } from "../../shared/frontmatter" +import { sanitizeModelField } from "../../shared/model-sanitizer" +import type { CommandScope, CommandDefinition, CommandFrontmatter, LoadedCommand } from "./types" + +function isMarkdownFile(entry: { name: string; isFile: () => boolean }): boolean { + return !entry.name.startsWith(".") && entry.name.endsWith(".md") && entry.isFile() +} + +function loadCommandsFromDir(commandsDir: string, scope: CommandScope): LoadedCommand[] { + if (!existsSync(commandsDir)) { + return [] + } + + const entries = readdirSync(commandsDir, { withFileTypes: true }) + const commands: LoadedCommand[] = [] + + 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 wrappedTemplate = ` +${body.trim()} + + + +$ARGUMENTS +` + + const formattedDescription = `(${scope}) ${data.description || ""}` + + const definition: CommandDefinition = { + name: commandName, + description: formattedDescription, + template: wrappedTemplate, + agent: data.agent, + model: sanitizeModelField(data.model), + subtask: data.subtask, + argumentHint: data["argument-hint"], + } + + commands.push({ + name: commandName, + path: commandPath, + definition, + scope, + }) + } catch { + continue + } + } + + return commands +} + +function commandsToRecord(commands: LoadedCommand[]): Record { + const result: Record = {} + for (const cmd of commands) { + result[cmd.name] = cmd.definition + } + return result +} + +export function loadUserCommands(): Record { + const userCommandsDir = join(homedir(), ".claude", "commands") + const commands = loadCommandsFromDir(userCommandsDir, "user") + return commandsToRecord(commands) +} + +export function loadProjectCommands(): Record { + const projectCommandsDir = join(process.cwd(), ".claude", "commands") + const commands = loadCommandsFromDir(projectCommandsDir, "project") + return commandsToRecord(commands) +} + +export function loadOpencodeGlobalCommands(): Record { + const opencodeCommandsDir = join(homedir(), ".config", "opencode", "command") + const commands = loadCommandsFromDir(opencodeCommandsDir, "opencode") + return commandsToRecord(commands) +} + +export function loadOpencodeProjectCommands(): Record { + const opencodeProjectDir = join(process.cwd(), ".opencode", "command") + const commands = loadCommandsFromDir(opencodeProjectDir, "opencode-project") + return commandsToRecord(commands) +} diff --git a/src/features/command-loader/types.ts b/src/features/command-loader/types.ts new file mode 100644 index 0000000..55b9b42 --- /dev/null +++ b/src/features/command-loader/types.ts @@ -0,0 +1,26 @@ +export type CommandScope = "user" | "project" | "opencode" | "opencode-project" + +export interface CommandDefinition { + name: string + description?: string + template: string + agent?: string + model?: string + subtask?: boolean + argumentHint?: string +} + +export interface CommandFrontmatter { + description?: string + "argument-hint"?: string + agent?: string + model?: string + subtask?: boolean +} + +export interface LoadedCommand { + name: string + path: string + definition: CommandDefinition + scope: CommandScope +}