import { readFile, readdir } from "node:fs/promises"; import { join } from "node:path"; import type { SlashCommand } from "@magiccode/core"; import { parseFrontmatter } from "./frontmatter"; import { expandShellCommands } from "./shell-commands"; /** Noms réservés aux commandes internes de MagicCode (gérées par la CLI). */ export const RESERVED_COMMANDS = new Set(["help", "clear", "mode", "model", "exit"]); async function listMarkdown(dir: string, prefix = ""): Promise<{ name: string; file: string }[]> { let entries; try { entries = await readdir(dir, { withFileTypes: true }); } catch { return []; } entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); const found: { name: string; file: string }[] = []; for (const e of entries) { if (e.isDirectory()) { found.push(...(await listMarkdown(join(dir, e.name), `${prefix}${e.name}:`))); } else if (e.isFile() && e.name.endsWith(".md")) { found.push({ name: `${prefix}${e.name.slice(0, -3)}`, file: join(dir, e.name) }); } } return found; } /** $ARGUMENTS = tous les arguments, $1…$9 = arguments séparés par des espaces. */ export function renderCommand(body: string, args: string): string { const tokens = args.trim() ? args.trim().split(/\s+/) : []; let usesArgs = false; const out = body.replace(/\$ARGUMENTS|\$([1-9])/g, (_m, n: string | undefined) => { usesArgs = true; return n ? (tokens[Number(n) - 1] ?? "") : args.trim(); }); return !usesArgs && args.trim() ? `${out.trimEnd()}\n\nARGUMENTS: ${args.trim()}` : out; } /** * Rend une commande slash avec expansion des arguments ($ARGUMENTS, $1-$9) * et des commandes shell (!...). L'expansion shell est asynchrone. */ export async function renderCommandAsync( body: string, args: string, options: { cwd: string; env?: Record }, ): Promise { // D'abord, expansion des arguments const withArgs = renderCommand(body, args); // Puis expansion des commandes shell (!...) const { expanded } = await expandShellCommands(withArgs, options); return expanded; } function fallbackDescription(body: string): string { const first = body.split("\n").find((l) => l.trim() !== "") ?? ""; return first.replace(/^#+\s*/, "").trim().slice(0, 80) || "(sans description)"; } /** * Charge .claude/commands/**.md de chaque dossier .claude (du plus général au plus spécifique : * une commande du projet remplace celle du même nom au niveau utilisateur). * Les sous-dossiers donnent des noms préfixés : commands/git/commit.md → /git:commit. */ export async function loadCommands( claudeDirs: string[], options?: { cwd?: string; env?: Record }, ): Promise<{ commands: SlashCommand[]; warnings: string[] }> { const byName = new Map(); const warnings: string[] = []; const cwd = options?.cwd ?? process.cwd(); const env = options?.env; for (const dir of claudeDirs) { for (const { name, file } of await listMarkdown(join(dir, "commands"))) { if (/\s/.test(name)) { warnings.push(`Commande ignorée (espace dans le nom) : ${file}`); continue; } if (RESERVED_COMMANDS.has(name)) { warnings.push(`Commande /${name} ignorée : nom réservé à MagicCode (${file})`); continue; } let raw: string; try { raw = await readFile(file, "utf8"); } catch { continue; } const { data, body } = parseFrontmatter(raw); byName.set(name, { name, description: data.description || fallbackDescription(body), argumentHint: data["argument-hint"] || undefined, prompt: async (args) => renderCommandAsync(body, args, { cwd, env }), }); } } return { commands: [...byName.values()], warnings }; }