2026-10-11 00:11:20 +00:00
|
|
|
import { readFile, readdir } from "node:fs/promises";
|
|
|
|
|
import { join } from "node:path";
|
|
|
|
|
import type { SlashCommand } from "@magiccode/core";
|
|
|
|
|
import { parseFrontmatter } from "./frontmatter";
|
2026-10-11 22:41:17 +00:00
|
|
|
import { expandShellCommands } from "./shell-commands";
|
2026-10-11 00:11:20 +00:00
|
|
|
|
|
|
|
|
/** 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 22:41:17 +00:00
|
|
|
/**
|
|
|
|
|
* 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<string, string> },
|
|
|
|
|
): Promise<string> {
|
|
|
|
|
// D'abord, expansion des arguments
|
|
|
|
|
const withArgs = renderCommand(body, args);
|
|
|
|
|
// Puis expansion des commandes shell (!...)
|
|
|
|
|
const { expanded } = await expandShellCommands(withArgs, options);
|
|
|
|
|
return expanded;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 00:11:20 +00:00
|
|
|
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[],
|
2026-10-11 22:41:17 +00:00
|
|
|
options?: { cwd?: string; env?: Record<string, string> },
|
2026-10-11 00:11:20 +00:00
|
|
|
): Promise<{ commands: SlashCommand[]; warnings: string[] }> {
|
|
|
|
|
const byName = new Map<string, SlashCommand>();
|
|
|
|
|
const warnings: string[] = [];
|
2026-10-11 22:41:17 +00:00
|
|
|
const cwd = options?.cwd ?? process.cwd();
|
|
|
|
|
const env = options?.env;
|
2026-10-11 00:11:20 +00:00
|
|
|
|
|
|
|
|
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,
|
2026-10-11 22:41:17 +00:00
|
|
|
prompt: async (args) => renderCommandAsync(body, args, { cwd, env }),
|
2026-10-11 00:11:20 +00:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return { commands: [...byName.values()], warnings };
|
|
|
|
|
}
|