bantamkit-mcp 0.35.8 → 0.35.9
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 +13 -2
- package/dist/assets.js +28 -4
- package/dist/cli.js +55 -24
- package/dist/eventlog.d.ts +8 -1
- package/dist/eventlog.js +7 -0
- package/dist/hookadapter.js +16 -2
- package/dist/hostinstall.d.ts +2 -2
- package/dist/hostinstall.js +69 -6
- package/dist/mcp/identity.js +27 -1
- package/dist/mcp/server.js +159 -14
- package/dist/mcp/transport.d.ts +51 -6
- package/dist/mcp/transport.js +217 -26
- package/dist/pyargparse.js +48 -2
- package/dist/tokenledger.js +20 -4
- package/dist/updatecheck.d.ts +15 -3
- package/dist/updatecheck.js +23 -8
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -445,7 +445,16 @@ all ship inside `dist/` and none of them is reachable through an import — they
|
|
|
445
445
|
running `bantamkit-mcp` and `bantamkit-memory`. Importing the package starts no server and
|
|
446
446
|
opens no transport. The vendored `assets/` is not importable either, but it is *readable*:
|
|
447
447
|
`assetsRoot()` resolves to the `assets/` directory inside the installed package, which is how
|
|
448
|
-
`loadTool('memory_save')` answers from a consumer that never unpacked anything itself.
|
|
448
|
+
`loadTool('memory_save')` answers from a consumer that never unpacked anything itself. The
|
|
449
|
+
loaders take a **bare** asset name: one with `/`, `\`, `..`, a drive colon or a NUL in it is
|
|
450
|
+
refused with `AssetNotFound: <kind> asset name is not a bare name: <name>` before the disk is
|
|
451
|
+
touched, so a name taken from user input cannot read a file outside the pack (the Python
|
|
452
|
+
loaders refuse it in the same sentence).
|
|
453
|
+
|
|
454
|
+
**The types need no `@types/node`.** The package declares no dependency on it, so nothing
|
|
455
|
+
reachable from `index.d.ts` names a Node-only global: a `strict` consumer with `types: []`
|
|
456
|
+
and `skipLibCheck: false` type-checks `import ... from 'bantamkit-mcp'` clean.
|
|
457
|
+
`encodeRecord` is declared to return `Uint8Array` (the value is a `Buffer`, a subclass of it).
|
|
449
458
|
|
|
450
459
|
**What the one entry point exports**, grouped by the module each name comes from:
|
|
451
460
|
|
|
@@ -530,7 +539,9 @@ node tools/conformance/npx-cold-start.mjs --offline # + the no-network arms (
|
|
|
530
539
|
The conformance harness shells out to a CPython for the reference side. That is a
|
|
531
540
|
**development-time** dependency of the test tooling; nothing under `tools/` ships, and
|
|
532
541
|
`files: ["dist", "assets"]` is the whole published surface. Runtime dependencies: exactly one,
|
|
533
|
-
`@modelcontextprotocol/sdk`, pinned to `1.
|
|
542
|
+
`@modelcontextprotocol/sdk`, pinned to `1.31.0`, the first release outside advisory
|
|
543
|
+
GHSA-6qxp-vccf-f47h (`>= 1.12.0, < 1.31.0`, the SDK's OAuth client, which this package never
|
|
544
|
+
imports).
|
|
534
545
|
|
|
535
546
|
## Measurements and history
|
|
536
547
|
|
package/dist/assets.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* reference; where this file deviates the deviation is a comment, not an accident.
|
|
6
6
|
*/
|
|
7
7
|
import { existsSync, realpathSync } from 'node:fs';
|
|
8
|
-
import { dirname, join } from 'node:path';
|
|
8
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
9
9
|
import { fileURLToPath } from 'node:url';
|
|
10
10
|
import { BantamError } from './errors.js';
|
|
11
11
|
import { pyReadText } from './memory/pyfs.js';
|
|
@@ -61,6 +61,30 @@ export function assetsRoot() {
|
|
|
61
61
|
}
|
|
62
62
|
throw new AssetNotFound('no assets directory found; set BANTAMKIT_ASSETS');
|
|
63
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* job66/#145: the one path every loader below builds, and the containment check on it.
|
|
66
|
+
*
|
|
67
|
+
* `name` is a BARE asset name — `memory`, `shiftwork-checkpoint`, `memory_recall` — and
|
|
68
|
+
* nothing else. Before this, each loader joined `name` under its directory verbatim, so a
|
|
69
|
+
* caller passing user input could read any `.json`/`.md` on disk: `loadSchema('../../x')`
|
|
70
|
+
* parsed a file outside the pack, and `loadTool('../schemas/shiftwork-checkpoint')` returned
|
|
71
|
+
* a Tool whose three fields were all `undefined`. A name holding a path separator (`/` or
|
|
72
|
+
* `\`, either platform's), `..`, a drive colon or a NUL is refused BEFORE any filesystem
|
|
73
|
+
* access, and the joined path is then checked LEXICALLY to sit directly inside
|
|
74
|
+
* `<root>/<kind>s/` — lexically, not by `realpath`, because a pack is allowed to be a
|
|
75
|
+
* symlink farm and the check is about the name, not about the disk. The refusal is an
|
|
76
|
+
* `AssetNotFound`, so the MCP resource handler's existing `unknown skill asset: <name>`
|
|
77
|
+
* answer is unchanged; the sentence is the reference's, byte for byte
|
|
78
|
+
* (`runtime-py/src/bantamkit/assets.py::_asset_path`).
|
|
79
|
+
*/
|
|
80
|
+
function assetPath(kind, name, ext) {
|
|
81
|
+
const dir = join(assetsRoot(), `${kind}s`);
|
|
82
|
+
const path = join(dir, `${name}${ext}`);
|
|
83
|
+
if (/[/\\:\0]/.test(name) || name.includes('..') || dirname(resolve(path)) !== resolve(dir)) {
|
|
84
|
+
throw new AssetNotFound(`${kind} asset name is not a bare name: ${name}`);
|
|
85
|
+
}
|
|
86
|
+
return path;
|
|
87
|
+
}
|
|
64
88
|
/**
|
|
65
89
|
* Return one tool asset VERBATIM — every key, including the ones no surface uses.
|
|
66
90
|
*
|
|
@@ -70,7 +94,7 @@ export function assetsRoot() {
|
|
|
70
94
|
* and neither belongs on the agent-facing `Tool`.
|
|
71
95
|
*/
|
|
72
96
|
export function loadToolAsset(name) {
|
|
73
|
-
const path =
|
|
97
|
+
const path = assetPath('tool', name, '.json');
|
|
74
98
|
if (!existsSync(path)) {
|
|
75
99
|
throw new AssetNotFound(`tool asset not found: ${path}`);
|
|
76
100
|
}
|
|
@@ -94,14 +118,14 @@ export function loadTool(name) {
|
|
|
94
118
|
}
|
|
95
119
|
/** Return a JSON Schema asset (parsed) — e.g. the shift-work checkpoint contract. */
|
|
96
120
|
export function loadSchema(name) {
|
|
97
|
-
const path =
|
|
121
|
+
const path = assetPath('schema', name, '.json');
|
|
98
122
|
if (!existsSync(path)) {
|
|
99
123
|
throw new AssetNotFound(`schema asset not found: ${path}`);
|
|
100
124
|
}
|
|
101
125
|
return JSON.parse(readTextUniversal(path));
|
|
102
126
|
}
|
|
103
127
|
export function loadSkill(name) {
|
|
104
|
-
const path =
|
|
128
|
+
const path = assetPath('skill', name, '.md');
|
|
105
129
|
if (!existsSync(path)) {
|
|
106
130
|
throw new AssetNotFound(`skill asset not found: ${path}`);
|
|
107
131
|
}
|
package/dist/cli.js
CHANGED
|
@@ -327,9 +327,40 @@ const PARSER = {
|
|
|
327
327
|
// line alone would silently make two unrelated flags mutually exclusive.
|
|
328
328
|
groups: [[13, 14]],
|
|
329
329
|
};
|
|
330
|
+
/**
|
|
331
|
+
* job66/#139: the flags that each ACT and then exit, in registration order — the reference's
|
|
332
|
+
* `_ONE_SHOT_DESTS`. At most one may be given. WAS: `main` ran the first in registration
|
|
333
|
+
* order and dropped the rest at exit 0.
|
|
334
|
+
*/
|
|
335
|
+
const ONE_SHOT_DESTS = [
|
|
336
|
+
'assets_root',
|
|
337
|
+
'hook',
|
|
338
|
+
'mcp_report',
|
|
339
|
+
'statusline',
|
|
340
|
+
'update',
|
|
341
|
+
'install',
|
|
342
|
+
'install_hooks',
|
|
343
|
+
'remove_hooks',
|
|
344
|
+
];
|
|
345
|
+
/**
|
|
346
|
+
* `_refuse_stacked_one_shots`: two one-shot flags together are a USAGE error — the usage
|
|
347
|
+
* block, argparse's mutually-exclusive sentence, exit 2. Not a `groups` entry, for the
|
|
348
|
+
* reference's measured reason: the eight are not contiguous, and CPython 3.14 brackets a
|
|
349
|
+
* non-contiguous group in the usage line where 3.12/3.13 omit it, so a real group would make
|
|
350
|
+
* the reference's `-h` depend on its interpreter. Checked after the parse, naming the two
|
|
351
|
+
* EARLIEST-REGISTERED flags given, exactly as the reference does.
|
|
352
|
+
*/
|
|
353
|
+
function refuseStackedOneShots(values) {
|
|
354
|
+
const given = PARSER.actions.filter((action) => ONE_SHOT_DESTS.includes(action.dest) && values[action.dest] !== null && values[action.dest] !== false);
|
|
355
|
+
if (given.length > 1) {
|
|
356
|
+
const [first, second] = given.map((action) => action.optionStrings.join('/'));
|
|
357
|
+
throw new ArgvError(PARSER, `argument ${second}: not allowed with argument ${first}`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
330
360
|
/** `_parse_args`, arm for arm, including the mutually exclusive group and `-h`. */
|
|
331
361
|
export function parseArgs(argv) {
|
|
332
362
|
const values = parseWithSpec(PARSER, argv);
|
|
363
|
+
refuseStackedOneShots(values);
|
|
333
364
|
return {
|
|
334
365
|
k: values['k'],
|
|
335
366
|
indexBudget: values['index_budget'],
|
|
@@ -613,6 +644,25 @@ function version() {
|
|
|
613
644
|
}
|
|
614
645
|
async function main(argv) {
|
|
615
646
|
const options = parseArgs(argv);
|
|
647
|
+
// job66/#139: THE MODIFIERS ARE CHECKED BEFORE ANY FLAG ACTS, in the reference's order and
|
|
648
|
+
// with its sentences. WAS: the two refusals sat after every acting arm, so `--hook --yes`,
|
|
649
|
+
// `--hook --force` and `--statusline --force` dropped the modifier at exit 0.
|
|
650
|
+
//
|
|
651
|
+
// `--force` alone is a typo with a plausible reading — somebody meant to install and
|
|
652
|
+
// dropped the flag that says where. Refusing names the missing half.
|
|
653
|
+
if (options.force && (options.install ?? null) === null)
|
|
654
|
+
throw new Refusal('--force is only meaningful with --install');
|
|
655
|
+
// The same reading for the consent flag; the sentence names BOTH flags since the ruling of
|
|
656
|
+
// 2026-09-20.
|
|
657
|
+
if (options.yes && !(options.installHooks || options.removeHooks)) {
|
|
658
|
+
throw new Refusal('--yes is only meaningful with --install-hooks or --remove-hooks');
|
|
659
|
+
}
|
|
660
|
+
// REFUSED, NOT HONOURED: the hook binds the store from the cwd the HOST put in the event,
|
|
661
|
+
// and `runHook()` takes no options, so `--hook --store X` used to inject nothing from X.
|
|
662
|
+
if (options.hook && options.store !== null)
|
|
663
|
+
throw new Refusal('--store is not meaningful with --hook');
|
|
664
|
+
if (options.hook && options.start !== null)
|
|
665
|
+
throw new Refusal('--start is not meaningful with --hook');
|
|
616
666
|
if (options.assetsRoot) {
|
|
617
667
|
const root = assetsRoot();
|
|
618
668
|
// `packFileCount`, not a second walk: the count printed here and the count
|
|
@@ -622,10 +672,8 @@ async function main(argv) {
|
|
|
622
672
|
return 0;
|
|
623
673
|
}
|
|
624
674
|
if (options.hook) {
|
|
625
|
-
//
|
|
626
|
-
//
|
|
627
|
-
// nothing. `--hook` is registered directly after `--index-budget`, so it is checked
|
|
628
|
-
// directly after `--assets-root`.
|
|
675
|
+
// ALONE ON ITS COMMAND LINE (job66/#139): another one-shot flag beside it is a usage
|
|
676
|
+
// error, and `--store`/`--start`/`--yes`/`--force` beside it are refused above.
|
|
629
677
|
//
|
|
630
678
|
// EXIT 0 ALWAYS, AND THAT IS THE CONTRACT, not a convenience. A hook that exits non-zero
|
|
631
679
|
// or lets a stack reach stderr is rendered by the host as an error on the user's screen,
|
|
@@ -679,11 +727,9 @@ async function main(argv) {
|
|
|
679
727
|
// that record no origin, and it is not a guess: it is where the code being executed lives.
|
|
680
728
|
return await runUpdate((text) => process.stdout.write(text), (text) => process.stderr.write(text), version(), dirname(fileURLToPath(import.meta.url)));
|
|
681
729
|
}
|
|
682
|
-
// ORDER
|
|
683
|
-
//
|
|
684
|
-
//
|
|
685
|
-
// earlier here made the same argv write a file on one runtime and not the other: one
|
|
686
|
-
// command line, two different states on the user's disk. Reviewed and moved.
|
|
730
|
+
// ORDER WAS OBSERVABLE, and it was the reference's order: `--mcp-report --install cursor`
|
|
731
|
+
// printed a report and wrote NOTHING. Since job66/#139 that argv is a usage error on both
|
|
732
|
+
// sides (`refuseStackedOneShots`), so at most one arm below is ever reached.
|
|
687
733
|
if (options.install !== null && options.install !== undefined) {
|
|
688
734
|
// Before any store or transport exists, the shape `--assets-root` established. The command
|
|
689
735
|
// is resolved INSIDE the try: on an `npx` cache it makes the kept install first, and npm
|
|
@@ -699,9 +745,6 @@ async function main(argv) {
|
|
|
699
745
|
}
|
|
700
746
|
return 0;
|
|
701
747
|
}
|
|
702
|
-
// REGISTRATION ORDER AGAIN, which is what makes `--mcp-report --install-hooks` print a
|
|
703
|
-
// report and write nothing, exactly as `--mcp-report --install cursor` already does.
|
|
704
|
-
//
|
|
705
748
|
// THREE EXITS, AND THE TWO THAT WRITE NOTHING ARE NOT THE SAME EXIT (S1 RULING Q3.3/Q3.4):
|
|
706
749
|
//
|
|
707
750
|
// 0 written, or already installed and matching
|
|
@@ -772,18 +815,6 @@ async function main(argv) {
|
|
|
772
815
|
}
|
|
773
816
|
return 0;
|
|
774
817
|
}
|
|
775
|
-
// `--force` alone is a typo with a plausible reading — somebody meant to install and
|
|
776
|
-
// dropped the flag that says where. Refusing names the missing half.
|
|
777
|
-
if (options.force)
|
|
778
|
-
throw new Refusal('--force is only meaningful with --install');
|
|
779
|
-
// The same reading, and the same refusal, for the consent flag: `--yes` on its own is
|
|
780
|
-
// somebody who meant to install or remove hooks and dropped the flag that says which. It is
|
|
781
|
-
// checked AFTER `--force` because that is registration order, and both are checked after
|
|
782
|
-
// every flag that acts, so `--install-hooks --yes` never reaches either of them. The
|
|
783
|
-
// sentence names BOTH flags since the ruling of 2026-09-20: naming only one would send an
|
|
784
|
-
// operator who mistyped `--remove-hooks` looking for a flag they already had.
|
|
785
|
-
if (options.yes)
|
|
786
|
-
throw new Refusal('--yes is only meaningful with --install-hooks or --remove-hooks');
|
|
787
818
|
// A PERSON TYPED IT. `typedBareAtATerminal` carries the whole argument; what belongs here is
|
|
788
819
|
// only that this sits BEFORE `buildMemory`, which is what creates a store. Somebody who typed
|
|
789
820
|
// a command to see what it does has not asked for a `.bantamkit/memory` directory in whatever
|
package/dist/eventlog.d.ts
CHANGED
|
@@ -50,8 +50,15 @@ export declare function formatTimestamp(epochMs: number): string;
|
|
|
50
50
|
* by CODE POINT and JavaScript's default comparator by UTF-16 code UNIT, and those two
|
|
51
51
|
* disagree above the BMP. Every key written today is ASCII; the comparator is what keeps
|
|
52
52
|
* that a fact about the data instead of a difference between the runtimes.
|
|
53
|
+
*
|
|
54
|
+
* job66/#144: the DECLARED return type is `Uint8Array`, not `Buffer`. The value is still a
|
|
55
|
+
* `Buffer` (a `Uint8Array` subclass), so nothing changes at runtime, but `Buffer` is a name
|
|
56
|
+
* only `@types/node` defines, and this signature is reachable from the package entry point
|
|
57
|
+
* (`index.d.ts` re-exports it). Spelled `Buffer`, it made a strict consumer without
|
|
58
|
+
* `@types/node` fail `tsc` on `import ... from 'bantamkit-mcp'` with TS2591 — a dependency
|
|
59
|
+
* the package never declared. `test/packaging.test.mjs` type-checks such a consumer.
|
|
53
60
|
*/
|
|
54
|
-
export declare function encodeRecord(epochMs: number, tool: string, outcome: string, detail: Readonly<Record<string, DetailValue>>):
|
|
61
|
+
export declare function encodeRecord(epochMs: number, tool: string, outcome: string, detail: Readonly<Record<string, DetailValue>>): Uint8Array;
|
|
55
62
|
/**
|
|
56
63
|
* `<store>/events/mcp.jsonl` — provably outside everything `MemoryStore` reads.
|
|
57
64
|
*
|
package/dist/eventlog.js
CHANGED
|
@@ -120,6 +120,13 @@ export function formatTimestamp(epochMs) {
|
|
|
120
120
|
* by CODE POINT and JavaScript's default comparator by UTF-16 code UNIT, and those two
|
|
121
121
|
* disagree above the BMP. Every key written today is ASCII; the comparator is what keeps
|
|
122
122
|
* that a fact about the data instead of a difference between the runtimes.
|
|
123
|
+
*
|
|
124
|
+
* job66/#144: the DECLARED return type is `Uint8Array`, not `Buffer`. The value is still a
|
|
125
|
+
* `Buffer` (a `Uint8Array` subclass), so nothing changes at runtime, but `Buffer` is a name
|
|
126
|
+
* only `@types/node` defines, and this signature is reachable from the package entry point
|
|
127
|
+
* (`index.d.ts` re-exports it). Spelled `Buffer`, it made a strict consumer without
|
|
128
|
+
* `@types/node` fail `tsc` on `import ... from 'bantamkit-mcp'` with TS2591 — a dependency
|
|
129
|
+
* the package never declared. `test/packaging.test.mjs` type-checks such a consumer.
|
|
123
130
|
*/
|
|
124
131
|
export function encodeRecord(epochMs, tool, outcome, detail) {
|
|
125
132
|
const sorted = {};
|
package/dist/hookadapter.js
CHANGED
|
@@ -776,8 +776,14 @@ const UPDATE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
|
776
776
|
* nothing this file injects is unbounded. The sentence itself is 137 bytes plus two version
|
|
777
777
|
* numbers; the one component that is not fixed is the operator's home path inside `{path}`, so
|
|
778
778
|
* the cap is generous and exists to bound that, not to trim the sentence.
|
|
779
|
+
*
|
|
780
|
+
* AMENDED 2026-10-15 (job66/#171): 500 → 1024. The line now carries THREE absolute paths (the
|
|
781
|
+
* manifest, `process.execPath`, the kept `cli.js`), and a cap that cuts the line cuts the
|
|
782
|
+
* command the operator is told to run — a truncated command is worse than none. Measured: the
|
|
783
|
+
* conformance bed's home (a long `$TMPDIR` scratch path) put the line past 500 and the printed
|
|
784
|
+
* command was cut before its closing backtick. 1024 still bounds it.
|
|
779
785
|
*/
|
|
780
|
-
const UPDATE_LINE_MAX =
|
|
786
|
+
const UPDATE_LINE_MAX = 1024;
|
|
781
787
|
/**
|
|
782
788
|
* THE SENTENCE. Only in the stale state, silent in the other four.
|
|
783
789
|
*
|
|
@@ -801,8 +807,15 @@ const UPDATE_LINE_MAX = 500;
|
|
|
801
807
|
* machine it was measured on. `node "<cli>"` is what a host is already told to run
|
|
802
808
|
* (`npminstall.keptCli`), needs no PATH and no `.cmd` shim on Windows, and the double quotes
|
|
803
809
|
* hold a home directory with a space in it in zsh, bash, cmd and PowerShell alike.
|
|
810
|
+
*
|
|
811
|
+
* AMENDED 2026-10-15 (job66/#171): "needs no PATH" was true of `{cli}` and false of `node`.
|
|
812
|
+
* Run through `/bin/sh` with `PATH=/usr/bin:/bin` — a GUI-launched host's PATH — the bare
|
|
813
|
+
* `node` answered `exit 127, node: command not found`. `{node}` is `process.execPath`, the
|
|
814
|
+
* absolute interpreter running THIS hook (the one the hook registration launched), quoted
|
|
815
|
+
* like `{cli}`. A quoted first word is a string in PowerShell, which runs it with `& `
|
|
816
|
+
* in front; zsh, bash, sh and cmd run it as printed.
|
|
804
817
|
*/
|
|
805
|
-
const UPDATE_LINE = '[bantamkit] {program} {installed} at {path} is running; the package index has {latest} — run `node "{cli}" --update`, then reconnect the host.';
|
|
818
|
+
const UPDATE_LINE = '[bantamkit] {program} {installed} at {path} is running; the package index has {latest} — run `"{node}" "{cli}" --update`, then reconnect the host.';
|
|
806
819
|
/** `{name}` substitution. A missing key is left alone rather than rendered `undefined`. */
|
|
807
820
|
function fillLine(template, values) {
|
|
808
821
|
return template.replace(/\{([a-z]+)\}/g, (whole, name) => values[name] ?? whole);
|
|
@@ -858,6 +871,7 @@ function updateSignal(run) {
|
|
|
858
871
|
installed: kept.version,
|
|
859
872
|
path: kept.path,
|
|
860
873
|
cli: kept.cli,
|
|
874
|
+
node: process.execPath,
|
|
861
875
|
latest,
|
|
862
876
|
}), UPDATE_LINE_MAX);
|
|
863
877
|
}
|
package/dist/hostinstall.d.ts
CHANGED
|
@@ -152,8 +152,8 @@ export declare const HOOK_MARKER_VALUE = "hook";
|
|
|
152
152
|
* 5. dated backup, then one atomic write.
|
|
153
153
|
*
|
|
154
154
|
* The write itself is `writeConfig` — the same temp-file replace, the same carried mode, the
|
|
155
|
-
* same
|
|
156
|
-
* are produced by one function (RULING Q3.5).
|
|
155
|
+
* same detected indent and literal non-ASCII through `hostJsonText` (#118) — so the bytes this
|
|
156
|
+
* leaves and the bytes `--install` leaves are produced by one function (RULING Q3.5).
|
|
157
157
|
*/
|
|
158
158
|
export declare function installHooks(options?: HookOptions): string;
|
|
159
159
|
/**
|
package/dist/hostinstall.js
CHANGED
|
@@ -12,9 +12,10 @@
|
|
|
12
12
|
* never `JSON.stringify`. Python writes `{"args": [], "command": "OLD"}` and
|
|
13
13
|
* `JSON.stringify` writes `{"args":[],"command":"OLD"}` — same object, different
|
|
14
14
|
* bytes, and the separators are the whole difference.
|
|
15
|
-
* 2. The file is written
|
|
16
|
-
* `
|
|
17
|
-
*
|
|
15
|
+
* 2. The file is written through `hostJsonText`, which reproduces the reference's
|
|
16
|
+
* `json.dumps(data, indent=<the file's own unit>, ensure_ascii=False)` (#118): the user's
|
|
17
|
+
* Thai text stays Thai, their 4-space or tab indent stays theirs, and the two runtimes
|
|
18
|
+
* still write identical bytes — `install-host-text` in the install suite compares them.
|
|
18
19
|
*
|
|
19
20
|
* WHAT IS RULED DIFFERENT, and it is one field: the COMMAND. This side records
|
|
20
21
|
* `<absolute node> <absolute .../dist/cli.js>`; the reference records its own console script.
|
|
@@ -243,7 +244,7 @@ function writeConfig(path, data) {
|
|
|
243
244
|
// before this line existed.
|
|
244
245
|
const mode = existsSync(target) ? statSync(target).mode & 0o7777 : null;
|
|
245
246
|
try {
|
|
246
|
-
writeFileSync(tmp, `${
|
|
247
|
+
writeFileSync(tmp, `${hostJsonText(data, detectedIndent(target))}\n`, 'utf8');
|
|
247
248
|
if (mode !== null)
|
|
248
249
|
chmodSync(tmp, mode);
|
|
249
250
|
renameSync(tmp, target);
|
|
@@ -253,6 +254,68 @@ function writeConfig(path, data) {
|
|
|
253
254
|
throw new InstallError(`cannot write ${path}: ${e.message}`);
|
|
254
255
|
}
|
|
255
256
|
}
|
|
257
|
+
/**
|
|
258
|
+
* The indent unit the file already uses: a tab, or N spaces; two for a new file (#118).
|
|
259
|
+
*
|
|
260
|
+
* Read off the FIRST line that starts with whitespace and has something after it. A JSON
|
|
261
|
+
* string cannot hold a raw newline, so every line start is structural and that line is a
|
|
262
|
+
* depth-one member. A file with no such line (absent, empty, all on one line) gets two
|
|
263
|
+
* spaces, which is what this writer always used. The reference's `_detected_indent` applies
|
|
264
|
+
* the same rule to the same text.
|
|
265
|
+
*
|
|
266
|
+
* Lines break where Python's universal newlines break them — `\r\n`, a bare `\r`, or `\n` —
|
|
267
|
+
* because the reference reads through `Path.read_text`, which translates all three. Splitting
|
|
268
|
+
* on `\n` alone saw a classic-Mac (CR-only) file as one line, fell back to two spaces, and so
|
|
269
|
+
* wrote different bytes from the reference and lost the file's indent (#118, review J66-12 B1).
|
|
270
|
+
*/
|
|
271
|
+
function detectedIndent(path) {
|
|
272
|
+
let text;
|
|
273
|
+
try {
|
|
274
|
+
text = readFileSync(path, 'utf8');
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
return ' ';
|
|
278
|
+
}
|
|
279
|
+
for (const line of text.split(/\r\n|\r|\n/)) {
|
|
280
|
+
if ((line[0] !== ' ' && line[0] !== '\t') || !/[^ \t\r]/.test(line))
|
|
281
|
+
continue;
|
|
282
|
+
if (line[0] === '\t')
|
|
283
|
+
return '\t';
|
|
284
|
+
return ' '.repeat(line.length - line.replace(/^ +/, '').length);
|
|
285
|
+
}
|
|
286
|
+
return ' ';
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* The host file's text: the user's indent unit, non-ASCII as literal UTF-8 (#118) — the
|
|
290
|
+
* bytes the reference's `json.dumps(data, indent=<unit>, ensure_ascii=False)` writes.
|
|
291
|
+
*
|
|
292
|
+
* Built from `dumpJson`'s `ensure_ascii` output at indent 2, which is already CPython's in
|
|
293
|
+
* every other respect, by two rewrites that cannot touch a string's meaning:
|
|
294
|
+
* - indentation: every line start in that output is structural (a string holds no raw
|
|
295
|
+
* newline), and its leading spaces are exactly 2 × depth, so they become unit × depth;
|
|
296
|
+
* - escapes: each `\uXXXX` at 0x7f or above goes back to the character itself — a valid
|
|
297
|
+
* surrogate PAIR as one astral character — which is what `ensure_ascii=False` writes
|
|
298
|
+
* (it escapes only `"`, `\` and C0). A LONE surrogate stays escaped: UTF-8 cannot carry
|
|
299
|
+
* it, and the reference writes it back the same way. A `\\` pair is consumed first, so
|
|
300
|
+
* the text `ก` inside a string (an escaped backslash) is never mistaken for one.
|
|
301
|
+
*/
|
|
302
|
+
function hostJsonText(data, indent) {
|
|
303
|
+
const ascii = dumpJson(data, { indent: 2 });
|
|
304
|
+
const unescaped = ascii.replace(/\\u(d[89ab][0-9a-f]{2})\\u(d[c-f][0-9a-f]{2})|\\u([0-9a-f]{4})|\\[^u]/g, (whole, high, low, single) => {
|
|
305
|
+
if (high !== undefined && low !== undefined) {
|
|
306
|
+
return String.fromCharCode(parseInt(high, 16), parseInt(low, 16));
|
|
307
|
+
}
|
|
308
|
+
if (single === undefined)
|
|
309
|
+
return whole;
|
|
310
|
+
const unit = parseInt(single, 16);
|
|
311
|
+
if (unit < 0x7f || (unit >= 0xd800 && unit <= 0xdfff))
|
|
312
|
+
return whole;
|
|
313
|
+
return String.fromCharCode(unit);
|
|
314
|
+
});
|
|
315
|
+
if (indent === ' ')
|
|
316
|
+
return unescaped;
|
|
317
|
+
return unescaped.replace(/^((?: {2})+)/gm, (lead) => indent.repeat(lead.length / 2));
|
|
318
|
+
}
|
|
256
319
|
/** `date.today().isoformat()` — the LOCAL date, which is what the reference takes. */
|
|
257
320
|
function today() {
|
|
258
321
|
const now = new Date();
|
|
@@ -824,8 +887,8 @@ function removalPlan(path, events, entries) {
|
|
|
824
887
|
* 5. dated backup, then one atomic write.
|
|
825
888
|
*
|
|
826
889
|
* The write itself is `writeConfig` — the same temp-file replace, the same carried mode, the
|
|
827
|
-
* same
|
|
828
|
-
* are produced by one function (RULING Q3.5).
|
|
890
|
+
* same detected indent and literal non-ASCII through `hostJsonText` (#118) — so the bytes this
|
|
891
|
+
* leaves and the bytes `--install` leaves are produced by one function (RULING Q3.5).
|
|
829
892
|
*/
|
|
830
893
|
export function installHooks(options = {}) {
|
|
831
894
|
const { command, args } = thisCommand(options);
|
package/dist/mcp/identity.js
CHANGED
|
@@ -75,9 +75,35 @@ const unavailable = (reason) => ({ unavailable: reason });
|
|
|
75
75
|
*/
|
|
76
76
|
function walkFiles(root) {
|
|
77
77
|
const found = [];
|
|
78
|
+
// job66/#140: `rglob` over a root that is not a directory yields NOTHING — missing, a
|
|
79
|
+
// regular file, or a dangling link alike (`select_from` checks `is_dir()` first) — and it
|
|
80
|
+
// swallows `PermissionError` on any directory it cannot list. WAS: `readdirSync` threw, so
|
|
81
|
+
// `BANTAMKIT_ASSETS=<missing> --assets-root` died with a raw ENOENT stack where the
|
|
82
|
+
// reference prints `<path>` / `0 files` at exit 0. Measured on CPython 3.12:
|
|
83
|
+
// `list(Path("<missing>").rglob("*"))`, `<file>` and a mode-000 directory are all `[]`.
|
|
84
|
+
let rootIsDir = false;
|
|
85
|
+
try {
|
|
86
|
+
rootIsDir = statSync(root).isDirectory();
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
rootIsDir = false;
|
|
90
|
+
}
|
|
91
|
+
if (!rootIsDir)
|
|
92
|
+
return [];
|
|
93
|
+
const listing = (here) => {
|
|
94
|
+
try {
|
|
95
|
+
return readdirSync(here, { withFileTypes: true });
|
|
96
|
+
}
|
|
97
|
+
catch (e) {
|
|
98
|
+
const code = e.code;
|
|
99
|
+
if (code === 'EACCES' || code === 'EPERM')
|
|
100
|
+
return [];
|
|
101
|
+
throw e;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
78
104
|
const descend = (parts) => {
|
|
79
105
|
const here = parts.length === 0 ? root : join(root, ...parts);
|
|
80
|
-
for (const entry of
|
|
106
|
+
for (const entry of listing(here)) {
|
|
81
107
|
const next = [...parts, entry.name];
|
|
82
108
|
if (entry.isDirectory()) {
|
|
83
109
|
descend(next);
|