diff --git a/plugins/allium/.claude-plugin/plugin.json b/plugins/allium/.claude-plugin/plugin.json index 778bcb7..6762607 100644 --- a/plugins/allium/.claude-plugin/plugin.json +++ b/plugins/allium/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "allium", - "version": "3.4.0", + "version": "3.5.0", "description": "Velocity through clarity.", "author": { "name": "JUXT", diff --git a/plugins/allium/.codex-plugin/plugin.json b/plugins/allium/.codex-plugin/plugin.json index 9683ac9..9b0ee1b 100644 --- a/plugins/allium/.codex-plugin/plugin.json +++ b/plugins/allium/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "allium", - "version": "3.4.0", + "version": "3.5.0", "description": "Velocity through clarity.", "author": { "name": "JUXT", diff --git a/plugins/allium/README.md b/plugins/allium/README.md index 5ee5df8..35d1789 100644 --- a/plugins/allium/README.md +++ b/plugins/allium/README.md @@ -237,6 +237,8 @@ The developer never mentioned invoicing or payment method capture. The Allium di When the CLI is installed, `.allium` files are validated automatically after every write or edit. Diagnostics appear inline and the model fixes issues in the same turn. +**If the CLI is missing**, the first time you edit a `.allium` file the post-write hook surfaces a one-time notice explaining what the CLI adds and prompting the model to offer to install it for you — with the right command for your platform, and a single confirmation. The notice fires only once per machine (and never once the `allium` binary is on your `PATH`), so editing without the CLI stays quiet after that. The "shown once" marker normally lives in your cache directory; if that isn't writable it falls back to a `.allium-cli-notice-shown` file in the project root (worth adding to `.gitignore`). Only if neither location is writable does the notice recur — in which case it tells you so and hands off to manual installation. + **Live diagnostics in Claude Code.** The Claude Code plugin also wires the `allium-lsp` language server, so Claude receives checker errors, go-to-definition and hover for `.allium` files immediately after each edit, without a separate `allium check` invocation. The language server is **not bundled** with the plugin — install the `allium-lsp` server from the [allium-tools repo](https://github.com/juxt/allium-tools) and make sure the `allium-lsp` binary is on your `PATH`. If it isn't found, Claude Code reports `Executable not found in $PATH` in the `/plugin` Errors tab and falls back to CLI checking. ## Language governance diff --git a/plugins/allium/hooks/allium-check.mjs b/plugins/allium/hooks/allium-check.mjs index 17c7082..0224964 100644 --- a/plugins/allium/hooks/allium-check.mjs +++ b/plugins/allium/hooks/allium-check.mjs @@ -1,9 +1,121 @@ import { execFileSync } from "child_process"; -import { realpathSync, statSync } from "fs"; +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; @@ -45,7 +157,8 @@ for (const r of roots) { // Skip unresolvable roots. } } -if (!resolvedRoots.some((root) => resolved.startsWith(root + path.sep))) { +const projectRoot = resolvedRoots.find((root) => resolved.startsWith(root + path.sep)); +if (!projectRoot) { process.exit(0); } @@ -56,7 +169,10 @@ try { }); } catch (e) { if (e.code === "ENOENT") { - process.exit(0); + // The allium binary isn't installed. Show the install notice once (per + // machine, or per project if the cache dir isn't writable); exit non-zero + // only when we actually emitted it so the model sees it. + process.exit(showInstallNoticeOnce(projectRoot) ? 1 : 0); } // Write checker diagnostics to stderr — the hook framework // surfaces stderr to the model on non-zero exit. diff --git a/plugins/allium/hooks/allium-check.test.mjs b/plugins/allium/hooks/allium-check.test.mjs index 6f65bbb..3be667c 100644 --- a/plugins/allium/hooks/allium-check.test.mjs +++ b/plugins/allium/hooks/allium-check.test.mjs @@ -1,5 +1,5 @@ import { execFileSync } from "child_process"; -import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from "fs"; +import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync, existsSync, chmodSync } from "fs"; import path from "path"; import { tmpdir } from "os"; @@ -651,7 +651,146 @@ assert( 0, ); +// --- CLI missing: one-time install notice --- +// Force the "binary not found" path by running with a PATH that contains no +// allium, and an isolated XDG_CACHE_HOME so the per-machine marker is hermetic. +// process.execPath is used so node itself resolves without relying on PATH. + +console.log("\nCLI missing — one-time install notice:"); + +const emptyPathDir = mkdtempSync(path.join(tmpdir(), "allium-hook-nopath-")); + +function runNoCli(input, extraEnv = {}) { + try { + execFileSync(process.execPath, [hook], { + input: JSON.stringify(input), + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + env: { ...process.env, PATH: emptyPathDir, ...extraEnv }, + }); + return { status: 0, stderr: "" }; + } catch (e) { + return { status: e.status, stderr: e.stderr || "" }; + } +} + +const noticeCache = mkdtempSync(path.join(tmpdir(), "allium-hook-cache-")); +const noticeEnv = { CLAUDE_PROJECT_ROOT: projectRoot, XDG_CACHE_HOME: noticeCache }; + +const firstNotice = runNoCli({ tool_input: { file_path: validFile } }, noticeEnv); +assert("first edit with no CLI surfaces notice (exit 1)", firstNotice.status, 1); +assert("notice tells the model to install the CLI", /install/i.test(firstNotice.stderr), true); +assert( + "notice carries a concrete install command", + /cargo install allium-cli/.test(firstNotice.stderr), + true, +); +assert( + "persisted notice promises it fires only once", + /only once per machine/.test(firstNotice.stderr), + true, +); + +const secondNotice = runNoCli({ tool_input: { file_path: validFile } }, noticeEnv); +assert("notice fires only once (subsequent edits exit 0)", secondNotice.status, 0); +assert("subsequent edit emits nothing", secondNotice.stderr, ""); + +// A fresh cache (e.g. another machine) shows the notice again. +const freshCache = mkdtempSync(path.join(tmpdir(), "allium-hook-cache-")); +const freshNotice = runNoCli( + { tool_input: { file_path: validFile } }, + { CLAUDE_PROJECT_ROOT: projectRoot, XDG_CACHE_HOME: freshCache }, +); +assert("notice shows again under a fresh cache (exit 1)", freshNotice.status, 1); + +// Scope: the notice must NOT leak onto non-spec edits even when the CLI is +// absent — those exit early, before the checker is ever invoked. +const scopeCache = mkdtempSync(path.join(tmpdir(), "allium-hook-cache-")); + +const mdEdit = runNoCli( + { tool_input: { file_path: path.join(projectRoot, "notes.md") } }, + { CLAUDE_PROJECT_ROOT: projectRoot, XDG_CACHE_HOME: scopeCache }, +); +assert("no notice on non-.allium edit when CLI absent (exit 0)", mdEdit.status, 0); +assert("non-.allium edit emits nothing", mdEdit.stderr, ""); + +const outOfRootEdit = runNoCli( + { tool_input: { file_path: outsideFile } }, + { CLAUDE_PROJECT_ROOT: projectRoot, XDG_CACHE_HOME: scopeCache }, +); +assert("no notice on out-of-root .allium edit when CLI absent (exit 0)", outOfRootEdit.status, 0); +assert("out-of-root edit emits nothing", outOfRootEdit.stderr, ""); + +// A blocked cache: XDG_CACHE_HOME points at a file, so the per-machine marker +// can't be written. Shared by the fallback and both-unwritable scenarios. +const blockedRoot = mkdtempSync(path.join(tmpdir(), "allium-hook-blocked-")); +const blockedCache = path.join(blockedRoot, "not-a-dir"); +writeFileSync(blockedCache, "x\n"); + +// Fallback: cache unwritable but project root writable → the marker falls back +// to .allium-cli-notice-shown in the project root, so the notice still fires +// only once (per project) and doesn't crash. +const fallbackProject = mkdtempSync(path.join(tmpdir(), "allium-hook-fallback-")); +const fallbackFile = path.join(fallbackProject, "spec.allium"); +writeFileSync(fallbackFile, "-- allium: 3\n"); +const fallbackEnv = { CLAUDE_PROJECT_ROOT: fallbackProject, XDG_CACHE_HOME: blockedCache }; + +const fb1 = runNoCli({ tool_input: { file_path: fallbackFile } }, fallbackEnv); +assert("notice shown when cache unwritable, via project fallback (exit 1)", fb1.status, 1); +assert( + "fallback notice names the project marker file", + /\.allium-cli-notice-shown/.test(fb1.stderr), + true, +); +assert( + "fallback notice does not claim per-machine once-only", + /only once per machine/.test(fb1.stderr), + false, +); +assert( + "project fallback marker file is actually created", + existsSync(path.join(fallbackProject, ".allium-cli-notice-shown")), + true, +); +const fb2 = runNoCli({ tool_input: { file_path: fallbackFile } }, fallbackEnv); +assert("project fallback marker suppresses re-firing (exit 0)", fb2.status, 0); +assert("suppressed fallback edit emits nothing", fb2.stderr, ""); + +// Both unwritable: cache blocked AND project root read-only → no marker can be +// persisted, so the hook hands off to manual install and keeps re-firing. +// (Skipped under root, which bypasses directory permissions.) +const roProject = mkdtempSync(path.join(tmpdir(), "allium-hook-roproj-")); +const roFile = path.join(roProject, "spec.allium"); +writeFileSync(roFile, "-- allium: 3\n"); +chmodSync(roProject, 0o500); +const runningAsRoot = typeof process.getuid === "function" && process.getuid() === 0; +if (!runningAsRoot) { + const roEnv = { CLAUDE_PROJECT_ROOT: roProject, XDG_CACHE_HOME: blockedCache }; + const ro1 = runNoCli({ tool_input: { file_path: roFile } }, roEnv); + assert("notice shown when neither marker can be saved (exit 1)", ro1.status, 1); + assert( + "both-unwritable notice tells the user it couldn't be saved", + /could NOT be saved/.test(ro1.stderr), + true, + ); + assert( + "both-unwritable notice asks the user to confirm self-install", + /confirm they're happy/.test(ro1.stderr), + true, + ); + const ro2 = runNoCli({ tool_input: { file_path: roFile } }, roEnv); + assert("both-unwritable notice re-fires (exit 1)", ro2.status, 1); +} +chmodSync(roProject, 0o700); + // Clean up +rmSync(emptyPathDir, { recursive: true }); +rmSync(noticeCache, { recursive: true }); +rmSync(freshCache, { recursive: true }); +rmSync(scopeCache, { recursive: true }); +rmSync(blockedRoot, { recursive: true }); +rmSync(fallbackProject, { recursive: true }); +rmSync(roProject, { recursive: true }); rmSync(projectRoot, { recursive: true }); rmSync(outsideDir, { recursive: true }); rmSync(secondRoot, { recursive: true }); diff --git a/scripts/allium-ref.txt b/scripts/allium-ref.txt index c219f72..c0c4025 100644 --- a/scripts/allium-ref.txt +++ b/scripts/allium-ref.txt @@ -1 +1 @@ -v3.4.0 +v3.5.0