MagicCode/packages/ext-claudecode/src/lot2.test.ts
Arnaud a467318e35 feat(ext-claudecode): Ajout de la commande shell ! dans ClaudeCode
- Exécution de commandes shell inline avec `!commande` ou `!`commande args``
- Support dans les prompts directs et les fichiers .claude/commands/**/*.md
- Exécution parallèle de multiples commandes
- Gestion robuste des erreurs et timeouts
- Intégration avec les variables d'environnement de settings.json

Nouveaux fichiers:
- packages/cli/src/user-shell.ts
- packages/core/src/shell.ts et shell.test.ts
- packages/ext-claudecode/src/tools/todowrite.test.ts et webfetch.test.ts
- packages/ui/src/banner.tsx, pixels.ts et pixels.test.ts

Tests: ✅ Tous les tests passent
Breaking Changes: Mineur - SlashCommand.prompt() async
2026-10-12 09:16:26 +02:00

148 lines
6.1 KiB
TypeScript

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]");
expect(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");
});
});