bantamkit-mcp 0.35.2 → 0.35.4

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/dist/cli.js CHANGED
@@ -68,11 +68,12 @@
68
68
  * reaches for is answered on the wire instead, by `build_identity`. `runtime-ts/README.md`
69
69
  * and `docs/install.md` carry the same correction in their own words.
70
70
  */
71
- import { readFileSync } from 'node:fs';
71
+ import { readFileSync, readSync } from 'node:fs';
72
72
  import { dirname } from 'node:path';
73
73
  import { fileURLToPath } from 'node:url';
74
74
  import { assetsRoot } from './assets.js';
75
75
  import { BantamError } from './errors.js';
76
+ import { logHookFailure, runHook } from './hookadapter.js';
76
77
  import { buildReport, resolveEventLogPath } from './mcpreport.js';
77
78
  import { Memory } from './memory/component.js';
78
79
  import { DEFAULT_INDEX_BUDGET } from './memory/store.js';
@@ -81,7 +82,7 @@ import { buildServer } from './mcp/server.js';
81
82
  import { RawStdioTransport } from './mcp/transport.js';
82
83
  import { ArgumentTypeError, ArgvError, formatHelp, formatUsageBlock, HelpRequested, helpWidth, parseArgs as parseWithSpec, pyIntStrict, } from './pyargparse.js';
83
84
  import { pyJoin, PyOSError, pyRepr, pyStatIsDir } from './memory/pyfs.js';
84
- import { HOSTS, InstallError, installSelf } from './hostinstall.js';
85
+ import { HOSTS, HookConsentUnavailable, HookDeclined, installHooks, InstallError, installSelf, removeHooks, } from './hostinstall.js';
85
86
  import { runUpdate } from './selfupdate.js';
86
87
  import { statusLine } from './statusline.js';
87
88
  /** `SystemExit("...")`: the message on stderr, exit 1. */
@@ -137,6 +138,38 @@ const PARSER = {
137
138
  typeName: 'int',
138
139
  defaultValue: DEFAULT_INDEX_BUDGET,
139
140
  },
141
+ // THE HOOK ADAPTER, AND IT IS A FLAG FOR THE REASON `--mcp-report` GIVES BELOW, ONLY
142
+ // HARDER. `package.json` declares exactly one bin, so anything hung off a second entry
143
+ // point is unreachable in the pure-npx install that is the shipped product -- and the
144
+ // hook adapter was, literally: it lived in `tools/hooks/bantamkit-hook.mjs`, and MEASURED
145
+ // at 0.35.3 with `npm pack --dry-run` the tarball is 174 files under
146
+ // `files: ["dist","assets"]` with not one of them matching `hook`. An operator who
147
+ // installed bantamkit the only way it is published had no adapter on disk to register.
148
+ //
149
+ // POSITION IS WIRE-VISIBLE AND IT IS MEASURED, the same as every flag below it. At the
150
+ // 80-column fallback argparse breaks the usage after `[--index-budget BYTES]`, and that
151
+ // first line is pinned in `test_mcpserver.py`, in `test/cli-surface.test.mjs` and as a
152
+ // THROWING precondition in `tools/conformance/suites/cli.mjs`. Registered HERE -- the
153
+ // first flag AFTER `--index-budget` -- it grows the SECOND usage line only, from 61
154
+ // columns to 70 against a fold at 78. Registering it any earlier would move the pinned
155
+ // line and turn a differential suite into a re-baselining one.
156
+ //
157
+ // BARE, WITH NO METAVAR, AND THE EVENT COMES FROM STDIN. The host sends one JSON object
158
+ // carrying `hook_event_name`, which is what the adapter has always dispatched on; a
159
+ // second spelling of the event on the command line would be a second thing to keep in
160
+ // step with the host, and the registration in `~/.claude/settings.json` would have to
161
+ // carry seven different commands instead of one.
162
+ //
163
+ // THE CONTRACT IS DELIBERATELY NARROW, WHICH IS WHAT MAKES IT GATEABLE: one JSON object
164
+ // in on stdin, at most one JSON object out on stdout, exit 0 ALWAYS. A conformance case
165
+ // can feed both runtimes the same payload and compare the emitted object byte for byte.
166
+ {
167
+ optionStrings: ['--hook'],
168
+ dest: 'hook',
169
+ kind: 'storeTrue',
170
+ help: 'run as a Claude Code hook: one JSON event on stdin, then exit',
171
+ defaultValue: false,
172
+ },
140
173
  // WHY THIS IS A FLAG ON THIS PROCESS AND NOT A NEW ENTRY POINT, and why it sits HERE.
