Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/allium/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "allium",
"version": "3.4.0",
"version": "3.5.0",
"description": "Velocity through clarity.",
"author": {
"name": "JUXT",
Expand Down
2 changes: 1 addition & 1 deletion plugins/allium/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "allium",
"version": "3.4.0",
"version": "3.5.0",
"description": "Velocity through clarity.",
"author": {
"name": "JUXT",
Expand Down
2 changes: 2 additions & 0 deletions plugins/allium/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
122 changes: 119 additions & 3 deletions plugins/allium/hooks/allium-check.mjs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);
}

Expand All @@ -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.
Expand Down
141 changes: 140 additions & 1 deletion plugins/allium/hooks/allium-check.test.mjs
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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 });
Expand Down
2 changes: 1 addition & 1 deletion scripts/allium-ref.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v3.4.0
v3.5.0
Loading