bantamkit-mcp 0.30.0 → 0.31.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.
@@ -0,0 +1,357 @@
1
+ /**
2
+ * Layer 4 (Policy/Profile): the price table, and the arithmetic that turns tokens into money.
3
+ *
4
+ * A port of `runtime-py/src/bantamkit/pricing.py`, arm for arm. The Python module is the
5
+ * reference and carries the full reasoning -- why the shipped table has NO rates, why the
6
+ * three properties are where they are, why money is an integer. Read it first; what follows
7
+ * here is only what this side does DIFFERENTLY, and every one of those is a mechanical
8
+ * consequence of the language rather than a decision.
9
+ *
10
+ * 1. `BigInt` FOR THE MULTIPLY, `number` ON THE WIRE. `count * rate` with both operands
11
+ * allowed up to 2**53-1 reaches 2**106, which a JS `number` rounds silently and Python
12
+ * does not. So the product, the half-up rounding and the running total are all `bigint`
13
+ * here, and only the final micro-USD -- bounded by the shared 2**53-1 ceiling, refused
14
+ * above it on BOTH sides with the same sentence -- becomes a `number`. Python needs none
15
+ * of this because its `int` is arbitrary precision; the ceiling exists so that the place
16
+ * where the two would part company is a refusal rather than a wrong answer.
17
+ *
18
+ * 2. `pyRepr` AND `cmpCodepoint`, NOT `JSON.stringify` AND `.sort()`. Every quoted name in
19
+ * a sentence goes through `pyRepr` because Python's `repr` is what the reference emits,
20
+ * and every "which offender is named first" goes through `cmpCodepoint` because
21
+ * `.sort()` is UTF-16 code-unit order and `sorted()` is code-point order. Both are
22
+ * already the port's shared primitives and both are already under conformance.
23
+ *
24
+ * 3. `JSON.parse` IS THE STRICTER READER AND PYTHON WAS BROUGHT TO IT. `json.loads` accepts
25
+ * the bare tokens `NaN` / `Infinity` / `-Infinity`; `JSON.parse` does not. The reference
26
+ * passes `parse_constant` to refuse them, so both sides answer
27
+ * `price table is not valid JSON: <path>` for the same bytes.
28
+ *
29
+ * 4. AN INTEGRAL FLOAT IS A COUNT. `JSON.parse('3000000.0')` is `3000000` and cannot be told
30
+ * from the integer; the reference therefore accepts an integral `float` and narrows it,
31
+ * rather than refusing a spelling this side cannot even see.
32
+ *
33
+ * `tools/conformance/suites/pricing.mjs` is the gate.
34
+ */
35
+ import { existsSync, readFileSync } from 'node:fs';
36
+ import { join } from 'node:path';
37
+ import { assetsRoot } from './assets.js';
38
+ import { BantamError } from './errors.js';
39
+ import { cmpCodepoint, pyRepr } from './pysem.js';
40
+ export const PRICES_ENV = 'BANTAMKIT_PRICES';
41
+ export const PRICE_SCHEMA_VERSION = 1;
42
+ export const CURRENCY = 'USD';
43
+ export const RATE_UNIT = 'micro_usd_per_million_tokens';
44
+ /** A rate is micro-USD per this many tokens. */
45
+ export const TOKENS_PER_RATE_UNIT = 1000000;
46
+ /** Micro-USD in one USD. Also the rounding divisor, hence `HALF` below. */
47
+ export const MICROS_PER_UNIT = 1000000;
48
+ const MICROS_PER_UNIT_BIG = 1000000n;
49
+ const HALF_BIG = 500000n;
50
+ /** The largest integer both runtimes represent exactly. */
51
+ export const MAX_SAFE_INT = 2 ** 53 - 1;
52
+ const MAX_SAFE_INT_BIG = BigInt(Number.MAX_SAFE_INTEGER);
53
+ /**
54
+ * The four classes the host's own `usage` block distinguishes, in the order the ledger
55
+ * accumulates them. This tuple is the unit of pricing; see property 1 in the reference.
56
+ */
57
+ export const TOKEN_CLASSES = [
58
+ 'input_tokens',
59
+ 'cache_creation_input_tokens',
60
+ 'cache_read_input_tokens',
61
+ 'output_tokens',
62
+ ];
63
+ const CLASSES = TOKEN_CLASSES.join(', ');
64
+ const TABLE_KEYS = ['schema_version', 'currency', 'unit', 'note', 'rates'];
65
+ const RATE_META = ['recorded', 'source'];
66
+ /**
67
+ * The table on disk is malformed.
68
+ *
69
+ * This THROWS rather than returning `{unavailable}`, and the split is deliberate: a broken
70
+ * price table is an operator configuration fault that must stop and name itself, while an
71
+ * unpriced model is a normal, expected answer that a caller keeps working past.
72
+ */
73
+ export class PriceTableError extends BantamError {
74
+ }
75
+ /**
76
+ * A non-negative integer both runtimes hold exactly, however JSON spelled it.
77
+ *
78
+ * `Number.isInteger` is false for `Infinity` and `NaN`, which is what makes `1e400` -- valid
79
+ * JSON here, `inf` after `json.loads` there -- refuse identically on both sides.
80
+ */
81
+ function isCount(value) {
82
+ return typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= MAX_SAFE_INT;
83
+ }
84
+ /** The number behind an accepted count. Only ever called after `isCount`. `-0` normalises to `0`. */
85
+ function asCount(value) {
86
+ return value + 0;
87
+ }
88
+ /**
89
+ * Sorted by code point, so "which key is named first" is not a function of object order.
90
+ *
91
+ * `JSON.parse` hoists integer-like keys ahead of the rest and orders them numerically;
92
+ * `json.loads` preserves the file. Naming the code-point-least offender makes the two answer
93
+ * the same sentence for the same file.
94
+ */
95
+ function sortedByCodePoint(names) {
96
+ return [...names].sort(cmpCodepoint);
97
+ }
98
+ /**
99
+ * `YYYY-MM-DD`, shape and range only -- deliberately not a calendar.
100
+ *
101
+ * A real calendar check would be `datetime.date` on the reference side, which this side has
102
+ * no equivalent of without writing a third leap-year rule. 2026-02-31 passes and that is a
103
+ * known, stated limit.
104
+ */
105
+ function isDate(value) {
106
+ if (typeof value !== 'string' || value.length !== 10)
107
+ return false;
108
+ if (value[4] !== '-' || value[7] !== '-')
109
+ return false;
110
+ for (const part of [value.slice(0, 4), value.slice(5, 7), value.slice(8, 10)]) {
111
+ for (const ch of part) {
112
+ if (ch < '0' || ch > '9')
113
+ return false;
114
+ }
115
+ }
116
+ const month = Number(value.slice(5, 7));
117
+ const day = Number(value.slice(8, 10));
118
+ return month >= 1 && month <= 12 && day >= 1 && day <= 31;
119
+ }
120
+ /**
121
+ * `$BANTAMKIT_PRICES` verbatim if set, else the shipped table in the asset pack.
122
+ *
123
+ * The override wins even when it is wrong -- same arm order and same reasoning as
124
+ * `assetsRoot()`: an override that points nowhere must fail naming itself rather than fall
125
+ * through to a table the operator did not ask for.
126
+ */
127
+ export function priceTablePath() {
128
+ const env = process.env[PRICES_ENV];
129
+ if (env)
130
+ return env;
131
+ return join(assetsRoot(), 'pricing', 'default.json');
132
+ }
133
+ /**
134
+ * Read and validate a price table. `path` is used VERBATIM in every message it appears in,
135
+ * never normalised, because `Path('./x')` is `x` on the reference side and `./x` here and the
136
+ * fault sentence would then differ over a path neither runtime chose.
137
+ */
138
+ export function loadPriceTable(path) {
139
+ const label = path === undefined ? priceTablePath() : path;
140
+ if (!existsSync(label)) {
141
+ throw new PriceTableError(`price table not found: ${label}`);
142
+ }
143
+ let raw;
144
+ try {
145
+ // The decoder's own text is NOT quoted. `validate.mjs` covers the two decoders'
146
+ // sentences separately and they are not identical everywhere; quoting one here would
147
+ // make every malformed table a divergence, over bytes that name the same fault.
148
+ raw = decodePriceJson(readFileSync(label, 'utf8'));
149
+ }
150
+ catch {
151
+ throw new PriceTableError(`price table is not valid JSON: ${label}`);
152
+ }
153
+ return validatePriceTable(raw);
154
+ }
155
+ /**
156
+ * The decode both runtimes must agree on, in one place so the gate can reach it.
157
+ *
158
+ * This side is already the strict one -- `JSON.parse` rejects the bare `NaN` / `Infinity` /
159
+ * `-Infinity` tokens that `json.loads` accepts -- so the function is a plain `JSON.parse`
160
+ * and the reference is what had to move. It exists as a named arm anyway, because a rule
161
+ * that lives in only one of two runtimes is a rule the conformance harness cannot reach.
162
+ */
163
+ export function decodePriceJson(text) {
164
+ return JSON.parse(text);
165
+ }
166
+ function isPlainObject(value) {
167
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
168
+ }
169
+ /**
170
+ * Every rule the table must satisfy, each with its own sentence.
171
+ *
172
+ * Returns a NORMALISED table: the top-level keys in a fixed order, rate names in code-point
173
+ * order, and every count narrowed. See the reference for the ONE thing normalisation cannot
174
+ * buy -- the iteration order of `rates` for a model name JavaScript reads as an array index.
175
+ */
176
+ export function validatePriceTable(raw) {
177
+ if (!isPlainObject(raw)) {
178
+ throw new PriceTableError('price table must be a JSON object');
179
+ }
180
+ const unknown = sortedByCodePoint(Object.keys(raw).filter((k) => !TABLE_KEYS.includes(k)));
181
+ if (unknown.length > 0) {
182
+ throw new PriceTableError(`price table has unknown key ${pyRepr(unknown[0])}`);
183
+ }
184
+ // No parsed VALUE is ever interpolated into a sentence below -- only key names and
185
+ // integers this module computed. A parsed value would drag the two runtimes' number
186
+ // formatting into the message, which is a serialiser divergence wearing an error
187
+ // message's clothes.
188
+ const version = raw['schema_version'];
189
+ if (!isCount(version) || version !== PRICE_SCHEMA_VERSION) {
190
+ throw new PriceTableError('price table schema_version must be the integer 1');
191
+ }
192
+ if (raw['currency'] !== CURRENCY) {
193
+ throw new PriceTableError("price table currency must be the string 'USD'");
194
+ }
195
+ if (raw['unit'] !== RATE_UNIT) {
196
+ throw new PriceTableError("price table unit must be the string 'micro_usd_per_million_tokens'");
197
+ }
198
+ if ('note' in raw && typeof raw['note'] !== 'string') {
199
+ throw new PriceTableError('price table note must be a string');
200
+ }
201
+ const rates = raw['rates'];
202
+ if (!isPlainObject(rates)) {
203
+ throw new PriceTableError('price table rates must be a JSON object');
204
+ }
205
+ const built = {};
206
+ for (const model of sortedByCodePoint(Object.keys(rates))) {
207
+ built[model] = validateRate(model, rates[model]);
208
+ }
209
+ // Assigned in the reference's own key order -- `note`, when present, sits between `unit`
210
+ // and `rates` -- because insertion order is what `JSON.stringify` emits and the suite
211
+ // compares the serialised table.
212
+ const out = {};
213
+ out.schema_version = PRICE_SCHEMA_VERSION;
214
+ out.currency = CURRENCY;
215
+ out.unit = RATE_UNIT;
216
+ if ('note' in raw)
217
+ out.note = raw['note'];
218
+ out.rates = built;
219
+ return out;
220
+ }
221
+ function validateRate(model, rate) {
222
+ if (!isPlainObject(rate)) {
223
+ throw new PriceTableError(`price table rate ${pyRepr(model)} must be a JSON object`);
224
+ }
225
+ const known = [...RATE_META, ...TOKEN_CLASSES];
226
+ const unknown = sortedByCodePoint(Object.keys(rate).filter((k) => !known.includes(k)));
227
+ if (unknown.length > 0) {
228
+ throw new PriceTableError(`price table rate ${pyRepr(model)} has unknown key ${pyRepr(unknown[0])}`);
229
+ }
230
+ // Property 2, enforced where it cannot be skipped: at load, before any arithmetic.
231
+ if (!('recorded' in rate)) {
232
+ throw new PriceTableError(`price table rate ${pyRepr(model)} is missing 'recorded' -- a rate with no date is not a fact`);
233
+ }
234
+ if (!isDate(rate['recorded'])) {
235
+ throw new PriceTableError(`price table rate ${pyRepr(model)} has a 'recorded' that is not a YYYY-MM-DD date`);
236
+ }
237
+ if (!('source' in rate)) {
238
+ throw new PriceTableError(`price table rate ${pyRepr(model)} is missing 'source' -- a rate with no source cannot be re-derived`);
239
+ }
240
+ if (typeof rate['source'] !== 'string' || rate['source'] === '') {
241
+ throw new PriceTableError(`price table rate ${pyRepr(model)} has a 'source' that is not a non-empty string`);
242
+ }
243
+ for (const cls of TOKEN_CLASSES) {
244
+ if (cls in rate && !isCount(rate[cls])) {
245
+ throw new PriceTableError(`price table rate ${pyRepr(model)} prices ${pyRepr(cls)} with something that is not a ` +
246
+ `non-negative integer below 2**53`);
247
+ }
248
+ }
249
+ if (!TOKEN_CLASSES.some((cls) => cls in rate)) {
250
+ throw new PriceTableError(`price table rate ${pyRepr(model)} prices no token class at all`);
251
+ }
252
+ const narrowed = { ...rate };
253
+ for (const cls of TOKEN_CLASSES) {
254
+ if (cls in rate)
255
+ narrowed[cls] = asCount(rate[cls]);
256
+ }
257
+ return rateView(narrowed);
258
+ }
259
+ /** `build_identity`'s shape, on purpose: one key, and the key IS the refusal. */
260
+ function unavailable(reason) {
261
+ return { unavailable: reason };
262
+ }
263
+ /**
264
+ * Micro-USD as a fixed-6-decimal string, built from integers only.
265
+ *
266
+ * Non-negative input only; every producer in this module is a sum of non-negative products.
267
+ */
268
+ export function formatMicros(micros) {
269
+ const n = BigInt(micros);
270
+ return `${n / MICROS_PER_UNIT_BIG}.${String(n % MICROS_PER_UNIT_BIG).padStart(6, '0')}`;
271
+ }
272
+ /**
273
+ * The cost of one `usage` block under one model's rate, or a named refusal.
274
+ *
275
+ * `table` must be a table `validatePriceTable` returned.
276
+ *
277
+ * THE ORDER OF THE CHECKS IS PART OF THE CONTRACT. The usage is validated BEFORE the model is
278
+ * looked up, so a caller who passed a malformed usage is told that, rather than being sent to
279
+ * edit a price table over a bug in the call.
280
+ *
281
+ * THE USAGE MUST CARRY ALL FOUR CLASSES AND NOTHING ELSE. See the reference: a token class
282
+ * silently ignored is money silently dropped, and a class silently defaulted to zero is money
283
+ * silently invented.
284
+ */
285
+ export function priceTokens(table, model, usage) {
286
+ if (typeof model !== 'string') {
287
+ return unavailable('model must be a string');
288
+ }
289
+ if (!isPlainObject(usage)) {
290
+ return unavailable('usage must be a mapping of token class to count');
291
+ }
292
+ const extra = sortedByCodePoint(Object.keys(usage).filter((k) => !TOKEN_CLASSES.includes(k)));
293
+ if (extra.length > 0) {
294
+ return unavailable(`usage has unknown token class ${pyRepr(extra[0])} -- the price table prices exactly ${CLASSES}`);
295
+ }
296
+ for (const cls of TOKEN_CLASSES) {
297
+ if (!(cls in usage)) {
298
+ return unavailable(`usage is missing token class ${pyRepr(cls)} -- all four of ${CLASSES} must be ` +
299
+ `given, because a missing count is not a zero`);
300
+ }
301
+ if (!isCount(usage[cls])) {
302
+ return unavailable(`usage token class ${pyRepr(cls)} is not a non-negative integer below 2**53`);
303
+ }
304
+ }
305
+ const rate = table.rates?.[model];
306
+ if (rate === undefined) {
307
+ return unavailable(`no rate recorded for model ${pyRepr(model)} -- add one with its date and source, or ` +
308
+ `point ${PRICES_ENV} at a table that has it`);
309
+ }
310
+ const breakdown = {};
311
+ let total = 0n;
312
+ for (const cls of TOKEN_CLASSES) {
313
+ const count = asCount(usage[cls]);
314
+ let micros;
315
+ if (!(cls in rate)) {
316
+ // Zero tokens cost zero under ANY rate, so this arm invents nothing. Any other count
317
+ // with no rate is property 3 and is refused rather than silently dropped.
318
+ if (count !== 0) {
319
+ return unavailable(`rate for model ${pyRepr(model)} does not price ${pyRepr(cls)}, and ${count} such ` +
320
+ `tokens were used -- a missing rate is not a zero`);
321
+ }
322
+ micros = 0n;
323
+ }
324
+ else {
325
+ micros = (BigInt(count) * BigInt(rate[cls]) + HALF_BIG) / MICROS_PER_UNIT_BIG;
326
+ }
327
+ breakdown[cls] = Number(micros);
328
+ total += micros;
329
+ }
330
+ if (total > MAX_SAFE_INT_BIG) {
331
+ return unavailable(`cost for model ${pyRepr(model)} exceeds 2**53-1 micro-USD, which is the largest ` +
332
+ `integer both runtimes represent exactly`);
333
+ }
334
+ const micros = Number(total);
335
+ return {
336
+ model,
337
+ currency: CURRENCY,
338
+ micros,
339
+ amount: formatMicros(micros),
340
+ breakdown,
341
+ rate: rateView(rate),
342
+ };
343
+ }
344
+ /**
345
+ * The rate that produced a cost, echoed in a fixed key order so the answer is re-derivable.
346
+ *
347
+ * Fixed order rather than the file's own order, because the file's order is not stable across
348
+ * the two JSON parsers and this object is compared byte for byte by the conformance suite.
349
+ */
350
+ function rateView(rate) {
351
+ const view = { recorded: rate['recorded'], source: rate['source'] };
352
+ for (const cls of TOKEN_CLASSES) {
353
+ if (cls in rate)
354
+ view[cls] = rate[cls];
355
+ }
356
+ return view;
357
+ }
@@ -0,0 +1,320 @@
1
+ /**
2
+ * The command the operator typed, on BOTH runtimes. Not the package name — see the module
3
+ * docstring: PyPI has `bantamkit`, npm has `bantamkit-mcp`, and only this word is the same on
4
+ * both, so only this word can appear in a sentence the reference and this file share verbatim.
5
+ */
6
+ export declare const PROGRAM = "bantamkit-mcp";
7
+ /** The npm package this install would upgrade. Divergent by construction (`DISTRIBUTION`). */
8
+ export declare const PACKAGE = "bantamkit-mcp";
9
+ /**
10
+ * The one URL this toolbox ever fetches. Divergent by construction (`INDEX_URL`).
11
+ *
12
+ * `/latest` and not the full packument: the packument carries every version ever published and
13
+ * grows without bound, and the question here is one string. Measured 2026-09-11 — this document
14
+ * is 26 keys and carries `version` at the top level.
15
+ */
16
+ export declare const INDEX_URL = "https://registry.npmjs.org/bantamkit-mcp/latest";
17
+ /**
18
+ * Explicit, and short enough that a person who typed the flag does not think it hung.
19
+ * AS-7(3): the network is not on any other path here, so nothing else inherits this.
20
+ */
21
+ export declare const DEFAULT_TIMEOUT_SECONDS = 10;
22
+ /**
23
+ * What stands in for an installer that printed nothing, so the report has ONE shape rather
24
+ * than a line that is sometimes there. A conditional line is a second thing to port.
25
+ */
26
+ export declare const NO_OUTPUT = "(nothing)";
27
+ /**
28
+ * Line 1 of every report, and the opening of the no-route refusal. It carries BOTH numbers
29
+ * because "up to date" without them is unfalsifiable by the person reading it.
30
+ */
31
+ export declare const COMPARISON = "{program} {installed} is installed; the package index has {latest}.";
32
+ /** The user asked for this word specifically: "ถ้า match ให้แสดงคำ uptodate". */
33
+ export declare const UP_TO_DATE = "up to date.";
34
+ /**
35
+ * The registry BEHIND the installed version. A real state, not a curiosity: a checkout build,
36
+ * or a release that has not been published yet, both land here.
37
+ */
38
+ export declare const AHEAD = "the installed version is ahead of the package index; there is nothing to update to.";
39
+ /** What the command is about to run. The command itself is divergent; this line is not. */
40
+ export declare const UPDATING = "updating from the package index: {command}";
41
+ /** The installer's own words, never summarised. Both runtimes print the block the same way. */
42
+ export declare const PRINTED = "the command printed:";
43
+ export declare const UPDATED = "updated {program} from {installed} to {latest}.";
44
+ /** AS-7(1), measured. This is the line that makes the success true rather than plausible. */
45
+ export declare const RESTART: string;
46
+ export declare const NO_ROUTE: string;
47
+ export declare const TIMED_OUT: string;
48
+ export declare const UNREACHABLE: string;
49
+ export declare const NOT_A_VERSION: string;
50
+ /**
51
+ * Multi-line on purpose. The installer's output is the only thing that says WHY it failed,
52
+ * and a refusal that swallowed it would send the operator to re-run the command by hand.
53
+ */
54
+ export declare const COMMAND_FAILED: string;
55
+ /**
56
+ * The install shape itself could not be derived — `identity.Undetermined`, which is what an
57
+ * origin that is neither an index nor a path on this machine raises (`git+https://…`). That
58
+ * exception already carries the operator's next step, so this sentence quotes it rather than
59
+ * paraphrasing it, and refuses rather than picking a route.
60
+ */
61
+ export declare const SHAPE_UNKNOWN: string;
62
+ /**
63
+ * The two ways a well-formed HTTP response can still not carry a version, as the `{why}` of
64
+ * `NOT_A_VERSION`. Both runtimes ask a different URL and read a different JSON path, so what
65
+ * is compared is that the same two failures produce the same two sentences.
66
+ */
67
+ export declare const NOT_JSON = "the response is not JSON";
68
+ export declare const NO_VERSION_FIELD = "the response carries no version string";
69
+ /**
70
+ * The fallthrough for a shape word `ROUTES` has never heard of — `INSTALL_SHAPES` growing a
71
+ * sixth word. Not divergent: it names no package manager, so it is the reference's sentence.
72
+ */
73
+ export declare const NO_RECORDED_ROUTE = "There is no recorded update route for a {shape} install, so --update will not guess one.";
74
+ /**
75
+ * Per shape, what the real update route is — the answer to "then how DO I update this?".
76
+ * DIVERGENT BY CONSTRUCTION and registered in `docs/porting.md` by J46-31: the reference's
77
+ * rows name pip and a Python tree; these name npm and a BUILT one.
78
+ *
79
+ * `registry` is absent on purpose, exactly as it is there. It is the one shape that HAS a
80
+ * route through this flag, so a sentence telling its operator to go do it by hand would never
81
+ * be reachable, and an unreachable sentence is one more thing for nobody to check.
82
+ *
83
+ * `{command}` is the SAME string `upgradeCommand` builds and `UPDATING` prints, never a second
84
+ * spelling of it — `local-file` is the one row whose advice is an actual command, and the
85
+ * README's measured fix for the real incident is exactly this: `npm i --prefix
86
+ * ~/.local/share/bantamkit-mcp bantamkit-mcp@latest`.
87
+ *
88
+ * `linked` and `checkout` BOTH say "rebuild it". That is not padding and it is the difference
89
+ * from the reference that matters most on this side: an editable Python install serves the
90
+ * `.py` files a `git pull` just changed, and a Node install serves `dist/`, which is build
91
+ * output. `README.md#updating`'s checkout row already says `git pull && npm ci && npm run
92
+ * build`, and a route that stopped at `git pull` would be a remedy that changes nothing.
93
+ *
94
+ * `ephemeral` IS NOT A TRANSLATION OF THE REFERENCE'S ROW, and making it one would make it
95
+ * false. There it says "the next run fetches {latest} by itself", which is true of a `pipx
96
+ * run` environment; here the shape is an `npx` cache, and `README.md#silent-version-float`
97
+ * measured that `npx -y bantamkit-mcp` RESOLVES `latest` ONCE AND CACHES IT — two people with
98
+ * byte-identical config can be running builds resolved weeks apart. So this row is true of the
99
+ * cache that actually exists rather than of a fresh resolve that does not happen, and it names
100
+ * the only thing that changes it: the spec on the host's command line.
101
+ */
102
+ export declare const ROUTES: Record<string, string>;
103
+ /**
104
+ * A refusal carrying the sentence the operator should read. Never a stack trace.
105
+ *
106
+ * The same contract `hostinstall.InstallError` has, and for the same reason: `--update` can
107
+ * fail for four reasons that are all somebody else's to fix, and a stack trace names none.
108
+ */
109
+ export declare class UpdateRefused extends Error {
110
+ }
111
+ /**
112
+ * The clock ran out. THE CONTRACT BETWEEN `fetchIndex` AND `update` IS THIS TYPE.
113
+ *
114
+ * On the reference side the distinction is `TimeoutError` vs any other `OSError`, and the
115
+ * ORDER of the two `except` clauses is load-bearing because `TimeoutError` IS an `OSError`.
116
+ * The Node hazard is the same one with different names, and it is MEASURED (Node v25.2.1):
117
+ * an `AbortSignal.timeout` abort arrives as a `DOMException` whose `name` is `'TimeoutError'`
118
+ * — and `DOMException` IS `instanceof Error`. So a `catch` that tested `instanceof Error`
119
+ * first would swallow every timeout into the unreachable arm, which names the wrong problem
120
+ * and sends the operator to check a network that is working. `test/selfupdate.test.mjs` pins
121
+ * both halves: that this class is an `Error`, and that the real abort reason is one too.
122
+ */
123
+ export declare class IndexTimeout extends Error {
124
+ }
125
+ /**
126
+ * The install shape and the path it can be pointed at, as `--update` needs them.
127
+ *
128
+ * DELIBERATELY NOT `identity.Install`. That record carries `sourceReason`, which is the
129
+ * `build_identity` wire shape's business and not this flag's; and this keeps the whole
130
+ * decision table testable by constructing a shape directly, including shape words this
131
+ * runtime would never derive. `runUpdate` does the one-line translation.
132
+ */
133
+ export interface Origin {
134
+ readonly shape: string;
135
+ readonly source: string;
136
+ }
137
+ /**
138
+ * WHERE an `npm install` would have to point to replace THIS copy, and whether that is the
139
+ * global tree. Not a second install-shape detector — it derives no shape and answers no shape
140
+ * question; the shape comes from `currentInstall()` and from nowhere else.
141
+ *
142
+ * `global` IS A RECORD, NOT A GUESS, AND THE DIFFERENCE IS DESTRUCTIVE. Measured on this
143
+ * machine 2026-09-11 with npm 11.6.2:
144
+ *
145
+ * - `npm install --prefix <dir> <pkg>` installs into `<dir>/node_modules` and, when `<dir>`
146
+ * already has the `package.json` npm itself wrote there, leaves every sibling package
147
+ * alone.
148
+ * - with NO `package.json` in `<dir>` — which is exactly the global tree's shape, confirmed
149
+ * at `/Users/kktest/.local/share/mise/installs/node/25.2.1/lib`, where `node_modules` holds
150
+ * `@shopify`, `eas-cli` and `npm` and there is no `package.json` beside it — the same
151
+ * command PRUNES the siblings. A fixture with two packages came back with one.
152
+ *
153
+ * So the presence of that `package.json` is the discriminator, and it is a file npm writes for
154
+ * a `--prefix` install and does not write for a global one. Getting this backwards would make
155
+ * `--update` delete a developer's other global CLIs, which is worse than every failure this
156
+ * flag has a sentence for.
157
+ *
158
+ * `root` is `''` when no `node_modules` ancestor exists at all. That arm cannot be reached
159
+ * from the `registry` shape — `deriveInstall` answers `registry` only from inside a
160
+ * `node_modules` — and the pairing is pinned in `test/selfupdate.test.mjs` rather than left as
161
+ * a comment, because the global command it produces needs no root and would otherwise be
162
+ * silently plausible.
163
+ */
164
+ export interface Environment {
165
+ readonly root: string;
166
+ readonly global: boolean;
167
+ }
168
+ export declare function installEnvironment(runningFile?: string): Environment;
169
+ /**
170
+ * `str.format` over the one substitution shape these sentences use — `{name}` and nothing else.
171
+ *
172
+ * A missing key THROWS, the way `str.format` raises `KeyError`, rather than leaving `{name}`
173
+ * in the text: an unsubstituted placeholder reaching the operator is a defect the reference's
174
+ * suite asserts against by name, and a silent passthrough is how one ships.
175
+ */
176
+ export declare function fill(template: string, values: Readonly<Record<string, string | number>>): string;
177
+ /**
178
+ * `shlex.join`: `shlex.quote` each word and space them.
179
+ *
180
+ * Hand-rolled from CPython's own rule — `_find_unsafe = re.compile(r'[^\w@%+=:,./-]', re.ASCII)`
181
+ * — because the rendered command is printed to the operator by `UPDATING`, by `COMMAND_FAILED`
182
+ * and by the `local-file` route, and a prefix path with a space in it is the normal case on
183
+ * macOS and on Windows. Single quotes, with an embedded `'` closed and reopened the way
184
+ * `shlex.quote` does it, so the line can be pasted back into a shell unchanged.
185
+ */
186
+ export declare function shlexJoin(command: readonly string[]): string;
187
+ /**
188
+ * The version string out of the registry's JSON, or a named refusal. Pure — no network here.
189
+ *
190
+ * SPLIT FROM THE FETCH SO THE GARBAGE CASE IS TESTED THROUGH THE REAL PARSER, exactly as the
191
+ * reference splits it: a test that stubbed a parsed version would prove nothing about what
192
+ * happens when a captive-portal login page comes back with a 200, which is the realistic shape
193
+ * of "the index answered and it was not the index".
194
+ *
195
+ * THE JSON PATH IS THE DIVERGENCE. PyPI nests it at `info.version`; `registry.npmjs.org/<pkg>/
196
+ * latest` carries it at the top level. Both refusals below are the same two sentences.
197
+ */
198
+ export declare function latestFromIndexPayload(text: string): string;
199
+ /**
200
+ * The ONLY network call in this runtime. One request, one timeout, two failure words.
201
+ *
202
+ * THE PLATFORM'S `fetch`, NOT A DEPENDENCY. `package.json` declares exactly one runtime
203
+ * dependency and the pure-node `npx` install is a standing ruling, so the HTTP client is the
204
+ * one Node ships (>= 18, and `engines` already requires >= 20) and the deadline is
205
+ * `AbortSignal.timeout`, which is the same object the abort arrives as.
206
+ *
207
+ * THE CONTRACT WITH `update` IS THE EXCEPTION TYPE — see `IndexTimeout`. Measured on Node
208
+ * v25.2.1 against a local socket that accepts and never answers: the rejection is a
209
+ * `DOMException` named `TimeoutError`. Everything else is a `TypeError: fetch failed` whose
210
+ * `cause` carries the sentence worth printing — `connect ECONNREFUSED 127.0.0.1:49999`,
211
+ * `getaddrinfo ENOTFOUND …` — so the cause's message is what `UNREACHABLE` interpolates and
212
+ * the bare "fetch failed" never reaches an operator.
213
+ *
214
+ * A non-2xx is an `Error` and not a timeout, the way the reference turns `HTTPError` into a
215
+ * plain `OSError`; and the body is decoded leniently, because a body that is not UTF-8 is not
216
+ * a version either and should be refused BY NAME in `latestFromIndexPayload`.
217
+ */
218
+ export declare function fetchIndex(url?: string, timeout?: number): Promise<string>;
219
+ /**
220
+ * What a `registry` install upgrades with, ON THE TREE IT IS ACTUALLY IN.
221
+ *
222
+ * The reference's reason transfers exactly: "the server may be running from a venv whose `pip`
223
+ * is not the one first on `PATH`, and upgrading the wrong environment is a failure that
224
+ * reports success." Here the wrong environment is a different `node_modules`, and `--prefix` is
225
+ * npm's own way of naming the right one.
226
+ *
227
+ * `--global` FOR THE GLOBAL TREE AND NEVER `--prefix`. See `installEnvironment`: on a tree with
228
+ * no `package.json` beside `node_modules`, `npm install --prefix` prunes every sibling package.
229
+ * Both arms are the rows `README.md#updating` already documents.
230
+ *
231
+ * `@latest` IS NOT DECORATION. `npm install <pkg>` in a tree that already declares `<pkg>`
232
+ * satisfies the existing range rather than moving it, which is a command that exits 0 having
233
+ * changed nothing — the J46-4 defect, arrived at through npm's semantics rather than through
234
+ * this code's.
235
+ */
236
+ export declare function upgradeCommand(environment: Environment): string[];
237
+ /**
238
+ * Run the installer, capture what it said, return both. The only side effect in this module.
239
+ *
240
+ * CAPTURED RATHER THAN INHERITED so the report has one shape whether or not anybody is
241
+ * watching, and so a test can inject a substitute and assert on the command WITHOUT a real
242
+ * install ever running.
243
+ *
244
+ * THE ONE PLACE THIS IS NOT THE REFERENCE'S BEHAVIOUR: `stderr=STDOUT` genuinely interleaves
245
+ * the two streams in the order they happened, and `spawnSync` has no fd-dup, so the two pipes
246
+ * are concatenated instead — stdout, then stderr. npm writes its progress to stderr and its
247
+ * result to stdout, so an operator reading a failure still gets both, in two blocks rather than
248
+ * one. The installer's own words are never compared across runtimes; the sentences around them
249
+ * are.
250
+ *
251
+ * `shell: true` ON WINDOWS ONLY, the idiom `hostinstall.installViaClaudeCli` established for
252
+ * the same reason: npm is a `npm.cmd` batch shim there and `spawnSync` returns ENOENT for a
253
+ * batch file without a shell. Arguments are quoted because `cmd.exe` gets a string.
254
+ */
255
+ export declare function runInstaller(command: readonly string[]): [number, string];
256
+ /**
257
+ * -1 when the index is ahead, 0 when they agree, 1 when the installed version is ahead.
258
+ *
259
+ * The shorter of the two is padded with numeric zeros, so `0.30` and `0.30.0` agree — which is
260
+ * what a person means by them and what both registries would print for one release.
261
+ */
262
+ export declare function compareVersions(installed: string, latest: string): number;
263
+ /**
264
+ * `10` rather than `10.0`, because the sentence is read by a person, not parsed.
265
+ *
266
+ * The reference spells this out so that Node's default rendering of a whole number is what
267
+ * both sides print, which is what keeps `TIMED_OUT` byte-identical instead of ruled. Here it
268
+ * is the identity function on a number, and it exists so the pairing is visible from both
269
+ * files rather than only from one.
270
+ */
271
+ export declare function seconds(timeout: number): string;
272
+ export interface UpdateOptions {
273
+ readonly fetch?: (url: string, timeout: number) => Promise<string> | string;
274
+ readonly installer?: (command: string[]) => [number, string];
275
+ readonly url?: string;
276
+ readonly timeout?: number;
277
+ readonly environment?: Environment;
278
+ }
279
+ /**
280
+ * The whole flag: ask the index, compare, and act — or refuse, by name.
281
+ *
282
+ * THE TWO SEAMS ARE `fetch` AND `installer`, and they are options with real defaults rather
283
+ * than module bindings a test rewrites. A test that reached the registry would fail on a plane
284
+ * and pass for the wrong reason off a cache; a test that ran a real installer would rewrite the
285
+ * developer's own `node_modules` mid-suite. Both substitutes are handed in here, so every arm
286
+ * below is reachable offline and the npm command is CAPTURED, never run.
287
+ *
288
+ * THE INDEX IS ASKED BEFORE THE SHAPE IS JUDGED, and that order is the reference's and is
289
+ * deliberate. The user asked the flag to go and check the latest version; an operator on a
290
+ * checkout five releases behind is owed that number even though this flag will not be the thing
291
+ * that installs it. The shape decides the ACTION, never whether the question is asked.
292
+ */
293
+ export declare function update(installed: string, origin: Origin, options?: UpdateOptions): Promise<string>;
294
+ /**
295
+ * `--update` on the CLI: ask the package index, act on the answer, print what happened, exit.
296
+ *
297
+ * THE SENTENCES ARE NOT HERE. This function owns three things and nothing else: where the
298
+ * shape comes from, which stream each outcome is written to, and the exit code.
299
+ *
300
+ * STDOUT + EXIT 0 IS ONLY FOR AN ANSWER THAT IS ALREADY TRUE — up to date, the index behind,
301
+ * or an update that actually ran. Everything else is `error: <sentence>` on stderr and exit 1,
302
+ * including the install shapes this flag will not touch: an operator who typed `--update`
303
+ * asked for an update, and a command that exits 0 having changed nothing is the J46-4 defect
304
+ * by name. Exit 1 rather than 2 because argparse already owns 2 for a usage error.
305
+ *
306
+ * THE SHAPE IS `currentInstall()`'s ANSWER AND NOT A SECOND DETECTOR. J46-12 (`c9372ca`)
307
+ * shipped it as a module-level function taking no arguments and touching no server state,
308
+ * explicitly so this flag could ask it before a store or a transport exists.
309
+ *
310
+ * `Undetermined` — a `git+https://…` origin, which is neither an index nor a path on this
311
+ * machine — is a refusal and not a fallback. Its own message already names the route, so it is
312
+ * quoted rather than paraphrased.
313
+ *
314
+ * THE WRITERS ARE ARGUMENTS so this arm is testable without a subprocess and without rewriting
315
+ * `process.stdout`. `cli.ts` hands in the real two. Both receive a UTF-8 string, which is LF
316
+ * on every platform — the same reason the reference goes through `sys.stdout.buffer`, where
317
+ * `print` would emit CRLF on Windows and a byte-comparing conformance runner would read that
318
+ * as a divergence belonging to the writer rather than to the product.
319
+ */
320
+ export declare function runUpdate(out: (text: string) => void, err: (text: string) => void, installed: string, packageDirectory: string, options?: UpdateOptions): Promise<number>;