bantamkit-mcp 0.35.6 → 0.35.8
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 +11 -7
- package/assets/tools/memory_compact.json +1 -2
- package/assets/tools/memory_recall.json +1 -3
- package/assets/tools/shiftwork_clock_in.json +1 -1
- package/assets/tools/shiftwork_clock_out.json +1 -1
- package/assets/tools/validate_json.json +1 -1
- package/assets/tools/work_plan.json +14 -8
- package/dist/cli.js +28 -0
- package/dist/contract.d.ts +0 -9
- package/dist/contract.js +51 -12
- package/dist/eventlog.d.ts +14 -1
- package/dist/eventlog.js +23 -3
- package/dist/hookadapter.js +260 -45
- package/dist/hostinstall.js +250 -79
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/mcp/pyargs.d.ts +22 -6
- package/dist/mcp/pyargs.js +113 -39
- package/dist/mcp/server.js +18 -27
- package/dist/mcp/status.js +11 -4
- package/dist/mcpreport.d.ts +12 -0
- package/dist/mcpreport.js +67 -15
- package/dist/memory/cli.js +18 -3
- package/dist/memory/component.d.ts +17 -0
- package/dist/memory/component.js +51 -7
- package/dist/memory/factfile.d.ts +21 -3
- package/dist/memory/factfile.js +351 -12
- package/dist/memory/layers.d.ts +19 -0
- package/dist/memory/layers.js +102 -2
- package/dist/memory/pyyaml.d.ts +6 -4
- package/dist/memory/pyyaml.js +6 -4
- package/dist/memory/store.d.ts +82 -7
- package/dist/memory/store.js +277 -29
- package/dist/npminstall.d.ts +18 -0
- package/dist/npminstall.js +26 -5
- package/dist/pyjsonschema.d.ts +85 -4
- package/dist/pyjsonschema.js +1812 -181
- package/dist/selfupdate.d.ts +112 -32
- package/dist/selfupdate.js +221 -79
- package/dist/shiftwork.d.ts +46 -7
- package/dist/shiftwork.js +246 -42
- package/dist/workplan.d.ts +8 -3
- package/dist/workplan.js +62 -3
- package/package.json +1 -1
package/dist/hostinstall.js
CHANGED
|
@@ -32,14 +32,14 @@
|
|
|
32
32
|
* launches it without the network. See `thisCommand`.
|
|
33
33
|
*/
|
|
34
34
|
import { spawnSync } from 'node:child_process';
|
|
35
|
-
import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
35
|
+
import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
36
36
|
import { homedir } from 'node:os';
|
|
37
|
-
import { dirname, join } from 'node:path';
|
|
37
|
+
import { dirname, join, resolve } from 'node:path';
|
|
38
38
|
import { fileURLToPath } from 'node:url';
|
|
39
39
|
import { currentInstall, Undetermined } from './mcp/identity.js';
|
|
40
40
|
import { bantamkitDirFor, ensureBantamkitGitignore } from './memory/store.js';
|
|
41
41
|
import { compareVersions, keptCli, keptManifest, keptPrefix, PACKAGE, runInstaller, shlexJoin } from './npminstall.js';
|
|
42
|
-
import { dumpJson, fromJs } from './pyjson.js';
|
|
42
|
+
import { dumpJson, fromJs, parseJson, PyJSONDecodeError } from './pyjson.js';
|
|
43
43
|
export { keptCli, keptPrefix };
|
|
44
44
|
/** The four hosts, in the order they print in `--help`. */
|
|
45
45
|
export const HOSTS = ['claude', 'claude-desktop', 'copilot', 'cursor'];
|
|
@@ -49,6 +49,7 @@ export const ENTRY = 'bantamkit';
|
|
|
49
49
|
export class InstallError extends Error {
|
|
50
50
|
}
|
|
51
51
|
const dumps = (value, indent = null, sortKeys = false) => dumpJson(fromJs(value), { indent, sortKeys });
|
|
52
|
+
const pyDict = (entries) => ({ t: 'dict', v: new Map(entries) });
|
|
52
53
|
/**
|
|
53
54
|
* The file `--install <host>` would write, on THIS platform.
|
|
54
55
|
*
|
|
@@ -100,25 +101,64 @@ export function entryFor(host, command, args) {
|
|
|
100
101
|
* `[1, 2, 3]`, the ONE input where the two vocabularies happen to agree, so neither suite
|
|
101
102
|
* could see it. Review measured `"hello"` and `5`.
|
|
102
103
|
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
104
|
+
* Since #118 the value is tagged by the same split `json` makes — a JSON number with no
|
|
105
|
+
* fraction and no exponent is `int`, everything else `float` — so the tag IS the name.
|
|
105
106
|
*/
|
|
106
107
|
function pyTypeName(value) {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
108
|
+
switch (value.t) {
|
|
109
|
+
case 'null':
|
|
110
|
+
return 'NoneType';
|
|
111
|
+
case 'bool':
|
|
112
|
+
return 'bool';
|
|
113
|
+
case 'int':
|
|
114
|
+
return 'int';
|
|
115
|
+
case 'float':
|
|
116
|
+
return 'float';
|
|
117
|
+
case 'str':
|
|
118
|
+
return 'str';
|
|
119
|
+
case 'list':
|
|
120
|
+
return 'list';
|
|
121
|
+
case 'dict':
|
|
122
|
+
return 'dict';
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The first non-finite float in `value`, with where it sits, or `null`. Depth-first, in
|
|
127
|
+
* document order, so both runtimes name the same number when a file holds two.
|
|
128
|
+
*/
|
|
129
|
+
function nonFiniteIn(value, trail) {
|
|
130
|
+
if (value.t === 'float' && !Number.isFinite(value.v))
|
|
131
|
+
return { where: whereIs(trail), value: value.v };
|
|
132
|
+
if (value.t === 'dict') {
|
|
133
|
+
for (const [key, item] of value.v) {
|
|
134
|
+
const found = nonFiniteIn(item, [...trail, key]);
|
|
135
|
+
if (found !== null)
|
|
136
|
+
return found;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
else if (value.t === 'list') {
|
|
140
|
+
for (const [index, item] of value.v.entries()) {
|
|
141
|
+
const found = nonFiniteIn(item, [...trail, index]);
|
|
142
|
+
if (found !== null)
|
|
143
|
+
return found;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
/** `mcpServers.other.huge`, `servers.list[2]`: keys joined by `.`, indexes in brackets. */
|
|
149
|
+
function whereIs(trail) {
|
|
150
|
+
let out = '';
|
|
151
|
+
for (const segment of trail) {
|
|
152
|
+
if (typeof segment === 'number')
|
|
153
|
+
out += `[${segment}]`;
|
|
154
|
+
else
|
|
155
|
+
out += out === '' ? segment : `.${segment}`;
|
|
156
|
+
}
|
|
157
|
+
return out === '' ? 'the top level' : out;
|
|
118
158
|
}
|
|
119
159
|
function readConfig(path) {
|
|
120
160
|
if (!existsSync(path))
|
|
121
|
-
return
|
|
161
|
+
return pyDict();
|
|
122
162
|
let text;
|
|
123
163
|
try {
|
|
124
164
|
text = readFileSync(path, 'utf8');
|
|
@@ -127,35 +167,86 @@ function readConfig(path) {
|
|
|
127
167
|
throw new InstallError(`cannot read ${path}: ${e.message}`);
|
|
128
168
|
}
|
|
129
169
|
if (text.trim() === '')
|
|
130
|
-
return
|
|
170
|
+
return pyDict();
|
|
131
171
|
let loaded;
|
|
132
172
|
try {
|
|
133
|
-
loaded =
|
|
173
|
+
loaded = parseJson(text);
|
|
134
174
|
}
|
|
135
175
|
catch (e) {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
//
|
|
139
|
-
|
|
176
|
+
if (!(e instanceof PyJSONDecodeError))
|
|
177
|
+
throw e;
|
|
178
|
+
// The reference reports CPython's message, line and column, and so does this parser —
|
|
179
|
+
// the ruling that once covered V8's `Unexpected token …` wording retired with #118.
|
|
180
|
+
throw new InstallError(`${path} is not valid JSON, so this refuses to touch it: ${e.msg} (line ${e.lineno}, column ${e.colno})`);
|
|
140
181
|
}
|
|
141
|
-
if (loaded
|
|
182
|
+
if (loaded.t !== 'dict') {
|
|
142
183
|
throw new InstallError(`${path} holds ${pyTypeName(loaded)}, not an object; refusing to touch it`);
|
|
143
184
|
}
|
|
185
|
+
// A NUMBER JSON CANNOT CARRY IS REFUSED HERE, before anything is planned (#118). `1e400`
|
|
186
|
+
// decodes to `Infinity` and `dumpJson` — faithful to `json.dumps` — would write it back as
|
|
187
|
+
// that bare word, which no JSON parser accepts: the host then cannot read ANY of its
|
|
188
|
+
// servers, and the next run of this very command refuses its own output. The literals
|
|
189
|
+
// `NaN`/`Infinity` that CPython's decoder tolerates take the same door.
|
|
190
|
+
const found = nonFiniteIn(loaded, []);
|
|
191
|
+
if (found !== null) {
|
|
192
|
+
const spelled = dumpJson({ t: 'float', v: found.value });
|
|
193
|
+
throw new InstallError(`${path} has a number at ${found.where} that JSON cannot carry (${spelled}); refusing to touch it`);
|
|
194
|
+
}
|
|
144
195
|
return loaded;
|
|
145
196
|
}
|
|
197
|
+
/**
|
|
198
|
+
* Where the bytes land: the file ITSELF, through any symlink (#136).
|
|
199
|
+
*
|
|
200
|
+
* A dotfiles user keeps `~/.cursor/mcp.json` as a link into a repository. Writing a temp file
|
|
201
|
+
* beside the LINK and renaming it onto the link's name replaces the link with a detached
|
|
202
|
+
* regular file: the repository copy never gets the entry and the next sync reverts it without
|
|
203
|
+
* a word. `realpathSync` wants the target to exist; a link whose target is not there yet is
|
|
204
|
+
* followed by hand (the reference's `os.path.realpath` is non-strict), so the write creates
|
|
205
|
+
* the target and the link stays a link either way.
|
|
206
|
+
*/
|
|
207
|
+
function writeTarget(path) {
|
|
208
|
+
try {
|
|
209
|
+
return realpathSync(path);
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
// ENOENT: a fresh file, or a link whose target does not exist yet.
|
|
213
|
+
}
|
|
214
|
+
let at = path;
|
|
215
|
+
for (let hops = 0; hops < 40; hops += 1) {
|
|
216
|
+
let link;
|
|
217
|
+
try {
|
|
218
|
+
if (!lstatSync(at).isSymbolicLink())
|
|
219
|
+
return at;
|
|
220
|
+
link = readlinkSync(at);
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return at;
|
|
224
|
+
}
|
|
225
|
+
at = resolve(dirname(at), link);
|
|
226
|
+
}
|
|
227
|
+
return at;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Write the whole document, atomically, leaving no half-file behind on a crash — and atomic
|
|
231
|
+
* ONTO THE REAL FILE: the temp file is made beside `writeTarget(path)` and renamed onto it, so
|
|
232
|
+
* a symlinked config is written through rather than swapped for a regular file.
|
|
233
|
+
*/
|
|
146
234
|
function writeConfig(path, data) {
|
|
147
235
|
mkdirSync(dirname(path), { recursive: true });
|
|
148
|
-
const
|
|
236
|
+
const target = writeTarget(path);
|
|
237
|
+
if (target !== path)
|
|
238
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
239
|
+
const tmp = `${target}.bantamkit-tmp`;
|
|
149
240
|
// The mode of the file being replaced, carried onto its replacement. A host config holds
|
|
150
241
|
// API keys in per-server `env` blocks, and a user who chmod'ed theirs to 0600 had it come
|
|
151
242
|
// back 0644, because a fresh temp file gets the process umask. Measured on both runtimes
|
|
152
243
|
// before this line existed.
|
|
153
|
-
const mode = existsSync(
|
|
244
|
+
const mode = existsSync(target) ? statSync(target).mode & 0o7777 : null;
|
|
154
245
|
try {
|
|
155
|
-
writeFileSync(tmp, `${
|
|
246
|
+
writeFileSync(tmp, `${dumpJson(data, { indent: 2 })}\n`, 'utf8');
|
|
156
247
|
if (mode !== null)
|
|
157
248
|
chmodSync(tmp, mode);
|
|
158
|
-
renameSync(tmp,
|
|
249
|
+
renameSync(tmp, target);
|
|
159
250
|
}
|
|
160
251
|
catch (e) {
|
|
161
252
|
rmSync(tmp, { force: true });
|
|
@@ -168,10 +259,48 @@ function today() {
|
|
|
168
259
|
const pad = (n) => String(n).padStart(2, '0');
|
|
169
260
|
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
|
170
261
|
}
|
|
262
|
+
/** `os.path.lexists`: there is SOMETHING at this name, a dangling link included. */
|
|
263
|
+
function lexists(path) {
|
|
264
|
+
try {
|
|
265
|
+
lstatSync(path);
|
|
266
|
+
return true;
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* The name the next backup of `path` gets: the first one that is not taken.
|
|
274
|
+
*
|
|
275
|
+
* `<name>.backup-<date>` when nothing holds that name, then `<name>.backup-<date>.1`, `.2`,
|
|
276
|
+
* ... on the same day. The plan lines print this same name before the write, so what the
|
|
277
|
+
* plan says and what the write does are one computation.
|
|
278
|
+
*/
|
|
279
|
+
function backupTarget(path) {
|
|
280
|
+
const stamp = today();
|
|
281
|
+
let candidate = `${path}.backup-${stamp}`;
|
|
282
|
+
for (let n = 1; lexists(candidate); n += 1)
|
|
283
|
+
candidate = `${path}.backup-${stamp}.${n}`;
|
|
284
|
+
return candidate;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Copy the file aside before changing it. Returns the copy, or `null` if there was none.
|
|
288
|
+
*
|
|
289
|
+
* Dated, so a person looking at their own config directory a week later can tell when it
|
|
290
|
+
* happened — and NEVER OVERWRITTEN (#138). Until job65 a second run on the same day wrote the
|
|
291
|
+
* same `<name>.backup-<date>` on purpose, "rather than growing a pile nobody prunes"; the
|
|
292
|
+
* README's undo promise is that the backup restores the file as it was BEFORE bantamkit
|
|
293
|
+
* touched it, and the second run was overwriting exactly that copy with the first run's
|
|
294
|
+
* output. A pile of dated copies is the price of an undo that works; the counter suffix is
|
|
295
|
+
* the price of keeping the documented name for the first one.
|
|
296
|
+
*
|
|
297
|
+
* `copyFileSync` follows a symlink on the source, so the copy holds the REAL file's bytes and
|
|
298
|
+
* sits beside the path the report names.
|
|
299
|
+
*/
|
|
171
300
|
function backup(path) {
|
|
172
301
|
if (!existsSync(path))
|
|
173
302
|
return null;
|
|
174
|
-
const target =
|
|
303
|
+
const target = backupTarget(path);
|
|
175
304
|
try {
|
|
176
305
|
copyFileSync(path, target);
|
|
177
306
|
}
|
|
@@ -218,25 +347,26 @@ export function install(host, command, args, force = false) {
|
|
|
218
347
|
const key = configKey(host);
|
|
219
348
|
const wanted = entryFor(host, command, args);
|
|
220
349
|
const data = readConfig(path);
|
|
221
|
-
const raw = data
|
|
222
|
-
if (raw !== undefined &&
|
|
350
|
+
const raw = data.v.get(key);
|
|
351
|
+
if (raw !== undefined && raw.t !== 'dict') {
|
|
223
352
|
throw new InstallError(`${path} has a '${key}' that is not an object; refusing to touch it`);
|
|
224
353
|
}
|
|
225
|
-
const servers =
|
|
226
|
-
|
|
227
|
-
|
|
354
|
+
const servers = raw ?? pyDict();
|
|
355
|
+
// MEMBERSHIP, not a null test: `"bantamkit": null` is an entry (`PY_NONE`, not `undefined`).
|
|
356
|
+
const existing = servers.v.get(ENTRY);
|
|
357
|
+
if (existing !== undefined && dumpJson(existing, { sortKeys: true }) === dumps(wanted, null, true)) {
|
|
228
358
|
return `bantamkit is already installed in ${host} and matches\n file : ${path}`;
|
|
229
359
|
}
|
|
230
360
|
if (existing !== undefined && !force) {
|
|
231
361
|
throw new InstallError(`${host} already has a bantamkit entry with different settings\n` +
|
|
232
362
|
` file : ${path}\n` +
|
|
233
|
-
` current : ${
|
|
363
|
+
` current : ${dumpJson(existing, { sortKeys: true })}\n` +
|
|
234
364
|
` proposed: ${dumps(wanted, null, true)}\n` +
|
|
235
365
|
' re-run with --force to replace it');
|
|
236
366
|
}
|
|
237
367
|
const copied = backup(path);
|
|
238
|
-
servers
|
|
239
|
-
data
|
|
368
|
+
servers.v.set(ENTRY, fromJs(wanted));
|
|
369
|
+
data.v.set(key, servers);
|
|
240
370
|
writeConfig(path, data);
|
|
241
371
|
const lines = [
|
|
242
372
|
`installed bantamkit into ${host}`,
|
|
@@ -494,10 +624,13 @@ function hookEntry(matcher, command) {
|
|
|
494
624
|
];
|
|
495
625
|
return entry;
|
|
496
626
|
}
|
|
497
|
-
/**
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
627
|
+
/** `{"event": [...]}` as a `PyValue`, for the one comparison and the one write that need it. */
|
|
628
|
+
const asPyValue = (table) => ({ t: 'dict', v: new Map([...table].map(([event, entries]) => [event, { t: 'list', v: entries }])) });
|
|
629
|
+
/** The `str` behind a key, or `undefined` when the key is absent or holds something else. */
|
|
630
|
+
const strAt = (dict, key) => {
|
|
631
|
+
const value = dict.v.get(key);
|
|
632
|
+
return value !== undefined && value.t === 'str' ? value.v : undefined;
|
|
633
|
+
};
|
|
501
634
|
/**
|
|
502
635
|
* The two command spellings bantamkit ever wrote, for entries that predate the marker.
|
|
503
636
|
*
|
|
@@ -516,13 +649,17 @@ function isObject(value) {
|
|
|
516
649
|
* third-party script with the word in its filename was claimed as ours and deleted.
|
|
517
650
|
*/
|
|
518
651
|
function isLegacyOurs(hook) {
|
|
519
|
-
if (hook
|
|
652
|
+
if (strAt(hook, 'type') !== 'command')
|
|
520
653
|
return false;
|
|
521
|
-
const command = hook
|
|
522
|
-
if (
|
|
654
|
+
const command = strAt(hook, 'command');
|
|
655
|
+
if (command === undefined || !command.includes('bantamkit'))
|
|
523
656
|
return false;
|
|
524
657
|
return command.includes('bantamkit-hook') || command === '--hook' || command.endsWith(' --hook');
|
|
525
658
|
}
|
|
659
|
+
/** ONE inner hook object: ours by marker, or by one of the two legacy spellings. */
|
|
660
|
+
function isOurHook(hook) {
|
|
661
|
+
return hook.t === 'dict' && (hook.v.has(HOOK_MARKER_KEY) || isLegacyOurs(hook));
|
|
662
|
+
}
|
|
526
663
|
/**
|
|
527
664
|
* RULING Q3.6's idempotence rule, RE-DECIDED ON THE ENTRY ALONE (J62-22).
|
|
528
665
|
*
|
|
@@ -539,60 +676,96 @@ function isLegacyOurs(hook) {
|
|
|
539
676
|
* shapes we wrote before the marker existed. Where any binary lives is not consulted.
|
|
540
677
|
*/
|
|
541
678
|
function isOurs(entry) {
|
|
542
|
-
if (
|
|
679
|
+
if (entry.t !== 'dict')
|
|
543
680
|
return false;
|
|
544
|
-
const hooks = entry
|
|
545
|
-
if (
|
|
681
|
+
const hooks = entry.v.get('hooks');
|
|
682
|
+
if (hooks === undefined || hooks.t !== 'list')
|
|
546
683
|
return false;
|
|
547
|
-
return hooks.some(
|
|
684
|
+
return hooks.v.some(isOurHook);
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* `entry` with bantamkit's own command objects taken out, and how many came out.
|
|
688
|
+
*
|
|
689
|
+
* THE UNIT OF OWNERSHIP IS THE COMMAND OBJECT, NOT THE MATCHER BLOCK (#129). `isOurs` answers
|
|
690
|
+
* "does this block hold one of ours", and dropping the whole block on that answer deleted a
|
|
691
|
+
* user's `notify-send` that shared the block with ours — after a plan that had just promised
|
|
692
|
+
* their hooks were left byte for byte. So: a block that is not ours comes back untouched (the
|
|
693
|
+
* same object, nothing removed); a block that holds ours loses ONLY those objects and keeps
|
|
694
|
+
* every other key it had; and a block left with no hooks at all is `null`, because
|
|
695
|
+
* `{"matcher": ..., "hooks": []}` is an artefact nobody wrote.
|
|
696
|
+
*/
|
|
697
|
+
function withoutOurs(entry) {
|
|
698
|
+
if (!isOurs(entry) || entry.t !== 'dict')
|
|
699
|
+
return [entry, 0];
|
|
700
|
+
const hooks = entry.v.get('hooks');
|
|
701
|
+
if (hooks === undefined || hooks.t !== 'list')
|
|
702
|
+
return [entry, 0];
|
|
703
|
+
const kept = hooks.v.filter((hook) => !isOurHook(hook));
|
|
704
|
+
const removed = hooks.v.length - kept.length;
|
|
705
|
+
if (kept.length === 0)
|
|
706
|
+
return [null, removed];
|
|
707
|
+
const trimmed = pyDict(entry.v);
|
|
708
|
+
trimmed.v.set('hooks', { t: 'list', v: kept });
|
|
709
|
+
return [trimmed, removed];
|
|
548
710
|
}
|
|
549
711
|
/** The `hooks` object, validated. Refuses by name rather than crashing on somebody's file. */
|
|
550
712
|
function readHooks(path, data) {
|
|
551
|
-
const raw = data
|
|
713
|
+
const raw = data.v.get('hooks');
|
|
552
714
|
if (raw === undefined)
|
|
553
|
-
return
|
|
554
|
-
if (raw
|
|
715
|
+
return new Map();
|
|
716
|
+
if (raw.t !== 'dict') {
|
|
555
717
|
throw new InstallError(`${path} has a 'hooks' that is not an object; refusing to touch it`);
|
|
556
718
|
}
|
|
557
|
-
const hooks =
|
|
558
|
-
for (const [event, entries] of
|
|
559
|
-
if (
|
|
719
|
+
const hooks = new Map();
|
|
720
|
+
for (const [event, entries] of raw.v) {
|
|
721
|
+
if (entries.t !== 'list') {
|
|
560
722
|
throw new InstallError(`${path} has a 'hooks.${event}' that is not a list; refusing to touch it`);
|
|
561
723
|
}
|
|
562
|
-
hooks
|
|
724
|
+
hooks.set(event, [...entries.v]);
|
|
563
725
|
}
|
|
564
726
|
return hooks;
|
|
565
727
|
}
|
|
566
728
|
/**
|
|
567
|
-
* The `hooks` object this run wants, built from whatever is there now
|
|
729
|
+
* The `hooks` object this run wants, built from whatever is there now, the events that change,
|
|
730
|
+
* and how many of OUR command objects came out.
|
|
568
731
|
*
|
|
569
|
-
* ONE PASS OVER THE SEVEN EVENTS AND NOTHING ELSE: every event keeps its non-bantamkit
|
|
570
|
-
*
|
|
571
|
-
* key rather than holding an empty list — `install.mjs`'s rule, kept
|
|
572
|
-
* full of empty arrays is a worse artefact than one with nothing in
|
|
732
|
+
* ONE PASS OVER THE SEVEN EVENTS AND NOTHING ELSE: every event keeps its non-bantamkit hooks in
|
|
733
|
+
* their existing order and their existing blocks, ours is appended after them, and an event
|
|
734
|
+
* left with none loses its key rather than holding an empty list — `install.mjs`'s rule, kept
|
|
735
|
+
* because a settings file full of empty arrays is a worse artefact than one with nothing in
|
|
736
|
+
* it. A block is dropped only when taking ours out of it leaves it empty (`withoutOurs`).
|
|
573
737
|
*
|
|
574
738
|
* Events outside the seven are not read, not reordered and not removed.
|
|
575
739
|
*/
|
|
576
740
|
function plannedHooks(current, command, remove) {
|
|
577
|
-
const hooks =
|
|
741
|
+
const hooks = new Map(current);
|
|
578
742
|
const touched = [];
|
|
743
|
+
let removed = 0;
|
|
579
744
|
for (const [event, matcher] of HOOK_EVENTS) {
|
|
580
|
-
const before = hooks
|
|
581
|
-
const kept =
|
|
745
|
+
const before = hooks.get(event) ?? [];
|
|
746
|
+
const kept = [];
|
|
747
|
+
let removedHere = 0;
|
|
748
|
+
for (const entry of before) {
|
|
749
|
+
const [trimmed, count] = withoutOurs(entry);
|
|
750
|
+
removedHere += count;
|
|
751
|
+
if (trimmed !== null)
|
|
752
|
+
kept.push(trimmed);
|
|
753
|
+
}
|
|
754
|
+
removed += removedHere;
|
|
582
755
|
if (remove) {
|
|
583
|
-
if (
|
|
756
|
+
if (removedHere > 0)
|
|
584
757
|
touched.push(event);
|
|
585
758
|
}
|
|
586
759
|
else {
|
|
587
|
-
kept.push(hookEntry(matcher, command));
|
|
760
|
+
kept.push(fromJs(hookEntry(matcher, command)));
|
|
588
761
|
touched.push(event);
|
|
589
762
|
}
|
|
590
763
|
if (kept.length > 0)
|
|
591
|
-
hooks
|
|
764
|
+
hooks.set(event, kept);
|
|
592
765
|
else
|
|
593
|
-
delete
|
|
766
|
+
hooks.delete(event);
|
|
594
767
|
}
|
|
595
|
-
return { hooks, touched };
|
|
768
|
+
return { hooks, touched, removed };
|
|
596
769
|
}
|
|
597
770
|
const EVENT_NAMES = HOOK_EVENTS.map(([event]) => event).join(' ');
|
|
598
771
|
/**
|
|
@@ -610,7 +783,7 @@ function hookPlan(path, command) {
|
|
|
610
783
|
` command: ${command}`,
|
|
611
784
|
];
|
|
612
785
|
if (existsSync(path))
|
|
613
|
-
lines.push(` backup : ${path
|
|
786
|
+
lines.push(` backup : ${backupTarget(path)}`);
|
|
614
787
|
lines.push("Existing hooks are left byte-for-byte; only bantamkit's own entries are replaced.");
|
|
615
788
|
return `${lines.join('\n')}\n`;
|
|
616
789
|
}
|
|
@@ -631,7 +804,7 @@ function removalPlan(path, events, entries) {
|
|
|
631
804
|
return ([
|
|
632
805
|
`bantamkit would remove ${entries} hook entries from ${path}`,
|
|
633
806
|
` events : ${events.join(' ')}`,
|
|
634
|
-
` backup : ${path
|
|
807
|
+
` backup : ${backupTarget(path)}`,
|
|
635
808
|
"Existing hooks are left byte-for-byte; only bantamkit's own entries are removed.",
|
|
636
809
|
].join('\n') + '\n');
|
|
637
810
|
}
|
|
@@ -661,7 +834,7 @@ export function installHooks(options = {}) {
|
|
|
661
834
|
const data = readConfig(path);
|
|
662
835
|
const current = readHooks(path, data);
|
|
663
836
|
const { hooks } = plannedHooks(current, rendered, false);
|
|
664
|
-
if (
|
|
837
|
+
if (dumpJson(asPyValue(hooks), { sortKeys: true }) === dumpJson(asPyValue(current), { sortKeys: true })) {
|
|
665
838
|
return `bantamkit hooks are already installed in ${path} and match`;
|
|
666
839
|
}
|
|
667
840
|
const tell = options.tell ?? (() => { });
|
|
@@ -681,7 +854,7 @@ export function installHooks(options = {}) {
|
|
|
681
854
|
tell(hookPlan(path, rendered));
|
|
682
855
|
}
|
|
683
856
|
const copied = backup(path);
|
|
684
|
-
data
|
|
857
|
+
data.v.set('hooks', asPyValue(hooks));
|
|
685
858
|
writeConfig(path, data);
|
|
686
859
|
const lines = [
|
|
687
860
|
`installed bantamkit hooks into ${path}`,
|
|
@@ -721,14 +894,12 @@ export function removeHooks(options = {}) {
|
|
|
721
894
|
const path = claudeSettingsPath();
|
|
722
895
|
const data = readConfig(path);
|
|
723
896
|
const current = readHooks(path, data);
|
|
724
|
-
const { hooks, touched } = plannedHooks(current, '', true);
|
|
897
|
+
const { hooks, touched, removed: entries } = plannedHooks(current, '', true);
|
|
725
898
|
if (touched.length === 0)
|
|
726
899
|
return `no bantamkit hooks are installed in ${path}`;
|
|
727
|
-
//
|
|
728
|
-
//
|
|
729
|
-
|
|
730
|
-
for (const event of touched)
|
|
731
|
-
entries += (current[event] ?? []).length - (hooks[event] ?? []).length;
|
|
900
|
+
// `entries` counts OUR COMMAND OBJECTS, not events and not matcher blocks: an event can hold
|
|
901
|
+
// more than one of ours if somebody hand-edited the file, and a block that also holds a
|
|
902
|
+
// user's hook stays while ours leaves it (#129) — the plan has to say what is actually going.
|
|
732
903
|
const tell = options.tell ?? (() => { });
|
|
733
904
|
const yes = options.yes ?? false;
|
|
734
905
|
const ask = options.ask ?? null;
|
|
@@ -746,7 +917,7 @@ export function removeHooks(options = {}) {
|
|
|
746
917
|
tell(removalPlan(path, touched, entries));
|
|
747
918
|
}
|
|
748
919
|
const copied = backup(path);
|
|
749
|
-
data
|
|
920
|
+
data.v.set('hooks', asPyValue(hooks));
|
|
750
921
|
writeConfig(path, data);
|
|
751
922
|
const lines = [`removed bantamkit hooks from ${path}`, ` events : ${touched.join(' ')}`];
|
|
752
923
|
if (copied !== null)
|
package/dist/index.d.ts
CHANGED
|
@@ -2,8 +2,8 @@ export { BantamError } from './errors.js';
|
|
|
2
2
|
export { ContractParseError, extractJson, loadContract, parseContractDocument, parseErrorMessage, REQUIRED_KEYS, schemaError, schemaRetryFeedback, validationErrorMessage, } from './contract.js';
|
|
3
3
|
export { dumpJson, fromJs, PY_NONE, parseJson, pyFloatRepr, PyJSONDecodeError, PyValueError, rawDecode, reprValue, toJs, } from './pyjson.js';
|
|
4
4
|
export type { DumpOptions, PyValue } from './pyjson.js';
|
|
5
|
-
export { absolutePath, bestMatch, iterErrors, PyJsonSchemaUnsupported, pyEqual, validate, } from './pyjsonschema.js';
|
|
6
|
-
export type { PyValidationError } from './pyjsonschema.js';
|
|
5
|
+
export { absolutePath, bestMatch, iterErrors, PyJsonSchemaRefusal, PyJsonSchemaUnsupported, pyUrlNormalize, pyEqual, validate, } from './pyjsonschema.js';
|
|
6
|
+
export type { Draft, PyValidationError } from './pyjsonschema.js';
|
|
7
7
|
export { AssetNotFound, assetsRoot, loadSchema, loadSkill, loadTool, loadToolAsset } from './assets.js';
|
|
8
8
|
export type { Tool } from './assets.js';
|
|
9
9
|
export { decodeFactBytes, FactParseError, formatFact, parseFactText, parseFrontmatter, pySplit, pyStrip, todayLocal, } from './memory/factfile.js';
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export { BantamError } from './errors.js';
|
|
2
2
|
export { ContractParseError, extractJson, loadContract, parseContractDocument, parseErrorMessage, REQUIRED_KEYS, schemaError, schemaRetryFeedback, validationErrorMessage, } from './contract.js';
|
|
3
3
|
export { dumpJson, fromJs, PY_NONE, parseJson, pyFloatRepr, PyJSONDecodeError, PyValueError, rawDecode, reprValue, toJs, } from './pyjson.js';
|
|
4
|
-
export { absolutePath, bestMatch, iterErrors, PyJsonSchemaUnsupported, pyEqual, validate, } from './pyjsonschema.js';
|
|
4
|
+
export { absolutePath, bestMatch, iterErrors, PyJsonSchemaRefusal, PyJsonSchemaUnsupported, pyUrlNormalize, pyEqual, validate, } from './pyjsonschema.js';
|
|
5
5
|
export { AssetNotFound, assetsRoot, loadSchema, loadSkill, loadTool, loadToolAsset } from './assets.js';
|
|
6
6
|
export { decodeFactBytes, FactParseError, formatFact, parseFactText, parseFrontmatter, pySplit, pyStrip, todayLocal, } from './memory/factfile.js';
|
|
7
7
|
export { constructPlain, PyScalar, resolveImplicitTag, safeDumpMapping, YamlConstructError, YamlEmitError, } from './memory/pyyaml.js';
|
package/dist/mcp/pyargs.d.ts
CHANGED
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
* ship a server whose recovery behaviour differs from the reference's on every mistyped
|
|
8
8
|
* argument, and that is a product difference, not a cosmetic one.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
10
|
+
* WHY THERE IS NO URL (job65/#185). pydantic ends each error with `For further information
|
|
11
|
+
* visit https://errors.pydantic.dev/<major.minor>/v/<type>`, naming whichever pydantic pip
|
|
12
|
+
* resolved. This port used to reproduce it with `2.13` hard-coded, and the reference printed
|
|
13
|
+
* `2.14` the day PyPI resolved 2.14 — the refusal texts differed by install date. The
|
|
14
|
+
* reference now sets `PYDANTIC_ERRORS_INCLUDE_URL=0` at import (`bantamkit.mcpserver`), so
|
|
15
|
+
* neither runtime prints the line; `tools/conformance/suites/wire.mjs` compares the two.
|
|
16
16
|
*
|
|
17
17
|
* EVERY RULE BELOW WAS MEASURED, not read off pydantic's documentation. The probes are in
|
|
18
18
|
* `scratchpad/job38-n7/probe{4,7,8,9}.py` and each drove the real stdio server:
|
|
@@ -35,6 +35,12 @@ export interface FieldSpec {
|
|
|
35
35
|
readonly optional: boolean;
|
|
36
36
|
/** `Field(le=...)`: an inclusive ceiling, checked AFTER the lax int parse succeeds. */
|
|
37
37
|
readonly le?: bigint;
|
|
38
|
+
/**
|
|
39
|
+
* `Field(ge=...)`: an inclusive floor, checked the same way. On a `dictInt` it binds the
|
|
40
|
+
* VALUES — `dict[str, Annotated[int, Field(ge=0)]]` on the reference — so the frame's
|
|
41
|
+
* location is `usage.<key>`, as the type frame's is.
|
|
42
|
+
*/
|
|
43
|
+
readonly ge?: bigint;
|
|
38
44
|
}
|
|
39
45
|
export interface ArgModel {
|
|
40
46
|
/** The pydantic model name, which is the FUNCTION name plus `Arguments`. */
|
|
@@ -60,6 +66,15 @@ export interface ArgModel {
|
|
|
60
66
|
* register a tool.
|
|
61
67
|
*/
|
|
62
68
|
export declare const OFFSET_MAXIMUM = 9007199254740991n;
|
|
69
|
+
/**
|
|
70
|
+
* `assets/tools/skill_audit.json` `budget.maximum` — the same `Number.MAX_SAFE_INTEGER` as
|
|
71
|
+
* `OFFSET_MAXIMUM`, spelled as its own constant because it is tied to a different asset
|
|
72
|
+
* and would stay where it is if the other ever moved. Bound on the reference as
|
|
73
|
+
* `Annotated[int, Field(le=SKILL_BUDGET_MAXIMUM)]` (#133, job65): until then the manifest
|
|
74
|
+
* advertised a ceiling neither runtime enforced, and `budget: 2**53` was audited as if it
|
|
75
|
+
* were a number a catalogue could be measured against.
|
|
76
|
+
*/
|
|
77
|
+
export declare const SKILL_BUDGET_MAXIMUM = 9007199254740991n;
|
|
63
78
|
/**
|
|
64
79
|
* One model per handler, mirroring `build_server`'s closures signature for signature.
|
|
65
80
|
*
|
|
@@ -122,6 +137,7 @@ export declare class PyValidationFailure extends Error {
|
|
|
122
137
|
readonly detail: string;
|
|
123
138
|
constructor(detail: string);
|
|
124
139
|
}
|
|
140
|
+
export declare function loneSurrogateArgument(args: PyValue): string | null;
|
|
125
141
|
/**
|
|
126
142
|
* `model.model_validate(arguments)` for one tool, or the exact `ValidationError` text.
|
|
127
143
|
*
|