AI & Agents
Hook
Hooks
What allium runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.
Install
$ npx -y skills add juxt/allium --agent claude-codeShips with allium. Installing the plugin gets these hooks.
What fires, and when
PostToolUse
- Matches
Write|Editnode "${CLAUDE_PLUGIN_ROOT}/hooks/allium-check.mjs"
Where it lives
- hooks/allium-check.mjsRunsGitHub
Read the script
import { execFileSync } from "child_process"; import { realpathSync, statSync, existsSync, mkdirSync, writeFileSync } from "fs"; import { homedir } from "os"; import path from "path"; process.on("uncaughtException", () => process.exit(0)); // Per-machine marker recording that the install notice has been shown once. // Lives in the user's cache dir so it spans every project and every spec on // this machine — once installed, the CLI is on PATH for all of them anyway. function installNoticeMarkerPath() { const cacheHome = process.env.XDG_CACHE_HOME || path.join(homedir(), ".cache"); return path.join(cacheHome, "allium", "cli-install-notice-shown"); } // Fallback marker in the project root, used when the per-machine cache dir // isn't writable. Scoped to one project rather than the whole machine, but it // still stops the notice from re-firing on every edit. function projectNoticeMarkerPath(projectRoot) { return path.join(projectRoot, ".allium-cli-notice-shown"); } function markerExists(p) { try { return existsSync(p); } catch { return false; } } function persistMarker(p) { try { mkdirSync(path.dirname(p), { recursive: true }); writeFileSync(p, "Allium CLI install notice shown.\n"); return true; } catch { return false; } } function installCommandFor(platform) { if (platform === "darwin") { return "brew tap juxt/allium && brew install allium (or: cargo install allium-cli)"; } if (platform === "win32") { return "cargo install allium-cli"; } return "cargo install allium-cli (or, with Homebrew: brew tap juxt/allium && brew install allium)"; } // On the first .allium edit where the CLI is absent, surface a one-time notice // instructing the model to offer to install it. Returns true if the notice was // emitted (caller should exit non-zero so the hook framework forwards stderr to // the model), false if it was already shown before (caller should exit cleanly). function showInstallNoticeOnce(projectRoot) { const cacheMarker = installNoticeMarkerPath(); const projectMarker = projectNoticeMarkerPath(projectRoot); // If either marker is already present, the user has been notified before. if (markerExists(cacheMarker) || markerExists(projectMarker)) return false; // Persist the per-machine marker first; if its cache dir isn't writable, fall // back to a marker in the project root. Knowing which (if any) succeeded lets // the notice tell the user, honestly, whether it will recur. let savedTo = null; if (persistMarker(cacheMarker)) savedTo = "cache"; else if (persistMarker(projectMarker)) savedTo = "project"; const command = installCommandFor(process.platform); const lines = [ "Allium CLI not found on PATH — this spec is NOT being formally checked.", "", "The CLI (`allium check` / `allium analyse`) adds structural verification the", "language model can't do reliably alone: tracing data flow across rules, proving", "every entity lifecycle can reach a terminal state, and spotting dead ends. With", "it installed, every .allium edit is checked and the results feed back into the", "conversation, producing more correct and complete specs.", "", "ACTION: Offer to install it for the user now. If they agree, first check the", "relevant package manager exists, then run the command below.", "", ` Suggested command for this machine (platform: ${process.platform}):`, ` ${command}`, " More detail: https://github.com/juxt/allium-tools — make sure the `allium`", " binary ends up on your PATH after installing.", ]; if (savedTo === "cache") { lines.push( "", "This notice fires only once per machine, so do not raise it again afterwards.", ); } else if (savedTo === "project") { lines.push( "", "NOTE: the per-machine marker couldn't be written, so this has been recorded", `in the project instead (${projectMarker}). The notice won't fire again for`, "this project; let the user know they may want to add that file to .gitignore.", ); } else { // Neither marker could be saved, so the hook can't remember it has notified // the user. Be upfront about that and hand off to manual install. lines.push( "", "NOTE: the notice marker could NOT be saved — neither the per-machine cache", `nor the project root (${projectRoot}) is writable — so this would otherwise`, "reappear on every .allium edit. Tell the user this directly, share the manual", "install steps above, and ask them to confirm they're happy to install the CLI", "themselves. Once they confirm, continue with their task without blocking, and", "treat the missing CLI as an acknowledged limitation rather than re-raising it", "each edit until the `allium` binary is on PATH.", ); } process.stderr.write(lines.join("\n") + "\n"); return true; } let data = ""; for await (const chunk of process.stdin) { data += chunk; } let input; try { input = JSON.parse(data); } catch { process.exit(0); } // Claude Code sends { tool_input: { file_path } }; // Cursor sends { file_path, workspace_roots }; // Windsurf sends { tool_info: { file_path } }. const filePath = input.tool_input?.file_path ?? input.file_path ?? input.tool_info?.file_path; if (typeof filePath !== "string" || path.extname(filePath) !== ".allium") { process.exit(0); } let resolved; try { resolved = realpathSync(filePath); if (!statSync(resolved).isFile()) process.exit(0); } catch { process.exit(0); } // Claude Code sets CLAUDE_PROJECT_ROOT; Cursor provides workspace_roots in the payload. const payloadRoots = Array.isArray(input.workspace_roots) ? input.workspace_roots : []; const roots = [process.env.CLAUDE_PROJECT_ROOT, ...payloadRoots].filter(Boolean); if (roots.length === 0) roots.push(process.cwd()); const resolvedRoots = []; for (const r of roots) { try { resolvedRoots.push(realpathSync(r)); } catch { // - hooks/allium-check.test.mjsGitHub
Read the script
import { execFileSync } from "child_process"; import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync, existsSync, chmodSync } from "fs"; import path from "path"; import { tmpdir } from "os"; const hook = new URL("./allium-check.mjs", import.meta.url).pathname; let passed = 0; let failed = 0; function run(input, env = {}) { try { execFileSync("node", [hook], { input: JSON.stringify(input), encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, ...env }, }); return { status: 0, stderr: "" }; } catch (e) { return { status: e.status, stderr: e.stderr || "" }; } } function assert(name, actual, expected) { if (actual === expected) { console.log(` pass: ${name}`); passed++; } else { console.log(` FAIL: ${name} (expected ${expected}, got ${actual})`); failed++; } } // Set up fixtures const projectRoot = mkdtempSync(path.join(tmpdir(), "allium-hook-test-")); const validFile = path.join(projectRoot, "test.allium"); writeFileSync(validFile, "-- allium: 3\n"); const invalidFile = path.join(projectRoot, "bad.allium"); writeFileSync(invalidFile, "this is not valid allium\n"); const subDir = path.join(projectRoot, "specs", "nested"); mkdirSync(subDir, { recursive: true }); const nestedFile = path.join(subDir, "deep.allium"); writeFileSync(nestedFile, "-- allium: 3\n"); const outsideDir = mkdtempSync(path.join(tmpdir(), "allium-hook-outside-")); const outsideFile = path.join(outsideDir, "evil.allium"); writeFileSync(outsideFile, "-- allium: 3\n"); // Second workspace root for multi-root tests const secondRoot = mkdtempSync(path.join(tmpdir(), "allium-hook-second-")); const secondRootFile = path.join(secondRoot, "other.allium"); writeFileSync(secondRootFile, "-- allium: 3\n"); const secondRootInvalid = path.join(secondRoot, "broken.allium"); writeFileSync(secondRootInvalid, "this is not valid allium\n"); // Symlink inside the project pointing to a file outside it const symlinkFile = path.join(projectRoot, "linked.allium"); symlinkSync(outsideFile, symlinkFile); const claudeEnv = { CLAUDE_PROJECT_ROOT: projectRoot }; // --- Claude Code format: { tool_input: { file_path } } --- console.log("Claude Code — early exit:"); assert( "missing file_path skipped", run({ tool_input: {} }, claudeEnv).status, 0, ); assert( "non-.allium extension skipped", run({ tool_input: { file_path: path.join(projectRoot, "readme.md") } }, claudeEnv).status, 0, ); assert( "non-existent .allium file skipped", run({ tool_input: { file_path: path.join(projectRoot, "ghost.allium") } }, claudeEnv).status, 0, ); console.log("\nClaude Code — path boundary:"); assert( "file outside project root rejected", run({ tool_input: { file_path: outsideFile } }, claudeEnv).status, 0, ); assert( "path traversal rejected", run({ tool_input: { file_path: path.join(projectRoot, "..", "etc", "passwd.allium") } }, claudeEnv).status, 0, ); assert( "prefix confusion rejected", run({ tool_input: { file_path: projectRoot + "other/file.allium" } }, claudeEnv).status, 0, ); assert( "symlink escaping project rejected", run({ tool_input: { file_path: symlinkFile } }, claudeEnv).status, 0, ); console.log("\nClaude Code — accepted:"); assert( "valid file at project root level", run({ tool_input: { file_path: validFile } }, claudeEnv).status, 0, ); assert( "valid file in nested subdirectory", run({ tool_input: { file_path: nestedFile } }, claudeEnv).status, 0, ); const invalidResult = run({ tool_input: { file_path: invalidFile } }, claudeEnv); assert( "invalid file reaches checker (exit 1)", invalidResult.status, 1, ); assert( "checker diagnostics forwarded to stderr", invalidResult.stderr.length > 0, true, ); console.log("\nClaude Code — resilience:"); assert( "invalid CLAUDE_PROJECT_ROOT exits cleanly", run({ tool_input: { file_path: validFile } }, { CLAUDE_PROJECT_ROOT: "/nonexistent/path" }).status, 0, ); // --- Cursor format: { file_path, workspace_roots } --- console.log("\nCursor — early exit:"); assert( "missing file_path skipped", run({ workspace_roots: [projectRoot] }).status, 0, ); assert( "non-.allium extension skipped", run({ file_path: path.join(projectRoot, "readme.md"), workspace_roots: [projectRoot] }).status, 0, ); assert( "non-existent .allium file skipped", run({ file_path: path.join(projectRoot, "ghost.allium"), workspace_roots: [projectRoot] }).status, 0, ); console.log("\nCursor — path boundary:"); assert( "file outside workspace roots rejected", run({ file_path: outsideFile, workspace_roots: [projectRoot] }).status, 0, ); assert( "symlink escaping workspace rejected", run({ file_path: symlinkFile, workspace_roots: [projectRoot] }).status, 0, ); console.log("\nCursor — accepted:"); assert( "valid file at workspace root level", run({ file_path: validFile, workspace_roots: [projectRoot] }).status, 0, ); assert( "valid file in nested subdirectory", run({ file_path: nestedFile, workspace_roots: [projectRoot] }).status, 0, ); const cursorInvalidResult = run({ file_path: invalidFile, workspace_roots: [projectRoot] }); assert( "invalid file reaches checker (exit 1)", cursorInvalidResult.status, 1, ); assert( "checker diagnostics forwarded to stderr", cursorInvalidResult.stderr.length > 0, true, ); console.log("\nCursor — resilience:"); assert( "missing workspace_roots falls back to cwd", run({ file_path: validFile }).status, 0, ); assert( "empty workspace_roots falls back to cwd", run({ file_path: validFile, workspace_roots: [] }).status, 0, ); // --- Windsurf format: { tool_info: { file_path }, ... } --- // Windsurf sends file_path inside tool_info. Working directory defaults to // workspace root, so no workspace_roots equivalent — cwd is the fallback. console.log("\nWindsurf — early exit:"); assert( "missing tool_info.file_path skipped", run({ tool_info: {} }).sta - hooks/allium-lint.shGitHub
Read the script
#!/bin/sh # Wrapper for Aider's --lint-cmd. Aider passes the file path as a CLI argument. # Only run allium check on .allium files; exit 0 for everything else. case "$1" in *.allium) exec allium check "$1" ;; *) exit 0 ;; esac
Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.
Ships withallium
Velocity through clarity Feed your AI something healthier than Markdown. allium-lang.org
Get the whole plugin