141
174
  // The lifecycle argument against printing from this process -- stdout is the JSON-RPC
142
175
  // channel -- is true of a RUNNING server and not of a flag that prints and returns before
@@ -226,6 +259,52 @@ const PARSER = {
226
259
  help: 'with --install, replace an existing bantamkit entry',
227
260
  defaultValue: false,
228
261
  },
262
+ // THE HOOK REGISTRATION, AND IT IS THREE FLAGS, NOT A MODIFIER ON `--install`.
263
+ //
264
+ // S1 RULING Q3.1: hooks are NEVER written as a side effect of `--install <host>`. Somebody
265
+ // asking to register an MCP server has not asked to register seven hooks that run on every
266
+ // tool call. RULING Q3.2 makes it its own opt-in, with a paired `--remove-hooks`, and gives
267
+ // the reason a modifier was refused: `--force`'s help above says "with --install", and
268
+ // overloading `--install` with a second, differently-consented write would make that
269
+ // sentence false.
270
+ //
271
+ // `--yes` IS THE THIRD STATE OF THE GATE, WHICH IS WHY IT IS A FLAG AND NOT AN ENV VAR.
272
+ // RULING Q3.3 makes the no-terminal-and-no-`--yes` case a REFUSAL at exit 2, so `--yes` is
273
+ // the only path a CI or scripted install has, and the ruling requires it to appear in `-h`
274
+ // so nobody has to guess it. It carries `--force`'s "with --install…" phrasing for the
275
+ // same reason `--force` does: a flag that is inert on its own says so in its own help.
276
+ //
277
+ // POSITION IS WIRE-VISIBLE, the same as every flag above. These three land AFTER `--force`
278
+ // and before the `--store`/`--start` group, which leaves the FIRST line of the 80-column
279
+ // usage — the line pinned by `test/cli-surface.test.mjs` and by the `cli` conformance
280
+ // suite — byte-identical, and moves the wrap boundary on the later lines only. The group
281
+ // indices below move with them.
282
+ {
283
+ optionStrings: ['--install-hooks'],
284
+ dest: 'install_hooks',
285
+ kind: 'storeTrue',
286
+ help: "add bantamkit's hook entries to ~/.claude/settings.json, then exit",
287
+ defaultValue: false,
288
+ },
289
+ {
290
+ optionStrings: ['--remove-hooks'],
291
+ dest: 'remove_hooks',
292
+ kind: 'storeTrue',
293
+ help: "take bantamkit's hook entries back out of ~/.claude/settings.json, then exit",
294
+ defaultValue: false,
295
+ },
296
+ {
297
+ optionStrings: ['--yes'],
298
+ dest: 'yes',
299
+ kind: 'storeTrue',
300
+ // NAMES BOTH FLAGS since the user's ruling of 2026-09-20 put the same gate on
301
+ // `--remove-hooks`. RULING Q3.3 requires the non-interactive consent path to be in `-h`
302
+ // so nobody has to guess it; a `--yes` that silently also worked with `--remove-hooks`
303
+ // would leave the only way to script a removal undocumented, which is the defect
304
+ // `--assets-root` shipped once already.
305
+ help: 'with --install-hooks or --remove-hooks, say yes in advance instead of being asked',
306
+ defaultValue: false,
307
+ },
229
308
  {
230
309
  optionStrings: ['--store'],
231
310
  dest: 'store',
@@ -242,10 +321,11 @@ const PARSER = {
242
321
  },
243
322
  ],
244
323
  // `--store`/`--start` moved from 6/7 to 8/9 when `--install` and `--force` were added
245
- // ahead of them, and from 8/9 to 9/10 when `--update` was. These are POSITIONS, not names,
246
- // so adding an action above the group and leaving this line alone would silently make two
247
- // unrelated flags mutually exclusive.
248
- groups: [[9, 10]],
324
+ // ahead of them, from 8/9 to 9/10 when `--update` was, from 9/10 to 10/11 when `--hook`
325
+ // was, and from 10/11 to 13/14 when `--install-hooks`, `--remove-hooks` and `--yes` were.
326
+ // These are POSITIONS, not names, so adding an action above the group and leaving this
327
+ // line alone would silently make two unrelated flags mutually exclusive.
328
+ groups: [[13, 14]],
249
329
  };
