sessionpipe-conformance 0.1.0 → 0.1.1

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.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # @sessionpipe/conformance
1
+ # sessionpipe-conformance
2
2
 
3
3
  Runs the sessionpipe conformance fixtures against a receiver URL or an adapter.
4
4
 
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // sessionpipe-conformance 0.1.0 · Apache-2.0 · https://sessionpipe.org
2
+ // sessionpipe-conformance 0.1.1 · Apache-2.0 · https://sessionpipe.org
3
3
 
4
4
  // src/cli.ts
5
5
  import { readdirSync as readdirSync5, statSync as statSync4 } from "node:fs";
@@ -9,7 +9,16 @@ import path7 from "node:path";
9
9
  import { readFileSync as readFileSync6 } from "node:fs";
10
10
 
11
11
  // ../core/src/adapters/antigravity.ts
12
- import { existsSync as existsSync2, mkdirSync as mkdirSync2, readdirSync as readdirSync2, readFileSync as readFileSync2, realpathSync, statSync as statSync2, writeFileSync as writeFileSync2 } from "node:fs";
12
+ import {
13
+ existsSync as existsSync2,
14
+ mkdirSync as mkdirSync2,
15
+ readdirSync as readdirSync2,
16
+ readFileSync as readFileSync2,
17
+ realpathSync,
18
+ statSync as statSync2,
19
+ unlinkSync as unlinkSync2,
20
+ writeFileSync as writeFileSync2
21
+ } from "node:fs";
13
22
  import path3 from "node:path";
14
23
 
15
24
  // ../core/src/paths.ts
@@ -217,16 +226,42 @@ function readAntigravity(lines, from) {
217
226
  }
218
227
 
219
228
  // ../core/src/adapters/claude-shaped.ts
220
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
229
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
221
230
  import path2 from "node:path";
