bantamkit-mcp 0.27.0 → 0.29.0
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 +22 -1
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +72 -9
- package/dist/hostinstall.d.ts +36 -0
- package/dist/hostinstall.js +247 -0
- package/dist/mcp/identity.d.ts +34 -1
- package/dist/mcp/identity.js +32 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -40,7 +40,28 @@ float* below — it is the failure this package makes easiest to hit and hardest
|
|
|
40
40
|
|
|
41
41
|
### Connect it to a host
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
**One command, and it writes the entry for you:**
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
npx -y bantamkit-mcp --install claude # Claude Code
|
|
47
|
+
npx -y bantamkit-mcp --install claude-desktop # Claude Desktop
|
|
48
|
+
npx -y bantamkit-mcp --install copilot # GitHub Copilot in VS Code
|
|
49
|
+
npx -y bantamkit-mcp --install cursor # Cursor
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`--install claude` runs `claude mcp add` rather than editing `~/.claude.json` directly:
|
|
53
|
+
that file is the host's, and it carries state that is not MCP configuration. What happens on
|
|
54
|
+
a second run there is Claude Code's decision, not this command's, and `--force` does not
|
|
55
|
+
reach it.
|
|
56
|
+
|
|
57
|
+
**For the other three**, which are edited directly: it never prompts. A second run that
|
|
58
|
+
finds its own entry says so and changes nothing; an entry that differs is refused, printed
|
|
59
|
+
beside the one it would write, and replaced only with `--force`. Every write backs the file
|
|
60
|
+
up first as `<name>.backup-<date>`, preserves the file's permissions, and a file that does
|
|
61
|
+
not parse is refused rather than replaced.
|
|
62
|
+
|
|
63
|
+
The rest of this section is what those commands write, for anyone who would rather do it by
|
|
64
|
+
hand. Every host runs the same command; only the file and the key around it change.
|
|
44
65
|
|
|
45
66
|
**Claude Code** — one command, no file to edit. `-s user` makes it available in every
|
|
46
67
|
project; drop it for this project only.
|
package/dist/cli.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { type Host } from './hostinstall.js';
|
|
2
3
|
export interface Options {
|
|
3
4
|
k: number;
|
|
4
5
|
indexBudget: number;
|
|
@@ -7,6 +8,8 @@ export interface Options {
|
|
|
7
8
|
assetsRoot: boolean;
|
|
8
9
|
mcpReport: boolean;
|
|
9
10
|
statusline: boolean;
|
|
11
|
+
install: Host | null;
|
|
12
|
+
force: boolean;
|
|
10
13
|
}
|
|
11
14
|
/** `_parse_args`, arm for arm, including the mutually exclusive group and `-h`. */
|
|
12
15
|
export declare function parseArgs(argv: readonly string[]): Options;
|
package/dist/cli.js
CHANGED
|
@@ -51,15 +51,18 @@
|
|
|
51
51
|
* reaches for is answered on the wire instead, by `build_identity`. `runtime-ts/README.md`
|
|
52
52
|
* and `docs/install.md` carry the same correction in their own words.
|
|
53
53
|
*/
|
|
54
|
-
import { readFileSync
|
|
54
|
+
import { readFileSync } from 'node:fs';
|
|
55
55
|
import { assetsRoot } from './assets.js';
|
|
56
56
|
import { BantamError } from './errors.js';
|
|
57
57
|
import { buildReport, resolveEventLogPath } from './mcpreport.js';
|
|
58
58
|
import { Memory } from './memory/component.js';
|
|
59
59
|
import { DEFAULT_INDEX_BUDGET } from './memory/store.js';
|
|
60
|
+
import { packFileCount } from './mcp/identity.js';
|
|
60
61
|
import { buildServer } from './mcp/server.js';
|
|
61
62
|
import { RawStdioTransport } from './mcp/transport.js';
|
|
62
|
-
import { ArgvError, formatHelp, formatUsageBlock, HelpRequested, helpWidth, parseArgs as parseWithSpec, pyIntStrict, } from './pyargparse.js';
|
|
63
|
+
import { ArgumentTypeError, ArgvError, formatHelp, formatUsageBlock, HelpRequested, helpWidth, parseArgs as parseWithSpec, pyIntStrict, } from './pyargparse.js';
|
|
64
|
+
import { pyRepr } from './memory/pyfs.js';
|
|
65
|
+
import { HOSTS, install as installHost, InstallError, thisCommand } from './hostinstall.js';
|
|
63
66
|
import { statusLine } from './statusline.js';
|
|
64
67
|
/** `SystemExit("...")`: the message on stderr, exit 1. */
|
|
65
68
|
class Refusal extends Error {
|
|
@@ -150,6 +153,40 @@ const PARSER = {
|
|
|
150
153
|
help: 'print one status line for a host status bar, then exit',
|
|
151
154
|
defaultValue: false,
|
|
152
155
|
},
|
|
156
|
+
// THE SAME PLACE AND THE SAME REASON AS THE TWO ABOVE, and the position is copied from
|
|
157
|
+
// the reference rather than chosen: argparse prints optionals in registration order, so
|
|
158
|
+
// this line decides where `[--install {...}]` sits in the generated usage. After
|
|
159
|
+
// `--statusline` leaves the FIRST line of the 80-column usage — pinned by the `cli`
|
|
160
|
+
// conformance suite — byte-identical.
|
|
161
|
+
//
|
|
162
|
+
// `choices` is NOT ported for ordinary options (see pyargparse.ts's not-ported list), and
|
|
163
|
+
// porting it for one flag would be a feature grown to serve a caller. The two things
|
|
164
|
+
// `choices` is visible through are reproduced directly instead: the metavar argparse
|
|
165
|
+
// builds, spelled out here, and the invalid-value sentence, thrown as an
|
|
166
|
+
// `ArgumentTypeError` so it prints VERBATIM after `argument --install: ` exactly as
|
|
167
|
+
// `_check_value` does.
|
|
168
|
+
{
|
|
169
|
+
optionStrings: ['--install'],
|
|
170
|
+
dest: 'install',
|
|
171
|
+
metavar: `{${HOSTS.join(',')}}`,
|
|
172
|
+
kind: 'store',
|
|
173
|
+
help: "wire this server into a host's MCP configuration, then exit",
|
|
174
|
+
convert: (raw) => {
|
|
175
|
+
if (!HOSTS.includes(raw)) {
|
|
176
|
+
throw new ArgumentTypeError(`invalid choice: ${pyRepr(raw)} (choose from ${HOSTS.join(', ')})`);
|
|
177
|
+
}
|
|
178
|
+
return raw;
|
|
179
|
+
},
|
|
180
|
+
typeName: 'str',
|
|
181
|
+
defaultValue: null,
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
optionStrings: ['--force'],
|
|
185
|
+
dest: 'force',
|
|
186
|
+
kind: 'storeTrue',
|
|
187
|
+
help: 'with --install, replace an existing bantamkit entry',
|
|
188
|
+
defaultValue: false,
|
|
189
|
+
},
|
|
153
190
|
{
|
|
154
191
|
optionStrings: ['--store'],
|
|
155
192
|
dest: 'store',
|
|
@@ -165,7 +202,10 @@ const PARSER = {
|
|
|
165
202
|
defaultValue: null,
|
|
166
203
|
},
|
|
167
204
|
],
|
|
168
|
-
|
|
205
|
+
// `--store`/`--start` moved from 6/7 to 8/9 when `--install` and `--force` were added
|
|
206
|
+
// ahead of them. These are POSITIONS, not names, so adding an action above the group and
|
|
207
|
+
// leaving this line alone would silently make two unrelated flags mutually exclusive.
|
|
208
|
+
groups: [[8, 9]],
|
|
169
209
|
};
|
|
170
210
|
/** `_parse_args`, arm for arm, including the mutually exclusive group and `-h`. */
|
|
171
211
|
export function parseArgs(argv) {
|
|
@@ -178,6 +218,8 @@ export function parseArgs(argv) {
|
|
|
178
218
|
assetsRoot: values['assets_root'],
|
|
179
219
|
mcpReport: values['mcp_report'],
|
|
180
220
|
statusline: values['statusline'],
|
|
221
|
+
install: values['install'],
|
|
222
|
+
force: values['force'],
|
|
181
223
|
};
|
|
182
224
|
}
|
|
183
225
|
/** `_build_memory`, including the two refusals argparse cannot express. */
|
|
@@ -207,12 +249,10 @@ async function main(argv) {
|
|
|
207
249
|
const options = parseArgs(argv);
|
|
208
250
|
if (options.assetsRoot) {
|
|
209
251
|
const root = assetsRoot();
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
}
|
|
215
|
-
process.stdout.write(`${root}\n${files} files\n`);
|
|
252
|
+
// `packFileCount`, not a second walk: the count printed here and the count
|
|
253
|
+
// `build_identity` reports must be the same number for the same pack, and on a
|
|
254
|
+
// `pip install` the unfiltered version made one process contradict itself.
|
|
255
|
+
process.stdout.write(`${root}\n${packFileCount(root)} files\n`);
|
|
216
256
|
return 0;
|
|
217
257
|
}
|
|
218
258
|
if (options.mcpReport) {
|
|
@@ -237,6 +277,29 @@ async function main(argv) {
|
|
|
237
277
|
process.stdout.write(`${statusLine(process.env, options.store, options.start)}\n`);
|
|
238
278
|
return 0;
|
|
239
279
|
}
|
|
280
|
+
// ORDER IS OBSERVABLE, and it is the reference's order. `main` in `mcpserver.py` checks
|
|
281
|
+
// assets_root, then mcp_report, then statusline, then install, then force — so
|
|
282
|
+
// `--mcp-report --install cursor` prints a report and writes NOTHING. Dispatching install
|
|
283
|
+
// earlier here made the same argv write a file on one runtime and not the other: one
|
|
284
|
+
// command line, two different states on the user's disk. Reviewed and moved.
|
|
285
|
+
if (options.install !== null && options.install !== undefined) {
|
|
286
|
+
// Before any store or transport exists, the shape `--assets-root` established.
|
|
287
|
+
const { command, args } = thisCommand();
|
|
288
|
+
try {
|
|
289
|
+
process.stdout.write(`${installHost(options.install, command, args, options.force)}\n`);
|
|
290
|
+
}
|
|
291
|
+
catch (e) {
|
|
292
|
+
if (!(e instanceof InstallError))
|
|
293
|
+
throw e;
|
|
294
|
+
process.stderr.write(`error: ${e.message}\n`);
|
|
295
|
+
return 1;
|
|
296
|
+
}
|
|
297
|
+
return 0;
|
|
298
|
+
}
|
|
299
|
+
// `--force` alone is a typo with a plausible reading — somebody meant to install and
|
|
300
|
+
// dropped the flag that says where. Refusing names the missing half.
|
|
301
|
+
if (options.force)
|
|
302
|
+
throw new Refusal('--force is only meaningful with --install');
|
|
240
303
|
const memory = buildMemory(options);
|
|
241
304
|
const wire = new RawStdioTransport();
|
|
242
305
|
const server = buildServer(memory, wire, version());
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** The four hosts, in the order they print in `--help`. */
|
|
2
|
+
export declare const HOSTS: readonly ["claude", "claude-desktop", "copilot", "cursor"];
|
|
3
|
+
export type Host = (typeof HOSTS)[number];
|
|
4
|
+
/** The entry name written into every host. */
|
|
5
|
+
export declare const ENTRY = "bantamkit";
|
|
6
|
+
/** A refusal, carrying the sentence the operator should read. Never a stack. */
|
|
7
|
+
export declare class InstallError extends Error {
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* The file `--install <host>` would write, on THIS platform.
|
|
11
|
+
*
|
|
12
|
+
* Resolved from `homedir()` and `APPDATA` and nothing else — point `HOME`/`USERPROFILE` at a
|
|
13
|
+
* temporary directory and every path moves with it, which is what makes the tests possible.
|
|
14
|
+
*
|
|
15
|
+
* `claude` has no entry here: its arm shells out to `claude mcp add` and never names a path.
|
|
16
|
+
*/
|
|
17
|
+
export declare function hostConfigPath(host: Host): string;
|
|
18
|
+
/** `servers` in VS Code, `mcpServers` everywhere else — the word that silently loses entries. */
|
|
19
|
+
export declare function configKey(host: Host): string;
|
|
20
|
+
/** The server entry, in the shape the host expects. `type` only where the host documents it. */
|
|
21
|
+
export declare function entryFor(host: Host, command: string, args: string[]): Record<string, unknown>;
|
|
22
|
+
/** Register this server with `host`. Returns the report to print; throws `InstallError`. */
|
|
23
|
+
export declare function install(host: Host, command: string, args: string[], force?: boolean): string;
|
|
24
|
+
/**
|
|
25
|
+
* What a host should run to get THIS server.
|
|
26
|
+
*
|
|
27
|
+
* `npx -y bantamkit-mcp` and not this process's own path, and the two are not the same
|
|
28
|
+
* thing: a global install, an `npx` cache entry and a checkout all have different paths,
|
|
29
|
+
* while the published name resolves the same everywhere. `-y` because `npx` historically
|
|
30
|
+
* prompts before installing a package it has not seen, and stdin here is the JSON-RPC
|
|
31
|
+
* channel — a prompt that ate one frame would look like a server that lost a request.
|
|
32
|
+
*/
|
|
33
|
+
export declare function thisCommand(): {
|
|
34
|
+
command: string;
|
|
35
|
+
args: string[];
|
|
36
|
+
};
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire this server into an MCP host's configuration — `--install <host>`.
|
|
3
|
+
*
|
|
4
|
+
* The Node half of `runtime-py/src/bantamkit/hostinstall.py`, sentence for sentence. Read
|
|
5
|
+
* that file for WHY each decision is what it is; this header records only what porting it
|
|
6
|
+
* cost.
|
|
7
|
+
*
|
|
8
|
+
* EVERY OPERATOR-VISIBLE STRING IS COMPARED BYTE FOR BYTE against the reference, so two
|
|
9
|
+
* things that look like implementation detail are not:
|
|
10
|
+
*
|
|
11
|
+
* 1. The JSON in the conflict message goes through `dumpJson(fromJs(...), {sortKeys})`,
|
|
12
|
+
* never `JSON.stringify`. Python writes `{"args": [], "command": "OLD"}` and
|
|
13
|
+
* `JSON.stringify` writes `{"args":[],"command":"OLD"}` — same object, different
|
|
14
|
+
* bytes, and the separators are the whole difference.
|
|
15
|
+
* 2. The file is written with the same helper at `indent: 2`, which also reproduces
|
|
16
|
+
* `ensure_ascii`. A home directory with a non-ASCII name would otherwise make the two
|
|
17
|
+
* runtimes write different bytes for the same install.
|
|
18
|
+
*
|
|
19
|
+
* WHAT IS RULED DIFFERENT, and it is one field: the COMMAND. This side records
|
|
20
|
+
* `npx -y bantamkit-mcp`; the reference records its own console script. The thing installed
|
|
21
|
+
* should be the thing that answers, and neither side should send a host looking for the
|
|
22
|
+
* other's runtime. `docs/porting.md` carries the row.
|
|
23
|
+
*/
|
|
24
|
+
import { spawnSync } from 'node:child_process';
|
|
25
|
+
import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
26
|
+
import { homedir } from 'node:os';
|
|
27
|
+
import { dirname, join } from 'node:path';
|
|
28
|
+
import { dumpJson, fromJs } from './pyjson.js';
|
|
29
|
+
/** The four hosts, in the order they print in `--help`. */
|
|
30
|
+
export const HOSTS = ['claude', 'claude-desktop', 'copilot', 'cursor'];
|
|
31
|
+
/** The entry name written into every host. */
|
|
32
|
+
export const ENTRY = 'bantamkit';
|
|
33
|
+
/** A refusal, carrying the sentence the operator should read. Never a stack. */
|
|
34
|
+
export class InstallError extends Error {
|
|
35
|
+
}
|
|
36
|
+
const dumps = (value, indent = null, sortKeys = false) => dumpJson(fromJs(value), { indent, sortKeys });
|
|
37
|
+
/**
|
|
38
|
+
* The file `--install <host>` would write, on THIS platform.
|
|
39
|
+
*
|
|
40
|
+
* Resolved from `homedir()` and `APPDATA` and nothing else — point `HOME`/`USERPROFILE` at a
|
|
41
|
+
* temporary directory and every path moves with it, which is what makes the tests possible.
|
|
42
|
+
*
|
|
43
|
+
* `claude` has no entry here: its arm shells out to `claude mcp add` and never names a path.
|
|
44
|
+
*/
|
|
45
|
+
export function hostConfigPath(host) {
|
|
46
|
+
const home = homedir();
|
|
47
|
+
const appdata = process.env.APPDATA;
|
|
48
|
+
const roaming = appdata !== undefined && appdata !== '' ? appdata : join(home, 'AppData', 'Roaming');
|
|
49
|
+
if (host === 'claude-desktop') {
|
|
50
|
+
if (process.platform === 'darwin') {
|
|
51
|
+
return join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
|
|
52
|
+
}
|
|
53
|
+
if (process.platform === 'win32')
|
|
54
|
+
return join(roaming, 'Claude', 'claude_desktop_config.json');
|
|
55
|
+
return join(home, '.config', 'Claude', 'claude_desktop_config.json');
|
|
56
|
+
}
|
|
57
|
+
if (host === 'copilot') {
|
|
58
|
+
if (process.platform === 'darwin') {
|
|
59
|
+
return join(home, 'Library', 'Application Support', 'Code', 'User', 'mcp.json');
|
|
60
|
+
}
|
|
61
|
+
if (process.platform === 'win32')
|
|
62
|
+
return join(roaming, 'Code', 'User', 'mcp.json');
|
|
63
|
+
return join(home, '.config', 'Code', 'User', 'mcp.json');
|
|
64
|
+
}
|
|
65
|
+
if (host === 'cursor')
|
|
66
|
+
return join(home, '.cursor', 'mcp.json');
|
|
67
|
+
throw new InstallError(`no configuration file is written for ${host}`);
|
|
68
|
+
}
|
|
69
|
+
/** `servers` in VS Code, `mcpServers` everywhere else — the word that silently loses entries. */
|
|
70
|
+
export function configKey(host) {
|
|
71
|
+
return host === 'copilot' ? 'servers' : 'mcpServers';
|
|
72
|
+
}
|
|
73
|
+
/** The server entry, in the shape the host expects. `type` only where the host documents it. */
|
|
74
|
+
export function entryFor(host, command, args) {
|
|
75
|
+
return host === 'copilot'
|
|
76
|
+
? { type: 'stdio', command, args: [...args] }
|
|
77
|
+
: { command, args: [...args] };
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* `type(x).__name__` for the values `json.loads` can return.
|
|
81
|
+
*
|
|
82
|
+
* The reference prints CPython's type name and this sentence is compared byte for byte, so
|
|
83
|
+
* `typeof` is wrong in five of the six cases: `string`/`str`, `number`/`int`, `boolean`/`bool`.
|
|
84
|
+
* The first version of this translated only `list` and `NoneType` — and both unit tests used
|
|
85
|
+
* `[1, 2, 3]`, the ONE input where the two vocabularies happen to agree, so neither suite
|
|
86
|
+
* could see it. Review measured `"hello"` and `5`.
|
|
87
|
+
*
|
|
88
|
+
* `int` vs `float` follows `json`'s own split: a JSON number with no fraction and no exponent
|
|
89
|
+
* is decoded by `int`, everything else by `float`.
|
|
90
|
+
*/
|
|
91
|
+
function pyTypeName(value) {
|
|
92
|
+
if (value === null)
|
|
93
|
+
return 'NoneType';
|
|
94
|
+
if (Array.isArray(value))
|
|
95
|
+
return 'list';
|
|
96
|
+
if (typeof value === 'string')
|
|
97
|
+
return 'str';
|
|
98
|
+
if (typeof value === 'boolean')
|
|
99
|
+
return 'bool';
|
|
100
|
+
if (typeof value === 'number')
|
|
101
|
+
return Number.isInteger(value) ? 'int' : 'float';
|
|
102
|
+
return typeof value;
|
|
103
|
+
}
|
|
104
|
+
function readConfig(path) {
|
|
105
|
+
if (!existsSync(path))
|
|
106
|
+
return {};
|
|
107
|
+
let text;
|
|
108
|
+
try {
|
|
109
|
+
text = readFileSync(path, 'utf8');
|
|
110
|
+
}
|
|
111
|
+
catch (e) {
|
|
112
|
+
throw new InstallError(`cannot read ${path}: ${e.message}`);
|
|
113
|
+
}
|
|
114
|
+
if (text.trim() === '')
|
|
115
|
+
return {};
|
|
116
|
+
let loaded;
|
|
117
|
+
try {
|
|
118
|
+
loaded = JSON.parse(text);
|
|
119
|
+
}
|
|
120
|
+
catch (e) {
|
|
121
|
+
// The reference reports CPython's message, line and column. `JSON.parse` gives neither
|
|
122
|
+
// the same wording nor a position, so this is a RULED difference rather than a silent
|
|
123
|
+
// one: both refuse, both name the file, and the reason after the colon differs.
|
|
124
|
+
throw new InstallError(`${path} is not valid JSON, so this refuses to touch it: ${e.message}`);
|
|
125
|
+
}
|
|
126
|
+
if (loaded === null || typeof loaded !== 'object' || Array.isArray(loaded)) {
|
|
127
|
+
throw new InstallError(`${path} holds ${pyTypeName(loaded)}, not an object; refusing to touch it`);
|
|
128
|
+
}
|
|
129
|
+
return loaded;
|
|
130
|
+
}
|
|
131
|
+
function writeConfig(path, data) {
|
|
132
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
133
|
+
const tmp = `${path}.bantamkit-tmp`;
|
|
134
|
+
// The mode of the file being replaced, carried onto its replacement. A host config holds
|
|
135
|
+
// API keys in per-server `env` blocks, and a user who chmod'ed theirs to 0600 had it come
|
|
136
|
+
// back 0644, because a fresh temp file gets the process umask. Measured on both runtimes
|
|
137
|
+
// before this line existed.
|
|
138
|
+
const mode = existsSync(path) ? statSync(path).mode & 0o7777 : null;
|
|
139
|
+
try {
|
|
140
|
+
writeFileSync(tmp, `${dumps(data, 2)}\n`, 'utf8');
|
|
141
|
+
if (mode !== null)
|
|
142
|
+
chmodSync(tmp, mode);
|
|
143
|
+
renameSync(tmp, path);
|
|
144
|
+
}
|
|
145
|
+
catch (e) {
|
|
146
|
+
rmSync(tmp, { force: true });
|
|
147
|
+
throw new InstallError(`cannot write ${path}: ${e.message}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/** `date.today().isoformat()` — the LOCAL date, which is what the reference takes. */
|
|
151
|
+
function today() {
|
|
152
|
+
const now = new Date();
|
|
153
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
154
|
+
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
|
155
|
+
}
|
|
156
|
+
function backup(path) {
|
|
157
|
+
if (!existsSync(path))
|
|
158
|
+
return null;
|
|
159
|
+
const target = `${path}.backup-${today()}`;
|
|
160
|
+
try {
|
|
161
|
+
copyFileSync(path, target);
|
|
162
|
+
}
|
|
163
|
+
catch (e) {
|
|
164
|
+
throw new InstallError(`cannot back up ${path}: ${e.message}`);
|
|
165
|
+
}
|
|
166
|
+
return target;
|
|
167
|
+
}
|
|
168
|
+
function installViaClaudeCli(command, args) {
|
|
169
|
+
const argv = ['mcp', 'add', ENTRY, '-s', 'user', '--', command, ...args];
|
|
170
|
+
// `shell: true` ON WINDOWS ONLY. An npm-installed `claude` is a `claude.cmd` shim, and
|
|
171
|
+
// `spawnSync` refuses to launch a batch file without a shell — it returns ENOENT, which
|
|
172
|
+
// this function would report as "not on PATH", the one message guaranteed to send someone
|
|
173
|
+
// looking in the wrong place. The reference has no such problem: `shutil.which` honours
|
|
174
|
+
// PATHEXT and `CreateProcess` runs the shim. Arguments are quoted because `shell: true`
|
|
175
|
+
// hands the string to `cmd.exe`, and a path with a space is the normal case on Windows.
|
|
176
|
+
const win = process.platform === 'win32';
|
|
177
|
+
const quoted = win ? argv.map((a) => (/[\s"^&|<>]/.test(a) ? `"${a.replace(/"/g, '\\"')}"` : a)) : argv;
|
|
178
|
+
const done = spawnSync('claude', quoted, { encoding: 'utf8', shell: win });
|
|
179
|
+
if (done.error) {
|
|
180
|
+
const code = done.error.code;
|
|
181
|
+
if (code === 'ENOENT') {
|
|
182
|
+
throw new InstallError('the `claude` command is not on PATH, so this cannot register with Claude Code. ' +
|
|
183
|
+
'Install Claude Code, or add the entry by hand — `docs/install.md` gives the shape.');
|
|
184
|
+
}
|
|
185
|
+
throw new InstallError(`could not run claude: ${done.error.message}`);
|
|
186
|
+
}
|
|
187
|
+
if (done.status !== 0) {
|
|
188
|
+
const detail = (done.stderr || done.stdout || '').trim();
|
|
189
|
+
throw new InstallError(`\`claude mcp add\` failed (exit ${done.status})${detail ? `\n${detail}` : ''}`);
|
|
190
|
+
}
|
|
191
|
+
return argv;
|
|
192
|
+
}
|
|
193
|
+
/** Register this server with `host`. Returns the report to print; throws `InstallError`. */
|
|
194
|
+
export function install(host, command, args, force = false) {
|
|
195
|
+
if (!HOSTS.includes(host)) {
|
|
196
|
+
throw new InstallError(`unknown host '${host}'; choose one of: ${HOSTS.join(', ')}`);
|
|
197
|
+
}
|
|
198
|
+
if (host === 'claude') {
|
|
199
|
+
const argv = installViaClaudeCli(command, args);
|
|
200
|
+
return `installed bantamkit into claude\n ran : claude ${argv.join(' ')}`;
|
|
201
|
+
}
|
|
202
|
+
const path = hostConfigPath(host);
|
|
203
|
+
const key = configKey(host);
|
|
204
|
+
const wanted = entryFor(host, command, args);
|
|
205
|
+
const data = readConfig(path);
|
|
206
|
+
const raw = data[key];
|
|
207
|
+
if (raw !== undefined && (raw === null || typeof raw !== 'object' || Array.isArray(raw))) {
|
|
208
|
+
throw new InstallError(`${path} has a '${key}' that is not an object; refusing to touch it`);
|
|
209
|
+
}
|
|
210
|
+
const servers = (raw ?? {});
|
|
211
|
+
const existing = servers[ENTRY];
|
|
212
|
+
if (existing !== undefined && dumps(existing, null, true) === dumps(wanted, null, true)) {
|
|
213
|
+
return `bantamkit is already installed in ${host} and matches\n file : ${path}`;
|
|
214
|
+
}
|
|
215
|
+
if (existing !== undefined && !force) {
|
|
216
|
+
throw new InstallError(`${host} already has a bantamkit entry with different settings\n` +
|
|
217
|
+
` file : ${path}\n` +
|
|
218
|
+
` current : ${dumps(existing, null, true)}\n` +
|
|
219
|
+
` proposed: ${dumps(wanted, null, true)}\n` +
|
|
220
|
+
' re-run with --force to replace it');
|
|
221
|
+
}
|
|
222
|
+
const copied = backup(path);
|
|
223
|
+
servers[ENTRY] = wanted;
|
|
224
|
+
data[key] = servers;
|
|
225
|
+
writeConfig(path, data);
|
|
226
|
+
const lines = [
|
|
227
|
+
`installed bantamkit into ${host}`,
|
|
228
|
+
` file : ${path}`,
|
|
229
|
+
` key : ${key}`,
|
|
230
|
+
` command: ${command} ${args.join(' ')}`.replace(/\s+$/, ''),
|
|
231
|
+
];
|
|
232
|
+
if (copied !== null)
|
|
233
|
+
lines.push(` backup : ${copied}`);
|
|
234
|
+
return lines.join('\n');
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* What a host should run to get THIS server.
|
|
238
|
+
*
|
|
239
|
+
* `npx -y bantamkit-mcp` and not this process's own path, and the two are not the same
|
|
240
|
+
* thing: a global install, an `npx` cache entry and a checkout all have different paths,
|
|
241
|
+
* while the published name resolves the same everywhere. `-y` because `npx` historically
|
|
242
|
+
* prompts before installing a package it has not seen, and stdin here is the JSON-RPC
|
|
243
|
+
* channel — a prompt that ate one frame would look like a server that lost a request.
|
|
244
|
+
*/
|
|
245
|
+
export function thisCommand() {
|
|
246
|
+
return { command: 'npx', args: ['-y', 'bantamkit-mcp'] };
|
|
247
|
+
}
|
package/dist/mcp/identity.d.ts
CHANGED
|
@@ -10,10 +10,43 @@ export declare const SERVER_NAME = "bantamkit";
|
|
|
10
10
|
* `as_posix()` is why the parts are joined with `/` here and not with `path.sep`: on Windows
|
|
11
11
|
* the walk yields the same components and the digest must not change with the separator.
|
|
12
12
|
*/
|
|
13
|
-
export declare function treeDigest(root: string): {
|
|
13
|
+
export declare function treeDigest(root: string, keep?: (parts: string[]) => boolean): {
|
|
14
14
|
digest: string;
|
|
15
15
|
files: number;
|
|
16
16
|
};
|
|
17
|
+
/**
|
|
18
|
+
* Fingerprint the asset pack this build would load — every file, not just the loaded ones.
|
|
19
|
+
*
|
|
20
|
+
* `contracts/default.yaml` is the reason: `RB-P84` measured it as the one asset that differed
|
|
21
|
+
* between two live builds while no resource template exposed it, so it was invisible on every
|
|
22
|
+
* probed surface. It is also why N1 ships all 84 files rather than the 20 that are read.
|
|
23
|
+
*
|
|
24
|
+
* EXCEPT bytecode caches, and that exception is the whole point of this field. The pack ships
|
|
25
|
+
* `.py` fixture files, so `pip install` byte-compiles them into `__pycache__` on the way in and
|
|
26
|
+
* the digest of an INSTALLED Python pack stopped matching the digest of the identical npm pack
|
|
27
|
+
* — measured on the published 0.27.0 artifacts, `sha256:fa8372f6…` over 98 files against
|
|
28
|
+
* `sha256:d47dcf4b…` over 87, and deleting `__pycache__` from the wheel's pack reproduced the
|
|
29
|
+
* npm digest byte for byte. `cross_runtime` tells the caller to compare `assets_digest` across
|
|
30
|
+
* runtimes; without this rule that instruction returned a false "different" on every real
|
|
31
|
+
* install. The pack is what was SHIPPED, never what an interpreter later wrote beside it.
|
|
32
|
+
*
|
|
33
|
+
* The rule is spelled identically in `runtime-py`'s `_assets_fingerprint`, and it has to be:
|
|
34
|
+
* this side never creates a `__pycache__`, so a Node-only exclusion would still agree today
|
|
35
|
+
* and diverge again the moment a pack carrying one reached both runtimes.
|
|
36
|
+
*/
|
|
37
|
+
export declare const notBytecodeCache: (parts: string[]) => boolean;
|
|
38
|
+
/**
|
|
39
|
+
* The pack as SHIPPED, counted — what `--assets-root` prints.
|
|
40
|
+
*
|
|
41
|
+
* Exported so the CLI shares this walk rather than keeping a second one. There are TWO
|
|
42
|
+
* walks over this directory and a rule spelled twice is a rule that gets fixed once: the
|
|
43
|
+
* first version of this fix filtered the digest alone, and a `pip install` then had one
|
|
44
|
+
* process contradicting itself, `build_identity` answering 87 files while `--assets-root`
|
|
45
|
+
* printed 98 for the pack it had just loaded. The CLI's own `readdirSync(recursive)` is
|
|
46
|
+
* gone with it — `walkFiles` is the walk that mirrors CPython's `rglob`, which is what the
|
|
47
|
+
* reference's printer uses and what the conformance case compares the count against.
|
|
48
|
+
*/
|
|
49
|
+
export declare function packFileCount(root: string): number;
|
|
17
50
|
/** `sha256(json.dumps(inputs, sort_keys=True, separators=(",", ":")))` — the identity. */
|
|
18
51
|
export declare function buildIdFor(inputs: Record<string, string>): string;
|
|
19
52
|
/**
|
package/dist/mcp/identity.js
CHANGED
|
@@ -108,8 +108,9 @@ function walkFiles(root) {
|
|
|
108
108
|
* `as_posix()` is why the parts are joined with `/` here and not with `path.sep`: on Windows
|
|
109
109
|
* the walk yields the same components and the digest must not change with the separator.
|
|
110
110
|
*/
|
|
111
|
-
export function treeDigest(root) {
|
|
112
|
-
const
|
|
111
|
+
export function treeDigest(root, keep) {
|
|
112
|
+
const walked = walkFiles(root);
|
|
113
|
+
const files = keep === undefined ? walked : walked.filter(keep);
|
|
113
114
|
const running = createHash('sha256');
|
|
114
115
|
for (const parts of files) {
|
|
115
116
|
const payload = readFileSync(join(root, ...parts));
|
|
@@ -164,7 +165,35 @@ function codeFingerprint() {
|
|
|
164
165
|
* `contracts/default.yaml` is the reason: `RB-P84` measured it as the one asset that differed
|
|
165
166
|
* between two live builds while no resource template exposed it, so it was invisible on every
|
|
166
167
|
* probed surface. It is also why N1 ships all 84 files rather than the 20 that are read.
|
|
168
|
+
*
|
|
169
|
+
* EXCEPT bytecode caches, and that exception is the whole point of this field. The pack ships
|
|
170
|
+
* `.py` fixture files, so `pip install` byte-compiles them into `__pycache__` on the way in and
|
|
171
|
+
* the digest of an INSTALLED Python pack stopped matching the digest of the identical npm pack
|
|
172
|
+
* — measured on the published 0.27.0 artifacts, `sha256:fa8372f6…` over 98 files against
|
|
173
|
+
* `sha256:d47dcf4b…` over 87, and deleting `__pycache__` from the wheel's pack reproduced the
|
|
174
|
+
* npm digest byte for byte. `cross_runtime` tells the caller to compare `assets_digest` across
|
|
175
|
+
* runtimes; without this rule that instruction returned a false "different" on every real
|
|
176
|
+
* install. The pack is what was SHIPPED, never what an interpreter later wrote beside it.
|
|
177
|
+
*
|
|
178
|
+
* The rule is spelled identically in `runtime-py`'s `_assets_fingerprint`, and it has to be:
|
|
179
|
+
* this side never creates a `__pycache__`, so a Node-only exclusion would still agree today
|
|
180
|
+
* and diverge again the moment a pack carrying one reached both runtimes.
|
|
181
|
+
*/
|
|
182
|
+
export const notBytecodeCache = (parts) => !parts.includes('__pycache__');
|
|
183
|
+
/**
|
|
184
|
+
* The pack as SHIPPED, counted — what `--assets-root` prints.
|
|
185
|
+
*
|
|
186
|
+
* Exported so the CLI shares this walk rather than keeping a second one. There are TWO
|
|
187
|
+
* walks over this directory and a rule spelled twice is a rule that gets fixed once: the
|
|
188
|
+
* first version of this fix filtered the digest alone, and a `pip install` then had one
|
|
189
|
+
* process contradicting itself, `build_identity` answering 87 files while `--assets-root`
|
|
190
|
+
* printed 98 for the pack it had just loaded. The CLI's own `readdirSync(recursive)` is
|
|
191
|
+
* gone with it — `walkFiles` is the walk that mirrors CPython's `rglob`, which is what the
|
|
192
|
+
* reference's printer uses and what the conformance case compares the count against.
|
|
167
193
|
*/
|
|
194
|
+
export function packFileCount(root) {
|
|
195
|
+
return walkFiles(root).filter(notBytecodeCache).length;
|
|
196
|
+
}
|
|
168
197
|
function assetsFingerprint() {
|
|
169
198
|
let root;
|
|
170
199
|
try {
|
|
@@ -177,7 +206,7 @@ function assetsFingerprint() {
|
|
|
177
206
|
}
|
|
178
207
|
let walked;
|
|
179
208
|
try {
|
|
180
|
-
walked = treeDigest(root);
|
|
209
|
+
walked = treeDigest(root, notBytecodeCache);
|
|
181
210
|
}
|
|
182
211
|
catch (e) {
|
|
183
212
|
const error = e instanceof PyOSError ? e : asPyOSError(e, root);
|