84 lines
3.1 KiB
TypeScript
84 lines
3.1 KiB
TypeScript
|
|
import { readFile, readdir } from "node:fs/promises";
|
||
|
|
import { join } from "node:path";
|
||
|
|
import type { SlashCommand } from "@magiccode/core";
|
||
|
|
import { parseFrontmatter } from "./frontmatter";
|
||
|
|
|
||
|
|
/** 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;
|
||
|
|
}
|
||
|
|
|
||
|
|
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[],
|
||
|
|
): Promise<{ commands: SlashCommand[]; warnings: string[] }> {
|
||
|
|
const byName = new Map<string, SlashCommand>();
|
||
|
|
const warnings: string[] = [];
|
||
|
|
|
||
|
|
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);
|
||
|
|
// TODO : allowed-tools, model, références @fichier et !`commande`
|
||
|
|
byName.set(name, {
|
||
|
|
name,
|
||
|
|
description: data.description || fallbackDescription(body),
|
||
|
|
argumentHint: data["argument-hint"] || undefined,
|
||
|
|
prompt: (args) => renderCommand(body, args),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return { commands: [...byName.values()], warnings };
|
||
|
|
}
|