@aron98/load-lock 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +61 -14
  2. package/dist/cli/copies.d.ts +3 -2
  3. package/dist/cli/copies.js +32 -3
  4. package/dist/cli/hook.js +48 -4
  5. package/dist/cli/install.d.ts +2 -0
  6. package/dist/cli/install.js +67 -13
  7. package/dist/cli/main.js +62 -5
  8. package/dist/cli/setup.d.ts +131 -0
  9. package/dist/cli/setup.js +942 -0
  10. package/dist/cli/update.d.ts +5 -1
  11. package/dist/cli/update.js +16 -3
  12. package/dist/core/daemon.d.ts +29 -3
  13. package/dist/core/daemon.js +111 -11
  14. package/dist/core/persist.d.ts +3 -0
  15. package/dist/core/persist.js +49 -10
  16. package/dist/core/state.d.ts +9 -0
  17. package/dist/core/state.js +41 -3
  18. package/dist/mcp/server.d.ts +4 -3
  19. package/dist/mcp/server.js +29 -6
  20. package/dist/policy/text.d.ts +13 -3
  21. package/dist/policy/text.js +14 -3
  22. package/dist/shared/agent-guide.d.ts +3 -0
  23. package/dist/shared/agent-guide.js +16 -0
  24. package/dist/shared/client.js +5 -3
  25. package/dist/shared/drift.d.ts +4 -2
  26. package/dist/shared/drift.js +24 -6
  27. package/dist/shared/identity.d.ts +51 -0
  28. package/dist/shared/identity.js +116 -0
  29. package/dist/shared/pkginfo.d.ts +9 -0
  30. package/dist/shared/pkginfo.js +16 -0
  31. package/dist/shared/protocol.d.ts +22 -0
  32. package/dist/shared/protocol.js +15 -0
  33. package/dist/shared/runtimes.d.ts +41 -0
  34. package/dist/shared/runtimes.js +81 -0
  35. package/dist/shared/version.d.ts +6 -0
  36. package/dist/shared/version.js +9 -1
  37. package/dist/test/ace-mcp.test.d.ts +1 -0
  38. package/dist/test/ace-mcp.test.js +157 -0
  39. package/dist/test/ace-opencode.test.d.ts +1 -0
  40. package/dist/test/ace-opencode.test.js +287 -0
  41. package/dist/test/check-pins.test.d.ts +1 -0
  42. package/dist/test/check-pins.test.js +108 -0
  43. package/dist/test/compat.test.d.ts +1 -0
  44. package/dist/test/compat.test.js +190 -0
  45. package/dist/test/hook.test.js +179 -1
  46. package/dist/test/identity.test.d.ts +1 -0
  47. package/dist/test/identity.test.js +155 -0
  48. package/dist/test/install.test.js +135 -1
  49. package/dist/test/launcher.test.d.ts +1 -0
  50. package/dist/test/launcher.test.js +222 -0
  51. package/dist/test/mcp.test.js +76 -7
  52. package/dist/test/plugin.test.d.ts +1 -0
  53. package/dist/test/plugin.test.js +144 -0
  54. package/dist/test/runtimes.test.d.ts +1 -0
  55. package/dist/test/runtimes.test.js +282 -0
  56. package/dist/test/setup.test.d.ts +1 -0
  57. package/dist/test/setup.test.js +354 -0
  58. package/dist/test/update-command.test.js +71 -0
  59. package/dist/test/update.test.js +33 -1
  60. package/docs/ADAPTERS.md +3 -2
  61. package/docs/AGENT_GUIDE.md +36 -2
  62. package/docs/ARCHITECTURE.md +122 -7
  63. package/docs/COMPATIBILITY.md +142 -13
  64. package/docs/OPERATIONS.md +292 -14
  65. package/package.json +3 -2
  66. package/skills/load-lock/SKILL.md +62 -0
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Session-derived agent identity (issue #49): the shared resolver
3
+ * (override → harness session env → stable harness ancestor → fallback)
4
+ * against planted /proc trees, plus the CLI default identity end to end:
5
+ * same session → one id (op-key dedupe, `pending`), different sessions →
6
+ * distinct ids.
7
+ */
8
+ import test, { before, after } from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { tmpdir } from "node:os";
13
+ import { sessionIdentity, harnessAncestor, readProc } from "../shared/identity.js";
14
+ import { startDaemon, runCli } from "./testutil.js";
15
+ const TMP_BASE = process.env.LL_TEST_TMP ?? join(tmpdir(), "opencode");
16
+ mkdirSync(TMP_BASE, { recursive: true });
17
+ /** Plant /proc/<pid>/{stat,cmdline} entries: [pid, ppid, comm, startTime, argv]. */
18
+ function plantProc(rows) {
19
+ const root = mkdtempSync(join(TMP_BASE, "ll-proc-"));
20
+ for (const [pid, ppid, comm, start, argv] of rows) {
21
+ mkdirSync(join(root, String(pid)));
22
+ // Fields after comm: state ppid pgrp session tty tpgid flags minflt
23
+ // cminflt majflt cmajflt utime stime cutime cstime priority nice
24
+ // num_threads itrealvalue starttime ...
25
+ const after = ["S", ppid, pid, pid, 0, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 20, 0, 1, 0, start, 1000, 10];
26
+ writeFileSync(join(root, String(pid), "stat"), `${pid} (${comm}) ${after.join(" ")}\n`);
27
+ writeFileSync(join(root, String(pid), "cmdline"), argv.join("\0") + "\0");
28
+ }
29
+ return root;
30
+ }
31
+ const tree = plantProc([
32
+ [100, 1, "claude", 5000, ["claude"]],
33
+ [110, 100, "bash", 5100, ["/bin/bash", "-c", "load-lock acquire"]],
34
+ [111, 100, "bash", 5200, ["/bin/bash", "-c", "load-lock pending"]],
35
+ // npx launcher between the harness and the CLI: node running npx-cli.js, then sh.
36
+ [120, 110, "node", 5300, ["node", "/usr/lib/node_modules/npm/bin/npx-cli.js", "-y", "@aron98/load-lock"]],
37
+ [121, 120, "sh", 5301, ["sh", "-c", "load-lock acquire"]],
38
+ [200, 1, "codex", 6000, ["codex"]],
39
+ [210, 200, "zsh", 6100, ["zsh", "-c", "load-lock acquire"]],
40
+ // comm with spaces and parens must parse.
41
+ [300, 1, "tmux: (server)", 7000, ["tmux"]],
42
+ [310, 300, "fish", 7100, ["fish"]],
43
+ // a shell chain that ends at init: no harness.
44
+ [400, 1, "bash", 8000, ["bash"]],
45
+ ]);
46
+ after(() => rmSync(tree, { recursive: true, force: true }));
47
+ const resolve = (startPid, env = {}, override) => sessionIdentity({ fallback: "cli:u@h", env, startPid, procRoot: tree, platform: "linux", override });
48
+ test("identity: two calls from the same harness session resolve to the same id", () => {
49
+ const a = resolve(110);
50
+ const b = resolve(111);
51
+ assert.equal(a.source, "ancestor");
52
+ assert.equal(a.id, "proc:100:5000");
53
+ assert.equal(b.id, a.id);
54
+ assert.equal(a.label, "claude:100");
55
+ // npx/sh wrappers are walked past too.
56
+ assert.equal(resolve(121).id, a.id);
57
+ });
58
+ test("identity: different harness sessions resolve to different ids", () => {
59
+ assert.equal(resolve(210).id, "proc:200:6000");
60
+ assert.notEqual(resolve(210).id, resolve(110).id);
61
+ assert.equal(resolve(310).id, "proc:300:7000");
62
+ assert.equal(readProc(300, tree)?.comm, "tmux: (server)");
63
+ });
64
+ test("identity: a reused pid with a new start time is a different session", () => {
65
+ const reused = plantProc([[100, 1, "claude", 9999, ["claude"]], [110, 100, "bash", 10000, ["bash"]]]);
66
+ try {
67
+ const id = sessionIdentity({ fallback: "f", env: {}, startPid: 110, procRoot: reused, platform: "linux" }).id;
68
+ assert.equal(id, "proc:100:9999");
69
+ assert.notEqual(id, resolve(110).id);
70
+ }
71
+ finally {
72
+ rmSync(reused, { recursive: true, force: true });
73
+ }
74
+ });
75
+ test("identity: falls back when the walk reaches init, /proc is missing, or not on Linux", () => {
76
+ assert.equal(harnessAncestor(400, tree), null);
77
+ assert.deepEqual(resolve(400), { id: "cli:u@h", label: "cli:u@h", source: "fallback" });
78
+ assert.equal(resolve(99999).source, "fallback");
79
+ const mac = sessionIdentity({ fallback: "cli:u@h", env: {}, startPid: 110, procRoot: tree, platform: "darwin" });
80
+ assert.equal(mac.source, "fallback");
81
+ });
82
+ test("identity: harness session env vars beat the ancestor walk", () => {
83
+ const cc = resolve(110, { CLAUDE_CODE_SESSION_ID: "ee690007-78c4-48d0-8f80-77b74e95a7fd" });
84
+ assert.deepEqual(cc, {
85
+ id: "claude-code:ee690007-78c4-48d0-8f80-77b74e95a7fd",
86
+ label: "claude-code:ee690007",
87
+ source: "harness-env",
88
+ });
89
+ // OpenCode: the bare sessionID, i.e. the native plugin's agentId.
90
+ assert.equal(resolve(110, { OPENCODE_SESSION_ID: "ses_abc123" }).id, "ses_abc123");
91
+ });
92
+ test("identity: overrides still win (explicit > LOAD_LOCK_AGENT > harness env)", () => {
93
+ const env = { LOAD_LOCK_AGENT: "pinned", CLAUDE_CODE_SESSION_ID: "s1" };
94
+ assert.equal(resolve(110, env).id, "pinned");
95
+ assert.equal(resolve(110, env).source, "env");
96
+ assert.equal(resolve(110, env, " explicit ").id, "explicit");
97
+ assert.equal(resolve(110, { LOAD_LOCK_AGENT: " " }).source, "ancestor", "blank override ignored");
98
+ });
99
+ // ------------------------------------------------------------ CLI end to end
100
+ let d;
101
+ before(async () => {
102
+ d = await startDaemon();
103
+ // The test runner itself may be running inside a harness session.
104
+ for (const k of ["LOAD_LOCK_AGENT", "CLAUDE_CODE_SESSION_ID", "OPENCODE_SESSION_ID"])
105
+ delete d.env[k];
106
+ });
107
+ after(async () => {
108
+ await d.stop();
109
+ });
110
+ function cliIn(env, args) {
111
+ return runCli({ ...d, env: { ...d.env, ...env } }, args);
112
+ }
113
+ async function acquireJson(env, opKey, extra = []) {
114
+ const r = await cliIn(env, ["acquire", "--op-key", opKey, "--reason", "identity test", "--json", ...extra]);
115
+ assert.ok(r.code === 0 || r.code === 68, r.stderr); // 68 = queued
116
+ return JSON.parse(r.stdout);
117
+ }
118
+ test("cli: same session shares one identity; different sessions get distinct ids", async () => {
119
+ const a1 = await acquireJson({ CLAUDE_CODE_SESSION_ID: "aaaaaaaa-1" }, "op/shared");
120
+ const a2 = await acquireJson({ CLAUDE_CODE_SESSION_ID: "aaaaaaaa-1" }, "op/shared");
121
+ const b = await acquireJson({ CLAUDE_CODE_SESSION_ID: "bbbbbbbb-2" }, "op/shared");
122
+ assert.equal(a1.entry.agentId, "claude-code:aaaaaaaa-1");
123
+ assert.equal(a1.entry.agentLabel, "claude-code:aaaaaaaa", "short readable label");
124
+ assert.equal(a2.entry.requestId, a1.entry.requestId, "same session: op-key dedupe returns the existing request");
125
+ assert.equal(b.entry.agentId, "claude-code:bbbbbbbb-2");
126
+ assert.notEqual(b.entry.requestId, a1.entry.requestId, "different sessions are not merged");
127
+ // Fill the pool so the next requests queue, then check `pending` isolation.
128
+ const status = JSON.parse((await cliIn({}, ["status", "--json"])).stdout);
129
+ const cap = status.config.pools.heavy.capacity;
130
+ for (let i = 2; i < cap; i++)
131
+ await acquireJson({ CLAUDE_CODE_SESSION_ID: "filler" }, `op/fill-${i}`);
132
+ const qa = await acquireJson({ CLAUDE_CODE_SESSION_ID: "aaaaaaaa-1" }, "op/queued");
133
+ assert.equal(qa.state, "queued");
134
+ const pa = JSON.parse((await cliIn({ CLAUDE_CODE_SESSION_ID: "aaaaaaaa-1" }, ["pending", "--json"])).stdout);
135
+ const pb = JSON.parse((await cliIn({ CLAUDE_CODE_SESSION_ID: "bbbbbbbb-2" }, ["pending", "--json"])).stdout);
136
+ assert.ok(JSON.stringify(pa).includes(qa.entry.requestId), "own queue entry visible");
137
+ assert.ok(!JSON.stringify(pb).includes(qa.entry.requestId), "other session does not see it");
138
+ });
139
+ test("cli: without session env, calls from one parent process share an ancestor identity", async () => {
140
+ if (process.platform !== "linux")
141
+ return;
142
+ const r1 = await acquireJson({}, "op/ancestor");
143
+ const r2 = await acquireJson({}, "op/ancestor");
144
+ // The CLI's parent is this test process (node, not a wrapper).
145
+ const start = readFileSync(`/proc/${process.pid}/stat`, "utf8").split(") ")[1].split(" ")[19];
146
+ assert.equal(r1.entry.agentId, `proc:${process.pid}:${start}`);
147
+ assert.equal(r2.entry.requestId, r1.entry.requestId);
148
+ });
149
+ test("cli: --agent and LOAD_LOCK_AGENT overrides still win", async () => {
150
+ const env = await acquireJson({ LOAD_LOCK_AGENT: "pinned-agent", CLAUDE_CODE_SESSION_ID: "aaaaaaaa-1" }, "op/override");
151
+ assert.equal(env.entry.agentId, "pinned-agent");
152
+ assert.equal(env.entry.agentLabel, undefined, "explicit ids label themselves");
153
+ const flag = await acquireJson({ CLAUDE_CODE_SESSION_ID: "aaaaaaaa-1" }, "op/override", ["--agent", "flag-agent"]);
154
+ assert.equal(flag.entry.agentId, "flag-agent");
155
+ });
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { test } from "node:test";
7
7
  import assert from "node:assert/strict";
8
- import { mkdtempSync, rmSync, mkdirSync, readFileSync, writeFileSync, existsSync, cpSync } from "node:fs";
8
+ import { mkdtempSync, rmSync, mkdirSync, readFileSync, writeFileSync, existsSync, cpSync, symlinkSync, lstatSync } from "node:fs";
9
9
  import { tmpdir } from "node:os";
10
10
  import { join, dirname, resolve } from "node:path";
11
11
  import { fileURLToPath } from "node:url";
@@ -424,6 +424,7 @@ test("claude-code: install/uninstall round-trip on a populated HOME; re-install
424
424
  const md = readFileSync(claudeMd, "utf8");
425
425
  assert.ok(md.includes("# My rules"), "existing CLAUDE.md content preserved");
426
426
  assert.equal(count(md, MARKER_BEGIN), 1, "exactly one marked section");
427
+ assert.ok(!md.includes("mcp__"), "section names tools neutrally (plugin tools are namespaced differently)");
427
428
  assert.ok(existsSync(join(claudeDir, "skills", "load-lock", "SKILL.md")), "skill installed");
428
429
  // -- idempotent re-install: no duplicate hook entries (issue #19)
429
430
  r = await runCli(home, ["install", "--harness", "claude-code"]);
@@ -464,6 +465,43 @@ test("claude-code: bare HOME install then uninstall removes exactly what we adde
464
465
  assert.ok(!("load-lock" in cj.mcpServers), "our MCP server key removed");
465
466
  rmSync(home, { recursive: true, force: true });
466
467
  });
468
+ test("claude-code: an ACE-stamped skill survives install and uninstall byte for byte (#89)", async () => {
469
+ const home = tempHome();
470
+ const skillDir = join(home, ".claude", "skills", "load-lock");
471
+ mkdirSync(skillDir, { recursive: true });
472
+ const aceSkill = "---\nname: load-lock\ndescription: from ACE\n---\nACE copy\n";
473
+ const stamp = JSON.stringify({ artifact: "load-lock", version: "1.4.0" }) + "\n";
474
+ writeFileSync(join(skillDir, "SKILL.md"), aceSkill);
475
+ writeFileSync(join(skillDir, ".ace.json"), stamp);
476
+ let r = await runCli(home, ["install", "--harness", "claude-code"]);
477
+ assert.equal(r.code, 0, `install failed: ${r.stderr}`);
478
+ assert.match(r.stdout, /kept the skill .*managed by ACE/);
479
+ assert.equal(readFileSync(join(skillDir, "SKILL.md"), "utf8"), aceSkill, "install must not overwrite ACE's skill");
480
+ // the rest of the loose wiring is still installed
481
+ const cj = JSON.parse(readFileSync(join(home, ".claude.json"), "utf8"));
482
+ assert.ok(cj.mcpServers["load-lock"], "MCP server installed");
483
+ r = await runCli(home, ["uninstall", "--harness", "claude-code"]);
484
+ assert.equal(r.code, 0, `uninstall failed: ${r.stderr}`);
485
+ assert.match(r.stdout, /kept the skill .*managed by ACE/);
486
+ assert.equal(readFileSync(join(skillDir, "SKILL.md"), "utf8"), aceSkill, "uninstall must not delete ACE's skill");
487
+ assert.equal(readFileSync(join(skillDir, ".ace.json"), "utf8"), stamp, "ACE stamp untouched");
488
+ rmSync(home, { recursive: true, force: true });
489
+ });
490
+ test("claude-code: a symlinked skill dir is neither written through nor removed (#89)", async () => {
491
+ const home = tempHome();
492
+ const checkout = join(home, "dev", "skill");
493
+ mkdirSync(checkout, { recursive: true });
494
+ writeFileSync(join(checkout, "SKILL.md"), "mine\n");
495
+ mkdirSync(join(home, ".claude", "skills"), { recursive: true });
496
+ symlinkSync(checkout, join(home, ".claude", "skills", "load-lock"));
497
+ let r = await runCli(home, ["install", "--harness", "claude-code"]);
498
+ assert.equal(r.code, 0, `install failed: ${r.stderr}`);
499
+ assert.equal(readFileSync(join(checkout, "SKILL.md"), "utf8"), "mine\n", "install must not write through the symlink");
500
+ r = await runCli(home, ["uninstall", "--harness", "claude-code"]);
501
+ assert.equal(r.code, 0, `uninstall failed: ${r.stderr}`);
502
+ assert.ok(lstatSync(join(home, ".claude", "skills", "load-lock")).isSymbolicLink(), "symlink kept");
503
+ rmSync(home, { recursive: true, force: true });
504
+ });
467
505
  test("claude-code: uninstall with nothing installed is a harmless no-op", async () => {
468
506
  const home = tempHome();
469
507
  const r = await runCli(home, ["uninstall", "--harness", "claude-code"]);
@@ -472,6 +510,101 @@ test("claude-code: uninstall with nothing installed is a harmless no-op", async
472
510
  assert.ok(!existsSync(join(home, ".claude.json")), "no files created by uninstall");
473
511
  rmSync(home, { recursive: true, force: true });
474
512
  });
513
+ // claude-code plugin migration (#62)
514
+ const PLUGIN_KEY = "load-lock-plugin@load-lock";
515
+ /** `doctor --json` against a temp HOME and a state dir no daemon ever runs in. */
516
+ async function doctorMode(home) {
517
+ const state = join(home, "st");
518
+ const r = await runCli(home, ["doctor", "--json"], {
519
+ LOAD_LOCK_STATE_DIR: state,
520
+ LOAD_LOCK_SOCKET: join(state, "d.sock"),
521
+ LOAD_LOCK_NO_SPAWN: "1",
522
+ });
523
+ assert.equal(r.code, 0, `doctor failed: ${r.stderr}`);
524
+ const rep = JSON.parse(r.stdout);
525
+ return { mode: rep.claudeCodeMode, warnings: rep.warnings };
526
+ }
527
+ function enablePlugin(home, on) {
528
+ const file = join(home, ".claude", "settings.json");
529
+ const s = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : {};
530
+ s.enabledPlugins = { ...s.enabledPlugins, [PLUGIN_KEY]: on };
531
+ mkdirSync(dirname(file), { recursive: true });
532
+ writeFileSync(file, JSON.stringify(s, null, 2) + "\n");
533
+ }
534
+ test("claude-code: loose → plugin migration, --legacy, uninstall; doctor reports the mode", async () => {
535
+ const home = tempHome();
536
+ const claudeDir = join(home, ".claude");
537
+ mkdirSync(claudeDir, { recursive: true });
538
+ writeFileSync(join(home, ".claude.json"), JSON.stringify({ mcpServers: { fetch: { command: "uvx" } } }, null, 2) + "\n");
539
+ writeFileSync(join(claudeDir, "settings.json"), JSON.stringify({ theme: "dark", hooks: { Stop: [{ hooks: [{ type: "command", command: "/usr/local/bin/user-stop" }] }] } }, null, 2) + "\n");
540
+ writeFileSync(join(claudeDir, "CLAUDE.md"), "# My rules\n");
541
+ const claudeJson = join(home, ".claude.json");
542
+ const settingsFile = join(claudeDir, "settings.json");
543
+ const claudeMd = join(claudeDir, "CLAUDE.md");
544
+ const skill = join(claudeDir, "skills", "load-lock", "SKILL.md");
545
+ let r = await runCli(home, ["install", "--harness", "claude-code"]);
546
+ assert.equal(r.code, 0, `install failed: ${r.stderr}`);
547
+ assert.equal((await doctorMode(home)).mode, "loose");
548
+ // the user enables the plugin: both are active, doctor flags it
549
+ enablePlugin(home, true);
550
+ let d = await doctorMode(home);
551
+ assert.equal(d.mode, "both");
552
+ assert.ok(d.warnings.some((w) => /plugin and the loose/.test(w)), "doctor flags both");
553
+ // -- install moves the loose entries out, reporting each step
554
+ r = await runCli(home, ["install", "--harness", "claude-code"]);
555
+ assert.equal(r.code, 0, `migrating install failed: ${r.stderr}`);
556
+ assert.match(r.stdout, new RegExp(`plugin ${PLUGIN_KEY} is enabled`));
557
+ assert.match(r.stdout, /removed mcpServers\.load-lock/);
558
+ assert.match(r.stdout, /removed load-lock hooks/);
559
+ assert.match(r.stdout, /removed the load-lock section/);
560
+ let cj = JSON.parse(readFileSync(claudeJson, "utf8"));
561
+ assert.ok(!("load-lock" in cj.mcpServers), "loose MCP server removed");
562
+ assert.ok(cj.mcpServers.fetch, "user MCP server untouched");
563
+ let s = JSON.parse(readFileSync(settingsFile, "utf8"));
564
+ assert.ok(!("SessionStart" in s.hooks), "loose SessionStart hooks removed");
565
+ assert.deepEqual(s.hooks.Stop, [{ hooks: [{ type: "command", command: "/usr/local/bin/user-stop" }] }], "user hook untouched");
566
+ assert.equal(s.enabledPlugins[PLUGIN_KEY], true, "plugin state untouched");
567
+ assert.equal(s.theme, "dark");
568
+ assert.equal(readFileSync(claudeMd, "utf8"), "# My rules\n", "CLAUDE.md section removed exactly");
569
+ assert.ok(existsSync(skill), "the skill stays (the plugin ships none)");
570
+ assert.equal((await doctorMode(home)).mode, "plugin");
571
+ // a second run is a no-op
572
+ r = await runCli(home, ["install", "--harness", "claude-code"]);
573
+ assert.equal(r.code, 0);
574
+ assert.match(r.stdout, /no loose MCP server, hooks or CLAUDE\.md section to remove/);
575
+ // -- --legacy keeps the loose install next to the plugin
576
+ r = await runCli(home, ["install", "--harness", "claude-code", "--legacy"]);
577
+ assert.equal(r.code, 0, `legacy install failed: ${r.stderr}`);
578
+ assert.match(r.stdout, /--legacy: plugin .* is enabled too/);
579
+ cj = JSON.parse(readFileSync(claudeJson, "utf8"));
580
+ assert.ok(cj.mcpServers["load-lock"], "--legacy installs the loose MCP server");
581
+ s = JSON.parse(readFileSync(settingsFile, "utf8"));
582
+ assert.equal(s.hooks.SessionStart.length, 2, "--legacy installs the loose hooks");
583
+ assert.equal((await doctorMode(home)).mode, "both");
584
+ // -- uninstall removes loose entries only and points at /plugin uninstall
585
+ r = await runCli(home, ["uninstall", "--harness", "claude-code"]);
586
+ assert.equal(r.code, 0, `uninstall failed: ${r.stderr}`);
587
+ assert.match(r.stdout, new RegExp(`/plugin uninstall ${PLUGIN_KEY}`));
588
+ s = JSON.parse(readFileSync(settingsFile, "utf8"));
589
+ assert.equal(s.enabledPlugins[PLUGIN_KEY], true, "uninstall never edits plugin state");
590
+ assert.ok(!("SessionStart" in s.hooks), "loose hooks removed");
591
+ cj = JSON.parse(readFileSync(claudeJson, "utf8"));
592
+ assert.ok(!("load-lock" in cj.mcpServers), "loose MCP server removed");
593
+ assert.equal(readFileSync(claudeMd, "utf8"), "# My rules\n");
594
+ assert.ok(!existsSync(skill), "our skill removed");
595
+ assert.equal((await doctorMode(home)).mode, "plugin");
596
+ rmSync(home, { recursive: true, force: true });
597
+ });
598
+ test("claude-code: a disabled plugin doesn't stop the loose install", async () => {
599
+ const home = tempHome();
600
+ enablePlugin(home, false);
601
+ const r = await runCli(home, ["install", "--harness", "claude-code"]);
602
+ assert.equal(r.code, 0, `install failed: ${r.stderr}`);
603
+ const cj = JSON.parse(readFileSync(join(home, ".claude.json"), "utf8"));
604
+ assert.ok(cj.mcpServers["load-lock"], "loose MCP server installed");
605
+ assert.equal((await doctorMode(home)).mode, "loose");
606
+ rmSync(home, { recursive: true, force: true });
607
+ });
475
608
  // --------------------------------------------------------------------- codex
476
609
  test("codex: install/uninstall round-trip on a populated HOME; re-install never duplicates hooks", async () => {
477
610
  const home = tempHome();
@@ -510,6 +643,7 @@ test("codex: install/uninstall round-trip on a populated HOME; re-install never
510
643
  const md = readFileSync(agentsMd, "utf8");
511
644
  assert.ok(md.includes("# My codex rules"), "existing AGENTS.md content preserved");
512
645
  assert.equal(count(md, MARKER_BEGIN), 1, "exactly one marked section");
646
+ assert.ok(!md.includes("mcp__"), "section names tools neutrally");
513
647
  // -- idempotent re-install: no duplicate hook entries (issue #19)
514
648
  r = await runCli(home, ["install", "--harness", "codex"], { CODEX_HOME: codexDir });
515
649
  assert.equal(r.code, 0, `re-install failed: ${r.stderr}`);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Tests for the dependency-free plugin launcher (issue #60):
3
+ * plugins/claude-code/scripts/load-lock.mjs over planted fake runtime
4
+ * copies (PATH, state dir) and a fake npx-cli.js. Light: no daemon, no
5
+ * network; every case runs in its own temp HOME/state dir.
6
+ */
7
+ import test from "node:test";
8
+ import assert from "node:assert/strict";
9
+ import { spawnSync } from "node:child_process";
10
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync, symlinkSync, readFileSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join, dirname, resolve } from "node:path";
13
+ import { fileURLToPath, pathToFileURL } from "node:url";
14
+ const HERE = dirname(fileURLToPath(import.meta.url));
15
+ const REPO_ROOT = resolve(HERE, "..", "..");
16
+ const LAUNCHER = join(REPO_ROOT, "plugins", "claude-code", "scripts", "load-lock.mjs");
17
+ const PKG_VERSION = JSON.parse(readFileSync(join(REPO_ROOT, "package.json"), "utf8")).version;
18
+ const L = await import(pathToFileURL(LAUNCHER).href);
19
+ const TMP_BASE = process.env.LL_TEST_TMP ?? tmpdir();
20
+ mkdirSync(TMP_BASE, { recursive: true });
21
+ /** A fake entry that reports what it was run with, then exits per FAKE_EXIT / FAKE_HANG. */
22
+ const FAKE_ENTRY = (tag) => `
23
+ let s = "";
24
+ process.stdin.on("data", (d) => (s += d));
25
+ process.stdin.on("end", () => {
26
+ process.stdout.write(JSON.stringify({ entry: ${JSON.stringify(tag)}, argv: process.argv.slice(2), stdin: s }) + "\\n");
27
+ if (process.env.FAKE_HANG === "1") setInterval(() => {}, 1000);
28
+ else process.exitCode = Number(process.env.FAKE_EXIT ?? 0);
29
+ });
30
+ `;
31
+ /** Plant a fake @aron98/load-lock package root with both bins. */
32
+ function plantPkg(root, version, name = "@aron98/load-lock") {
33
+ mkdirSync(join(root, "dist", "cli"), { recursive: true });
34
+ mkdirSync(join(root, "dist", "mcp"), { recursive: true });
35
+ writeFileSync(join(root, "package.json"), JSON.stringify({ name, version }));
36
+ writeFileSync(join(root, "dist", "cli", "main.js"), FAKE_ENTRY(`cli@${version}`));
37
+ writeFileSync(join(root, "dist", "mcp", "main.js"), FAKE_ENTRY(`mcp@${version}`));
38
+ return root;
39
+ }
40
+ function sandbox() {
41
+ const dir = mkdtempSync(join(TMP_BASE, "ll-launch-"));
42
+ const bin = join(dir, "bin");
43
+ const state = join(dir, "state");
44
+ mkdirSync(bin, { recursive: true });
45
+ mkdirSync(state, { recursive: true });
46
+ const env = {
47
+ HOME: dir,
48
+ PATH: bin, // only planted copies; the launcher runs node via process.execPath
49
+ LOAD_LOCK_STATE_DIR: state,
50
+ LOAD_LOCK_LAUNCHER_DEBUG: "1",
51
+ // No npx unless a test plants one: a missing override never falls back.
52
+ LOAD_LOCK_LAUNCHER_NPX_CLI: join(dir, "no-npx-cli.js"),
53
+ };
54
+ return { dir, bin, state, env };
55
+ }
56
+ /** Put a POSIX-npm-style `load-lock` link for `root` on the sandbox PATH. */
57
+ function linkOnPath(sb, root) {
58
+ symlinkSync(join(root, "dist", "cli", "main.js"), join(sb.bin, "load-lock"));
59
+ }
60
+ function run(sb, args, extraEnv = {}, input = '{"session_id":"s1"}') {
61
+ const t0 = Date.now();
62
+ const r = spawnSync(process.execPath, [LAUNCHER, ...args], {
63
+ env: { ...sb.env, ...extraEnv },
64
+ input,
65
+ encoding: "utf8",
66
+ timeout: 20_000,
67
+ });
68
+ return { ...r, ms: Date.now() - t0 };
69
+ }
70
+ const posix = process.platform !== "win32";
71
+ test("launcher pin equals package.json version", () => {
72
+ const text = readFileSync(LAUNCHER, "utf8");
73
+ assert.match(text, new RegExp(`"@aron98/load-lock@${PKG_VERSION.replace(/\./g, "\\.")}"`));
74
+ });
75
+ test("PATH branch: a new-enough load-lock on PATH runs its compiled entry with stdin and args", { skip: !posix }, () => {
76
+ const sb = sandbox();
77
+ try {
78
+ linkOnPath(sb, plantPkg(join(sb.dir, "global", "lib", "node_modules", "@aron98", "load-lock"), "99.0.0"));
79
+ const r = run(sb, ["hook", "claude-code", "stop"]);
80
+ assert.equal(r.status, 0, r.stderr);
81
+ const out = JSON.parse(r.stdout);
82
+ assert.equal(out.entry, "cli@99.0.0");
83
+ assert.deepEqual(out.argv, ["hook", "claude-code", "stop"]);
84
+ assert.equal(out.stdin, '{"session_id":"s1"}');
85
+ assert.match(r.stderr, /launcher: path 99\.0\.0/);
86
+ const m = run(sb, ["--bin", "load-lock-mcp"], {}, "");
87
+ assert.equal(m.status, 0, m.stderr);
88
+ assert.equal(JSON.parse(m.stdout).entry, "mcp@99.0.0");
89
+ }
90
+ finally {
91
+ rmSync(sb.dir, { recursive: true, force: true });
92
+ }
93
+ });
94
+ test("state branch: a too-old PATH copy is skipped for the newest known copy >= pin", { skip: !posix }, () => {
95
+ const sb = sandbox();
96
+ try {
97
+ linkOnPath(sb, plantPkg(join(sb.dir, "old"), "0.1.0"));
98
+ const a = plantPkg(join(sb.dir, "copy-a"), "98.0.0");
99
+ const b = plantPkg(join(sb.dir, "copy-b"), "98.1.0");
100
+ const tooOld = plantPkg(join(sb.dir, "copy-old"), "0.0.1");
101
+ const notOurs = plantPkg(join(sb.dir, "copy-other"), "999.0.0", "something-else");
102
+ writeFileSync(join(sb.state, "runtimes.json"), JSON.stringify({
103
+ copies: [a, tooOld, notOurs, join(sb.dir, "gone")].map((path) => ({ path, version: "x", lastSeen: 1 })),
104
+ }));
105
+ writeFileSync(join(sb.state, "daemon.json"), JSON.stringify({ pid: 1, startedAt: 1, socket: "s", packageRoot: b }));
106
+ const r = run(sb, ["hook", "claude-code", "stop"]);
107
+ assert.equal(r.status, 0, r.stderr);
108
+ assert.equal(JSON.parse(r.stdout).entry, "cli@98.1.0");
109
+ assert.match(r.stderr, /launcher: state 98\.1\.0/);
110
+ }
111
+ finally {
112
+ rmSync(sb.dir, { recursive: true, force: true });
113
+ }
114
+ });
115
+ test("npx branch: pinned --package, --offline only in hook mode, bin after --", () => {
116
+ const sb = sandbox();
117
+ try {
118
+ const npxCli = join(sb.dir, "npx-cli.js");
119
+ writeFileSync(npxCli, `process.stdout.write(JSON.stringify(process.argv.slice(2)) + "\\n");`);
120
+ const env = { LOAD_LOCK_LAUNCHER_NPX_CLI: npxCli };
121
+ const h = run(sb, ["hook", "claude-code", "session-start"], env);
122
+ assert.equal(h.status, 0, h.stderr);
123
+ assert.deepEqual(JSON.parse(h.stdout), [
124
+ "--yes", "--offline", `--package=@aron98/load-lock@${PKG_VERSION}`, "--", "load-lock", "hook", "claude-code", "session-start",
125
+ ]);
126
+ const m = run(sb, ["--bin", "load-lock-mcp"], env, "");
127
+ assert.equal(m.status, 0, m.stderr);
128
+ assert.deepEqual(JSON.parse(m.stdout), ["--yes", `--package=@aron98/load-lock@${PKG_VERSION}`, "--", "load-lock-mcp"]);
129
+ }
130
+ finally {
131
+ rmSync(sb.dir, { recursive: true, force: true });
132
+ }
133
+ });
134
+ test("no runtime at all: hooks exit 0, SessionStart prints one install hint, the MCP server fails loudly", () => {
135
+ const sb = sandbox();
136
+ try {
137
+ const s = run(sb, ["hook", "claude-code", "session-start"]);
138
+ assert.equal(s.status, 0);
139
+ assert.equal(s.stdout.trim().split("\n").length, 1);
140
+ assert.match(s.stdout, /npm i -g @aron98\/load-lock/);
141
+ for (const ev of ["stop", "await-offer"]) {
142
+ const r = run(sb, ["hook", "claude-code", ev]);
143
+ assert.equal(r.status, 0);
144
+ assert.equal(r.stdout, "");
145
+ }
146
+ const m = run(sb, ["--bin", "load-lock-mcp"], {}, "");
147
+ assert.equal(m.status, 1);
148
+ assert.match(m.stderr, /no runtime found/);
149
+ assert.equal(run(sb, ["--bin", "nope"]).status, 64);
150
+ }
151
+ finally {
152
+ rmSync(sb.dir, { recursive: true, force: true });
153
+ }
154
+ });
155
+ test("exit codes: hooks pass 0/2 through and swallow the rest; the MCP server passes everything through", { skip: !posix }, () => {
156
+ const sb = sandbox();
157
+ try {
158
+ linkOnPath(sb, plantPkg(join(sb.dir, "pkg"), "99.0.0"));
159
+ assert.equal(run(sb, ["hook", "claude-code", "await-offer"], { FAKE_EXIT: "2" }).status, 2);
160
+ assert.equal(run(sb, ["hook", "claude-code", "stop"], { FAKE_EXIT: "1" }).status, 0);
161
+ // SessionStart that already printed output gets no extra hint.
162
+ const s = run(sb, ["hook", "claude-code", "session-start"], { FAKE_EXIT: "1" });
163
+ assert.equal(s.status, 0);
164
+ assert.doesNotMatch(s.stdout, /npm i -g/);
165
+ assert.equal(run(sb, ["--bin", "load-lock-mcp"], { FAKE_EXIT: "3" }, "").status, 3);
166
+ }
167
+ finally {
168
+ rmSync(sb.dir, { recursive: true, force: true });
169
+ }
170
+ });
171
+ test("timeout: a stalled hook is killed and exits 0 fast; await-offer is not bounded by it", { skip: !posix }, () => {
172
+ const sb = sandbox();
173
+ try {
174
+ const npxCli = join(sb.dir, "npx-cli.js");
175
+ // A stalled npx whose own child holds stdout: the process group dies too.
176
+ writeFileSync(npxCli, `const { spawn } = require("node:child_process");
177
+ spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "inherit" });
178
+ setInterval(() => {}, 1000);`);
179
+ writeFileSync(join(sb.dir, "package.json"), JSON.stringify({ type: "commonjs" }));
180
+ const env = { LOAD_LOCK_LAUNCHER_NPX_CLI: npxCli, LOAD_LOCK_LAUNCHER_TIMEOUT_MS: "300" };
181
+ const r = run(sb, ["hook", "claude-code", "session-start"], env);
182
+ assert.equal(r.status, 0);
183
+ assert.ok(r.ms < 5000, `took ${r.ms}ms`);
184
+ assert.match(r.stdout, /npm i -g/); // nothing came out, so the hint does
185
+ linkOnPath(sb, plantPkg(join(sb.dir, "pkg"), "99.0.0"));
186
+ const w = spawnSync(process.execPath, [LAUNCHER, "hook", "claude-code", "await-offer"], {
187
+ env: { ...sb.env, FAKE_HANG: "1", LOAD_LOCK_LAUNCHER_TIMEOUT_MS: "200" },
188
+ input: "{}",
189
+ encoding: "utf8",
190
+ timeout: 1500,
191
+ });
192
+ assert.equal(w.error?.code, "ETIMEDOUT", "await-offer must outlive the hook timeout");
193
+ }
194
+ finally {
195
+ rmSync(sb.dir, { recursive: true, force: true });
196
+ }
197
+ });
198
+ test("resolution helpers: Windows npm shim layout, npx.cmd fallback, state dir precedence", () => {
199
+ const sb = sandbox();
200
+ try {
201
+ // Windows npm global layout: <prefix>\load-lock.cmd + <prefix>\node_modules\@aron98\load-lock
202
+ const prefix = join(sb.dir, "npm-prefix");
203
+ plantPkg(join(prefix, "node_modules", "@aron98", "load-lock"), "99.2.0");
204
+ writeFileSync(join(prefix, "load-lock.cmd"), "@echo off\r\n");
205
+ const found = L.pathCopy({ PATH: prefix }, "win32", "dist/cli/main.js");
206
+ assert.equal(found?.version, "99.2.0");
207
+ assert.equal(found?.source, "path");
208
+ const noNode = join(sb.dir, "nodeless", "node.exe");
209
+ assert.deepEqual(L.npxCommand({}, "win32", noNode), { command: "npx.cmd", args: [], shell: true });
210
+ assert.deepEqual(L.npxCommand({}, "linux", noNode), { command: "npx", args: [], shell: false });
211
+ assert.equal(L.stateDir({ LOAD_LOCK_STATE_DIR: "/x" }, "linux"), "/x");
212
+ assert.equal(L.stateDir({ XDG_STATE_HOME: "/xdg", HOME: "/h" }, "linux"), join("/xdg", "load-lock"));
213
+ assert.equal(L.stateDir({ HOME: "/h" }, "linux"), join("/h", ".local", "state", "load-lock"));
214
+ assert.equal(L.stateDir({ LOCALAPPDATA: "C:/AppData" }, "win32"), join("C:/AppData", "load-lock"));
215
+ assert.equal(L.compareVersions("1.10.0", "1.9.9"), 1);
216
+ assert.equal(L.compareVersions("1.5.0", "1.5"), 0);
217
+ assert.equal(L.compareVersions("1.4.9", "1.5.0"), -1);
218
+ }
219
+ finally {
220
+ rmSync(sb.dir, { recursive: true, force: true });
221
+ }
222
+ });