- 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
130 lines
4.8 KiB
TypeScript
130 lines
4.8 KiB
TypeScript
import { mkdtemp } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { basename, join } from "node:path";
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
describeShellStatus,
|
|
formatShellContext,
|
|
parseShellInvocation,
|
|
runShell,
|
|
truncateMiddle,
|
|
type ShellResult,
|
|
} from "./shell";
|
|
|
|
describe("parseShellInvocation", () => {
|
|
const cmd = (input: string) => parseShellInvocation(input);
|
|
|
|
it("sans guillemets : tout ce qui suit le ! est la commande", () => {
|
|
expect(cmd("!ls")).toEqual({ command: "ls" });
|
|
expect(cmd("!ls -la")).toEqual({ command: "ls -la" });
|
|
expect(cmd(" ! git status ")).toEqual({ command: "git status" });
|
|
});
|
|
|
|
it("avec guillemets simples : le texte entre guillemets est la commande", () => {
|
|
expect(cmd("!'ls -la'")).toEqual({ command: "ls -la" });
|
|
expect(cmd("! 'ls -la'")).toEqual({ command: "ls -la" });
|
|
expect(cmd("!'git log --oneline -5'")).toEqual({ command: "git log --oneline -5" });
|
|
});
|
|
|
|
it("ce qui suit le guillemet fermant est ajouté comme paramètres", () => {
|
|
expect(cmd("!'ls' -la src")).toEqual({ command: "ls -la src" });
|
|
expect(cmd("!'ls -la' | wc -l")).toEqual({ command: "ls -la | wc -l" });
|
|
expect(cmd("!'ls'-la")).toEqual({ command: "ls -la" });
|
|
});
|
|
|
|
it("\\' donne un guillemet littéral dans la commande", () => {
|
|
expect(cmd("!'echo \"l\\'ami\"'")).toEqual({ command: 'echo "l\'ami"' });
|
|
});
|
|
|
|
it("! seul ou guillemets vides : commande vide (aide)", () => {
|
|
expect(cmd("!")).toEqual({ command: "" });
|
|
expect(cmd("!''")).toEqual({ command: "" });
|
|
});
|
|
|
|
it("guillemet simple non fermé : erreur, rien n'est exécuté", () => {
|
|
const r = cmd("!'ls -la");
|
|
expect(r).toHaveProperty("error");
|
|
expect(r).not.toHaveProperty("command");
|
|
});
|
|
|
|
it("ignore les autres saisies", () => {
|
|
expect(cmd("bonjour !")).toBeNull();
|
|
expect(cmd("/help")).toBeNull();
|
|
expect(cmd("§skill")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("runShell", () => {
|
|
const cwd = process.cwd();
|
|
|
|
it("capture stdout et stderr, code 0", async () => {
|
|
const r = await runShell("echo out && echo err 1>&2", { cwd });
|
|
expect(r.exitCode).toBe(0);
|
|
expect(r.output).toContain("out");
|
|
expect(r.output).toContain("err");
|
|
expect(describeShellStatus(r)).toBeNull();
|
|
});
|
|
|
|
it("code de sortie non nul", async () => {
|
|
const r = await runShell("exit 3", { cwd });
|
|
expect(r.exitCode).toBe(3);
|
|
expect(describeShellStatus(r)).toBe("code de sortie : 3");
|
|
});
|
|
|
|
it("s'exécute dans le dossier demandé", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "magiccode-shell-"));
|
|
const r = await runShell('node -p "process.cwd()"', { cwd: dir });
|
|
expect(r.output).toContain(basename(dir));
|
|
});
|
|
|
|
it("transmet les variables d'environnement additionnelles", async () => {
|
|
const r = await runShell('node -p "process.env.MA_VAR"', { cwd, env: { MA_VAR: "42" } });
|
|
expect(r.output).toBe("42");
|
|
});
|
|
|
|
it("interruption par AbortSignal", async () => {
|
|
const controller = new AbortController();
|
|
const started = Date.now();
|
|
const promise = runShell('node -e "setTimeout(() => {}, 10000)"', { cwd, signal: controller.signal });
|
|
setTimeout(() => controller.abort(), 150);
|
|
const r = await promise;
|
|
expect(r.aborted).toBe(true);
|
|
expect(describeShellStatus(r)).toBe("interrompu (Ctrl+C)");
|
|
expect(Date.now() - started).toBeLessThan(5000);
|
|
});
|
|
|
|
it("délai dépassé", async () => {
|
|
const r = await runShell('node -e "setTimeout(() => {}, 10000)"', { cwd, timeoutMs: 200 });
|
|
expect(r.timedOut).toBe(true);
|
|
expect(describeShellStatus(r)).toBe("délai dépassé (0 s)");
|
|
});
|
|
|
|
it("dossier introuvable : pas d'exception, résultat en échec", async () => {
|
|
const r = await runShell("echo x", { cwd: join(tmpdir(), "magiccode-n-existe-pas-xyz") });
|
|
expect(describeShellStatus(r)).toBe("échec du lancement de la commande");
|
|
expect(r.output).not.toBe("");
|
|
});
|
|
});
|
|
|
|
describe("formatShellContext", () => {
|
|
const ok: ShellResult = { output: "a\nb", exitCode: 0, timedOut: false, aborted: false, spawnError: false, timeoutMs: 1000 };
|
|
|
|
it("contient la commande et la sortie ; pas de statut si succès", () => {
|
|
const text = formatShellContext("ls", ok);
|
|
expect(text).toContain("$ ls\na\nb");
|
|
expect(text).not.toContain("[code de sortie");
|
|
});
|
|
|
|
it("sortie vide et échec", () => {
|
|
const text = formatShellContext("false", { ...ok, output: "", exitCode: 1 });
|
|
expect(text).toContain("(aucune sortie)");
|
|
expect(text).toContain("[code de sortie : 1]");
|
|
});
|
|
|
|
it("tronque le milieu des très longues sorties", () => {
|
|
const text = formatShellContext("cat gros", { ...ok, output: "x".repeat(100_000) });
|
|
expect(text).toContain("caractères omis");
|
|
expect(text.length).toBeLessThan(31_000);
|
|
expect(truncateMiddle("abc", 10)).toBe("abc");
|
|
});
|
|
});
|