bantamkit-mcp 0.35.7 → 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 +24 -9
- 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/assets.js +28 -4
- package/dist/cli.js +83 -24
- package/dist/contract.d.ts +0 -9
- package/dist/contract.js +51 -12
- package/dist/eventlog.d.ts +22 -2
- package/dist/eventlog.js +30 -3
- package/dist/hookadapter.js +263 -43
- package/dist/hostinstall.d.ts +2 -2
- package/dist/hostinstall.js +318 -84
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/mcp/identity.js +27 -1
- package/dist/mcp/pyargs.d.ts +22 -6
- package/dist/mcp/pyargs.js +113 -39
- package/dist/mcp/server.js +177 -41
- package/dist/mcp/status.js +11 -4
- package/dist/mcp/transport.d.ts +51 -6
- package/dist/mcp/transport.js +217 -26
- 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/pyargparse.js +48 -2
- 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/tokenledger.js +20 -4
- package/dist/updatecheck.d.ts +15 -3
- package/dist/updatecheck.js +23 -8
- package/dist/workplan.d.ts +8 -3
- package/dist/workplan.js +62 -3
- package/package.json +2 -2
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.
|
|
@@ -32,14 +33,14 @@
|
|
|
32
33
|
* launches it without the network. See `thisCommand`.
|
|
33
34
|
*/
|
|
34
35
|
import { spawnSync } from 'node:child_process';
|
|
35
|
-
import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
36
|
+
import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
36
37
|
import { homedir } from 'node:os';
|
|
37
|
-
import { dirname, join } from 'node:path';
|
|
38
|
+
import { dirname, join, resolve } from 'node:path';
|
|
38
39
|
import { fileURLToPath } from 'node:url';
|
|
39
40
|
import { currentInstall, Undetermined } from './mcp/identity.js';
|
|
40
41
|
import { bantamkitDirFor, ensureBantamkitGitignore } from './memory/store.js';
|
|
41
42
|
import { compareVersions, keptCli, keptManifest, keptPrefix, PACKAGE, runInstaller, shlexJoin } from './npminstall.js';
|
|
42
|
-
import { dumpJson, fromJs } from './pyjson.js';
|
|
43
|
+
import { dumpJson, fromJs, parseJson, PyJSONDecodeError } from './pyjson.js';
|
|
43
44
|
export { keptCli, keptPrefix };
|
|
44
45
|
/** The four hosts, in the order they print in `--help`. */
|
|
45
46
|
export const HOSTS = ['claude', 'claude-desktop', 'copilot', 'cursor'];
|
|
@@ -49,6 +50,7 @@ export const ENTRY = 'bantamkit';
|
|
|
49
50
|
export class InstallError extends Error {
|
|
50
51
|
}
|
|
51
52
|
const dumps = (value, indent = null, sortKeys = false) => dumpJson(fromJs(value), { indent, sortKeys });
|
|
53
|
+
const pyDict = (entries) => ({ t: 'dict', v: new Map(entries) });
|
|
52
54
|
/**
|
|
53
55
|
* The file `--install <host>` would write, on THIS platform.
|
|
54
56
|
*
|
|
@@ -100,25 +102,64 @@ export function entryFor(host, command, args) {
|
|
|
100
102
|
* `[1, 2, 3]`, the ONE input where the two vocabularies happen to agree, so neither suite
|
|
101
103
|
* could see it. Review measured `"hello"` and `5`.
|
|
102
104
|
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
+
* Since #118 the value is tagged by the same split `json` makes — a JSON number with no
|
|
106
|
+
* fraction and no exponent is `int`, everything else `float` — so the tag IS the name.
|
|
105
107
|
*/
|
|
106
108
|
function pyTypeName(value) {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
109
|
+
switch (value.t) {
|
|
110
|
+
case 'null':
|
|
111
|
+
return 'NoneType';
|
|
112
|
+
case 'bool':
|
|
113
|
+
return 'bool';
|
|
114
|
+
case 'int':
|
|
115
|
+
return 'int';
|
|
116
|
+
case 'float':
|
|
117
|
+
return 'float';
|
|
118
|
+
case 'str':
|
|
119
|
+
return 'str';
|
|
120
|
+
case 'list':
|
|
121
|
+
return 'list';
|
|
122
|
+
case 'dict':
|
|
123
|
+
return 'dict';
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* The first non-finite float in `value`, with where it sits, or `null`. Depth-first, in
|
|
128
|
+
* document order, so both runtimes name the same number when a file holds two.
|
|
129
|
+
*/
|
|
130
|
+
function nonFiniteIn(value, trail) {
|
|
131
|
+
if (value.t === 'float' && !Number.isFinite(value.v))
|
|
132
|
+
return { where: whereIs(trail), value: value.v };
|
|
133
|
+
if (value.t === 'dict') {
|
|
134
|
+
for (const [key, item] of value.v) {
|
|
135
|
+
const found = nonFiniteIn(item, [...trail, key]);
|
|
136
|
+
if (found !== null)
|
|
137
|
+
return found;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
else if (value.t === 'list') {
|
|
141
|
+
for (const [index, item] of value.v.entries()) {
|
|
142
|
+
const found = nonFiniteIn(item, [...trail, index]);
|
|
143
|
+
if (found !== null)
|
|
144
|
+
return found;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
/** `mcpServers.other.huge`, `servers.list[2]`: keys joined by `.`, indexes in brackets. */
|
|
150
|
+
function whereIs(trail) {
|
|
151
|
+
let out = '';
|
|
152
|
+
for (const segment of trail) {
|
|
153
|
+
if (typeof segment === 'number')
|
|
154
|
+
out += `[${segment}]`;
|
|
155
|
+
else
|
|
156
|
+
out += out === '' ? segment : `.${segment}`;
|
|
157
|
+
}
|
|
158
|
+
return out === '' ? 'the top level' : out;
|
|
118
159
|
}
|
|
119
160
|
function readConfig(path) {
|
|
120
161
|
if (!existsSync(path))
|
|
121
|
-
return
|
|
162
|
+
return pyDict();
|
|
122
163
|
let text;
|
|
123
164
|
try {
|
|
124
165
|
text = readFileSync(path, 'utf8');
|
|
@@ -127,51 +168,202 @@ function readConfig(path) {
|
|
|
127
168
|
throw new InstallError(`cannot read ${path}: ${e.message}`);
|
|
128
169
|
}
|
|
129
170
|
if (text.trim() === '')
|
|
130
|
-
return
|
|
171
|
+
return pyDict();
|
|
131
172
|
let loaded;
|
|
132
173
|
try {
|
|
133
|
-
loaded =
|
|
174
|
+
loaded = parseJson(text);
|
|
134
175
|
}
|
|
135
176
|
catch (e) {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
//
|
|
139
|
-
|
|
177
|
+
if (!(e instanceof PyJSONDecodeError))
|
|
178
|
+
throw e;
|
|
179
|
+
// The reference reports CPython's message, line and column, and so does this parser —
|
|
180
|
+
// the ruling that once covered V8's `Unexpected token …` wording retired with #118.
|
|
181
|
+
throw new InstallError(`${path} is not valid JSON, so this refuses to touch it: ${e.msg} (line ${e.lineno}, column ${e.colno})`);
|
|
140
182
|
}
|
|
141
|
-
if (loaded
|
|
183
|
+
if (loaded.t !== 'dict') {
|
|
142
184
|
throw new InstallError(`${path} holds ${pyTypeName(loaded)}, not an object; refusing to touch it`);
|
|
143
185
|
}
|
|
186
|
+
// A NUMBER JSON CANNOT CARRY IS REFUSED HERE, before anything is planned (#118). `1e400`
|
|
187
|
+
// decodes to `Infinity` and `dumpJson` — faithful to `json.dumps` — would write it back as
|
|
188
|
+
// that bare word, which no JSON parser accepts: the host then cannot read ANY of its
|
|
189
|
+
// servers, and the next run of this very command refuses its own output. The literals
|
|
190
|
+
// `NaN`/`Infinity` that CPython's decoder tolerates take the same door.
|
|
191
|
+
const found = nonFiniteIn(loaded, []);
|
|
192
|
+
if (found !== null) {
|
|
193
|
+
const spelled = dumpJson({ t: 'float', v: found.value });
|
|
194
|
+
throw new InstallError(`${path} has a number at ${found.where} that JSON cannot carry (${spelled}); refusing to touch it`);
|
|
195
|
+
}
|
|
144
196
|
return loaded;
|
|
145
197
|
}
|
|
198
|
+
/**
|
|
199
|
+
* Where the bytes land: the file ITSELF, through any symlink (#136).
|
|
200
|
+
*
|
|
201
|
+
* A dotfiles user keeps `~/.cursor/mcp.json` as a link into a repository. Writing a temp file
|
|
202
|
+
* beside the LINK and renaming it onto the link's name replaces the link with a detached
|
|
203
|
+
* regular file: the repository copy never gets the entry and the next sync reverts it without
|
|
204
|
+
* a word. `realpathSync` wants the target to exist; a link whose target is not there yet is
|
|
205
|
+
* followed by hand (the reference's `os.path.realpath` is non-strict), so the write creates
|
|
206
|
+
* the target and the link stays a link either way.
|
|
207
|
+
*/
|
|
208
|
+
function writeTarget(path) {
|
|
209
|
+
try {
|
|
210
|
+
return realpathSync(path);
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
// ENOENT: a fresh file, or a link whose target does not exist yet.
|
|
214
|
+
}
|
|
215
|
+
let at = path;
|
|
216
|
+
for (let hops = 0; hops < 40; hops += 1) {
|
|
217
|
+
let link;
|
|
218
|
+
try {
|
|
219
|
+
if (!lstatSync(at).isSymbolicLink())
|
|
220
|
+
return at;
|
|
221
|
+
link = readlinkSync(at);
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
return at;
|
|
225
|
+
}
|
|
226
|
+
at = resolve(dirname(at), link);
|
|
227
|
+
}
|
|
228
|
+
return at;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Write the whole document, atomically, leaving no half-file behind on a crash — and atomic
|
|
232
|
+
* ONTO THE REAL FILE: the temp file is made beside `writeTarget(path)` and renamed onto it, so
|
|
233
|
+
* a symlinked config is written through rather than swapped for a regular file.
|
|
234
|
+
*/
|
|
146
235
|
function writeConfig(path, data) {
|
|
147
236
|
mkdirSync(dirname(path), { recursive: true });
|
|
148
|
-
const
|
|
237
|
+
const target = writeTarget(path);
|
|
238
|
+
if (target !== path)
|
|
239
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
240
|
+
const tmp = `${target}.bantamkit-tmp`;
|
|
149
241
|
// The mode of the file being replaced, carried onto its replacement. A host config holds
|
|
150
242
|
// API keys in per-server `env` blocks, and a user who chmod'ed theirs to 0600 had it come
|
|
151
243
|
// back 0644, because a fresh temp file gets the process umask. Measured on both runtimes
|
|
152
244
|
// before this line existed.
|
|
153
|
-
const mode = existsSync(
|
|
245
|
+
const mode = existsSync(target) ? statSync(target).mode & 0o7777 : null;
|
|
154
246
|
try {
|
|
155
|
-
writeFileSync(tmp, `${
|
|
247
|
+
writeFileSync(tmp, `${hostJsonText(data, detectedIndent(target))}\n`, 'utf8');
|
|
156
248
|
if (mode !== null)
|
|
157
249
|
chmodSync(tmp, mode);
|
|
158
|
-
renameSync(tmp,
|
|
250
|
+
renameSync(tmp, target);
|
|
159
251
|
}
|
|
160
252
|
catch (e) {
|
|
161
253
|
rmSync(tmp, { force: true });
|
|
162
254
|
throw new InstallError(`cannot write ${path}: ${e.message}`);
|
|
163
255
|
}
|
|
164
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
|
+
}
|
|
165
319
|
/** `date.today().isoformat()` — the LOCAL date, which is what the reference takes. */
|
|
166
320
|
function today() {
|
|
167
321
|
const now = new Date();
|
|
168
322
|
const pad = (n) => String(n).padStart(2, '0');
|
|
169
323
|
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
|
170
324
|
}
|
|
325
|
+
/** `os.path.lexists`: there is SOMETHING at this name, a dangling link included. */
|
|
326
|
+
function lexists(path) {
|
|
327
|
+
try {
|
|
328
|
+
lstatSync(path);
|
|
329
|
+
return true;
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* The name the next backup of `path` gets: the first one that is not taken.
|
|
337
|
+
*
|
|
338
|
+
* `<name>.backup-<date>` when nothing holds that name, then `<name>.backup-<date>.1`, `.2`,
|
|
339
|
+
* ... on the same day. The plan lines print this same name before the write, so what the
|
|
340
|
+
* plan says and what the write does are one computation.
|
|
341
|
+
*/
|
|
342
|
+
function backupTarget(path) {
|
|
343
|
+
const stamp = today();
|
|
344
|
+
let candidate = `${path}.backup-${stamp}`;
|
|
345
|
+
for (let n = 1; lexists(candidate); n += 1)
|
|
346
|
+
candidate = `${path}.backup-${stamp}.${n}`;
|
|
347
|
+
return candidate;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Copy the file aside before changing it. Returns the copy, or `null` if there was none.
|
|
351
|
+
*
|
|
352
|
+
* Dated, so a person looking at their own config directory a week later can tell when it
|
|
353
|
+
* happened — and NEVER OVERWRITTEN (#138). Until job65 a second run on the same day wrote the
|
|
354
|
+
* same `<name>.backup-<date>` on purpose, "rather than growing a pile nobody prunes"; the
|
|
355
|
+
* README's undo promise is that the backup restores the file as it was BEFORE bantamkit
|
|
356
|
+
* touched it, and the second run was overwriting exactly that copy with the first run's
|
|
357
|
+
* output. A pile of dated copies is the price of an undo that works; the counter suffix is
|
|
358
|
+
* the price of keeping the documented name for the first one.
|
|
359
|
+
*
|
|
360
|
+
* `copyFileSync` follows a symlink on the source, so the copy holds the REAL file's bytes and
|
|
361
|
+
* sits beside the path the report names.
|
|
362
|
+
*/
|
|
171
363
|
function backup(path) {
|
|
172
364
|
if (!existsSync(path))
|
|
173
365
|
return null;
|
|
174
|
-
const target =
|
|
366
|
+
const target = backupTarget(path);
|
|
175
367
|
try {
|
|
176
368
|
copyFileSync(path, target);
|
|
177
369
|
}
|
|
@@ -218,25 +410,26 @@ export function install(host, command, args, force = false) {
|
|
|
218
410
|
const key = configKey(host);
|
|
219
411
|
const wanted = entryFor(host, command, args);
|
|
220
412
|
const data = readConfig(path);
|
|
221
|
-
const raw = data
|
|
222
|
-
if (raw !== undefined &&
|
|
413
|
+
const raw = data.v.get(key);
|
|
414
|
+
if (raw !== undefined && raw.t !== 'dict') {
|
|
223
415
|
throw new InstallError(`${path} has a '${key}' that is not an object; refusing to touch it`);
|
|
224
416
|
}
|
|
225
|
-
const servers =
|
|
226
|
-
|
|
227
|
-
|
|
417
|
+
const servers = raw ?? pyDict();
|
|
418
|
+
// MEMBERSHIP, not a null test: `"bantamkit": null` is an entry (`PY_NONE`, not `undefined`).
|
|
419
|
+
const existing = servers.v.get(ENTRY);
|
|
420
|
+
if (existing !== undefined && dumpJson(existing, { sortKeys: true }) === dumps(wanted, null, true)) {
|
|
228
421
|
return `bantamkit is already installed in ${host} and matches\n file : ${path}`;
|
|
229
422
|
}
|
|
230
423
|
if (existing !== undefined && !force) {
|
|
231
424
|
throw new InstallError(`${host} already has a bantamkit entry with different settings\n` +
|
|
232
425
|
` file : ${path}\n` +
|
|
233
|
-
` current : ${
|
|
426
|
+
` current : ${dumpJson(existing, { sortKeys: true })}\n` +
|
|
234
427
|
` proposed: ${dumps(wanted, null, true)}\n` +
|
|
235
428
|
' re-run with --force to replace it');
|
|
236
429
|
}
|
|
237
430
|
const copied = backup(path);
|
|
238
|
-
servers
|
|
239
|
-
data
|
|
431
|
+
servers.v.set(ENTRY, fromJs(wanted));
|
|
432
|
+
data.v.set(key, servers);
|
|
240
433
|
writeConfig(path, data);
|
|
241
434
|
const lines = [
|
|
242
435
|
`installed bantamkit into ${host}`,
|
|
@@ -494,10 +687,13 @@ function hookEntry(matcher, command) {
|
|
|
494
687
|
];
|
|
495
688
|
return entry;
|
|
496
689
|
}
|
|
497
|
-
/**
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
690
|
+
/** `{"event": [...]}` as a `PyValue`, for the one comparison and the one write that need it. */
|
|
691
|
+
const asPyValue = (table) => ({ t: 'dict', v: new Map([...table].map(([event, entries]) => [event, { t: 'list', v: entries }])) });
|
|
692
|
+
/** The `str` behind a key, or `undefined` when the key is absent or holds something else. */
|
|
693
|
+
const strAt = (dict, key) => {
|
|
694
|
+
const value = dict.v.get(key);
|
|
695
|
+
return value !== undefined && value.t === 'str' ? value.v : undefined;
|
|
696
|
+
};
|
|
501
697
|
/**
|
|
502
698
|
* The two command spellings bantamkit ever wrote, for entries that predate the marker.
|
|
503
699
|
*
|
|
@@ -516,13 +712,17 @@ function isObject(value) {
|
|
|
516
712
|
* third-party script with the word in its filename was claimed as ours and deleted.
|
|
517
713
|
*/
|
|
518
714
|
function isLegacyOurs(hook) {
|
|
519
|
-
if (hook
|
|
715
|
+
if (strAt(hook, 'type') !== 'command')
|
|
520
716
|
return false;
|
|
521
|
-
const command = hook
|
|
522
|
-
if (
|
|
717
|
+
const command = strAt(hook, 'command');
|
|
718
|
+
if (command === undefined || !command.includes('bantamkit'))
|
|
523
719
|
return false;
|
|
524
720
|
return command.includes('bantamkit-hook') || command === '--hook' || command.endsWith(' --hook');
|
|
525
721
|
}
|
|
722
|
+
/** ONE inner hook object: ours by marker, or by one of the two legacy spellings. */
|
|
723
|
+
function isOurHook(hook) {
|
|
724
|
+
return hook.t === 'dict' && (hook.v.has(HOOK_MARKER_KEY) || isLegacyOurs(hook));
|
|
725
|
+
}
|
|
526
726
|
/**
|
|
527
727
|
* RULING Q3.6's idempotence rule, RE-DECIDED ON THE ENTRY ALONE (J62-22).
|
|
528
728
|
*
|
|
@@ -539,60 +739,96 @@ function isLegacyOurs(hook) {
|
|
|
539
739
|
* shapes we wrote before the marker existed. Where any binary lives is not consulted.
|
|
540
740
|
*/
|
|
541
741
|
function isOurs(entry) {
|
|
542
|
-
if (
|
|
742
|
+
if (entry.t !== 'dict')
|
|
543
743
|
return false;
|
|
544
|
-
const hooks = entry
|
|
545
|
-
if (
|
|
744
|
+
const hooks = entry.v.get('hooks');
|
|
745
|
+
if (hooks === undefined || hooks.t !== 'list')
|
|
546
746
|
return false;
|
|
547
|
-
return hooks.some(
|
|
747
|
+
return hooks.v.some(isOurHook);
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* `entry` with bantamkit's own command objects taken out, and how many came out.
|
|
751
|
+
*
|
|
752
|
+
* THE UNIT OF OWNERSHIP IS THE COMMAND OBJECT, NOT THE MATCHER BLOCK (#129). `isOurs` answers
|
|
753
|
+
* "does this block hold one of ours", and dropping the whole block on that answer deleted a
|
|
754
|
+
* user's `notify-send` that shared the block with ours — after a plan that had just promised
|
|
755
|
+
* their hooks were left byte for byte. So: a block that is not ours comes back untouched (the
|
|
756
|
+
* same object, nothing removed); a block that holds ours loses ONLY those objects and keeps
|
|
757
|
+
* every other key it had; and a block left with no hooks at all is `null`, because
|
|
758
|
+
* `{"matcher": ..., "hooks": []}` is an artefact nobody wrote.
|
|
759
|
+
*/
|
|
760
|
+
function withoutOurs(entry) {
|
|
761
|
+
if (!isOurs(entry) || entry.t !== 'dict')
|
|
762
|
+
return [entry, 0];
|
|
763
|
+
const hooks = entry.v.get('hooks');
|
|
764
|
+
if (hooks === undefined || hooks.t !== 'list')
|
|
765
|
+
return [entry, 0];
|
|
766
|
+
const kept = hooks.v.filter((hook) => !isOurHook(hook));
|
|
767
|
+
const removed = hooks.v.length - kept.length;
|
|
768
|
+
if (kept.length === 0)
|
|
769
|
+
return [null, removed];
|
|
770
|
+
const trimmed = pyDict(entry.v);
|
|
771
|
+
trimmed.v.set('hooks', { t: 'list', v: kept });
|
|
772
|
+
return [trimmed, removed];
|
|
548
773
|
}
|
|
549
774
|
/** The `hooks` object, validated. Refuses by name rather than crashing on somebody's file. */
|
|
550
775
|
function readHooks(path, data) {
|
|
551
|
-
const raw = data
|
|
776
|
+
const raw = data.v.get('hooks');
|
|
552
777
|
if (raw === undefined)
|
|
553
|
-
return
|
|
554
|
-
if (raw
|
|
778
|
+
return new Map();
|
|
779
|
+
if (raw.t !== 'dict') {
|
|
555
780
|
throw new InstallError(`${path} has a 'hooks' that is not an object; refusing to touch it`);
|
|
556
781
|
}
|
|
557
|
-
const hooks =
|
|
558
|
-
for (const [event, entries] of
|
|
559
|
-
if (
|
|
782
|
+
const hooks = new Map();
|
|
783
|
+
for (const [event, entries] of raw.v) {
|
|
784
|
+
if (entries.t !== 'list') {
|
|
560
785
|
throw new InstallError(`${path} has a 'hooks.${event}' that is not a list; refusing to touch it`);
|
|
561
786
|
}
|
|
562
|
-
hooks
|
|
787
|
+
hooks.set(event, [...entries.v]);
|
|
563
788
|
}
|
|
564
789
|
return hooks;
|
|
565
790
|
}
|
|
566
791
|
/**
|
|
567
|
-
* The `hooks` object this run wants, built from whatever is there now
|
|
792
|
+
* The `hooks` object this run wants, built from whatever is there now, the events that change,
|
|
793
|
+
* and how many of OUR command objects came out.
|
|
568
794
|
*
|
|
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
|
|
795
|
+
* ONE PASS OVER THE SEVEN EVENTS AND NOTHING ELSE: every event keeps its non-bantamkit hooks in
|
|
796
|
+
* their existing order and their existing blocks, ours is appended after them, and an event
|
|
797
|
+
* left with none loses its key rather than holding an empty list — `install.mjs`'s rule, kept
|
|
798
|
+
* because a settings file full of empty arrays is a worse artefact than one with nothing in
|
|
799
|
+
* it. A block is dropped only when taking ours out of it leaves it empty (`withoutOurs`).
|
|
573
800
|
*
|
|
574
801
|
* Events outside the seven are not read, not reordered and not removed.
|
|
575
802
|
*/
|
|
576
803
|
function plannedHooks(current, command, remove) {
|
|
577
|
-
const hooks =
|
|
804
|
+
const hooks = new Map(current);
|
|
578
805
|
const touched = [];
|
|
806
|
+
let removed = 0;
|
|
579
807
|
for (const [event, matcher] of HOOK_EVENTS) {
|
|
580
|
-
const before = hooks
|
|
581
|
-
const kept =
|
|
808
|
+
const before = hooks.get(event) ?? [];
|
|
809
|
+
const kept = [];
|
|
810
|
+
let removedHere = 0;
|
|
811
|
+
for (const entry of before) {
|
|
812
|
+
const [trimmed, count] = withoutOurs(entry);
|
|
813
|
+
removedHere += count;
|
|
814
|
+
if (trimmed !== null)
|
|
815
|
+
kept.push(trimmed);
|
|
816
|
+
}
|
|
817
|
+
removed += removedHere;
|
|
582
818
|
if (remove) {
|
|
583
|
-
if (
|
|
819
|
+
if (removedHere > 0)
|
|
584
820
|
touched.push(event);
|
|
585
821
|
}
|
|
586
822
|
else {
|
|
587
|
-
kept.push(hookEntry(matcher, command));
|
|
823
|
+
kept.push(fromJs(hookEntry(matcher, command)));
|
|
588
824
|
touched.push(event);
|
|
589
825
|
}
|
|
590
826
|
if (kept.length > 0)
|
|
591
|
-
hooks
|
|
827
|
+
hooks.set(event, kept);
|
|
592
828
|
else
|
|
593
|
-
delete
|
|
829
|
+
hooks.delete(event);
|
|
594
830
|
}
|
|
595
|
-
return { hooks, touched };
|
|
831
|
+
return { hooks, touched, removed };
|
|
596
832
|
}
|
|
597
833
|
const EVENT_NAMES = HOOK_EVENTS.map(([event]) => event).join(' ');
|
|
598
834
|
/**
|
|
@@ -610,7 +846,7 @@ function hookPlan(path, command) {
|
|
|
610
846
|
` command: ${command}`,
|
|
611
847
|
];
|
|
612
848
|
if (existsSync(path))
|
|
613
|
-
lines.push(` backup : ${path
|
|
849
|
+
lines.push(` backup : ${backupTarget(path)}`);
|
|
614
850
|
lines.push("Existing hooks are left byte-for-byte; only bantamkit's own entries are replaced.");
|
|
615
851
|
return `${lines.join('\n')}\n`;
|
|
616
852
|
}
|
|
@@ -631,7 +867,7 @@ function removalPlan(path, events, entries) {
|
|
|
631
867
|
return ([
|
|
632
868
|
`bantamkit would remove ${entries} hook entries from ${path}`,
|
|
633
869
|
` events : ${events.join(' ')}`,
|
|
634
|
-
` backup : ${path
|
|
870
|
+
` backup : ${backupTarget(path)}`,
|
|
635
871
|
"Existing hooks are left byte-for-byte; only bantamkit's own entries are removed.",
|
|
636
872
|
].join('\n') + '\n');
|
|
637
873
|
}
|
|
@@ -651,8 +887,8 @@ function removalPlan(path, events, entries) {
|
|
|
651
887
|
* 5. dated backup, then one atomic write.
|
|
652
888
|
*
|
|
653
889
|
* The write itself is `writeConfig` — the same temp-file replace, the same carried mode, the
|
|
654
|
-
* same
|
|
655
|
-
* 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).
|
|
656
892
|
*/
|
|
657
893
|
export function installHooks(options = {}) {
|
|
658
894
|
const { command, args } = thisCommand(options);
|
|
@@ -661,7 +897,7 @@ export function installHooks(options = {}) {
|
|
|
661
897
|
const data = readConfig(path);
|
|
662
898
|
const current = readHooks(path, data);
|
|
663
899
|
const { hooks } = plannedHooks(current, rendered, false);
|
|
664
|
-
if (
|
|
900
|
+
if (dumpJson(asPyValue(hooks), { sortKeys: true }) === dumpJson(asPyValue(current), { sortKeys: true })) {
|
|
665
901
|
return `bantamkit hooks are already installed in ${path} and match`;
|
|
666
902
|
}
|
|
667
903
|
const tell = options.tell ?? (() => { });
|
|
@@ -681,7 +917,7 @@ export function installHooks(options = {}) {
|
|
|
681
917
|
tell(hookPlan(path, rendered));
|
|
682
918
|
}
|
|
683
919
|
const copied = backup(path);
|
|
684
|
-
data
|
|
920
|
+
data.v.set('hooks', asPyValue(hooks));
|
|
685
921
|
writeConfig(path, data);
|
|
686
922
|
const lines = [
|
|
687
923
|
`installed bantamkit hooks into ${path}`,
|
|
@@ -721,14 +957,12 @@ export function removeHooks(options = {}) {
|
|
|
721
957
|
const path = claudeSettingsPath();
|
|
722
958
|
const data = readConfig(path);
|
|
723
959
|
const current = readHooks(path, data);
|
|
724
|
-
const { hooks, touched } = plannedHooks(current, '', true);
|
|
960
|
+
const { hooks, touched, removed: entries } = plannedHooks(current, '', true);
|
|
725
961
|
if (touched.length === 0)
|
|
726
962
|
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;
|
|
963
|
+
// `entries` counts OUR COMMAND OBJECTS, not events and not matcher blocks: an event can hold
|
|
964
|
+
// more than one of ours if somebody hand-edited the file, and a block that also holds a
|
|
965
|
+
// user's hook stays while ours leaves it (#129) — the plan has to say what is actually going.
|
|
732
966
|
const tell = options.tell ?? (() => { });
|
|
733
967
|
const yes = options.yes ?? false;
|
|
734
968
|
const ask = options.ask ?? null;
|
|
@@ -746,7 +980,7 @@ export function removeHooks(options = {}) {
|
|
|
746
980
|
tell(removalPlan(path, touched, entries));
|
|
747
981
|
}
|
|
748
982
|
const copied = backup(path);
|
|
749
|
-
data
|
|
983
|
+
data.v.set('hooks', asPyValue(hooks));
|
|
750
984
|
writeConfig(path, data);
|
|
751
985
|
const lines = [`removed bantamkit hooks from ${path}`, ` events : ${touched.join(' ')}`];
|
|
752
986
|
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/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);
|