Skip to content

Commit 3020cfb

Browse files
Bump vendored allium to v3.5.0
Pin scripts/allium-ref.txt to v3.5.0 and re-vendor plugins/allium via scripts/sync-allium.sh. Ships the CLI install notice (juxt/allium#51) and the accompanying version bump (juxt/allium#52) to the marketplace. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 48898b1 commit 3020cfb

6 files changed

Lines changed: 264 additions & 7 deletions

File tree

plugins/allium/.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "allium",
3-
"version": "3.4.0",
3+
"version": "3.5.0",
44
"description": "Velocity through clarity.",
55
"author": {
66
"name": "JUXT",

plugins/allium/.codex-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "allium",
3-
"version": "3.4.0",
3+
"version": "3.5.0",
44
"description": "Velocity through clarity.",
55
"author": {
66
"name": "JUXT",

plugins/allium/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,8 @@ The developer never mentioned invoicing or payment method capture. The Allium di
237237

238238
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.
239239

240+
**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.
241+
240242
**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.
241243

242244
## Language governance

plugins/allium/hooks/allium-check.mjs

Lines changed: 119 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,121 @@
11
import { execFileSync } from "child_process";
2-
import { realpathSync, statSync } from "fs";
2+
import { realpathSync, statSync, existsSync, mkdirSync, writeFileSync } from "fs";
3+
import { homedir } from "os";
34
import path from "path";
45

56
process.on("uncaughtException", () => process.exit(0));
67

8+
// Per-machine marker recording that the install notice has been shown once.
9+
// Lives in the user's cache dir so it spans every project and every spec on
10+
// this machine — once installed, the CLI is on PATH for all of them anyway.
11+
function installNoticeMarkerPath() {
12+
const cacheHome = process.env.XDG_CACHE_HOME || path.join(homedir(), ".cache");
13+
return path.join(cacheHome, "allium", "cli-install-notice-shown");
14+
}
15+
16+
// Fallback marker in the project root, used when the per-machine cache dir
17+
// isn't writable. Scoped to one project rather than the whole machine, but it
18+
// still stops the notice from re-firing on every edit.
19+
function projectNoticeMarkerPath(projectRoot) {
20+
return path.join(projectRoot, ".allium-cli-notice-shown");
21+
}
22+
23+
function markerExists(p) {
24+
try {
25+
return existsSync(p);
26+
} catch {
27+
return false;
28+
}
29+
}
30+
31+
function persistMarker(p) {
32+
try {
33+
mkdirSync(path.dirname(p), { recursive: true });
34+
writeFileSync(p, "Allium CLI install notice shown.\n");
35+
return true;
36+
} catch {
37+
return false;
38+
}
39+
}
40+
41+
function installCommandFor(platform) {
42+
if (platform === "darwin") {
43+
return "brew tap juxt/allium && brew install allium (or: cargo install allium-cli)";
44+
}
45+
if (platform === "win32") {
46+
return "cargo install allium-cli";
47+
}
48+
return "cargo install allium-cli (or, with Homebrew: brew tap juxt/allium && brew install allium)";
49+
}
50+
51+
// On the first .allium edit where the CLI is absent, surface a one-time notice
52+
// instructing the model to offer to install it. Returns true if the notice was
53+
// emitted (caller should exit non-zero so the hook framework forwards stderr to
54+
// the model), false if it was already shown before (caller should exit cleanly).
55+
function showInstallNoticeOnce(projectRoot) {
56+
const cacheMarker = installNoticeMarkerPath();
57+
const projectMarker = projectNoticeMarkerPath(projectRoot);
58+
59+
// If either marker is already present, the user has been notified before.
60+
if (markerExists(cacheMarker) || markerExists(projectMarker)) return false;
61+
62+
// Persist the per-machine marker first; if its cache dir isn't writable, fall
63+
// back to a marker in the project root. Knowing which (if any) succeeded lets
64+
// the notice tell the user, honestly, whether it will recur.
65+
let savedTo = null;
66+
if (persistMarker(cacheMarker)) savedTo = "cache";
67+
else if (persistMarker(projectMarker)) savedTo = "project";
68+
69+
const command = installCommandFor(process.platform);
70+
const lines = [
71+
"Allium CLI not found on PATH — this spec is NOT being formally checked.",
72+
"",
73+
"The CLI (`allium check` / `allium analyse`) adds structural verification the",
74+
"language model can't do reliably alone: tracing data flow across rules, proving",
75+
"every entity lifecycle can reach a terminal state, and spotting dead ends. With",
76+
"it installed, every .allium edit is checked and the results feed back into the",
77+
"conversation, producing more correct and complete specs.",
78+
"",
79+
"ACTION: Offer to install it for the user now. If they agree, first check the",
80+
"relevant package manager exists, then run the command below.",
81+
"",
82+
` Suggested command for this machine (platform: ${process.platform}):`,
83+
` ${command}`,
84+
" More detail: https://github.com/juxt/allium-tools — make sure the `allium`",
85+
" binary ends up on your PATH after installing.",
86+
];
87+
88+
if (savedTo === "cache") {
89+
lines.push(
90+
"",
91+
"This notice fires only once per machine, so do not raise it again afterwards.",
92+
);
93+
} else if (savedTo === "project") {
94+
lines.push(
95+
"",
96+
"NOTE: the per-machine marker couldn't be written, so this has been recorded",
97+
`in the project instead (${projectMarker}). The notice won't fire again for`,
98+
"this project; let the user know they may want to add that file to .gitignore.",
99+
);
100+
} else {
101+
// Neither marker could be saved, so the hook can't remember it has notified
102+
// the user. Be upfront about that and hand off to manual install.
103+
lines.push(
104+
"",
105+
"NOTE: the notice marker could NOT be saved — neither the per-machine cache",
106+
`nor the project root (${projectRoot}) is writable — so this would otherwise`,
107+
"reappear on every .allium edit. Tell the user this directly, share the manual",
108+
"install steps above, and ask them to confirm they're happy to install the CLI",
109+
"themselves. Once they confirm, continue with their task without blocking, and",
110+
"treat the missing CLI as an acknowledged limitation rather than re-raising it",
111+
"each edit until the `allium` binary is on PATH.",
112+
);
113+
}
114+
115+
process.stderr.write(lines.join("\n") + "\n");
116+
return true;
117+
}
118+
7119
let data = "";
8120
for await (const chunk of process.stdin) {
9121
data += chunk;
@@ -45,7 +157,8 @@ for (const r of roots) {
45157
// Skip unresolvable roots.
46158
}
47159
}
48-
if (!resolvedRoots.some((root) => resolved.startsWith(root + path.sep))) {
160+
const projectRoot = resolvedRoots.find((root) => resolved.startsWith(root + path.sep));
161+
if (!projectRoot) {
49162
process.exit(0);
50163
}
51164

@@ -56,7 +169,10 @@ try {
56169
});
57170
} catch (e) {
58171
if (e.code === "ENOENT") {
59-
process.exit(0);
172+
// The allium binary isn't installed. Show the install notice once (per
173+
// machine, or per project if the cache dir isn't writable); exit non-zero
174+
// only when we actually emitted it so the model sees it.
175+
process.exit(showInstallNoticeOnce(projectRoot) ? 1 : 0);
60176
}
61177
// Write checker diagnostics to stderr — the hook framework
62178
// surfaces stderr to the model on non-zero exit.

plugins/allium/hooks/allium-check.test.mjs

Lines changed: 140 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { execFileSync } from "child_process";
2-
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from "fs";
2+
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync, existsSync, chmodSync } from "fs";
33
import path from "path";
44
import { tmpdir } from "os";
55

@@ -651,7 +651,146 @@ assert(
651651
0,
652652
);
653653

654+
// --- CLI missing: one-time install notice ---
655+
// Force the "binary not found" path by running with a PATH that contains no
656+
// allium, and an isolated XDG_CACHE_HOME so the per-machine marker is hermetic.
657+
// process.execPath is used so node itself resolves without relying on PATH.
658+
659+
console.log("\nCLI missing — one-time install notice:");
660+
661+
const emptyPathDir = mkdtempSync(path.join(tmpdir(), "allium-hook-nopath-"));
662+
663+
function runNoCli(input, extraEnv = {}) {
664+
try {
665+
execFileSync(process.execPath, [hook], {
666+
input: JSON.stringify(input),
667+
encoding: "utf-8",
668+
stdio: ["pipe", "pipe", "pipe"],
669+
env: { ...process.env, PATH: emptyPathDir, ...extraEnv },
670+
});
671+
return { status: 0, stderr: "" };
672+
} catch (e) {
673+
return { status: e.status, stderr: e.stderr || "" };
674+
}
675+
}
676+
677+
const noticeCache = mkdtempSync(path.join(tmpdir(), "allium-hook-cache-"));
678+
const noticeEnv = { CLAUDE_PROJECT_ROOT: projectRoot, XDG_CACHE_HOME: noticeCache };
679+
680+
const firstNotice = runNoCli({ tool_input: { file_path: validFile } }, noticeEnv);
681+
assert("first edit with no CLI surfaces notice (exit 1)", firstNotice.status, 1);
682+
assert("notice tells the model to install the CLI", /install/i.test(firstNotice.stderr), true);
683+
assert(
684+
"notice carries a concrete install command",
685+
/cargo install allium-cli/.test(firstNotice.stderr),
686+
true,
687+
);
688+
assert(
689+
"persisted notice promises it fires only once",
690+
/only once per machine/.test(firstNotice.stderr),
691+
true,
692+
);
693+
694+
const secondNotice = runNoCli({ tool_input: { file_path: validFile } }, noticeEnv);
695+
assert("notice fires only once (subsequent edits exit 0)", secondNotice.status, 0);
696+
assert("subsequent edit emits nothing", secondNotice.stderr, "");
697+
698+
// A fresh cache (e.g. another machine) shows the notice again.
699+
const freshCache = mkdtempSync(path.join(tmpdir(), "allium-hook-cache-"));
700+
const freshNotice = runNoCli(
701+
{ tool_input: { file_path: validFile } },
702+
{ CLAUDE_PROJECT_ROOT: projectRoot, XDG_CACHE_HOME: freshCache },
703+
);
704+
assert("notice shows again under a fresh cache (exit 1)", freshNotice.status, 1);
705+
706+
// Scope: the notice must NOT leak onto non-spec edits even when the CLI is
707+
// absent — those exit early, before the checker is ever invoked.
708+
const scopeCache = mkdtempSync(path.join(tmpdir(), "allium-hook-cache-"));
709+
710+
const mdEdit = runNoCli(
711+
{ tool_input: { file_path: path.join(projectRoot, "notes.md") } },
712+
{ CLAUDE_PROJECT_ROOT: projectRoot, XDG_CACHE_HOME: scopeCache },
713+
);
714+
assert("no notice on non-.allium edit when CLI absent (exit 0)", mdEdit.status, 0);
715+
assert("non-.allium edit emits nothing", mdEdit.stderr, "");
716+
717+
const outOfRootEdit = runNoCli(
718+
{ tool_input: { file_path: outsideFile } },
719+
{ CLAUDE_PROJECT_ROOT: projectRoot, XDG_CACHE_HOME: scopeCache },
720+
);
721+
assert("no notice on out-of-root .allium edit when CLI absent (exit 0)", outOfRootEdit.status, 0);
722+
assert("out-of-root edit emits nothing", outOfRootEdit.stderr, "");
723+
724+
// A blocked cache: XDG_CACHE_HOME points at a file, so the per-machine marker
725+
// can't be written. Shared by the fallback and both-unwritable scenarios.
726+
const blockedRoot = mkdtempSync(path.join(tmpdir(), "allium-hook-blocked-"));
727+
const blockedCache = path.join(blockedRoot, "not-a-dir");
728+
writeFileSync(blockedCache, "x\n");
729+
730+
// Fallback: cache unwritable but project root writable → the marker falls back
731+
// to .allium-cli-notice-shown in the project root, so the notice still fires
732+
// only once (per project) and doesn't crash.
733+
const fallbackProject = mkdtempSync(path.join(tmpdir(), "allium-hook-fallback-"));
734+
const fallbackFile = path.join(fallbackProject, "spec.allium");
735+
writeFileSync(fallbackFile, "-- allium: 3\n");
736+
const fallbackEnv = { CLAUDE_PROJECT_ROOT: fallbackProject, XDG_CACHE_HOME: blockedCache };
737+
738+
const fb1 = runNoCli({ tool_input: { file_path: fallbackFile } }, fallbackEnv);
739+
assert("notice shown when cache unwritable, via project fallback (exit 1)", fb1.status, 1);
740+
assert(
741+
"fallback notice names the project marker file",
742+
/\.allium-cli-notice-shown/.test(fb1.stderr),
743+
true,
744+
);
745+
assert(
746+
"fallback notice does not claim per-machine once-only",
747+
/only once per machine/.test(fb1.stderr),
748+
false,
749+
);
750+
assert(
751+
"project fallback marker file is actually created",
752+
existsSync(path.join(fallbackProject, ".allium-cli-notice-shown")),
753+
true,
754+
);
755+
const fb2 = runNoCli({ tool_input: { file_path: fallbackFile } }, fallbackEnv);
756+
assert("project fallback marker suppresses re-firing (exit 0)", fb2.status, 0);
757+
assert("suppressed fallback edit emits nothing", fb2.stderr, "");
758+
759+
// Both unwritable: cache blocked AND project root read-only → no marker can be
760+
// persisted, so the hook hands off to manual install and keeps re-firing.
761+
// (Skipped under root, which bypasses directory permissions.)
762+
const roProject = mkdtempSync(path.join(tmpdir(), "allium-hook-roproj-"));
763+
const roFile = path.join(roProject, "spec.allium");
764+
writeFileSync(roFile, "-- allium: 3\n");
765+
chmodSync(roProject, 0o500);
766+
const runningAsRoot = typeof process.getuid === "function" && process.getuid() === 0;
767+
if (!runningAsRoot) {
768+
const roEnv = { CLAUDE_PROJECT_ROOT: roProject, XDG_CACHE_HOME: blockedCache };
769+
const ro1 = runNoCli({ tool_input: { file_path: roFile } }, roEnv);
770+
assert("notice shown when neither marker can be saved (exit 1)", ro1.status, 1);
771+
assert(
772+
"both-unwritable notice tells the user it couldn't be saved",
773+
/could NOT be saved/.test(ro1.stderr),
774+
true,
775+
);
776+
assert(
777+
"both-unwritable notice asks the user to confirm self-install",
778+
/confirm they're happy/.test(ro1.stderr),
779+
true,
780+
);
781+
const ro2 = runNoCli({ tool_input: { file_path: roFile } }, roEnv);
782+
assert("both-unwritable notice re-fires (exit 1)", ro2.status, 1);
783+
}
784+
chmodSync(roProject, 0o700);
785+
654786
// Clean up
787+
rmSync(emptyPathDir, { recursive: true });
788+
rmSync(noticeCache, { recursive: true });
789+
rmSync(freshCache, { recursive: true });
790+
rmSync(scopeCache, { recursive: true });
791+
rmSync(blockedRoot, { recursive: true });
792+
rmSync(fallbackProject, { recursive: true });
793+
rmSync(roProject, { recursive: true });
655794
rmSync(projectRoot, { recursive: true });
656795
rmSync(outsideDir, { recursive: true });
657796
rmSync(secondRoot, { recursive: true });

scripts/allium-ref.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
v3.4.0
1+
v3.5.0

0 commit comments

Comments
 (0)