bantamkit-mcp 0.35.3 → 0.35.5

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.
@@ -66,3 +66,118 @@ export declare function thisCommand(options?: CommandOptions): {
66
66
  * file byte-unchanged with no backup, because `install` has not been reached.
67
67
  */
68
68
  export declare function installSelf(host: Host, force?: boolean, options?: CommandOptions): string;
69
+ /**
70
+ * The seven events, their matchers, and the ORDER RULING Q3.4 prints them in.
71
+ *
72
+ * The data is `tools/hooks/install.mjs:22–32`; the order is the ruled `events :` line, which
73
+ * is not that file's order. Both runtimes iterate this list, so it also decides the key order
74
+ * of a freshly written `hooks` object and therefore the bytes on disk.
75
+ *
76
+ * `PostToolUse` IS MATCHER-LESS AND MUST STAY THAT WAY. Its arm logs a usage event for EVERY
77
+ * tool call and runs the `memory_save` half only when the tool was that one. A second, narrower
78
+ * entry beside it would fire the save half twice.
79
+ */
80
+ export declare const HOOK_EVENTS: readonly (readonly [string, string | null])[];
81
+ /** The per-entry `timeout`, in seconds, carried from `tools/hooks/install.mjs`. */
82
+ export declare const HOOK_TIMEOUT = 10;
83
+ /**
84
+ * The only file these two flags touch: `~/.claude/settings.json`, user scope.
85
+ *
86
+ * RULING Q3.8 — hooks are a Claude Code concept, `--install-hooks` takes no host argument, and
87
+ * the other three hosts in `HOSTS` get nothing. Resolved from `homedir()` and nothing else, so
88
+ * pointing `HOME` at a scratch directory moves it, which is what makes the tests possible.
89
+ */
90
+ export declare function claudeSettingsPath(): string;
91
+ /** The one command all seven entries run. The event arrives on stdin, never in argv. */
92
+ export declare function hookCommand(command: string, args: readonly string[]): string;
93
+ /** There is no terminal to ask at and `--yes` was not given. Exit 2, and nothing was written. */
94
+ export declare class HookConsentUnavailable extends Error {
95
+ }
96
+ /** The person was asked and did not say yes. Exit 1, and nothing was written. */
97
+ export declare class HookDeclined extends Error {
98
+ }
99
+ /** The seams the two hook flags take. Nothing here reaches a terminal or the network. */
100
+ export interface HookOptions extends CommandOptions {
101
+ /** Consent. A function when there is a terminal; `null` when there is none. */
102
+ readonly ask?: (() => boolean) | null;
103
+ /** `--yes`: the written-down consent that stands in for the terminal. */
104
+ readonly yes?: boolean;
105
+ /** Where the plan and the question go. Default: nowhere — `cli.ts` sends them to stderr. */
106
+ readonly tell?: (text: string) => void;
107
+ }
108
+ /**
109
+ * THE OWNERSHIP MARKER, AND IT IS THE WHOLE OF THE ANSWER TO "is this entry ours".
110
+ *
111
+ * WRITTEN ON THE INNER HOOK OBJECT, not on the entry, and that placement is MEASURED rather
112
+ * than assumed (J62-22, Claude Code 2.1.278, extracted from the binary at the `edit_hook`
113
+ * implementation). The host parses a hook with a non-strict zod union and then, on a `/hooks`
114
+ * edit, puts back every key the parse dropped:
115
+ *
116
+ * function R(e,o){let t=o,a={};
117
+ * if(h(e)){let n=gSe().safeParse(e);
118
+ * if(n.success){for(let i of Object.keys(e))if(!(i in n.data)&&!A.has(i))a[i]=e[i]; …}}
119
+ * let r={...a,...t}; …} // A = {"__proto__","constructor","prototype"}
120
+ *
121
+ * That loop is unknown-key preservation written on purpose, and the deny-list it consults holds
122
+ * only the three prototype-pollution names. The surrounding entry is preserved too, but only
123
+ * incidentally (`(d??[]).map((f)=>…?{...f,hooks:[...f.hooks]}:f)` — a raw spread), so the key
124
+ * goes where the host has code that means to keep it.
125
+ *
126
+ * AND THE HOOK STILL FIRES WITH IT THERE. Measured live, not reasoned from the schema: two
127
+ * sandboxed HOMEs, identical settings but for this key, `claude -p` pointed at a dead
128
+ * localhost so the session starts and the model call cannot leave the machine — SessionStart
129
+ * and UserPromptSubmit fired twice on BOTH sides. The control matters: the same probe run
130
+ * through `claude mcp list` fires nothing on either side, and would have "passed" vacuously.
131
+ *
132
+ * PRESENCE IS THE TEST AND THE VALUE IS NEVER READ. A future release may want to write a
133
+ * different value here; if the value were part of the test, that release would orphan every
134
+ * entry the previous one wrote — which is the exact bug this marker exists to end.
135
+ */
136
+ export declare const HOOK_MARKER_KEY = "bantamkit";
137
+ /** What we write today. Informational only — {@link HOOK_MARKER_KEY}'s presence decides. */
138
+ export declare const HOOK_MARKER_VALUE = "hook";
139
+ /**
140
+ * `--install-hooks`: the seven entries, in ONE write, AFTER asking.
141
+ *
142
+ * THE ORDER IS THE PROPERTY, and it is `installSelf`'s order for `installSelf`'s reason:
143
+ *
144
+ * 1. resolve the command — on an `npx` cache this makes the kept install, and npm failing
145
+ * there is an `InstallError` thrown before the settings file has been opened at all;
146
+ * 2. read and validate the settings file — a file that does not parse is reported, never
147
+ * overwritten;
148
+ * 3. ALREADY-INSTALLED-AND-MATCHING RETURNS HERE, before the gate. There is no write to
149
+ * consent to, so a second `--install-hooks` is a no-op at exit 0 whether or not anybody
150
+ * is at a terminal, which is what makes it safe in a setup script;
151
+ * 4. print the plan, then the gate;
152
+ * 5. dated backup, then one atomic write.
153
+ *
154
+ * The write itself is `writeConfig` — the same temp-file replace, the same carried mode, the
155
+ * same `indent: 2` through `dumps` — so the bytes this leaves and the bytes `--install` leaves
156
+ * are produced by one function (RULING Q3.5).
157
+ */
158
+ export declare function installHooks(options?: HookOptions): string;
159
+ /**
160
+ * `--remove-hooks`: take out what bantamkit wrote, and nothing else — AFTER asking.
161
+ *
162
+ * THE GATE IS `installHooks`' GATE, deliberately identical: a TTY answer, or `--yes`, or a
163
+ * refusal at exit 2 with nothing written. RULING Q3.7 exempted this flag; the user overturned
164
+ * that on 2026-09-20 after this exact flag, unsandboxed and unasked, rewrote the operator's
165
+ * real settings file. The module header above carries the measurement. Two flags that rewrite
166
+ * one file do not get two consent stories.
167
+ *
168
+ * THE ORDER IS `installHooks`' ORDER, minus the step it does not have:
169
+ *
170
+ * 1. read and validate the settings file — a file that does not parse is reported, never
171
+ * overwritten;
172
+ * 2. NOTHING-TO-REMOVE RETURNS HERE, BEFORE THE GATE, which is the mirror of install's
173
+ * already-installed no-op and matters for the same reason: there is no write to consent
174
+ * to, so a second `--remove-hooks` stays exit 0 with no terminal and no `--yes`, and a
175
+ * teardown script that runs it twice does not suddenly start refusing;
176
+ * 3. print the plan, then the gate;
177
+ * 4. dated backup, then one atomic write.
178
+ *
179
+ * `thisCommand` IS STILL NOT CALLED. Removal does not need to know what a host should launch,
180
+ * and calling it would put an `npx` cache's kept install between an operator and the ability
181
+ * to undo. That is also why the plan this prints has no `command:` line.
182
+ */
183
+ export declare function removeHooks(options?: HookOptions): string;
@@ -361,3 +361,396 @@ export function installSelf(host, force = false, options = {}) {
361
361
  const { command, args } = thisCommand(options);
362
362
  return install(host, command, args, force);
363
363
  }
364
+ /*
365
+ * ============================================================================================
366
+ * `--install-hooks` / `--remove-hooks` — the SEVEN hook entries in `~/.claude/settings.json`
367
+ * ============================================================================================
368
+ *
369
+ * THIS IS THE ONE SURFACE IN THIS PROGRAM THAT ASKS BEFORE IT WRITES, AND THAT IS A RULING,
370
+ * not a style. `--install` writes a file that exists to hold server entries; this writes the
371
+ * user's own settings file and registers a command that runs on EVERY tool call. The stronger
372
+ * write gets the stronger gate (S1 RULING Q3.3).
373
+ *
374
+ * BOTH FLAGS TAKE IT. RULING Q3.7 used to exempt `--remove-hooks` on the reasoning that taking
375
+ * back out what bantamkit put in is not a write to somebody else's configuration. THE USER
376
+ * OVERTURNED THAT ON 2026-09-20, and the reason is on disk: earlier the same day an
377
+ * unsandboxed probe let the abbreviation `--remove` resolve to the newly-added
378
+ * `--remove-hooks`, and it ran against the operator's REAL `~/.claude/settings.json` with no
379
+ * terminal, no `--yes` and exit 0 — 256 lines / 9031 bytes / 12 hook-event keys / 18 matcher
380
+ * blocks became 188 / 6980 / 10 / 11, with `PreCompact` and `PostCompact` gone. The file it
381
+ * rewrites is the same file either way, so the gate is the same gate either way: an operator
382
+ * who has learned one of these two flags must not be surprised by the other.
383
+ *
384
+ * `tools/hooks/install.mjs` is the ANTI-PATTERN this replaces, not the template. It writes the
385
+ * same seven entries unconditionally: no plan, no question, no backup. Everything below is the
386
+ * same data with a gate and `--install`'s existing write discipline around it.
387
+ *
388
+ * THE THREE-STATE GATE LIVES HERE AND THE TERMINAL DOES NOT. `ask` is a seam: a function when
389
+ * there is a terminal to ask at, `null` when there is none. `cli.ts` supplies the real one off
390
+ * `process.stdin.isTTY` — the same signal `typedBareAtATerminal` uses and for the same reason.
391
+ * Keeping the seam here is what makes the two REFUSING states testable without a pty, and a
392
+ * refusal nothing can test is a refusal nobody has seen work.
393
+ *
394
+ * ask is a function -> print the plan, ask, and honour the answer
395
+ * yes is true -> print the plan and proceed; `--yes` is written-down consent
396
+ * ask is null and no --yes -> HookConsentUnavailable. Nothing is printed, nothing is written.
397
+ *
398
+ * WHAT IS NOT WRITTEN HERE, EVER: `BANTAMKIT_DREAM_TIMEOUT_MS`. It is a TEST seam, and a seam
399
+ * that reaches a user's settings file stops being one.
400
+ */
401
+ /**
402
+ * The seven events, their matchers, and the ORDER RULING Q3.4 prints them in.
403
+ *
404
+ * The data is `tools/hooks/install.mjs:22–32`; the order is the ruled `events :` line, which
405
+ * is not that file's order. Both runtimes iterate this list, so it also decides the key order
406
+ * of a freshly written `hooks` object and therefore the bytes on disk.
407
+ *
408
+ * `PostToolUse` IS MATCHER-LESS AND MUST STAY THAT WAY. Its arm logs a usage event for EVERY
409
+ * tool call and runs the `memory_save` half only when the tool was that one. A second, narrower
410
+ * entry beside it would fire the save half twice.
411
+ */
412
+ export const HOOK_EVENTS = [
413
+ ['SessionStart', 'startup|resume|clear|compact'],
414
+ ['PreToolUse', 'Read'],
415
+ ['PostToolUse', null],
416
+ ['UserPromptSubmit', null],
417
+ ['PreCompact', null],
418
+ ['PostCompact', null],
419
+ ['Stop', null],
420
+ ];
421
+ /** The per-entry `timeout`, in seconds, carried from `tools/hooks/install.mjs`. */
422
+ export const HOOK_TIMEOUT = 10;
423
+ /**
424
+ * The only file these two flags touch: `~/.claude/settings.json`, user scope.
425
+ *
426
+ * RULING Q3.8 — hooks are a Claude Code concept, `--install-hooks` takes no host argument, and
427
+ * the other three hosts in `HOSTS` get nothing. Resolved from `homedir()` and nothing else, so
428
+ * pointing `HOME` at a scratch directory moves it, which is what makes the tests possible.
429
+ */
430
+ export function claudeSettingsPath() {
431
+ return join(homedir(), '.claude', 'settings.json');
432
+ }
433
+ /** The one command all seven entries run. The event arrives on stdin, never in argv. */
434
+ export function hookCommand(command, args) {
435
+ return shlexJoin([command, ...args, '--hook']);
436
+ }
437
+ /** There is no terminal to ask at and `--yes` was not given. Exit 2, and nothing was written. */
438
+ export class HookConsentUnavailable extends Error {
439
+ }
440
+ /** The person was asked and did not say yes. Exit 1, and nothing was written. */
441
+ export class HookDeclined extends Error {
442
+ }
443
+ /**
444
+ * THE REFUSAL, FOR BOTH FLAGS, FROM ONE TEMPLATE.
445
+ *
446
+ * Written as a function rather than twice as a literal so the two flags CANNOT grow two
447
+ * different consent stories by drift: the only thing either one may vary is its own name and
448
+ * the verb for what it is about to do to the file. The `--install-hooks` string this produces
449
+ * is byte-identical to the one that shipped before `--remove-hooks` joined it.
450
+ */
451
+ function consentUnavailable(flag, verb) {
452
+ return new HookConsentUnavailable(`${flag} ${verb} your ~/.claude/settings.json and needs a terminal to ask.\n` +
453
+ 'There is no terminal here, so nothing was written. Re-run it at a prompt, or pass\n' +
454
+ '--yes to say yes in advance.');
455
+ }
456
+ /**
457
+ * THE OWNERSHIP MARKER, AND IT IS THE WHOLE OF THE ANSWER TO "is this entry ours".
458
+ *
459
+ * WRITTEN ON THE INNER HOOK OBJECT, not on the entry, and that placement is MEASURED rather
460
+ * than assumed (J62-22, Claude Code 2.1.278, extracted from the binary at the `edit_hook`
461
+ * implementation). The host parses a hook with a non-strict zod union and then, on a `/hooks`
462
+ * edit, puts back every key the parse dropped:
463
+ *
464
+ * function R(e,o){let t=o,a={};
465
+ * if(h(e)){let n=gSe().safeParse(e);
466
+ * if(n.success){for(let i of Object.keys(e))if(!(i in n.data)&&!A.has(i))a[i]=e[i]; …}}
467
+ * let r={...a,...t}; …} // A = {"__proto__","constructor","prototype"}
468
+ *
469
+ * That loop is unknown-key preservation written on purpose, and the deny-list it consults holds
470
+ * only the three prototype-pollution names. The surrounding entry is preserved too, but only
471
+ * incidentally (`(d??[]).map((f)=>…?{...f,hooks:[...f.hooks]}:f)` — a raw spread), so the key
472
+ * goes where the host has code that means to keep it.
473
+ *
474
+ * AND THE HOOK STILL FIRES WITH IT THERE. Measured live, not reasoned from the schema: two
475
+ * sandboxed HOMEs, identical settings but for this key, `claude -p` pointed at a dead
476
+ * localhost so the session starts and the model call cannot leave the machine — SessionStart
477
+ * and UserPromptSubmit fired twice on BOTH sides. The control matters: the same probe run
478
+ * through `claude mcp list` fires nothing on either side, and would have "passed" vacuously.
479
+ *
480
+ * PRESENCE IS THE TEST AND THE VALUE IS NEVER READ. A future release may want to write a
481
+ * different value here; if the value were part of the test, that release would orphan every
482
+ * entry the previous one wrote — which is the exact bug this marker exists to end.
483
+ */
484
+ export const HOOK_MARKER_KEY = 'bantamkit';
485
+ /** What we write today. Informational only — {@link HOOK_MARKER_KEY}'s presence decides. */
486
+ export const HOOK_MARKER_VALUE = 'hook';
487
+ /** One entry, in the shape Claude Code reads. `matcher` first, and only where there is one. */
488
+ function hookEntry(matcher, command) {
489
+ const entry = {};
490
+ if (matcher !== null)
491
+ entry['matcher'] = matcher;
492
+ entry['hooks'] = [
493
+ { type: 'command', command, timeout: HOOK_TIMEOUT, [HOOK_MARKER_KEY]: HOOK_MARKER_VALUE },
494
+ ];
495
+ return entry;
496
+ }
497
+ /** A plain object — not null, not an array. The shape every test below needs first. */
498
+ function isObject(value) {
499
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
500
+ }
501
+ /**
502
+ * The two command spellings bantamkit ever wrote, for entries that predate the marker.
503
+ *
504
+ * 1. `tools/hooks/install.mjs` wrote `node <abs>/tools/hooks/bantamkit-hook.mjs`, and its own
505
+ * ownership test was the literal `bantamkit-hook`. That token is in the FILENAME, so it is
506
+ * there whatever the checkout is called — this arm is path-independent and stays exact.
507
+ * 2. This module, before the marker, wrote `<interpreter> <entry point> --hook`. Recognising
508
+ * it needs the command to name bantamkit, which is the defect itself: on an install path
509
+ * that does not carry the word, such an entry says nothing about who wrote it and NO
510
+ * entry-local test can claim it. `--install-hooks` never shipped (it is new in 0.35.4), so
511
+ * that population is bounded to this repository's own development checkouts; `docs/hooks.md`
512
+ * says so and says to delete those by hand.
513
+ *
514
+ * BOTH ARMS ARE NARROWER THAN THE TEST THEY REPLACE, ON PURPOSE. The old one asked whether the
515
+ * entry's JSON happened to contain `bantamkit` anywhere, so a matcher, a `statusMessage` or a
516
+ * third-party script with the word in its filename was claimed as ours and deleted.
517
+ */
518
+ function isLegacyOurs(hook) {
519
+ if (hook['type'] !== 'command')
520
+ return false;
521
+ const command = hook['command'];
522
+ if (typeof command !== 'string' || !command.includes('bantamkit'))
523
+ return false;
524
+ return command.includes('bantamkit-hook') || command === '--hook' || command.endsWith(' --hook');
525
+ }
526
+ /**
527
+ * RULING Q3.6's idempotence rule, RE-DECIDED ON THE ENTRY ALONE (J62-22).
528
+ *
529
+ * It used to read `dumps(entry, null, true).includes('bantamkit')` — does this entry's JSON
530
+ * happen to contain the product's name. On this machine every checkout is called `bantamkit*`,
531
+ * so the entry's own command carried the word and the test looked correct. MEASURED from an
532
+ * install tree whose path does not: three `--install-hooks --yes` left TWENTY-ONE entries
533
+ * instead of seven, and `--remove-hooks --yes` then answered `no bantamkit hooks are installed`
534
+ * on BOTH runtimes — hook entries in a user's settings that neither runtime could ever take
535
+ * back out, growing by seven on every reinstall. An npm install under another name, a Docker
536
+ * image that copies `dist/` to `/app/dist/`, and any vendored build all reach it.
537
+ *
538
+ * Ownership is now a property of the ENTRY and of nothing else: our marker, or one of the two
539
+ * shapes we wrote before the marker existed. Where any binary lives is not consulted.
540
+ */
541
+ function isOurs(entry) {
542
+ if (!isObject(entry))
543
+ return false;
544
+ const hooks = entry['hooks'];
545
+ if (!Array.isArray(hooks))
546
+ return false;
547
+ return hooks.some((hook) => isObject(hook) && (HOOK_MARKER_KEY in hook || isLegacyOurs(hook)));
548
+ }
549
+ /** The `hooks` object, validated. Refuses by name rather than crashing on somebody's file. */
550
+ function readHooks(path, data) {
551
+ const raw = data['hooks'];
552
+ if (raw === undefined)
553
+ return {};
554
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
555
+ throw new InstallError(`${path} has a 'hooks' that is not an object; refusing to touch it`);
556
+ }
557
+ const hooks = {};
558
+ for (const [event, entries] of Object.entries(raw)) {
559
+ if (!Array.isArray(entries)) {
560
+ throw new InstallError(`${path} has a 'hooks.${event}' that is not a list; refusing to touch it`);
561
+ }
562
+ hooks[event] = [...entries];
563
+ }
564
+ return hooks;
565
+ }
566
+ /**
567
+ * The `hooks` object this run wants, built from whatever is there now.
568
+ *
569
+ * ONE PASS OVER THE SEVEN EVENTS AND NOTHING ELSE: every event keeps its non-bantamkit entries
570
+ * in their existing order, ours is appended after them, and an event left with none loses its
571
+ * key rather than holding an empty list — `install.mjs`'s rule, kept because a settings file
572
+ * full of empty arrays is a worse artefact than one with nothing in it.
573
+ *
574
+ * Events outside the seven are not read, not reordered and not removed.
575
+ */
576
+ function plannedHooks(current, command, remove) {
577
+ const hooks = { ...current };
578
+ const touched = [];
579
+ for (const [event, matcher] of HOOK_EVENTS) {
580
+ const before = hooks[event] ?? [];
581
+ const kept = before.filter((entry) => !isOurs(entry));
582
+ if (remove) {
583
+ if (kept.length !== before.length)
584
+ touched.push(event);
585
+ }
586
+ else {
587
+ kept.push(hookEntry(matcher, command));
588
+ touched.push(event);
589
+ }
590
+ if (kept.length > 0)
591
+ hooks[event] = kept;
592
+ else
593
+ delete hooks[event];
594
+ }
595
+ return { hooks, touched };
596
+ }
597
+ const EVENT_NAMES = HOOK_EVENTS.map(([event]) => event).join(' ');
598
+ /**
599
+ * RULING Q3.4's plan, printed before any question and before any write.
600
+ *
601
+ * THE `backup :` LINE IS OMITTED WHEN THERE IS NO FILE TO BACK UP, which is `--install`'s own
602
+ * report discipline (`backup` returns `null` and the line does not print). The ruled template
603
+ * shows the line because the ordinary case has a file; printing a backup path for a file that
604
+ * does not exist would be the plan stating something the write will not do.
605
+ */
606
+ function hookPlan(path, command) {
607
+ const lines = [
608
+ `bantamkit would add ${HOOK_EVENTS.length} hook entries to ${path}`,
609
+ ` events : ${EVENT_NAMES}`,
610
+ ` command: ${command}`,
611
+ ];
612
+ if (existsSync(path))
613
+ lines.push(` backup : ${path}.backup-${today()}`);
614
+ lines.push("Existing hooks are left byte-for-byte; only bantamkit's own entries are replaced.");
615
+ return `${lines.join('\n')}\n`;
616
+ }
617
+ /**
618
+ * The same plan for the other direction: what is about to come OUT, and out of what.
619
+ *
620
+ * FOUR LINES, NOT FIVE. There is no `command:` line because `removeHooks` never resolves one —
621
+ * see its own note — and printing one would be the plan naming something the write will not
622
+ * touch. The `backup :` line is UNCONDITIONAL here, where `hookPlan`'s is guarded: the gate is
623
+ * only reached when at least one entry is actually coming out, and an entry cannot be in a
624
+ * file that does not exist.
625
+ *
626
+ * `events :` NAMES ONLY THE EVENTS THAT LOSE SOMETHING, not all seven, which is the honest
627
+ * answer to "what will this do to my file" when only some of ours are there. The count on the
628
+ * first line is entries, not events, for the same reason.
629
+ */
630
+ function removalPlan(path, events, entries) {
631
+ return ([
632
+ `bantamkit would remove ${entries} hook entries from ${path}`,
633
+ ` events : ${events.join(' ')}`,
634
+ ` backup : ${path}.backup-${today()}`,
635
+ "Existing hooks are left byte-for-byte; only bantamkit's own entries are removed.",
636
+ ].join('\n') + '\n');
637
+ }
638
+ /**
639
+ * `--install-hooks`: the seven entries, in ONE write, AFTER asking.
640
+ *
641
+ * THE ORDER IS THE PROPERTY, and it is `installSelf`'s order for `installSelf`'s reason:
642
+ *
643
+ * 1. resolve the command — on an `npx` cache this makes the kept install, and npm failing
644
+ * there is an `InstallError` thrown before the settings file has been opened at all;
645
+ * 2. read and validate the settings file — a file that does not parse is reported, never
646
+ * overwritten;
647
+ * 3. ALREADY-INSTALLED-AND-MATCHING RETURNS HERE, before the gate. There is no write to
648
+ * consent to, so a second `--install-hooks` is a no-op at exit 0 whether or not anybody
649
+ * is at a terminal, which is what makes it safe in a setup script;
650
+ * 4. print the plan, then the gate;
651
+ * 5. dated backup, then one atomic write.
652
+ *
653
+ * The write itself is `writeConfig` — the same temp-file replace, the same carried mode, the
654
+ * same `indent: 2` through `dumps` — so the bytes this leaves and the bytes `--install` leaves
655
+ * are produced by one function (RULING Q3.5).
656
+ */
657
+ export function installHooks(options = {}) {
658
+ const { command, args } = thisCommand(options);
659
+ const rendered = hookCommand(command, args);
660
+ const path = claudeSettingsPath();
661
+ const data = readConfig(path);
662
+ const current = readHooks(path, data);
663
+ const { hooks } = plannedHooks(current, rendered, false);
664
+ if (dumps(hooks, null, true) === dumps(current, null, true)) {
665
+ return `bantamkit hooks are already installed in ${path} and match`;
666
+ }
667
+ const tell = options.tell ?? (() => { });
668
+ const yes = options.yes ?? false;
669
+ const ask = options.ask ?? null;
670
+ if (!yes) {
671
+ if (ask === null) {
672
+ // NOTHING IS PRINTED HERE. The plan describes a write that is not going to happen, and
673
+ // the refusal is the whole message.
674
+ throw consentUnavailable('--install-hooks', 'writes');
675
+ }
676
+ tell(hookPlan(path, rendered));
677
+ if (!ask())
678
+ throw new HookDeclined('no hooks were written');
679
+ }
680
+ else {
681
+ tell(hookPlan(path, rendered));
682
+ }
683
+ const copied = backup(path);
684
+ data['hooks'] = hooks;
685
+ writeConfig(path, data);
686
+ const lines = [
687
+ `installed bantamkit hooks into ${path}`,
688
+ ` events : ${EVENT_NAMES}`,
689
+ ` command: ${rendered}`,
690
+ ];
691
+ if (copied !== null)
692
+ lines.push(` backup : ${copied}`);
693
+ lines.push('restart Claude Code (or run /hooks) for this to take effect');
694
+ return lines.join('\n');
695
+ }
696
+ /**
697
+ * `--remove-hooks`: take out what bantamkit wrote, and nothing else — AFTER asking.
698
+ *
699
+ * THE GATE IS `installHooks`' GATE, deliberately identical: a TTY answer, or `--yes`, or a
700
+ * refusal at exit 2 with nothing written. RULING Q3.7 exempted this flag; the user overturned
701
+ * that on 2026-09-20 after this exact flag, unsandboxed and unasked, rewrote the operator's
702
+ * real settings file. The module header above carries the measurement. Two flags that rewrite
703
+ * one file do not get two consent stories.
704
+ *
705
+ * THE ORDER IS `installHooks`' ORDER, minus the step it does not have:
706
+ *
707
+ * 1. read and validate the settings file — a file that does not parse is reported, never
708
+ * overwritten;
709
+ * 2. NOTHING-TO-REMOVE RETURNS HERE, BEFORE THE GATE, which is the mirror of install's
710
+ * already-installed no-op and matters for the same reason: there is no write to consent
711
+ * to, so a second `--remove-hooks` stays exit 0 with no terminal and no `--yes`, and a
712
+ * teardown script that runs it twice does not suddenly start refusing;
713
+ * 3. print the plan, then the gate;
714
+ * 4. dated backup, then one atomic write.
715
+ *
716
+ * `thisCommand` IS STILL NOT CALLED. Removal does not need to know what a host should launch,
717
+ * and calling it would put an `npx` cache's kept install between an operator and the ability
718
+ * to undo. That is also why the plan this prints has no `command:` line.
719
+ */
720
+ export function removeHooks(options = {}) {
721
+ const path = claudeSettingsPath();
722
+ const data = readConfig(path);
723
+ const current = readHooks(path, data);
724
+ const { hooks, touched } = plannedHooks(current, '', true);
725
+ if (touched.length === 0)
726
+ return `no bantamkit hooks are installed in ${path}`;
727
+ // Entries, not events: an event can hold more than one of ours if somebody hand-edited the
728
+ // file, and the plan has to say what is actually going.
729
+ let entries = 0;
730
+ for (const event of touched)
731
+ entries += (current[event] ?? []).length - (hooks[event] ?? []).length;
732
+ const tell = options.tell ?? (() => { });
733
+ const yes = options.yes ?? false;
734
+ const ask = options.ask ?? null;
735
+ if (!yes) {
736
+ if (ask === null) {
737
+ // NOTHING IS PRINTED HERE, the same as install: the plan describes a write that is not
738
+ // going to happen, and the refusal is the whole message.
739
+ throw consentUnavailable('--remove-hooks', 'rewrites');
740
+ }
741
+ tell(removalPlan(path, touched, entries));
742
+ if (!ask())
743
+ throw new HookDeclined('no hooks were removed');
744
+ }
745
+ else {
746
+ tell(removalPlan(path, touched, entries));
747
+ }
748
+ const copied = backup(path);
749
+ data['hooks'] = hooks;
750
+ writeConfig(path, data);
751
+ const lines = [`removed bantamkit hooks from ${path}`, ` events : ${touched.join(' ')}`];
752
+ if (copied !== null)
753
+ lines.push(` backup : ${copied}`);
754
+ lines.push('restart Claude Code (or run /hooks) for this to take effect');
755
+ return lines.join('\n');
756
+ }
@@ -78,6 +78,40 @@ export declare const OFFSET_MAXIMUM = 9007199254740991n;
78
78
  * byte by the conformance suite's `advertisement: id 2` case.