250
330
  /** `_parse_args`, arm for arm, including the mutually exclusive group and `-h`. */
251
331
  export function parseArgs(argv) {
@@ -256,11 +336,15 @@ export function parseArgs(argv) {
256
336
  store: values['store'],
257
337
  start: values['start'],
258
338
  assetsRoot: values['assets_root'],
339
+ hook: values['hook'],
259
340
  mcpReport: values['mcp_report'],
260
341
  statusline: values['statusline'],
261
342
  update: values['update'],
262
343
  install: values['install'],
263
344
  force: values['force'],
345
+ installHooks: values['install_hooks'],
346
+ removeHooks: values['remove_hooks'],
347
+ yes: values['yes'],
264
348
  };
265
349
  }
266
350
  /**
@@ -320,6 +404,86 @@ function typedBareAtATerminal(argv) {
320
404
  return false;
321
405
  return Boolean(stream.isTTY);
322
406
  }
407
+ /**
408
+ * Is there a terminal to ask a question at? The SAME signal `typedBareAtATerminal` uses.
409
+ *
410
+ * `process.stdin.isTTY` is `true` on a terminal and **`undefined`** — not `false` — on a pipe,
411
+ * a file or `/dev/null`, so this is a truthiness test for the reason spelled out above: a
412
+ * `=== false` test would make every non-terminal invocation look like a terminal, and on THIS
413
+ * path that inverts a consent gate. Neither the getter throwing nor a null `process.stdin` is
414
+ * a terminal, and neither may take the process down: what is at stake is a refusal.
415
+ */
416
+ function haveATerminal() {
417
+ let stream;
418
+ try {
419
+ stream = process.stdin;
420
+ }
421
+ catch {
422
+ return false;
423
+ }
424
+ if (stream === null || stream === undefined)
425
+ return false;
426
+ return Boolean(stream.isTTY);
427
+ }
428
+ /**
429
+ * RULING Q3.4's question, on stderr, answered on stdin. Only ever called at a terminal.
430
+ *
431
+ * THE QUESTION IS A PARAMETER AND THE READER IS NOT. `--install-hooks` asks "Write these hook
432
+ * entries?" and `--remove-hooks` asks "Remove these hook entries?"; everything after the
433
+ * question mark — the `[y/N]` default, the one-byte read, the EAGAIN retry, and the rule that
434
+ * only `y`/`Y` is a yes — is one implementation on purpose, because two flags rewriting one
435
+ * file must not answer differently to the same keystroke.
436
+ *
437
+ * ONE LINE, READ SYNCHRONOUSLY, ONE BYTE AT A TIME. `readFileSync(0)` would block until EOF —
438
+ * at a terminal that is Ctrl-D, not Enter — so the answer is read to the first newline and no
439
+ * further, which leaves anything the person typed after it for whoever asks next.
440
+ *
441
+ * ANYTHING OTHER THAN `y` OR `Y` MEANS NO, including empty and including EOF. That is the
442
+ * ruling, and it is why the default in the prompt is spelled `[y/N]`: the safe answer is the
443
+ * one you get by pressing Enter, by piping nothing, or by closing the terminal.
444
+ *
445
+ * EAGAIN IS NOT AN ANSWER, AND THE FIRST VERSION OF THIS FUNCTION THOUGHT IT WAS. Node leaves
446
+ * a terminal's fd 0 in NON-BLOCKING mode, so a synchronous read of one the person has not
447
+ * typed into yet throws `EAGAIN` instead of waiting. Measured on a real pty on 2026-09-20:
448
+ * `isTTY=true`, and the very first `readSync(0, …)` threw
449
+ * `EAGAIN: resource temporarily unavailable, read` before anything had been typed. Treating
450
+ * that as EOF answered "no" to a question nobody had been given a chance to answer — the
451
+ * refusal was safe, but the feature was unreachable at the only place it is meant to work,
452
+ * and NO UNIT TEST COULD SEE IT because every one of them injects the answer through the
453
+ * `ask` seam. A pty found it; a suite could not.
454
+ *
455
+ * `Atomics.wait` IS THE ONLY SYNCHRONOUS SLEEP THERE IS, and one is needed: without it the
456
+ * retry is a busy spin on a terminal waiting for a human.
457
+ */
458
+ function askAtTheTerminal(question) {
459
+ process.stderr.write(`${question} [y/N] `);
460
+ const byte = Buffer.alloc(1);
461
+ const idle = new Int32Array(new SharedArrayBuffer(4));
462
+ let answer = '';
463
+ for (;;) {
464
+ let read;
465
+ try {
466
+ read = readSync(0, byte, 0, 1, null);
467
+ }
468
+ catch (e) {
469
+ if (e.code === 'EAGAIN') {
470
+ Atomics.wait(idle, 0, 0, 20); // nothing typed yet; wait for the person, not for the cpu
471
+ continue;
472
+ }
473
+ break; // a closed fd, a stream the platform cannot read this way — not a yes
474
+ }
475
+ if (read === 0)
476
+ break; // EOF: Ctrl-D, or a terminal that went away
477
+ const ch = byte.toString('utf8');
478
+ if (ch === '\n')
479
+ break;
480
+ if (ch !== '\r')
481
+ answer += ch;
482
+ }
483
+ process.stderr.write('\n');
484
+ const said = answer.trim();
485
+ return said === 'y' || said === 'Y';
486
+ }
323
487
  /** `_build_memory`, including the two refusals argparse cannot express. */