222
231
  var OURS = /sessionpipe[^"\n]*[\\/]hook\.js"?\s+[a-z][a-z-]*\s+[A-Za-z]+\s*$/;
223
232
  var isOurs = (h) => Array.isArray(h?.hooks) && h.hooks.some((x) => typeof x.command === "string" && OURS.test(x.command));
224
- function readJson(file) {
233
+ function readJsonFile(file) {
234
+ let raw;
225
235
  try {
226
- return JSON.parse(readFileSync(file, "utf8"));
236
+ raw = readFileSync(file, "utf8");
227
237
  } catch {
228
- return {};
238
+ return { ok: true, existed: false, value: {}, raw: "", indent: " ", eol: "\n", finalNewline: true };
229
239
  }
240
+ let value;
241
+ try {
242
+ value = JSON.parse(raw);
243
+ } catch {
244
+ const hint = /^\s*\/\/|\/\*/m.test(raw) ? "not plain JSON (it has comments)" : "not plain JSON";
245
+ return { ok: false, existed: true, reason: hint };
246
+ }
247
+ if (!value || typeof value !== "object" || Array.isArray(value))
248
+ return { ok: false, existed: true, reason: "not a JSON object" };
249
+ const eol = raw.includes("\r\n") ? "\r\n" : "\n";
250
+ const m = /^( +|\t+)\S/m.exec(raw);
251
+ const indent = m?.[1] ?? " ";
252
+ return {
253
+ ok: true,
254
+ existed: true,
255
+ value,
256
+ raw,
257
+ indent,
258
+ eol,
259
+ finalNewline: /\r?\n$/.test(raw)
260
+ };
261
+ }
262
+ function formatJson(value, f) {
263
+ const text = JSON.stringify(value, null, f.indent).replace(/\n/g, f.eol);
264
+ return f.finalNewline ? text + f.eol : text;
230
265
  }
231
266
  function entryFor(event, harness, cmd, o) {
232
267
  const matcher = o.matcherFor(event);
@@ -236,9 +271,22 @@ function entryFor(event, harness, cmd, o) {
236
271
  };
237
272
  }
238
273
  function installClaudeShaped(file, harness, events, cmd, o) {
239
- const settings2 = readJson(file);
274
+ const f = readJsonFile(file);
275
+ if (!f.ok) return { file, changed: false, skipped: true, note: `${f.reason}; not touched` };
276
+ const settings2 = f.value;
240
277
  const hooks = settings2.hooks && typeof settings2.hooks === "object" ? settings2.hooks : {};
241
278
  let changed = false;
279
+ const want = new Set(events);
280
+ for (const ev of Object.keys(hooks)) {
281
+ if (want.has(ev)) continue;
282
+ const list = Array.isArray(hooks[ev]) ? hooks[ev] : [];
283
+ const kept = list.filter((h) => !isOurs(h));
284
+ if (kept.length !== list.length) {
285
+ changed = true;
286
+ if (kept.length) hooks[ev] = kept;
287
+ else delete hooks[ev];
288
+ }
289
+ }
242
290
  for (const ev of events) {
243
291
  const list = Array.isArray(hooks[ev]) ? hooks[ev] : [];
244
292
  const entry2 = entryFor(ev, harness, cmd, o);
@@ -257,19 +305,19 @@ function installClaudeShaped(file, harness, events, cmd, o) {
257
305
  if (changed) {
258
306
  settings2.hooks = hooks;
259
307
  mkdirSync(path2.dirname(file), { recursive: true });
260
- writeFileSync(file, `${JSON.stringify(settings2, null, 2)}
261
- `);
308
+ writeFileSync(file, formatJson(settings2, f));
262
309
  }
263
- return { file, changed };
310
+ return { file, changed, created: !f.existed && changed };
264
311
  }
265
- function uninstallClaudeShaped(file, events) {
312
+ function uninstallClaudeShaped(file, events, opts = {}) {
266
313
  if (!existsSync(file)) return { file, changed: false };
267
- const raw = readFileSync(file, "utf8");
268
- const settings2 = readJson(file);
314
+ const f = readJsonFile(file);
315
+ if (!f.ok) return { file, changed: false, skipped: true, note: `${f.reason}; not touched` };
316
+ const settings2 = f.value;
269
317
  const hooks = settings2.hooks;
270
318
  if (!hooks || typeof hooks !== "object") return { file, changed: false };
271
319
  let changed = false;
272
- for (const ev of events) {
320
+ for (const ev of [...events, ...Object.keys(hooks)]) {
273
321
  const list = Array.isArray(hooks[ev]) ? hooks[ev] : [];
274
322
  const kept = list.filter((h) => !isOurs(h));
275
323
  if (kept.length !== list.length) {
@@ -280,13 +328,17 @@ function uninstallClaudeShaped(file, events) {
280
328
  }
281
329
  if (!changed) return { file, changed: false };
282
330
  if (!Object.keys(hooks).length) delete settings2.hooks;
283
- const next = `${JSON.stringify(settings2, null, 2)}
284
- `;
285
- if (next !== raw) writeFileSync(file, next);
331
+ if (opts.created && !Object.keys(settings2).length) {
332
+ unlinkSync(file);
333
+ return { file, changed: true, note: "removed (sessionpipe created it)" };
334
+ }
335
+ const next = formatJson(settings2, f);
336
+ if (next !== f.raw) writeFileSync(file, next);
286
337
  return { file, changed: true };
287
338
  }
288
339
  function installedClaudeShaped(file, harness, events, cmd, o) {
289
- const hooks = readJson(file).hooks ?? {};
340
+ const f = readJsonFile(file);
341
+ const hooks = f.ok ? f.value.hooks ?? {} : {};
290
342
  let present = 0;
291
343
  let current = 0;
292
344
  for (const ev of events) {
@@ -295,6 +347,9 @@ function installedClaudeShaped(file, harness, events, cmd, o) {
295
347
  if (mine.length) present++;
296
348
  if (mine.length === 1 && JSON.stringify(mine[0]) === JSON.stringify(entryFor(ev, harness, cmd, o))) current++;
297
349
  }
350
+ for (const ev of Object.keys(hooks))
351
+ if (!events.includes(ev) && (Array.isArray(hooks[ev]) ? hooks[ev] : []).some((h) => isOurs(h)))
352
+ return "stale";
298
353
  if (!present) return "missing";
299
354
  return current === events.length ? "current" : "stale";
300
355
  }
@@ -309,10 +364,13 @@ var projectsDir = (env) => path3.join(gemini(env), "config", "projects");
309
364
  var dataDirs = (env) => ["antigravity", "antigravity-cli", "antigravity-ide"].map((d) => path3.join(gemini(env), d));
310
365
  var ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
311
366
  var validTool = (n) => typeof n === "string" && /^[A-Za-z0-9_.:-]{1,120}$/.test(n);
312
- function entry(cmd) {
367
+ function entry(cmd, lean) {
313
368
  const handler = (e) => ({ type: "command", command: cmd([NAME, e]).command, timeout: 5 });
314
369
  const out = { enabled: true };
315
- for (const e of AG_EVENTS) out[e] = /Tool/.test(e) ? [{ matcher: "*", hooks: [handler(e)] }] : [handler(e)];
370
+ for (const e of AG_EVENTS) {
371
+ if (lean && e === "PreToolUse") continue;
372
+ out[e] = /Tool/.test(e) ? [{ matcher: "*", hooks: [handler(e)] }] : [handler(e)];
373
+ }
316
374
  return out;
317
375
  }
318
376
  var antigravity = {
@@ -320,34 +378,49 @@ var antigravity = {
320
378
  events: AG_EVENTS,
321
379
  detect: (env = process.env) => existsSync2(path3.join(gemini(env), "config")) || dataDirs(env).some((d) => existsSync2(d)),
322
380
  configFiles: (env = process.env) => [hooksFile(env)],
323
- install(cmd, env = process.env) {
381
+ install(cmd, env = process.env, opts = {}) {
324
382
  const file = hooksFile(env);
325
- const hooks = readJson(file);
326
- const e = entry(cmd);
383
+ const f = readJsonFile(file);
384
+ if (!f.ok) return [{ file, changed: false, skipped: true, note: `${f.reason}; not touched` }];
385
+ const hooks = f.value;
386
+ const e = entry(cmd, opts.lean);
327
387
  if (JSON.stringify(hooks[HOOK_NAME]) === JSON.stringify(e)) return [{ file, changed: false }];
328
388
  hooks[HOOK_NAME] = e;
329
389
  mkdirSync2(path3.dirname(file), { recursive: true });
330
- writeFileSync2(file, `${JSON.stringify(hooks, null, 2)}
331
- `);
390
+ writeFileSync2(file, formatJson(hooks, f));
332
391
  return [
333
- { file, changed: true, note: "hooks.json is read when a conversation starts; open ones report after a restart" }
392
+ {
393
+ file,
394
+ changed: true,
395
+ created: !f.existed,
396
+ note: "hooks.json is read when a conversation starts; open ones report after a restart"
397
+ }
334
398
  ];
335
399
  },
336
- uninstall(env = process.env) {
400
+ uninstall(env = process.env, opts = {}) {
337
401
  const file = hooksFile(env);
338
402
  if (!existsSync2(file)) return [{ file, changed: false }];
339
- const hooks = readJson(file);
403
+ const f = readJsonFile(file);
404
+ if (!f.ok) return [{ file, changed: false, skipped: true, note: `${f.reason}; not touched` }];
405
+ const hooks = f.value;
340
406
  if (!(HOOK_NAME in hooks)) return [{ file, changed: false }];
341
407
  delete hooks[HOOK_NAME];
342
- writeFileSync2(file, `${JSON.stringify(hooks, null, 2)}
343
- `);
408
+ if (opts.created?.includes(file) && !Object.keys(hooks).length) {
409
+ unlinkSync2(file);
410
+ return [{ file, changed: true, note: "removed (sessionpipe created it)" }];
411
+ }
412
+ writeFileSync2(file, formatJson(hooks, f));
344
413
  return [{ file, changed: true }];
345
414
  },
346
- installed(cmd, env = process.env) {
415
+ installed(cmd, env = process.env, opts = {}) {
347
416
  const file = hooksFile(env);
348
- const have = readJson(file)[HOOK_NAME];
417
+ const f = readJsonFile(file);
418
+ const have = f.ok ? f.value[HOOK_NAME] : void 0;
349
419
  return [
350
- { file, state: !have ? "missing" : JSON.stringify(have) === JSON.stringify(entry(cmd)) ? "current" : "stale" }
420
+ {
421
+ file,
422
+ state: !have ? "missing" : JSON.stringify(have) === JSON.stringify(entry(cmd, opts.lean)) ? "current" : "stale"
423
+ }
351
424
  ];
352
425
  },
353
426
  fromHook(input) {
@@ -613,8 +686,11 @@ var CLAUDE_EVENTS = [
613
686
  "SubagentStop",
614
687
  "PreCompact",
615
688
  "PostCompact",
616
- "SessionEnd"
689
+ "SessionEnd",
690
+ "StopFailure"
617
691
  ];
692
+ var LEAN_SKIP = /* @__PURE__ */ new Set(["PreToolUse", "PostToolUseFailure"]);
693
+ var eventsFor = (lean) => lean ? CLAUDE_EVENTS.filter((e) => !LEAN_SKIP.has(e)) : [...CLAUDE_EVENTS];
618
694
  var OPTS = { matcherFor: (e) => /Tool|Permission/.test(e) ? "" : void 0, timeout: 5 };
619
695
  var DESKTOP_ID = /^local_[A-Za-z0-9-]{1,64}$/;
620
696
  function claudeDirs(env = process.env, home = HOME) {
@@ -646,11 +722,13 @@ var claudeCode = {
646
722
  events: CLAUDE_EVENTS,
647
723
  detect: (env = process.env) => claudeDirs(env).some((d) => existsSync3(d)),
648
724
  configFiles: (env = process.env) => claudeDirs(env).map(settingsIn),
649
- install: (cmd, env = process.env) => claudeDirs(env).map((d) => installClaudeShaped(settingsIn(d), NAME2, CLAUDE_EVENTS, cmd, OPTS)),
650
- uninstall: (env = process.env) => claudeDirs(env).map((d) => uninstallClaudeShaped(settingsIn(d), CLAUDE_EVENTS)),
651
- installed: (cmd, env = process.env) => claudeDirs(env).map((d) => ({
725
+ install: (cmd, env = process.env, opts = {}) => claudeDirs(env).map((d) => installClaudeShaped(settingsIn(d), NAME2, eventsFor(opts.lean), cmd, OPTS)),
726
+ uninstall: (env = process.env, opts = {}) => claudeDirs(env).map(
727
+ (d) => uninstallClaudeShaped(settingsIn(d), CLAUDE_EVENTS, { created: opts.created?.includes(settingsIn(d)) })
728
+ ),
729
+ installed: (cmd, env = process.env, opts = {}) => claudeDirs(env).map((d) => ({
652
730
  file: settingsIn(d),
653
- state: installedClaudeShaped(settingsIn(d), NAME2, CLAUDE_EVENTS, cmd, OPTS)
731
+ state: installedClaudeShaped(settingsIn(d), NAME2, eventsFor(opts.lean), cmd, OPTS)
654
732
  })),
655
733
  fromHook(input) {
656
734
  const event = input.argv[1] ?? "";
@@ -741,6 +819,15 @@ var claudeCode = {
741
819
  case "Stop":
742
820
  events.push(ev("turn.ended", { turn_id, reason: "stop" }));
743
821
  break;
822
+ case "StopFailure":
823
+ events.push(
824
+ ev("turn.ended", {
825
+ turn_id,
826
+ reason: "error",
827
+ error: typeof s.error === "string" ? s.error.slice(0, 200) : void 0
828
+ })
829
+ );
830
+ break;
744
831
  case "SubagentStart":
745
832
  events.push(
746
833
  ev("subagent.started", {
@@ -1252,6 +1339,7 @@ var CODEX_EVENTS = [
1252
1339
  "SessionEnd"
1253
1340
  ];
1254
1341
  var OPTS2 = { matcherFor: (e) => /Tool|Permission/.test(e) ? "" : void 0, timeout: 5 };
1342
+ var eventsFor2 = (lean) => lean ? CODEX_EVENTS.filter((e) => e !== "PreToolUse") : [...CODEX_EVENTS];
1255
1343
  var codexHome = (env) => env.CODEX_HOME || path5.join(HOME, ".codex");
1256
1344
  var hooksFile2 = (env) => path5.join(codexHome(env), "hooks.json");
1257
1345
  var configFile = (env) => path5.join(codexHome(env), "config.toml");
@@ -1300,7 +1388,8 @@ function installNotify(cmd, env) {
1300
1388
  return {
1301
1389
  file,
1302
1390
  changed: false,
1303
- note: `left alone: notify runs ${n.raw.slice(0, 80)} (hooks.json carries the events; the notify fallback is off)`
1391
+ skipped: true,
1392
+ note: `notify belongs to ${(n.argv?.[0] ?? n.raw).replace(/^.*\//, "").slice(0, 40)}; left alone. Codex will report nothing until you approve the hooks: start \`codex\` and accept the hook review`
1304
1393
  };
1305
1394
  }
1306
1395
  write(withNotify(text, line));
@@ -1357,14 +1446,21 @@ var codex = {
1357
1446
  events: CODEX_EVENTS,
1358
1447
  detect: (env = process.env) => existsSync4(codexHome(env)),
1359
1448
  configFiles: (env = process.env) => [hooksFile2(env), configFile(env)],
1360
- install: (cmd, env = process.env) => [
1361
- installClaudeShaped(hooksFile2(env), NAME3, CODEX_EVENTS, cmd, OPTS2),
1449
+ install: (cmd, env = process.env, opts = {}) => [
1450
+ installClaudeShaped(hooksFile2(env), NAME3, eventsFor2(opts.lean), cmd, OPTS2),
1362
1451
  installNotify(cmd, env)
1363
1452
  ],
1364
- uninstall: (env = process.env) => [uninstallClaudeShaped(hooksFile2(env), CODEX_EVENTS), uninstallNotify(env)],
1365
- installed: (cmd, env = process.env) => {
1453
+ uninstall: (env = process.env, opts = {}) => [
1454
+ uninstallClaudeShaped(hooksFile2(env), CODEX_EVENTS, { created: opts.created?.includes(hooksFile2(env)) }),
1455
+ uninstallNotify(env)
1456
+ ],
1457
+ installed: (cmd, env = process.env, opts = {}) => {
1366
1458
  const out = [
1367
- { file: hooksFile2(env), state: installedClaudeShaped(hooksFile2(env), NAME3, CODEX_EVENTS, cmd, OPTS2) }
1459
+ {
1460
+ file: hooksFile2(env),
1461
+ state: installedClaudeShaped(hooksFile2(env), NAME3, eventsFor2(opts.lean), cmd, OPTS2),
1462
+ note: "Codex runs non-managed hooks only after the review in `codex` (/hooks); until then nothing is reported unless the notify fallback is ours"
1463
+ }
1368
1464
  ];
1369
1465
  let text = "";
1370
1466
  try {
@@ -1552,6 +1648,7 @@ function codexFacts(file) {
1552
1648
  }
1553
1649
 
1554
1650
  // ../core/src/adapters/gemini-cli.ts
1651
+ import { execFileSync as execFileSync3 } from "node:child_process";
1555
1652
  import { existsSync as existsSync5, readFileSync as readFileSync5 } from "node:fs";
1556
1653
  import path6 from "node:path";
1557
1654
  var NAME4 = "gemini-cli";
@@ -1569,17 +1666,61 @@ var OPTS3 = { matcherFor: (e) => /Tool/.test(e) ? ".*" : void 0, timeout: 5e3 };
1569
1666
  var geminiDir = (env) => env.GEMINI_CLI_HOME || path6.join(HOME, ".gemini");
1570
1667
  var settings = (env) => path6.join(geminiDir(env), "settings.json");
1571
1668
  var validTool4 = (n) => typeof n === "string" && /^[A-Za-z0-9_.:-]{1,120}$/.test(n);
1669
+ var eventsFor3 = (lean) => lean ? GEMINI_EVENTS.filter((e) => e !== "BeforeTool") : [...GEMINI_EVENTS];
1670
+ function geminiHooksActive(settingsFile) {
1671
+ let tools;
1672
+ try {
1673
+ tools = JSON.parse(readFileSync5(settingsFile, "utf8")).tools;
1674
+ } catch {
1675
+ }
1676
+ if (tools?.enableHooks === true) return { active: true };
1677
+ if (tools?.enableHooks === false)
1678
+ return { active: false, note: "inactive: tools.enableHooks is false in settings.json" };
1679
+ let version = "";
1680
+ try {
1681
+ version = execFileSync3("gemini", ["--version"], {
1682
+ encoding: "utf8",
1683
+ timeout: 4e3,
1684
+ stdio: ["ignore", "pipe", "ignore"]
1685
+ }).trim();
1686
+ } catch {
1687
+ return {
1688
+ active: true,
1689
+ note: "could not run `gemini --version`; hooks need tools.enableHooks on versions before 0.46"
1690
+ };
1691
+ }
1692
+ const m = /(\d+)\.(\d+)/.exec(version);
1693
+ if (m && (Number(m[1]) > 0 || Number(m[2]) >= 46)) return { active: true };
1694
+ return {
1695
+ active: false,
1696
+ note: `inactive: Gemini CLI ${version || "?"} runs hooks only with "tools": {"enableHooks": true} in settings.json`
1697
+ };
1698
+ }
1572
1699
  var geminiCli = {
1573
1700
  name: NAME4,
1574
1701
  events: GEMINI_EVENTS,
1575
1702
  // ~/.gemini also belongs to Antigravity; Gemini CLI is here when its settings or its chat logs exist.
1576
1703
  detect: (env = process.env) => existsSync5(settings(env)) || existsSync5(path6.join(geminiDir(env), "tmp")),
1577
1704
  configFiles: (env = process.env) => [settings(env)],
1578
- install: (cmd, env = process.env) => [installClaudeShaped(settings(env), NAME4, GEMINI_EVENTS, cmd, OPTS3)],
1579
- uninstall: (env = process.env) => [uninstallClaudeShaped(settings(env), GEMINI_EVENTS)],
1580
- installed: (cmd, env = process.env) => [
1581
- { file: settings(env), state: installedClaudeShaped(settings(env), NAME4, GEMINI_EVENTS, cmd, OPTS3) }
1705
+ install: (cmd, env = process.env, opts = {}) => {
1706
+ const r2 = installClaudeShaped(settings(env), NAME4, eventsFor3(opts.lean), cmd, OPTS3);
1707
+ const a = geminiHooksActive(settings(env));
1708
+ return [a.note && !r2.skipped ? { ...r2, note: [r2.note, a.note].filter(Boolean).join("; ") } : r2];
1709
+ },
1710
+ uninstall: (env = process.env, opts = {}) => [
1711
+ uninstallClaudeShaped(settings(env), GEMINI_EVENTS, { created: opts.created?.includes(settings(env)) })
1582
1712
  ],
1713
+ installed: (cmd, env = process.env, opts = {}) => {
1714
+ const state = installedClaudeShaped(settings(env), NAME4, eventsFor3(opts.lean), cmd, OPTS3);
1715
+ const a = geminiHooksActive(settings(env));
1716
+ return [
1717
+ {
1718
+ file: settings(env),
1719
+ state: state === "current" && !a.active ? "inactive" : state,
1720
+ ...a.note ? { note: a.note } : {}
1721
+ }
1722
+ ];
1723
+ },
1583
1724
  fromHook(input) {
1584
1725
  const event = input.argv[1] ?? "";
1585
1726
  let s = {};