MagicCode/packages/agent/src/commands.ts

84 lines
3.1 KiB
TypeScript
Raw Normal View History

2026-10-11 00:11:20 +00:00
import { readFile, readdir } from "node:fs/promises";
import { join } from "node:path";
import type { SlashCommand } from "@maia/core";
2026-10-11 00:11:20 +00:00
import { parseFrontmatter } from "./frontmatter";
/** Noms réservés aux commandes internes de MAIA (gérées par la CLI). */
export const RESERVED_COMMANDS = new Set(["help", "clear", "model", "exit", "rename", "load", "new"]);
2026-10-11 00:11:20 +00:00
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é à MAIA (${file})`);
2026-10-11 00:11:20 +00:00
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`
2026-10-11 00:11:20 +00:00
byName.set(name, {
name,
description: data.description || fallbackDescription(body),
argumentHint: data["argument-hint"] || undefined,
prompt: (args) => renderCommand(body, args),
2026-10-11 00:11:20 +00:00
});
}
}
return { commands: [...byName.values()], warnings };
}