324
488
  function buildMemory(options) {
325
489
  if (options.k < 1)
@@ -429,6 +593,28 @@ async function main(argv) {
429
593
  process.stdout.write(`${root}\n${packFileCount(root)} files\n`);
430
594
  return 0;
431
595
  }
596
+ if (options.hook) {
597
+ // DISPATCH ORDER IS REGISTRATION ORDER, which is the rule the four flags below already
598
+ // follow and the reason `--mcp-report --install cursor` prints a report and writes
599
+ // nothing. `--hook` is registered directly after `--index-budget`, so it is checked
600
+ // directly after `--assets-root`.
601
+ //
602
+ // EXIT 0 ALWAYS, AND THAT IS THE CONTRACT, not a convenience. A hook that exits non-zero
603
+ // or lets a stack reach stderr is rendered by the host as an error on the user's screen,
604
+ // so every failure is logged into `~/.bantamkit/hooks/hook-log.jsonl` and swallowed. This
605
+ // is the one arm in this file whose refusal path is a log line rather than a message.
606
+ //
607
+ // It returns before `buildMemory` for the same reason every flag around it does: the
608
+ // adapter binds whatever store the winning MCP registration pins, from the cwd the HOST
609
+ // sent in the payload — not from whatever directory the hook process was spawned in.
610
+ try {
611
+ await runHook();
612
+ }
613
+ catch (e) {
614
+ logHookFailure(e);
615
+ }
616
+ return 0;
617
+ }
432
618
  if (options.mcpReport) {
433
619
  // Same discipline as `--assets-root`, and the same place in `main`: BEFORE
434
620
  // `buildMemory`, which touches the filesystem, and before the transport exists at all.
@@ -485,10 +671,91 @@ async function main(argv) {
485
671
  }
486
672
  return 0;
487
673
  }
674
+ // REGISTRATION ORDER AGAIN, which is what makes `--mcp-report --install-hooks` print a
675
+ // report and write nothing, exactly as `--mcp-report --install cursor` already does.
676
+ //
677
+ // THREE EXITS, AND THE TWO THAT WRITE NOTHING ARE NOT THE SAME EXIT (S1 RULING Q3.3/Q3.4):
678
+ //
679
+ // 0 written, or already installed and matching
680
+ // 1 the person was asked at a terminal and did not say yes — `no hooks were written`
681
+ // 2 there was no terminal to ask at and no `--yes`
682
+ //
683
+ // Neither refusal carries the `error:` prefix `InstallError` gets. Nothing went wrong: the
684
+ // program asked, or found it could not ask, and then did nothing — which is the feature.
685
+ //
686
+ // THE PLAN AND THE QUESTION GO TO STDERR, the report to stdout. One stream is the operator's
687
+ // answer and the other is the conversation that led to it, and a caller piping stdout into
688
+ // something should get the report and not the prompt.
689
+ if (options.installHooks) {
690
+ try {
691
+ process.stdout.write(`${installHooks({
692
+ version: version(),
693
+ yes: options.yes,
694
+ ask: haveATerminal() ? () => askAtTheTerminal('Write these hook entries?') : null,
695
+ tell: (text) => process.stderr.write(text),
696
+ })}\n`);
697
+ }
698
+ catch (e) {
699
+ if (e instanceof HookConsentUnavailable) {
700
+ process.stderr.write(`${e.message}\n`);
701
+ return 2;
702
+ }
703
+ if (e instanceof HookDeclined) {
704
+ process.stderr.write(`${e.message}\n`);
705
+ return 1;
706
+ }
707
+ if (!(e instanceof InstallError))
708
+ throw e;
709
+ process.stderr.write(`error: ${e.message}\n`);
710
+ return 1;
711
+ }
712
+ return 0;
713
+ }
714
+ if (options.removeHooks) {
715
+ // THE SAME THREE EXITS, AND THEY ARE THE SAME THREE NUMBERS. RULING Q3.7 used to exempt
716
+ // this flag from the gate; the user overturned that on 2026-09-20 after `--remove-hooks`
717
+ // rewrote the operator's real `~/.claude/settings.json` with no terminal and no `--yes`.
718
+ //
719
+ // 0 removed, or there was nothing of ours to remove
720
+ // 1 the person was asked at a terminal and did not say yes — `no hooks were removed`
721
+ // 2 there was no terminal to ask at and no `--yes`
722
+ //
723
+ // The arms below are `--install-hooks`' arms, in the same order, for the same reasons.
724
+ try {
725
+ process.stdout.write(`${removeHooks({
726
+ yes: options.yes,
727
+ ask: haveATerminal() ? () => askAtTheTerminal('Remove these hook entries?') : null,
728
+ tell: (text) => process.stderr.write(text),
729
+ })}\n`);
730
+ }
731
+ catch (e) {
732
+ if (e instanceof HookConsentUnavailable) {
733
+ process.stderr.write(`${e.message}\n`);
734
+ return 2;
735
+ }
736
+ if (e instanceof HookDeclined) {
737
+ process.stderr.write(`${e.message}\n`);
738
+ return 1;
739
+ }
740
+ if (!(e instanceof InstallError))
741
+ throw e;
742
+ process.stderr.write(`error: ${e.message}\n`);
743
+ return 1;
744
+ }
745
+ return 0;
746
+ }
488
747
  // `--force` alone is a typo with a plausible reading — somebody meant to install and
489
748
  // dropped the flag that says where. Refusing names the missing half.
490
749
  if (options.force)
491
750
  throw new Refusal('--force is only meaningful with --install');
751
+ // The same reading, and the same refusal, for the consent flag: `--yes` on its own is
752
+ // somebody who meant to install or remove hooks and dropped the flag that says which. It is
753
+ // checked AFTER `--force` because that is registration order, and both are checked after
754
+ // every flag that acts, so `--install-hooks --yes` never reaches either of them. The
755
+ // sentence names BOTH flags since the ruling of 2026-09-20: naming only one would send an
756
+ // operator who mistyped `--remove-hooks` looking for a flag they already had.
757
+ if (options.yes)
758
+ throw new Refusal('--yes is only meaningful with --install-hooks or --remove-hooks');
492
759
  // A PERSON TYPED IT. `typedBareAtATerminal` carries the whole argument; what belongs here is
493
760
  // only that this sits BEFORE `buildMemory`, which is what creates a store. Somebody who typed
494
761
  // a command to see what it does has not asked for a `.bantamkit/memory` directory in whatever
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The host's hook payload. Every field is optional because the host sends a DIFFERENT shape
3
+ * per event and this module is handed whatever arrives — including, on a malformed line,
4
+ * nothing at all. Narrowing happens in the arm that needs the field, never here.
5
+ */
6
+ export interface HookInput {
7
+ hook_event_name?: string;
8
+ session_id?: string;
9
+ transcript_path?: string;
10
+ cwd?: string;
11
+ source?: string;
12
+ prompt?: string;
13
+ tool_name?: string;
14
+ tool_input?: Record<string, unknown>;
15
+ tool_use_id?: string;
16
+ trigger?: string;
17
+ stop_hook_active?: boolean;
18
+ }
19
+ /**
20
+ * `bantamkit-mcp --hook`: ONE JSON object in, AT MOST ONE JSON object out, and the caller
21
+ * exits 0 whatever happens.
22
+ *
23
+ * NOTHING HERE THROWS PAST THE CALLER. `cli.ts` wraps this in the same `catch → log → 0` the
24
+ * standalone script's last line was, because a hook that exits non-zero or writes a stack to
25
+ * stderr is rendered by the host as an error on the user's screen.
26
+ */
27
+ export declare function runHook(): Promise<void>;
28
+ /** The `catch` half of the contract, so `cli.ts` states it in one line. */
29
+ export declare function logHookFailure(e: unknown): void;