bantamkit-mcp 0.28.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 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
- Every host below runs the same command; only the file and the key around it change.
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
@@ -60,7 +60,9 @@ import { DEFAULT_INDEX_BUDGET } from './memory/store.js';
60
60
  import { packFileCount } from './mcp/identity.js';
61
61
  import { buildServer } from './mcp/server.js';
62
62
  import { RawStdioTransport } from './mcp/transport.js';
63
- 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';
64
66
  import { statusLine } from './statusline.js';
65
67
  /** `SystemExit("...")`: the message on stderr, exit 1. */
66
68
  class Refusal extends Error {
@@ -151,6 +153,40 @@ const PARSER = {
151
153
  help: 'print one status line for a host status bar, then exit',
152
154
  defaultValue: false,
153
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
+ },
154
190
  {
155
191
  optionStrings: ['--store'],
156
192
  dest: 'store',
@@ -166,7 +202,10 @@ const PARSER = {
166
202
  defaultValue: null,
167
203
  },
168
204
  ],
169
- groups: [[6, 7]],
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]],
170
209
  };
171
210
  /** `_parse_args`, arm for arm, including the mutually exclusive group and `-h`. */
172
211
  export function parseArgs(argv) {
@@ -179,6 +218,8 @@ export function parseArgs(argv) {
179
218
  assetsRoot: values['assets_root'],
180
219
  mcpReport: values['mcp_report'],
181
220
  statusline: values['statusline'],
221
+ install: values['install'],
222
+ force: values['force'],
182
223
  };
183
224
  }
184
225
  /** `_build_memory`, including the two refusals argparse cannot express. */
@@ -236,6 +277,29 @@ async function main(argv) {
236
277
  process.stdout.write(`${statusLine(process.env, options.store, options.start)}\n`);
237
278
  return 0;
238
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');
239
303
  const memory = buildMemory(options);
240
304
  const wire = new RawStdioTransport();
241
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bantamkit-mcp",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "description": "bantamkit MCP server — pure Node, runnable with npx",
5
5
  "type": "module",
6
6
  "bin": {