79
79
  */
80
80
  export declare const ARG_MODELS: Readonly<Record<string, ArgModel>>;
81
+ /**
82
+ * `FuncMetadata.pre_parse_json` — the SDK step that runs BEFORE `model_validate`.
83
+ *
84
+ * WHY THIS EXISTS AT ALL. The reference does not validate the arguments a client sent; it
85
+ * validates the arguments the SDK handed pydantic, and the SDK unwraps a JSON-encoded string
86
+ * first, because "Claude Desktop is prone to this — in fact it seems incapable of NOT doing
87
+ * it" (the SDK's own words). Until this function the port skipped that step entirely, so the
88
+ * two runtimes read the same `tools/call` differently on every non-`str` argument. Measured
89
+ * over the real stdio servers on both sides, 2026-09-21, sixteen shapes disagreed, including
90
+ * the register's own item: `validate_json(output='{"a": 1}', schema='{"type": "object"}')`
91
+ * answered `{"valid": true}` on Python and `dict_type` on Node. The reference is the
92
+ * reference, so the port gains the step rather than the reference losing it.
93
+ *
94
+ * THE RULE IS THE SDK'S, NOT A PARAPHRASE. A key is unwrapped when all of this holds:
95
+ *
96
+ * * the key names a field of the model — an unknown key is not in `key_to_field_info` and
97
+ * is left alone (it is ignored by validation anyway);
98
+ * * the value is a string;
99
+ * * the field's annotation `is not str`. That is an IDENTITY test on the annotation
100
+ * object, so `str | None` is NOT `str` and DOES get unwrapped: `skill_audit(check=
101
+ * 'null')` reaches the handler as `None` on the reference. Here that is exactly
102
+ * "required `str`" — every other kind, and every OPTIONAL `str`, qualifies;
103
+ * * `json.loads` succeeds. It can fail two ways and both keep the string: a decode error,
104
+ * and the digit cap above;
105
+ * * the result is not a `str`, `int` or `float`. `isinstance(True, int)` is true in
106
+ * Python, so a `bool` is skipped by that same line — `dry_run='true'` stays the string
107
+ * `'true'` and pydantic's lax bool parse is what reads it. What is left, and what this
108
+ * therefore replaces, is exactly a list, a dict, or `null`.
109
+ *
110
+ * It is NOT applied per tool. The reference's one opt-out, `_NO_JSON_UNWRAP`, is dormant —
111
+ * `bantamkit_read` left the roster in job50 — so every served tool unwraps today, and a
112
+ * roster line coming back is the moment to give this function the same set.
113
+ */
114
+ export declare function preParseJson(model: ArgModel, args: PyValue): PyValue;
81
115
  /**
82
116
  * The pydantic `ValidationError` text, WITHOUT the `Error executing tool <name>: ` prefix.
83
117
  *
@@ -95,5 +129,10 @@ export declare class PyValidationFailure extends Error {
95
129
  * measured server accepts `{"query": "a", "extra": 1}` without complaint. The absent-key case
96
130
  * and the explicit-`null` case are different: `{}` is `missing`, `{"handoff_patch": null}` is
97
131
  * `dict_type`, and only an OPTIONAL field treats `null` as its default.
132
+ *
133
+ * `preParseJson` runs FIRST, and what it returns is what every error below reports. That
134
+ * ordering is the SDK's: `model_validate` never sees the arguments a client sent, only the
135
+ * unwrapped ones, so a `missing` error on `memory_recall(k='[1]')` carries `{'k': [1]}` as
136
+ * its `input_value` and not the string the wire held.
98
137
  */
99
138
  export declare function validateArguments(model: ArgModel, args: PyValue): Map<string, PyValue>;