@inferall/cli 0.2.0 → 0.2.1

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/bin/inferall.js CHANGED
@@ -23,15 +23,222 @@ import {
23
23
  existsSync,
24
24
  readdirSync,
25
25
  statSync,
26
+ realpathSync,
27
+ accessSync,
28
+ constants as fsConstants,
26
29
  } from "node:fs";
27
- import { join, relative, extname } from "node:path";
30
+ import { join, relative, extname, basename, dirname } from "node:path";
31
+ import { pathToFileURL } from "node:url";
32
+ import { spawn, spawnSync } from "node:child_process";
28
33
 
29
34
  const API_BASE = env.INFERALL_API_BASE || "https://api.inferall.ai/v1";
30
35
  // The Anthropic SDK appends /v1/messages itself, so its baseURL is the host root.
31
36
  const ANTHROPIC_BASE = API_BASE.replace(/\/v1$/, "");
32
37
  const KEYS_URL = "https://inferall.ai/keys";
33
38
  const BILLING_URL = "https://inferall.ai/billing?src=cli_init";
34
- const FREE_MODEL = "meta/llama-3.1-8b-instruct";
39
+
40
+ /**
41
+ * Did we fail to REACH the gateway, as opposed to reaching it and disliking the
42
+ * answer? 2026-07-27 tick 832.
43
+ *
44
+ * With the gateway unreachable, `doctor` printed
45
+ *
46
+ * ✗ key rejected by gateway (network error). Check https://inferall.ai/keys
47
+ *
48
+ * The key was never checked. That sentence names a cause that did not happen
49
+ * and sends the user to mint a replacement key, which cannot fix an unreachable
50
+ * host. `models` was worse: the throw reached the top-level handler and printed
51
+ * a bare `✗ fetch failed`, which is Node's words, not ours.
52
+ *
53
+ * One predicate so every caller classifies it the same way. Node/undici phrases
54
+ * the same condition several ways depending on how it fails, so match on all of
55
+ * them rather than the one seen first.
56
+ */
57
+ /**
58
+ * Every request this CLI makes is bounded.
59
+ *
60
+ * WHY THIS EXISTS (2026-07-31, tick 883)
61
+ * -------------------------------------
62
+ * Node's fetch applies no request timeout, and none of the call sites set one.
63
+ * Measured against a stub that accepts the connection and never replies:
64
+ *
65
+ * inferall status -> 45s+, ZERO bytes of output, no exit
66
+ *
67
+ * Not a hang forever (undici gives up on headers around 300s) but five minutes
68
+ * of a completely blank terminal, with nothing printed to say a request is even
69
+ * in flight. That is the worst shape a failure can take: there is nothing to
70
+ * read, nothing to act on, and no error to search for. A blackholing firewall
71
+ * or a proxy that swallows outbound traffic produces exactly this.
72
+ */
73
+ const REQUEST_TIMEOUT_MS = 30_000;
74
+ const timeoutSignal = () => AbortSignal.timeout(REQUEST_TIMEOUT_MS);
75
+
76
+ function isNetworkError(err) {
77
+ // A timeout arrives as a DOMException named TimeoutError carrying no code,
78
+ // so matching on codes alone would drop it into the generic catch and print
79
+ // Node's words instead of ours.
80
+ if (err?.name === "TimeoutError" || err?.cause?.name === "TimeoutError") return true;
81
+ const m = `${err?.cause?.code || ""} ${err?.code || ""} ${err?.message || err || ""}`;
82
+ return /fetch failed|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|ETIMEDOUT|ECONNRESET|socket hang up|UND_ERR|network/i.test(m);
83
+ }
84
+
85
+ /** What to say when we could not reach the gateway. Never blames the key. */
86
+ /**
87
+ * Name WHY the gateway could not be reached, from the cause the error already
88
+ * carries.
89
+ *
90
+ * 2026-07-29 tick 852. `err.message` is always the string "fetch failed",
91
+ * whatever went wrong, so the old single remedy ("check your connection") was
92
+ * printed for every cause. MEASURED against real failures:
93
+ *
94
+ * self-signed TLS cause.code = DEPTH_ZERO_SELF_SIGNED_CERT
95
+ * DNS failure cause.code = ENOTFOUND
96
+ * nothing listening cause.code = ECONNREFUSED
97
+ *
98
+ * These need completely different fixes, and the one that matters most is the
99
+ * TLS case: behind a corporate proxy that re-signs traffic, the user's
100
+ * connection is fine and "check your connection" sends them looking in the
101
+ * wrong place entirely. The remedy is a trust store, which they will not guess.
102
+ *
103
+ * Returns null for a cause we cannot name, and the caller then prints the raw
104
+ * code. Inventing a confident explanation for an unrecognised failure is how a
105
+ * diagnostic starts lying, and this file has paid for that twice already.
106
+ */
107
+ export function describeNetworkCause(err) {
108
+ const code = err?.cause?.code || err?.code || "";
109
+ if (err?.name === "TimeoutError" || err?.cause?.name === "TimeoutError") {
110
+ return {
111
+ code: "TIMEOUT",
112
+ why: `the gateway accepted the connection but sent nothing back within ${REQUEST_TIMEOUT_MS / 1000}s`,
113
+ fix: "Often a firewall or proxy that swallows outbound traffic. Retry, or check INFERALL_API_BASE.",
114
+ };
115
+ }
116
+ if (!code) return null;
117
+ if (/SELF_SIGNED|UNABLE_TO_VERIFY|CERT_|_CERT$|ERR_TLS/i.test(code)) {
118
+ return {
119
+ code,
120
+ why: "the TLS certificate could not be verified, which usually means a proxy is re-signing traffic",
121
+ fix: `Point Node at your organisation's CA: ${c.cyan("export NODE_EXTRA_CA_CERTS=/path/to/corporate-ca.pem")}`,
122
+ };
123
+ }
124
+ if (/ENOTFOUND|EAI_AGAIN/i.test(code)) {
125
+ return { code, why: "the hostname did not resolve", fix: "Check DNS, a VPN, or a typo in the host name." };
126
+ }
127
+ if (/ECONNREFUSED/i.test(code)) {
128
+ return {
129
+ code,
130
+ why: "nothing is listening there",
131
+ fix: `Usually a stale ${c.cyan("INFERALL_API_BASE")} pointing at a local port that is not running.`,
132
+ };
133
+ }
134
+ if (/ETIMEDOUT|UND_ERR_CONNECT_TIMEOUT|ECONNRESET|EPIPE/i.test(code)) {
135
+ return { code, why: "the connection timed out or was dropped", fix: "Often a firewall or a proxy that blocks outbound HTTPS." };
136
+ }
137
+ return { code, why: null, fix: null };
138
+ }
139
+
140
+ /**
141
+ * Explain a NON-OK HTTP response without guessing at the cause.
142
+ *
143
+ * WHY THIS EXISTS (2026-07-31, tick 882)
144
+ * -------------------------------------
145
+ * Tick 832 established that an unreachable gateway must not be reported as a
146
+ * rejected key, "so users went and regenerated a key that was fine". That fix
147
+ * covered NETWORK errors, which arrive as a thrown exception. A 500 is not a
148
+ * network error: it is a completed HTTP exchange with a failure status, so it
149
+ * never entered that path and the old wording survived there untouched.
150
+ *
151
+ * Measured this tick against a stub forced to 500:
152
+ *
153
+ * status -> "status check failed (HTTP 500). Check the key at <keys page>"
154
+ * doctor -> "key rejected by gateway (HTTP 500)"
155
+ *
156
+ * Both blame the key for a server fault. During a real gateway incident every
157
+ * user would be told their key was rejected, and the remedy on offer, minting a
158
+ * new key, cannot fix a 500. doctor carries a comment two lines above saying a
159
+ * confident wrong cause is the failure that costs the most there.
160
+ *
161
+ * Only 401/403 are actually a verdict on the key.
162
+ */
163
+ export function httpFailureLines(status) {
164
+ if (status === 401 || status === 403) {
165
+ return [
166
+ `${c.red("✗")} key rejected by gateway (HTTP ${status})`,
167
+ ` The gateway does not recognise this key. Mint one at ${KEYS_URL}`,
168
+ ];
169
+ }
170
+ if (status === 429) {
171
+ return [
172
+ `${c.red("✗")} rate limited by the gateway (HTTP 429)`,
173
+ ` Your key is fine. Wait a moment and retry.`,
174
+ ];
175
+ }
176
+ if (status >= 500) {
177
+ return [
178
+ `${c.red("✗")} the gateway is failing (HTTP ${status})`,
179
+ ` This is NOT your key and there is nothing to fix on your side.`,
180
+ ` Retry shortly. A new key will not help.`,
181
+ ];
182
+ }
183
+ return [
184
+ `${c.red("✗")} unexpected response from the gateway (HTTP ${status})`,
185
+ ` This is not a verdict on your key. Retry, and report it if it persists.`,
186
+ ];
187
+ }
188
+
189
+ function unreachableLines(err) {
190
+ const cause = describeNetworkCause(err);
191
+ return [
192
+ `${c.red("✗")} cannot reach the InferAll gateway at ${c.bold(API_BASE)}`,
193
+ // The cause is printed even when unrecognised: a bare code is still a
194
+ // search term, and hiding it leaves the user with nothing.
195
+ ...(cause
196
+ ? [` cause: ${c.bold(cause.code)}${cause.why ? `, ${cause.why}` : ""}`, ...(cause.fix ? [` ${cause.fix}`] : [])]
197
+ : []),
198
+ // Phrased to be exactly true from every caller: `models` never sends a key
199
+ // at all, so "your key was not checked" would be true but beside the point
200
+ // there. What every caller needs is the same: do not read this as a verdict
201
+ // on your setup.
202
+ ` ${c.bold("Nothing")} was verified, so this says nothing about your key or your setup.`,
203
+ ` Check your connection, or whether ${c.cyan("INFERALL_API_BASE")} points somewhere unexpected.`,
204
+ ];
205
+ }
206
+ // 2026-10-03: the previous default was end-of-lifed by NVIDIA; ultra is the default free model.
207
+ const FREE_MODEL = "nvidia/nemotron-3-ultra-550b-a55b";
208
+ const CLI_VERSION = (() => {
209
+ try {
210
+ return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
211
+ } catch {
212
+ return "0.0.0";
213
+ }
214
+ })();
215
+
216
+ // Written by scripts/publish-gate.sh at publish time, and never committed.
217
+ // npm publishes the working tree rather than a commit, so without this the
218
+ // version string is the only identity the artifact has - and one version string
219
+ // has covered 34 different trees so far.
220
+ //
221
+ // ABSENT MEANS "unstamped", NOT "broken". A dev checkout and an `npm link` are
222
+ // legitimate states; neither should be reported as a fault. Only a stamp that
223
+ // is present AND carries a sha counts.
224
+ const BUILD_INFO = (() => {
225
+ try {
226
+ const j = JSON.parse(readFileSync(new URL("./build-info.json", import.meta.url), "utf8"));
227
+ return j && typeof j.sha === "string" && j.sha ? j : null;
228
+ } catch {
229
+ return null;
230
+ }
231
+ })();
232
+
233
+ // An off-main publish is surfaced to the user rather than buried in a CI log,
234
+ // because the CI log is exactly what nobody reads on a green run.
235
+ export function versionLine(version, build) {
236
+ if (!build || typeof build.sha !== "string" || !build.sha) return version;
237
+ const offMain = typeof build.offMain === "string" && build.offMain.trim() ? build.offMain.trim() : null;
238
+ return offMain
239
+ ? `${version} (${build.sha}, published off-main: ${offMain})`
240
+ : `${version} (${build.sha})`;
241
+ }
35
242
 
36
243
  const c = {
37
244
  dim: (s) => `\x1b[2m${s}\x1b[0m`,
@@ -43,8 +250,37 @@ const c = {
43
250
  };
44
251
 
45
252
  const args = argv.slice(2);
46
- const cmd = args.find((a) => !a.startsWith("-")) || "init";
47
- const flag = (name) => args.includes(`--${name}`);
253
+ // 2026-07-31 tick 876. `|| "init"` swallowed every flag-only invocation.
254
+ // `inferall --help` has no non-flag argument, so cmd fell through to init and
255
+ // the user got an interactive setup wizard prompting "Paste your InferAll API
256
+ // key" instead of help. Measured on the packed artifact: --help, -h and -v all
257
+ // ran the wizard; only the bare word `help` and the long `--version` worked.
258
+ //
259
+ // --help is the first thing anyone types on an unfamiliar CLI, so the default
260
+ // has to run AFTER the help and version flags are considered, not before.
261
+ const HELP_FLAGS = new Set(["--help", "-h"]);
262
+ const VERSION_FLAGS = new Set(["--version", "-v", "-V"]);
263
+ const cmd =
264
+ args.find((a) => !a.startsWith("-")) ||
265
+ (args.some((a) => HELP_FLAGS.has(a))
266
+ ? "help"
267
+ : args.some((a) => VERSION_FLAGS.has(a))
268
+ ? "version"
269
+ : "init");
270
+ // Single-character names also accept the conventional single-dash form.
271
+ //
272
+ // 2026-08-01 tick 899. `const YES = flag("yes") || flag("y")` on the line below
273
+ // reads as a `-y` alias and was clearly written to be one, but flag() only ever
274
+ // built `--${name}`, so it enabled `--y` (which nobody types and no doc
275
+ // mentions) and NOT `-y`. Meanwhile README.md documents `--yes` / `-y` in its
276
+ // flag table. MEASURED end to end against a stub: `init --key ... --yes` exits
277
+ // 0 and wires the project; the identical run with `-y` exits 1 and prints the
278
+ // non-interactive help. So the landing page documented a flag that failed, and
279
+ // a user copying it into CI got a broken step.
280
+ //
281
+ // Restricted to length 1 so this cannot start accepting `-yes` or `-no-ai`,
282
+ // which are not real forms and would be a new way to be wrong.
283
+ const flag = (name) => args.includes(`--${name}`) || (name.length === 1 && args.includes(`-${name}`));
48
284
  const flagValue = (name) => {
49
285
  const i = args.indexOf(`--${name}`);
50
286
  return i >= 0 && args[i + 1] && !args[i + 1].startsWith("-") ? args[i + 1] : undefined;
@@ -52,12 +288,114 @@ const flagValue = (name) => {
52
288
  const YES = flag("yes") || flag("y");
53
289
  const NO_AI = flag("no-ai");
54
290
 
55
- const rl = createInterface({ input: stdin, output: stdout });
291
+ // Lazily created. At module scope this held stdin open, so merely IMPORTING
292
+ // this file kept a Node process alive forever — which is why `node --test`
293
+ // hung the first time the codemod was given tests. Interactive state must not
294
+ // be a side effect of loading the module.
295
+ let _rl = null;
296
+ const rlHandle = () => (_rl ??= createInterface({ input: stdin, output: stdout }));
297
+ const rl = {
298
+ question: (q) => rlHandle().question(q),
299
+ close: () => { _rl?.close(); _rl = null; },
300
+ };
301
+ /**
302
+ * Raised when a prompt is reached but there is no more input to answer it.
303
+ *
304
+ * 2026-07-31 tick 879: piping answers into `init` crashed with a bare
305
+ * `✗ readline was closed`, and Ctrl+D produced `✗ Aborted with Ctrl+D`.
306
+ * Neither string is ours; both are Node internals reaching the user through
307
+ * the generic catch. The crash landed AFTER the key was written and after we
308
+ * had printed that it was saved, so the last thing a user saw was an internal
309
+ * error with no way to tell whether setup had finished.
310
+ */
311
+ class InputEndedError extends Error {
312
+ constructor(cause) {
313
+ super("input ended before setup finished");
314
+ this.name = "InputEndedError";
315
+ this.cause = cause;
316
+ }
317
+ }
318
+
56
319
  async function ask(q, fallback) {
57
320
  if (YES) return fallback ?? "y";
58
- const a = (await rl.question(q)).trim();
321
+ // Ending stdin fails in TWO different ways and both had to be caught.
322
+ //
323
+ // 1. At a LATER prompt, after a close/reopen, question() REJECTS with
324
+ // "readline was closed". Closing a readline over a pipe ends stdin for
325
+ // good, so queued answers are discarded; feeding eight answers failed
326
+ // exactly like feeding one.
327
+ // 2. At the FIRST prompt, when stdin is already ended, question() simply
328
+ // NEVER SETTLES. The event loop empties and the process exits with no
329
+ // message at all, which is worse than a bad message.
330
+ //
331
+ // Racing the readline's own close event covers both, so this cannot depend
332
+ // on which prompt happened to be reached first.
333
+ const handle = rlHandle();
334
+ let a = await new Promise((resolve, reject) => {
335
+ const onClose = () => reject(new InputEndedError());
336
+ handle.once("close", onClose);
337
+ handle.question(q).then(
338
+ (v) => { handle.off("close", onClose); resolve(v); },
339
+ (err) => { handle.off("close", onClose); reject(new InputEndedError(err)); },
340
+ );
341
+ });
342
+ a = a.trim();
59
343
  return a === "" ? (fallback ?? "") : a;
60
344
  }
345
+ /**
346
+ * Open a URL in the user's default browser. Returns true if the command was
347
+ * dispatched, false if we could not.
348
+ *
349
+ * Deliberately fire-and-forget and never throws: this is a convenience on the
350
+ * signup path, and a headless box, a locked-down container, or a missing
351
+ * xdg-open must degrade to "here is the URL" rather than taking down a wizard
352
+ * that was about to work fine.
353
+ *
354
+ * The URL is a module constant, never user input, so there is no injection
355
+ * surface here — but it is passed as an argv element rather than interpolated
356
+ * into a shell string, so that stays true if someone later makes it dynamic.
357
+ */
358
+ function openBrowser(url) {
359
+ try {
360
+ const cmd =
361
+ process.platform === "darwin" ? "open" :
362
+ process.platform === "win32" ? "cmd" : "xdg-open";
363
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
364
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
365
+ child.on("error", () => {}); // ENOENT on a box with no opener — already handled by the return
366
+ child.unref();
367
+ return true;
368
+ } catch {
369
+ return false;
370
+ }
371
+ }
372
+
373
+ /**
374
+ * Keep the patched file's trailing-newline state identical to the original.
375
+ *
376
+ * 2026-07-26 tick 801: the wizard was STRIPPING the final newline from every
377
+ * source file it rewrote. Found by running `init` on a Python project and
378
+ * noticing the next shell prompt ran onto the last line of the patched file.
379
+ *
380
+ * Small, but this is someone else's repository. A missing final newline is
381
+ * `W292` to flake8, `eol-last` to eslint, and renders as
382
+ * "" in the first git diff a user sees after
383
+ * running our installer. Introducing lint errors into a project we were
384
+ * invited to configure is exactly the kind of thing that makes a tool feel
385
+ * unsafe to run twice.
386
+ *
387
+ * Symmetric on purpose: a file that never ended with a newline does not get
388
+ * one added. The rule is "leave it as you found it", not "normalise it" — this
389
+ * tool has no business having an opinion about a convention it did not set.
390
+ */
391
+ export function preserveTrailingNewline(original, patched) {
392
+ const had = original.endsWith("\n");
393
+ const has = patched.endsWith("\n");
394
+ if (had && !has) return patched + "\n";
395
+ if (!had && has) return patched.replace(/\n+$/, "");
396
+ return patched;
397
+ }
398
+
61
399
  async function confirm(q, defaultYes = true) {
62
400
  const hint = defaultYes ? "Y/n" : "y/N";
63
401
  const a = (await ask(`${q} ${c.dim(`[${hint}]`)} `, defaultYes ? "y" : "n")).toLowerCase();
@@ -66,89 +404,739 @@ async function confirm(q, defaultYes = true) {
66
404
 
67
405
  // ---------- project detection ----------
68
406
 
69
- function detectProject(root) {
70
- const p = { root, kind: null, sdks: [], pkg: null };
407
+ const NODE_SDKS = ["openai", "@anthropic-ai/sdk", "@google/generative-ai", "@google/genai", "ai", "@inferall/sdk"];
408
+
409
+ /**
410
+ * Every package.json at or below `root`, skipping node_modules.
411
+ *
412
+ * 2026-07-26 tick 803: detection used to read ONLY the root package.json. In a
413
+ * workspace monorepo the root declares `workspaces` and no dependencies, so
414
+ * `sdks` came back empty, the wizard took its greenfield branch, wrote a
415
+ * hello-world example, and printed "Done. Your project is wired to InferAll"
416
+ * while packages/api/src/service.ts still pointed at OpenAI with
417
+ * OPENAI_API_KEY. Reported success, migrated nothing. Same failure family as
418
+ * the tick-800 codemod bug, and monorepos are not an edge case in this
419
+ * ecosystem.
420
+ *
421
+ * Deliberately a filesystem walk rather than parsing the `workspaces` globs:
422
+ * it also catches pnpm/nx/turbo layouts and plain nested packages that never
423
+ * declare workspaces at the root. The call-site scanner already walks to depth
424
+ * 6, so detection was the only thing looking in one place.
425
+ *
426
+ * Depth 3 covers packages/<name>/package.json and apps/<name>/package.json,
427
+ * which is where these live in practice.
428
+ */
429
+ function findPackageJsons(root, depth = 0, acc = []) {
430
+ if (depth > 3) return acc;
431
+ const here = join(root, "package.json");
432
+ if (existsSync(here)) acc.push(here);
433
+ let entries = [];
434
+ try { entries = readdirSync(root); } catch { return acc; }
435
+ for (const name of entries) {
436
+ if (SKIP_DIRS.has(name) || name.startsWith(".")) continue;
437
+ const full = join(root, name);
438
+ try { if (!statSync(full).isDirectory()) continue; } catch { continue; }
439
+ findPackageJsons(full, depth + 1, acc);
440
+ }
441
+ return acc;
442
+ }
443
+
444
+ /**
445
+ * Does .gitignore list .env?
446
+ *
447
+ * TRI-STATE ON PURPOSE, and `covers: null` is the whole reason this exists.
448
+ *
449
+ * 2026-07-28 tick 844: `doctor` did `existsSync(giPath) && readFileSync(giPath)`
450
+ * with nothing around it. existsSync answers TRUE for a directory, so a
451
+ * .gitignore that was a directory threw EISDIR and an unreadable one threw
452
+ * EACCES -- from step 2 of five, which meant the hygiene verdict AND the key
453
+ * check never ran at all. MEASURED, both spellings:
454
+ *
455
+ * ✗ EISDIR: illegal operation on a directory, read
456
+ * ✗ EACCES: permission denied, open '/private/tmp/.../giperm/.gitignore'
457
+ *
458
+ * People run `doctor` precisely because something is already wrong, so handing
459
+ * them a bare errno, the absolute path of their own machine, and no diagnosis
460
+ * is the worst moment to fail. This is the tick 835 unreadable-.env bug in the
461
+ * one reader I never enumerated: 835 fixed the .env READ, 836 fixed the WRITES
462
+ * including .gitignore, and this READ sat between them untouched.
463
+ *
464
+ * Returning `false` on failure would have been the smaller change and the wrong
465
+ * one. false means "not ignored" and would print a confident warning about a
466
+ * file we could not open; the tick-839 lesson is that a boolean meaning both
467
+ * "no" and "could not tell" is two facts wearing one type.
468
+ */
469
+ export function gitignoreCoversEnv(root) {
470
+ const giPath = join(root, ".gitignore");
471
+ if (!existsSync(giPath)) return { covers: false, problem: null };
472
+ try {
473
+ const gi = readFileSync(giPath, "utf8");
474
+ return {
475
+ covers: gi.split("\n").some((l) => [".env", ".env*", "*.env"].includes(l.trim())),
476
+ problem: null,
477
+ };
478
+ } catch (err) {
479
+ // describeFsError basenames the path, so this stops leaking the absolute
480
+ // path of the user's machine along with the errno.
481
+ return { covers: null, problem: describeFsError(err, giPath, "read") };
482
+ }
483
+ }
484
+
485
+ export function detectProject(root) {
486
+ const p = { root, kind: null, sdks: [], pkg: null, readProblems: [] };
71
487
  const pkgPath = join(root, "package.json");
72
488
  if (existsSync(pkgPath)) {
73
489
  p.kind = "node";
74
490
  try {
75
491
  p.pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
76
- const deps = { ...p.pkg.dependencies, ...p.pkg.devDependencies };
77
- for (const name of ["openai", "@anthropic-ai/sdk", "@google/generative-ai", "@google/genai", "ai", "@inferall/sdk"]) {
78
- if (deps?.[name]) p.sdks.push(name);
79
- }
80
492
  } catch {
81
493
  /* unparseable package.json — treat as bare node */
82
494
  }
495
+ const found = new Set();
496
+ for (const f of findPackageJsons(root)) {
497
+ try {
498
+ const pkg = JSON.parse(readFileSync(f, "utf8"));
499
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
500
+ for (const name of NODE_SDKS) if (deps?.[name]) found.add(name);
501
+ } catch { /* skip unparseable */ }
502
+ }
503
+ p.sdks = [...found];
83
504
  } else if (existsSync(join(root, "pyproject.toml")) || existsSync(join(root, "requirements.txt"))) {
84
505
  p.kind = "python";
506
+ // 2026-07-28 tick 844: same unguarded existsSync-then-readFileSync as the
507
+ // .gitignore reader in `doctor`. MEASURED: a pyproject.toml that is a
508
+ // DIRECTORY killed `init` with "EISDIR: illegal operation on a directory,
509
+ // read", and an unreadable requirements.txt with EACCES plus the absolute
510
+ // path. Found by enumerating every read in this file rather than by fixing
511
+ // only the one that was reported, which is how the first copy survived.
512
+ //
513
+ // Swallowing the error to "" would be the tempting one-liner and it
514
+ // recreates the tick 805 bug documented immediately below: with no manifest
515
+ // text, detection finds no SDK, the wizard takes its greenfield branch and
516
+ // announces "Done. Your project is wired to InferAll" over a project that
517
+ // still calls OpenAI directly. So the failure is recorded and surfaced.
85
518
  const reqs = ["pyproject.toml", "requirements.txt"]
86
519
  .filter((f) => existsSync(join(root, f)))
87
- .map((f) => readFileSync(join(root, f), "utf8"))
520
+ .map((f) => {
521
+ try {
522
+ return readFileSync(join(root, f), "utf8");
523
+ } catch (err) {
524
+ p.readProblems.push(describeFsError(err, join(root, f), "read"));
525
+ return "";
526
+ }
527
+ })
88
528
  .join("\n");
89
- for (const name of ["openai", "anthropic", "google-generativeai", "google-genai", "inferall-ai"]) {
529
+ // 2026-07-26 tick 805: langchain-openai added. A LangChain project declares
530
+ // `langchain-openai`, never `openai`, so detection found nothing, the wizard
531
+ // took its greenfield branch and printed "Done. Your project is wired to
532
+ // InferAll" while app/chain.py still called OpenAI directly. Third variant
533
+ // of the detected-reported-success-migrated-nothing family (t803 monorepo,
534
+ // t804 Vercel AI). LangChain is one of the most common Python AI stacks.
535
+ for (const name of ["openai", "anthropic", "google-generativeai", "google-genai", "inferall-ai",
536
+ "langchain-openai", "langchain_openai",
537
+ "langchain-anthropic", "langchain_anthropic"]) {
90
538
  if (new RegExp(`^\\s*"?${name}`, "m").test(reqs)) p.sdks.push(name);
91
539
  }
92
540
  }
541
+
542
+ // 2026-07-27 tick 828: a Python project with NO manifest.
543
+ //
544
+ // pip install without a committed requirements.txt is ordinary — scripts,
545
+ // notebooks-turned-scripts, anything using a venv or conda or uv where the
546
+ // lockfile is not in the repo. Neither branch above matched, so kind stayed
547
+ // null and the wizard said:
548
+ //
549
+ // No package.json / pyproject.toml / requirements.txt found here.
550
+ // Run this from your project root, or continue for a bare .env setup.
551
+ //
552
+ // while standing IN the project root, with .py files calling the exact SDKs
553
+ // it migrates. The advice was not merely unhelpful, it was impossible to
554
+ // follow: there is no other directory to run it from. Then it labelled the
555
+ // project "node" and offered a bare .env, so the Python code was never
556
+ // migrated and the user was told to go somewhere that does not exist.
557
+ //
558
+ // The manifest is evidence, not the definition. Imports are the better
559
+ // evidence, and the codemod handles these files perfectly once detection
560
+ // stops filtering them out (verified: both an OpenAI and an Anthropic site
561
+ // patch with the correct per-SDK base). So when no manifest is found, look
562
+ // for actual SDK imports in the source.
563
+ if (!p.kind) {
564
+ const imported = findPythonSdkImports(root);
565
+ if (imported.length) {
566
+ p.kind = "python";
567
+ p.sdks = imported;
568
+ p.inferredFrom = "imports";
569
+ } else {
570
+ // 2026-07-27 tick 829: the same hole, one surface over.
571
+ //
572
+ // A notebook-only project (the ordinary data-science shape: one .ipynb,
573
+ // pip install, nothing committed) still had no manifest and no .py, so
574
+ // the tick-828 fallback missed it and we printed
575
+ //
576
+ // No project detected here: ... and no Python file importing an AI SDK.
577
+ // If your code lives elsewhere, cd there and re-run.
578
+ //
579
+ // while findNotebookSites finds that exact notebook in under a
580
+ // millisecond. The assertion was false and the advice was again
581
+ // impossible to follow.
582
+ //
583
+ // Notebooks are deliberately NOT patched -- handing JSON to the text
584
+ // codemod corrupts the file (t816) -- but they ARE reported, and being
585
+ // reported requires getting past detection first. Detecting them sets up
586
+ // the honest path: .env written, notebook guidance printed, and the
587
+ // "still need the one-line change" count includes them.
588
+ //
589
+ // Reuses findNotebookSites and CLIENT_CLASSES rather than parsing cells
590
+ // again here. A parallel copy is how these two would drift apart.
591
+ const nbSites = findNotebookSites(root);
592
+ if (nbSites.length) {
593
+ p.kind = "python";
594
+ p.sdks = [...new Set(nbSites.flatMap((s) => s.hits.map((h) => CLIENT_CLASSES[h.sdk])))];
595
+ p.inferredFrom = "notebooks";
596
+ }
597
+ }
598
+ }
93
599
  return p;
94
600
  }
95
601
 
602
+ /**
603
+ * Which Python SDKs does this tree actually import? Used only when there is no
604
+ * manifest to read. Reuses the same walker as the codemod so the two can never
605
+ * disagree about which files count -- a mismatch there would recreate the
606
+ * detected-but-not-migrated family of bugs (t803/t804/t805) from the other end.
607
+ */
608
+ const PY_IMPORT_SDKS = [
609
+ ["openai", /^\s*(?:from\s+openai\b|import\s+openai\b)/m],
610
+ ["anthropic", /^\s*(?:from\s+anthropic\b|import\s+anthropic\b)/m],
611
+ ["langchain-openai", /^\s*(?:from\s+langchain_openai\b|import\s+langchain_openai\b)/m],
612
+ ["langchain-anthropic", /^\s*(?:from\s+langchain_anthropic\b|import\s+langchain_anthropic\b)/m],
613
+ ["google-generativeai", /^\s*(?:from\s+google\.generativeai\b|import\s+google\.generativeai\b)/m],
614
+ ["google-genai", /^\s*(?:from\s+google\s+import\s+genai\b|from\s+google\.genai\b)/m],
615
+ ];
616
+
617
+ export function findPythonSdkImports(root) {
618
+ const found = new Set();
619
+ for (const file of walk(root)) {
620
+ if (extname(file) !== ".py") continue;
621
+ let body;
622
+ try {
623
+ body = readFileSync(file, "utf8");
624
+ } catch {
625
+ continue; // unreadable file is not a detection failure (t824)
626
+ }
627
+ for (const [name, re] of PY_IMPORT_SDKS) if (re.test(body)) found.add(name);
628
+ }
629
+ return [...found];
630
+ }
631
+
96
632
  // ---------- env / key setup ----------
97
633
 
98
- function upsertEnvFile(root, key) {
634
+ /**
635
+ * The INFERALL_API_KEY currently in .env, or null.
636
+ *
637
+ * 2026-07-27 tick 820: added because upsertEnvFile replaced any existing key
638
+ * line unconditionally and the caller announced it as "+ written". A user with
639
+ * a working key in .env who re-ran init had it silently swapped for whatever
640
+ * the wizard resolved, and the only report was a plus sign. Measured:
641
+ *
642
+ * before INFERALL_API_KEY=ifu_MY_EXISTING_WORKING_KEY
643
+ * after INFERALL_API_KEY=ifu_NEW_DIFFERENT_KEY
644
+ * printed + INFERALL_API_KEY written to .env
645
+ *
646
+ * Destroying somebody's credential is bad; describing it as an addition is
647
+ * what makes it undiscoverable.
648
+ */
649
+ /**
650
+ * Pull INFERALL_API_KEY out of .env text, the way dotenv would.
651
+ *
652
+ * 2026-07-27 tick 834. Three copies of `/^INFERALL_API_KEY=(.+)$/m` lived in
653
+ * this file and none of them understood ordinary .env syntax. MEASURED against
654
+ * a stub that echoes the Authorization header it received:
655
+ *
656
+ * INFERALL_API_KEY=ifu_x -> Bearer ifu_x ok
657
+ * INFERALL_API_KEY="ifu_x" -> Bearer "ifu_x" 401
658
+ * INFERALL_API_KEY='ifu_x' -> Bearer 'ifu_x' 401
659
+ * INFERALL_API_KEY=ifu_x # my key -> Bearer ifu_x # my key 401
660
+ * export INFERALL_API_KEY=ifu_x -> not found at all
661
+ * INFERALL_API_KEY = ifu_x -> not found at all
662
+ *
663
+ * Every one of those is valid dotenv. The first three produce a 401, and
664
+ * `doctor` then reports "key rejected by gateway. Check <keys page>" -- a
665
+ * confident wrong cause about a key that is fine, sending the user to mint a
666
+ * replacement that will fail in exactly the same way. The last two produce
667
+ * "No key in INFERALL_API_KEY or .env. Run init" while the key is sitting in
668
+ * the file being read.
669
+ *
670
+ * Quoting rules follow dotenv rather than anything invented here: quotes are
671
+ * stripped only when the same quote opens and closes the value, and an inline
672
+ * `#` comment is stripped only from an UNQUOTED value, because a quoted one may
673
+ * legitimately contain a hash.
674
+ */
675
+ export function parseEnvKey(text) {
676
+ if (!text) return null;
677
+ const line = text.split(/\r?\n/).find((l) => /^\s*(?:export\s+)?INFERALL_API_KEY\s*=/.test(l));
678
+ if (line === undefined) return null;
679
+ let v = line.replace(/^\s*(?:export\s+)?INFERALL_API_KEY\s*=/, "").trim();
680
+ const quote = v[0];
681
+ if (quote === '"' || quote === "'") {
682
+ // 2026-07-28 tick 840: find the CLOSING quote rather than requiring it to
683
+ // be the last character of the line. Requiring last-char meant
684
+ //
685
+ // INFERALL_API_KEY="ifu_x" # note
686
+ //
687
+ // failed the pair test (last char is "e"), fell through to the unquoted
688
+ // branch, and returned "ifu_x" WITH the quotes -- the tick 834 bug alive in
689
+ // the one spelling that has both features at once. Tick 834 tested quotes
690
+ // and comments separately and never together, which is exactly what the
691
+ // build-the-awkward-combination rule exists to prevent.
692
+ const close = v.indexOf(quote, 1);
693
+ if (close === -1) return v || null; // unterminated: leave exactly as written
694
+ v = v.slice(1, close); // trailing whitespace/comment discarded
695
+ } else {
696
+ v = v.replace(/\s+#.*$/, "").trim(); // inline comment, unquoted values only
697
+ }
698
+ return v || null;
699
+ }
700
+
701
+ /**
702
+ * Read .env as text, reporting WHY it could not be read rather than throwing.
703
+ *
704
+ * 2026-07-27 tick 835. Tick 834 unified how the key is PARSED across three call
705
+ * sites and left how the file is READ alone. Two of those three sites called
706
+ * readFileSync bare, so an ordinary filesystem state escaped as a Node errno:
707
+ *
708
+ * .env is a directory -> "✗ EISDIR: illegal operation on a directory, read"
709
+ * .env is chmod 000 -> "✗ EACCES: permission denied, open '/long/path/.env'"
710
+ *
711
+ * Neither names `.env` as the thing at fault or says what to do, and the first
712
+ * does not even name a file. Same leak as the bare "fetch failed" fixed in tick
713
+ * 832: Node's words reaching a user who asked us a question.
714
+ *
715
+ * Returns { text, code }: code is null when the file was read, "ENOENT" when
716
+ * there is no .env at all (not an error, just absent), or the errno.
717
+ */
718
+ /**
719
+ * Is there a .env holding a key in an ANCESTOR directory?
720
+ *
721
+ * 2026-07-29 tick 850, measured in a monorepo whose key sits at the repo root
722
+ * with the code in packages/api:
723
+ *
724
+ * doctor (from packages/api) -> "INFERALL_API_KEY not found in environment
725
+ * or .env. Run npx @inferall/cli init"
726
+ * init (from packages/api) -> writes packages/api/.env with a SECOND key
727
+ *
728
+ * Neither step is wrong on its own. `.env` is conventionally read from the
729
+ * working directory and this deliberately does NOT change that. The damage is
730
+ * the CHAIN: doctor reports absence without saying where it looked, tells the
731
+ * user to run init, and init then creates a second credential for one repo
732
+ * without mentioning the one already there. Which key the app uses afterwards
733
+ * depends on the directory it happens to start in.
734
+ *
735
+ * So this reports LOCATION ONLY, never the key itself, and both commands use it
736
+ * to say what they checked rather than to change what they read.
737
+ *
738
+ * Stops at the repository root: a .env above someone's checkout is not ours to
739
+ * reason about. Bounded rather than while(true) because a symlink cycle in a
740
+ * path walk is a hang, and a hang in `doctor` is the worst failure it has.
741
+ */
742
+ export function findAncestorEnvKey(startDir) {
743
+ let dir = startDir;
744
+ for (let hops = 0; hops < 32; hops++) {
745
+ const parent = dirname(dir);
746
+ if (!parent || parent === dir) return null; // filesystem root
747
+ dir = parent;
748
+ // Check the file BEFORE the .git stop, or a key sitting at the repo root
749
+ // (the single most likely place for it) would be missed by one step.
750
+ const { text } = readEnvText(dir);
751
+ if (text && parseEnvKey(text)) return { dir };
752
+ if (existsSync(join(dir, ".git"))) return null;
753
+ }
754
+ return null;
755
+ }
756
+
757
+ export function readEnvText(root) {
758
+ const envPath = join(root, ".env");
759
+ if (!existsSync(envPath)) return { text: null, code: "ENOENT" };
760
+ try {
761
+ return { text: readFileSync(envPath, "utf8"), code: null };
762
+ } catch (err) {
763
+ return { text: null, code: err?.code || "EUNKNOWN" };
764
+ }
765
+ }
766
+
767
+ /** One line explaining an unreadable .env, or null when it read fine. */
768
+ export function envReadProblem(code) {
769
+ if (!code || code === "ENOENT") return null;
770
+ if (code === "EISDIR") return ".env is a directory, not a file";
771
+ if (code === "EACCES" || code === "EPERM") return ".env exists but cannot be read (permission denied)";
772
+ return `.env could not be read (${code})`;
773
+ }
774
+
775
+ export function readEnvKey(root) {
776
+ // Contract unchanged for existing callers: a key, or null for any reason.
777
+ return parseEnvKey(readEnvText(root).text);
778
+ }
779
+
780
+ /** Mask a key for display. Never print a whole credential. */
781
+ export function maskKey(k) {
782
+ return k && k.length > 12 ? `${k.slice(0, 8)}...${k.slice(-4)}` : "(set)";
783
+ }
784
+
785
+ /**
786
+ * Why a filesystem operation failed, in words rather than an errno.
787
+ *
788
+ * 2026-07-27 tick 836. Companion to envReadProblem (tick 835), which covered
789
+ * READING .env. This covers the WRITES, which is where it mattered more.
790
+ */
791
+ export function describeFsError(err, path, action) {
792
+ const code = err?.code;
793
+ const why =
794
+ code === "EISDIR" ? "it is a directory, not a file" :
795
+ code === "EACCES" || code === "EPERM" ? "permission denied" :
796
+ code === "EROFS" ? "the filesystem is read-only" :
797
+ code === "ENOSPC" ? "there is no space left on the device" :
798
+ code === "ENOTDIR" ? "part of the path is not a directory" :
799
+ code ? `of ${code}` : "of an unknown error";
800
+ return `could not ${action} ${basename(path)}: ${why}`;
801
+ }
802
+
803
+ // writeKey=false leaves the .env key line alone (the user declined a replace)
804
+ // while still ensuring .env.example and .gitignore are correct.
805
+ //
806
+ // 2026-07-27 tick 836: every step here used to be a bare filesystem call. With
807
+ // .gitignore as a directory, MEASURED:
808
+ //
809
+ // verifying key against … ok
810
+ // ✗ EISDIR: illegal operation on a directory, read
811
+ //
812
+ // and `.env` on disk containing the key. So the wizard wrote a live credential,
813
+ // failed to protect it, crashed with a Node errno that names no file, and said
814
+ // nothing about either. That is worse than the tick-835 read case: this is a
815
+ // crash MID-MUTATION that leaves a secret exposed.
816
+ //
817
+ // Each step now degrades on its own (the per-file rule from tick 824) and the
818
+ // caller is told what actually happened, in particular whether the key reached
819
+ // disk without gitignore protection.
820
+ function upsertEnvFile(root, key, { writeKey = true } = {}) {
821
+ const result = { keyWritten: false, problems: [], gitignoreOk: false };
99
822
  const envPath = join(root, ".env");
100
823
  const line = `INFERALL_API_KEY=${key}`;
101
- if (existsSync(envPath)) {
102
- const body = readFileSync(envPath, "utf8");
103
- if (/^INFERALL_API_KEY=/m.test(body)) {
104
- writeFileSync(envPath, body.replace(/^INFERALL_API_KEY=.*$/m, line));
105
- } else {
106
- appendFileSync(envPath, `${body.endsWith("\n") || body === "" ? "" : "\n"}${line}\n`);
824
+ if (writeKey) {
825
+ try {
826
+ if (existsSync(envPath)) {
827
+ const body = readFileSync(envPath, "utf8");
828
+ if (/^INFERALL_API_KEY=/m.test(body)) {
829
+ writeFileSync(envPath, body.replace(/^INFERALL_API_KEY=.*$/m, line));
830
+ } else {
831
+ appendFileSync(envPath, `${body.endsWith("\n") || body === "" ? "" : "\n"}${line}\n`);
832
+ }
833
+ } else {
834
+ writeFileSync(envPath, `${line}\n`);
835
+ }
836
+ result.keyWritten = true;
837
+ } catch (err) {
838
+ result.problems.push(describeFsError(err, envPath, "write"));
107
839
  }
108
- } else {
109
- writeFileSync(envPath, `${line}\n`);
110
840
  }
111
841
 
112
842
  const examplePath = join(root, ".env.example");
113
843
  const exampleLine = "INFERALL_API_KEY=ifu_your_key_here";
114
- if (existsSync(examplePath)) {
115
- const body = readFileSync(examplePath, "utf8");
116
- if (!/^INFERALL_API_KEY=/m.test(body)) {
117
- appendFileSync(examplePath, `${body.endsWith("\n") || body === "" ? "" : "\n"}${exampleLine}\n`);
844
+ try {
845
+ if (existsSync(examplePath)) {
846
+ const body = readFileSync(examplePath, "utf8");
847
+ if (!/^INFERALL_API_KEY=/m.test(body)) {
848
+ appendFileSync(examplePath, `${body.endsWith("\n") || body === "" ? "" : "\n"}${exampleLine}\n`);
849
+ }
850
+ } else {
851
+ writeFileSync(examplePath, `${exampleLine}\n`);
118
852
  }
119
- } else {
120
- writeFileSync(examplePath, `${exampleLine}\n`);
853
+ } catch (err) {
854
+ result.problems.push(describeFsError(err, examplePath, "write"));
121
855
  }
122
856
 
123
857
  const giPath = join(root, ".gitignore");
124
- if (existsSync(giPath)) {
125
- const gi = readFileSync(giPath, "utf8");
126
- if (!gi.split("\n").some((l) => l.trim() === ".env" || l.trim() === ".env*" || l.trim() === "*.env")) {
127
- appendFileSync(giPath, `${gi.endsWith("\n") ? "" : "\n"}.env\n`);
128
- console.log(` ${c.green("+")} added ${c.bold(".env")} to .gitignore`);
858
+ try {
859
+ if (existsSync(giPath)) {
860
+ const gi = readFileSync(giPath, "utf8");
861
+ if (!gi.split("\n").some((l) => l.trim() === ".env" || l.trim() === ".env*" || l.trim() === "*.env")) {
862
+ appendFileSync(giPath, `${gi.endsWith("\n") ? "" : "\n"}.env\n`);
863
+ console.log(` ${c.green("+")} added ${c.bold(".env")} to .gitignore`);
864
+ }
865
+ result.gitignoreOk = true;
866
+ } else {
867
+ writeFileSync(giPath, ".env\n");
868
+ console.log(` ${c.green("+")} created .gitignore with .env`);
869
+ result.gitignoreOk = true;
129
870
  }
130
- } else {
131
- writeFileSync(giPath, ".env\n");
132
- console.log(` ${c.green("+")} created .gitignore with .env`);
871
+ } catch (err) {
872
+ result.problems.push(describeFsError(err, giPath, "update"));
873
+ }
874
+
875
+ // 2026-07-27 tick 821. .gitignore does NOTHING for a file git is ALREADY
876
+ // tracking, and we have just written an API key into that file.
877
+ //
878
+ // Measured: on a repo with .env committed, init printed
879
+ // + created .gitignore with .env
880
+ // + INFERALL_API_KEY written to .env
881
+ // and left `git status` showing " M .env". The next `git add .` commits a
882
+ // live credential, and our own output had just implied it was protected.
883
+ //
884
+ // ls-files reads the INDEX, which is the question being asked. check-ignore
885
+ // reads the working tree and would answer a different one (that scar is why
886
+ // this is spelled out).
887
+ // 2026-07-28 tick 839: tri-state. We have just written a credential, so
888
+ // "could not check" is worth a line of its own here rather than silence.
889
+ const track = result.keyWritten ? gitTrackStatus(root) : "untracked";
890
+ if (track === "tracked") {
891
+ console.log(` ${c.red("!")} ${c.bold(".env is tracked by git")} and now contains your API key.`);
892
+ console.log(` .gitignore does not apply to already-tracked files. Untrack it before committing:`);
893
+ console.log(c.cyan(` git rm --cached .env`));
894
+ } else if (track === "unknown") {
895
+ console.log(` ${c.yellow("~")} could not check whether ${c.bold(".env")} is tracked by git (git is not available here).`);
896
+ console.log(` If it is already committed, .gitignore will not protect the key you just saved.`);
133
897
  }
898
+ return result;
899
+ }
900
+
901
+ /**
902
+ * Is .env in the git index? Returns false when there is no repo, no git on
903
+ * PATH, or the file is untracked. Never throws: this is advisory, and a wizard
904
+ * that dies because git is missing is worse than one that stays quiet.
905
+ */
906
+ /**
907
+ * Is .env in git's index? "tracked" | "untracked" | "unknown".
908
+ *
909
+ * 2026-07-28 tick 839. This used to be a bare boolean, and it answered FALSE in
910
+ * two very different situations: git said no, and git could not be asked.
911
+ * MEASURED on a repo whose .env IS committed, with git removed from PATH:
912
+ *
913
+ * git present -> true
914
+ * git absent -> false <-- same answer as "your .env is safe"
915
+ *
916
+ * doctor then prints "✓ .env hygiene ok" over a committed credential, which is
917
+ * precisely the bug fixed in tick 825 coming back through a different door
918
+ * whenever git is not installed (slim containers, minimal CI images).
919
+ *
920
+ * A guard that cannot run must not report a pass. "unknown" is only for a git
921
+ * that could not be executed: a directory that is not a repository at all
922
+ * genuinely has no tracked files, so that stays "untracked" and the green tick
923
+ * there is honest.
924
+ */
925
+ export function gitTrackStatus(root) {
926
+ let r;
927
+ try {
928
+ r = spawnSync("git", ["ls-files", "--error-unmatch", ".env"], {
929
+ cwd: root, stdio: "ignore", timeout: 5000,
930
+ });
931
+ } catch {
932
+ return "unknown";
933
+ }
934
+ if (r.error || r.status === null) return "unknown"; // git missing, or killed
935
+ if (r.status === 0) return "tracked";
936
+ return "untracked"; // includes "not a repo"
937
+ }
938
+
939
+ /** Boolean contract preserved for callers that only care about the yes case. */
940
+ export function envIsGitTracked(root) {
941
+ return gitTrackStatus(root) === "tracked";
134
942
  }
135
943
 
136
944
  // ---------- gateway API helpers ----------
137
945
 
946
+ // Distinctive User-Agent so gateway-side ai_usage_log.client_ua attributes
947
+ // wizard-originated calls to the CLI (and which command) — lets us measure
948
+ // how many people actually run `init` without any gateway change or new
949
+ // telemetry column. `cmd` is the subcommand (init/doctor/models/status).
950
+ // The gateway records client_ua on every call, so the sha rides along and
951
+ // telemetry can tell two builds of one version apart. Semver build-metadata
952
+ // spelling (+sha) so anything already parsing the version keeps working.
953
+ const CLI_UA = `@inferall/cli/${CLI_VERSION}${BUILD_INFO?.sha ? `+${BUILD_INFO.sha}` : ""} (${cmd})`;
954
+
138
955
  async function apiGet(path, key) {
139
956
  const res = await fetch(`${API_BASE}${path}`, {
140
- headers: { Authorization: `Bearer ${key}` },
957
+ signal: timeoutSignal(),
958
+ headers: { Authorization: `Bearer ${key}`, "User-Agent": CLI_UA },
141
959
  });
142
960
  return { status: res.status, body: await res.json().catch(() => ({})) };
143
961
  }
144
962
 
145
- async function apiChat(key, messages, model = FREE_MODEL) {
963
+ // maxTokens is a parameter rather than a constant as of tick 817: the chat
964
+ // command now tells a user whose reasoning model ran out of budget to retry
965
+ // with a larger --max-tokens, and a message that names a flag which does not
966
+ // exist is worse than no message. Default is the previous hardcoded 512, so
967
+ // every other caller is unchanged.
968
+ async function apiChat(key, messages, model = FREE_MODEL, maxTokens = 512) {
146
969
  const res = await fetch(`${API_BASE}/chat/completions`, {
970
+ signal: timeoutSignal(),
147
971
  method: "POST",
148
- headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
149
- body: JSON.stringify({ model, messages, max_tokens: 512 }),
972
+ headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", "User-Agent": CLI_UA },
973
+ body: JSON.stringify({ model, messages, max_tokens: maxTokens }),
150
974
  });
151
- return { status: res.status, body: await res.json().catch(() => ({})) };
975
+ // 2026-07-27 tick 833: this used to be `await res.json().catch(() => ({}))`,
976
+ // which collapsed two different facts into one empty object: "the server sent
977
+ // JSON with nothing useful in it" and "the server sent something that is not
978
+ // JSON at all". A proxy 502 is an HTML page, and the caller printed
979
+ //
980
+ // HTTP 502: {}
981
+ //
982
+ // Reading the body as text first keeps the distinction, and keeps a snippet
983
+ // so we can quote what actually arrived instead of an empty object.
984
+ const raw = await res.text().catch(() => "");
985
+ let body = {};
986
+ let parseFailed = false;
987
+ if (raw) {
988
+ try {
989
+ body = JSON.parse(raw);
990
+ } catch {
991
+ parseFailed = true;
992
+ }
993
+ }
994
+ return {
995
+ status: res.status,
996
+ body,
997
+ parseFailed,
998
+ raw,
999
+ contentType: res.headers.get("content-type") || "",
1000
+ retryAfter: res.headers.get("retry-after"),
1001
+ };
1002
+ }
1003
+
1004
+
1005
+ /**
1006
+ * Read a response as JSON, or say plainly that it was not JSON.
1007
+ *
1008
+ * WHY THIS EXISTS (2026-07-31, tick 886)
1009
+ * -------------------------------------
1010
+ * `chat` has handled this correctly since tick 833: a body it cannot parse is a
1011
+ * FAILED call whatever the status line said. That fix was never carried to the
1012
+ * other three commands, and they each failed differently against a stub serving
1013
+ * a corporate proxy login page with HTTP 200:
1014
+ *
1015
+ * models raw Node text: Unexpected token '<', "<!DOCTYPE "...
1016
+ * status the same raw text
1017
+ * doctor rc=0 and "key accepted by gateway" <-- the bad one
1018
+ *
1019
+ * doctor is the DIAGNOSTIC command. Behind a captive portal or a proxy that
1020
+ * intercepts HTTPS it told the user their setup was healthy, because it looked
1021
+ * only at r.status and a login page is served with 200.
1022
+ *
1023
+ * Returns the same shape apiChat already returns, so nonJsonSummary works on it.
1024
+ */
1025
+ async function readJsonBody(res) {
1026
+ const raw = await res.text().catch(() => "");
1027
+ let body = {};
1028
+ let parseFailed = false;
1029
+ if (raw) {
1030
+ try { body = JSON.parse(raw); } catch { parseFailed = true; }
1031
+ }
1032
+ /*
1033
+ * TRIAL HEADERS, 2026-08-03 tick 929.
1034
+ *
1035
+ * The gateway emits X-InferAll-Trial-Usage ("N/cap") and
1036
+ * X-InferAll-Trial-Status on every SUCCESSFUL call from a trial key. We were
1037
+ * reading content-type off this response and throwing the rest away, so the
1038
+ * countdown was on the wire for every call the CLI ever made and never once
1039
+ * reached the person spending it.
1040
+ *
1041
+ * Read here rather than at the call site because every caller goes through
1042
+ * this function, so a future command gets the counter for free instead of
1043
+ * rediscovering the header.
1044
+ */
1045
+ return {
1046
+ status: res.status,
1047
+ body,
1048
+ parseFailed,
1049
+ raw,
1050
+ contentType: res.headers.get("content-type") || "",
1051
+ trialUsage: res.headers.get("x-inferall-trial-usage") || "",
1052
+ trialStatus: res.headers.get("x-inferall-trial-status") || "",
1053
+ };
1054
+ }
1055
+
1056
+ /**
1057
+ * Render "N/cap" from the gateway into a line worth printing, or null.
1058
+ *
1059
+ * Returns null when the header is absent (a paid key sends none, and a paid
1060
+ * user must not be shown a trial counter) or unparseable. Exported shape kept
1061
+ * pure so the thresholds below are testable without a network call.
1062
+ *
1063
+ * WHY IT ESCALATES RATHER THAN ALWAYS PRINTING THE SAME LINE. Measured since
1064
+ * the micro-trial: 9 real users hit the ceiling, and SEVEN OF THE NINE spent
1065
+ * all five calls within 0.0 to 0.3 hours of their first one, several through
1066
+ * agent tooling that burns the whole allowance inside a single task. A line
1067
+ * that reads identically at 4 remaining and at 1 remaining is wallpaper by the
1068
+ * time it matters, so the last call before the wall says so plainly.
1069
+ */
1070
+ function trialCountdownLine(trialUsage) {
1071
+ const m = /^(\d+)\s*\/\s*(\d+)$/.exec((trialUsage || "").trim());
1072
+ if (!m) return null;
1073
+ const used = Number(m[1]);
1074
+ const cap = Number(m[2]);
1075
+ if (!Number.isFinite(used) || !Number.isFinite(cap) || cap <= 0) return null;
1076
+ const left = Math.max(0, cap - used);
1077
+ if (left === 0) {
1078
+ return `${c.yellow("~")} that was your last free call. ${c.dim(`Activate at https://inferall.ai/billing to keep going.`)}`;
1079
+ }
1080
+ if (left === 1) {
1081
+ return `${c.yellow("~")} 1 free call left of ${cap}. ${c.dim(`Run ${"npx @inferall/cli status"} for details.`)}`;
1082
+ }
1083
+ return `${c.dim(`· ${left} free calls left of ${cap}`)}`;
1084
+ }
1085
+
1086
+ /** The lines every caller prints when the gateway sent something unparseable. */
1087
+ function nonJsonLines(r, indent = "") {
1088
+ return [
1089
+ `${indent}${c.red("✗")} ${nonJsonSummary(r)}`,
1090
+ `${indent} HTTP ${r.status}, but the body is not JSON, so nothing was verified.`,
1091
+ `${indent} A proxy or captive portal intercepting HTTPS produces exactly this.`,
1092
+ ];
1093
+ }
1094
+
1095
+ /**
1096
+ * When a write fails, is the DIRECTORY the problem rather than the file?
1097
+ *
1098
+ * WHY THIS EXISTS (2026-07-31, tick 887)
1099
+ * -------------------------------------
1100
+ * Measured with `init` in a directory at mode 555: every write failed with
1101
+ * "permission denied" and the wizard then said "fix the file above" and "until
1102
+ * the file above is fixed". There IS no file to fix. .env was never created,
1103
+ * so `chmod +w .env` cannot work, and the user is sent to look for something
1104
+ * that does not exist.
1105
+ *
1106
+ * That is the same defect this changelog keeps recording in a new place: the
1107
+ * message names a cause confidently and names the wrong one. The remedy for an
1108
+ * unwritable directory is different from the remedy for an unwritable file, so
1109
+ * the two have to be told apart before either is recommended.
1110
+ */
1111
+ function dirIsUnwritable(root) {
1112
+ try {
1113
+ accessSync(root, fsConstants.W_OK);
1114
+ return false;
1115
+ } catch {
1116
+ return true;
1117
+ }
1118
+ }
1119
+
1120
+ /**
1121
+ * Is this a model catalog, an object of {id: pricingObject}?
1122
+ *
1123
+ * An empty object is a legitimate catalog; emptiness is a different complaint
1124
+ * and check-pricing-coverage already owns it.
1125
+ */
1126
+ export function isCatalogShape(map) {
1127
+ if (!map || typeof map !== "object" || Array.isArray(map)) return false;
1128
+ return Object.values(map).every((v) => v && typeof v === "object" && !Array.isArray(v));
1129
+ }
1130
+
1131
+ /**
1132
+ * One line describing a body we could not parse. Used wherever a caller has to
1133
+ * explain a non-JSON response without pasting an HTML page into a terminal.
1134
+ */
1135
+ export function nonJsonSummary({ contentType, raw }) {
1136
+ const kind = /html/i.test(contentType) ? "an HTML page" : contentType ? `content-type ${contentType}` : "a non-JSON body";
1137
+ const title = /<title>([^<]{1,80})<\/title>/i.exec(raw || "")?.[1]?.trim();
1138
+ const snippet = title || (raw || "").replace(/\s+/g, " ").trim().slice(0, 80);
1139
+ return `the gateway returned ${kind}${snippet ? `: ${c.dim(snippet)}` : ""}`;
152
1140
  }
153
1141
 
154
1142
  // ---------- codemod: point OpenAI SDK at InferAll ----------
@@ -156,8 +1144,43 @@ async function apiChat(key, messages, model = FREE_MODEL) {
156
1144
  const SOURCE_EXTS = new Set([".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx", ".py"]);
157
1145
  const SKIP_DIRS = new Set(["node_modules", ".git", ".next", "dist", "build", "venv", ".venv", "__pycache__", ".turbo"]);
158
1146
 
159
- function* walk(dir, depth = 0) {
160
- if (depth > 6) return;
1147
+ // Notebooks are deliberately NOT in SOURCE_EXTS. A .ipynb is JSON, so handing
1148
+ // it to the text codemod would rewrite escaped source inside a JSON string and
1149
+ // corrupt the file. They get their own walk and their own honest report.
1150
+ const NOTEBOOK_EXTS = new Set([".ipynb"]);
1151
+ // Notebooks carry base64 image outputs, so the source-file cap is far too low
1152
+ // for them. Only cell.source is ever read; the outputs are skipped.
1153
+ const NOTEBOOK_MAX_BYTES = 8 * 1024 * 1024;
1154
+
1155
+ /**
1156
+ * Walk the project for source files.
1157
+ *
1158
+ * 2026-07-30 tick 854. Two of the limits here used to stop the walk SILENTLY,
1159
+ * and the caller then printed a count and declared success over it. MEASURED,
1160
+ * with three identical `new OpenAI({...})` sites in one project:
1161
+ *
1162
+ * Found 1 client constructor site(s):
1163
+ * Done. Your project is wired to InferAll.
1164
+ *
1165
+ * while `packages/api/src/lib/clients/openai/v1/deep.js` (one level past the
1166
+ * depth cap) and a 600KB file were never opened, and both still called OpenAI
1167
+ * directly. That is the tick 805 failure verbatim: a wizard that reports a wired
1168
+ * project and hands back one that throws "OPENAI_API_KEY is missing or empty" on
1169
+ * its first run. The codemod suite exists because of that exact outcome.
1170
+ *
1171
+ * THE LIMITS STAY. A depth bound and a size bound are correct: they keep `init`
1172
+ * from walking a vendored tree or reading a bundle, and raising them trades one
1173
+ * failure for a slower one. What was wrong is that stopping early was invisible.
1174
+ *
1175
+ * `capped` collects ONLY the limit-driven skips. SKIP_DIRS, dotfiles and
1176
+ * unreadable entries are deliberate and universal, and reporting them would bury
1177
+ * the two that actually hide a user's code in noise nobody reads.
1178
+ */
1179
+ function* walk(dir, depth = 0, exts = SOURCE_EXTS, maxBytes = 512 * 1024, capped = null) {
1180
+ if (depth > 6) {
1181
+ capped?.push({ path: dir, why: "deeper than 6 directory levels" });
1182
+ return;
1183
+ }
161
1184
  let entries = [];
162
1185
  try {
163
1186
  entries = readdirSync(dir);
@@ -173,14 +1196,111 @@ function* walk(dir, depth = 0) {
173
1196
  } catch {
174
1197
  continue;
175
1198
  }
176
- if (st.isDirectory()) yield* walk(full, depth + 1);
177
- else if (st.isFile() && SOURCE_EXTS.has(extname(name)) && st.size < 512 * 1024) yield full;
1199
+ if (st.isDirectory()) yield* walk(full, depth + 1, exts, maxBytes, capped);
1200
+ else if (st.isFile() && exts.has(extname(name))) {
1201
+ if (st.size < maxBytes) yield full;
1202
+ else capped?.push({ path: full, why: `larger than ${Math.round(maxBytes / 1024)}KB` });
1203
+ }
178
1204
  }
179
1205
  }
180
1206
 
181
- function findCallSites(root) {
1207
+ /**
1208
+ * One line naming what the walk never opened, or null when it opened everything.
1209
+ * Separate from walk() so both the scan and its reporting read the same list.
1210
+ */
1211
+ export function cappedSummary(capped) {
1212
+ if (!capped?.length) return null;
1213
+ const dirs = capped.filter((c) => c.why.startsWith("deeper")).length;
1214
+ const big = capped.length - dirs;
1215
+ const parts = [];
1216
+ if (dirs) parts.push(`${dirs} director${dirs === 1 ? "y" : "ies"} deeper than 6 levels`);
1217
+ if (big) parts.push(`${big} file${big === 1 ? "" : "s"} over the size limit`);
1218
+ return `${parts.join(" and ")} were not scanned`;
1219
+ }
1220
+
1221
+ /**
1222
+ * Jupyter notebooks that construct an AI client.
1223
+ *
1224
+ * 2026-07-26 tick 816. `.ipynb` was not in SOURCE_EXTS, so findCallSites never
1225
+ * looked inside one. But requirements.txt still lists `openai`, so `usesSdk`
1226
+ * was true, the wizard called findCallSites, got ZERO sites back and printed:
1227
+ *
1228
+ * ✓ AI SDK present but no un-migrated client constructors found.
1229
+ *
1230
+ * over a notebook still calling OpenAI directly. That is the same false green
1231
+ * tick as t811's async clients, and notebooks are where a large share of Python
1232
+ * AI code actually lives.
1233
+ *
1234
+ * REPORTED, NOT PATCHED, and that is a deliberate scope choice rather than an
1235
+ * oversight. A notebook is JSON carrying execution counts and output blobs;
1236
+ * round-tripping it through parse/stringify rewrites the formatting of every
1237
+ * line in the file, and this tool promises "your code otherwise untouched".
1238
+ * The existing Gemini path makes the same call for the same reason. A user
1239
+ * pastes one line into a cell; a mangled notebook diff is much worse than that.
1240
+ *
1241
+ * Class list comes from CLIENT_CLASSES so notebooks cannot silently support a
1242
+ * different set of SDKs than plain .py files do.
1243
+ */
1244
+ /**
1245
+ * Does this source text already send its traffic to InferAll?
1246
+ *
1247
+ * 2026-07-27 tick 830. Extracted so `findCallSites` and `findNotebookSites`
1248
+ * cannot answer it differently. `doctor` needed the notebook answer and there
1249
+ * was no way to get one, which is how it ended up reporting "no client
1250
+ * constructors found" over a notebook holding a live OpenAI key.
1251
+ */
1252
+ export function pointsAtInferAll(text) {
1253
+ return text.includes(new URL(API_BASE).host);
1254
+ }
1255
+
1256
+ export function findNotebookSites(root) {
182
1257
  const sites = [];
183
- for (const file of walk(root)) {
1258
+ for (const file of walk(root, 0, NOTEBOOK_EXTS, NOTEBOOK_MAX_BYTES)) {
1259
+ let nb;
1260
+ try {
1261
+ nb = JSON.parse(readFileSync(file, "utf8"));
1262
+ } catch {
1263
+ continue; // not valid JSON, or not a notebook we understand
1264
+ }
1265
+ if (!Array.isArray(nb.cells)) continue;
1266
+ const hits = [];
1267
+ nb.cells.forEach((cell, index) => {
1268
+ if (cell.cell_type !== "code") return;
1269
+ // nbformat allows source as either a string or an array of line strings.
1270
+ // Both shapes occur in the wild; JupyterLab writes the array form.
1271
+ const src = Array.isArray(cell.source) ? cell.source.join("") : (cell.source || "");
1272
+ if (typeof src !== "string" || !src) return;
1273
+ for (const sdk of Object.keys(CLIENT_CLASSES)) {
1274
+ // alreadyPointed is per CELL, not per file: a notebook routinely holds
1275
+ // one migrated client and one somebody forgot.
1276
+ if (new RegExp(`\\b${sdk}\\s*\\(`).test(src)) {
1277
+ hits.push({ index, sdk, alreadyPointed: pointsAtInferAll(src) });
1278
+ }
1279
+ }
1280
+ });
1281
+ if (hits.length) sites.push({ file, hits });
1282
+ }
1283
+ return sites;
1284
+ }
1285
+
1286
+ function printNotebookGuidance(root, sites) {
1287
+ const total = sites.reduce((n, s) => n + s.hits.length, 0);
1288
+ console.log(`\n ${c.bold("Jupyter notebook")} client(s) found in ${sites.length} file(s), ${total} cell(s):`);
1289
+ for (const s of sites) {
1290
+ for (const h of s.hits) {
1291
+ console.log(` - ${relative(root, s.file)} ${c.dim(`cell ${h.index} (${h.sdk})`)}`);
1292
+ }
1293
+ }
1294
+ console.log(` These were ${c.bold("NOT changed")}. Rewriting a notebook would reformat the whole`);
1295
+ console.log(` file, so edit the cell yourself. Add these two arguments to the client:`);
1296
+ const anySdk = sites[0].hits[0].sdk;
1297
+ console.log(c.cyan(` ${anySdk}(base_url="${baseForSdk(anySdk)}", api_key=os.environ["INFERALL_API_KEY"])`));
1298
+ console.log(` ${c.dim(`(and \`import os\` in that cell if it is not there already)`)}`);
1299
+ }
1300
+
1301
+ export function findCallSites(root, capped = null) {
1302
+ const sites = [];
1303
+ for (const file of walk(root, 0, SOURCE_EXTS, 512 * 1024, capped)) {
184
1304
  let body;
185
1305
  try {
186
1306
  body = readFileSync(file, "utf8");
@@ -189,7 +1309,35 @@ function findCallSites(root) {
189
1309
  }
190
1310
  const isPy = file.endsWith(".py");
191
1311
  const baseKey = isPy ? "base_url" : "baseURL";
192
- for (const sdk of ["OpenAI", "Anthropic"]) {
1312
+ // LangChain chat classes. \b does not match inside "ChatOpenAI" (t and O
1313
+ // are both word characters), so the plain "OpenAI" pattern never caught
1314
+ // these.
1315
+ //
1316
+ // 2026-07-26 tick 807 -- CORRECTING MYSELF. At tick 805 I excluded
1317
+ // ChatAnthropic and wrote that langchain-anthropic "uses a different
1318
+ // parameter name, and emitting base_url= there would produce a constructor
1319
+ // that raises at import". That was an assumption, and it was WRONG.
1320
+ // Measured by installing the package and introspecting it:
1321
+ //
1322
+ // ChatAnthropic.model_fields["anthropic_api_url"].alias == "base_url"
1323
+ // ChatAnthropic.model_fields["anthropic_api_key"].alias == "api_key"
1324
+ //
1325
+ // and both forms construct successfully. `base_url=` is a supported alias,
1326
+ // not a foreign parameter. Deferring it was right (I had not checked);
1327
+ // the REASON I recorded was not.
1328
+ // 2026-07-26 tick 811: AsyncOpenAI and AsyncAnthropic were invisible here.
1329
+ // \b does not match inside "AsyncOpenAI" (c and O are both word
1330
+ // characters), the same reason ChatOpenAI was missed at t805 -- but this
1331
+ // one failed WORSE. `openai` is in CODEMODDABLE, so usesSdk was true, the
1332
+ // wizard called findCallSites, got zero sites back, and printed
1333
+ //
1334
+ // ✓ AI SDK present but no un-migrated client constructors found.
1335
+ //
1336
+ // over an async FastAPI service that was still calling OpenAI directly.
1337
+ // A confident green tick, not the honest "couldn't patch" fallback. Async
1338
+ // clients are the norm for Python AI backends, so this is not a corner.
1339
+ // Both classes verified against the installed SDKs to accept base_url.
1340
+ for (const sdk of Object.keys(CLIENT_CLASSES)) {
193
1341
  const ctorRe = isPy ? new RegExp(`\\b${sdk}\\s*\\(`) : new RegExp(`new\\s+${sdk}\\s*\\(`);
194
1342
  if (!ctorRe.test(body)) continue;
195
1343
  sites.push({
@@ -197,7 +1345,7 @@ function findCallSites(root) {
197
1345
  body,
198
1346
  isPy,
199
1347
  sdk,
200
- alreadyPointed: body.includes(new URL(API_BASE).host),
1348
+ alreadyPointed: pointsAtInferAll(body),
201
1349
  hasBaseUrl: new RegExp(`${baseKey}\\s*[:=]`).test(body),
202
1350
  });
203
1351
  }
@@ -205,11 +1353,151 @@ function findCallSites(root) {
205
1353
  return sites;
206
1354
  }
207
1355
 
1356
+ /**
1357
+ * Every client class we can rewrite, and which base URL each one needs.
1358
+ *
1359
+ * ONE SOURCE OF TRUTH, deliberately. Before tick 811 this knowledge lived in
1360
+ * three places: the class list in findCallSites, and two separate
1361
+ * `sdk === "Anthropic" || sdk === "ChatAnthropic"` chains. The third copy had
1362
+ * ALREADY drifted: the manual-fallback message tested only `=== "Anthropic"`,
1363
+ * so a ChatAnthropic site we could not patch told the user to set the /v1 base
1364
+ * when LangChain's Anthropic client needs the host root. Wrong instructions to
1365
+ * the one user we had just failed to help automatically.
1366
+ *
1367
+ * That is the same shape as the tick-805 "third hardcoded list" bug. Adding
1368
+ * AsyncOpenAI and AsyncAnthropic to a fourth chain would have been asking for
1369
+ * it a third time, so the chains are gone.
1370
+ *
1371
+ * "anthropic" here means the SDK appends /v1 itself, so it gets the host root.
1372
+ *
1373
+ * NOT IN THIS TABLE, ON PURPOSE: llama_index.llms.openai.OpenAI. It collides
1374
+ * with the plain OpenAI class name, and it is TEMPTING to assume it takes
1375
+ * base_url. It does not. Measured against the installed package at tick 811:
1376
+ *
1377
+ * OpenAI.model_fields has api_base, and has NO base_url
1378
+ * OpenAI(base_url="https://api.inferall.ai/v1", ...) does NOT raise
1379
+ * ...and the resolved api_base stays https://api.openai.com/v1
1380
+ *
1381
+ * So patching it with base_url= would leave the project calling OpenAI
1382
+ * directly while we printed a green tick over it. Silently ignored is worse
1383
+ * than rejected. It needs an api_base= branch before it can be added.
1384
+ */
1385
+ const CLIENT_CLASSES = {
1386
+ OpenAI: "openai",
1387
+ AsyncOpenAI: "openai",
1388
+ Anthropic: "anthropic",
1389
+ AsyncAnthropic: "anthropic",
1390
+ ChatOpenAI: "openai",
1391
+ ChatAnthropic: "anthropic",
1392
+ };
1393
+
1394
+ // The base URL a given client class must be given. Derived, never re-typed.
1395
+ export function baseForSdk(sdk) {
1396
+ return CLIENT_CLASSES[sdk] === "anthropic" ? ANTHROPIC_BASE : API_BASE;
1397
+ }
1398
+
1399
+ // Repoint the CREDENTIAL inside a constructor we have just pointed at InferAll.
1400
+ //
1401
+ // 2026-07-25: the object-form branch below used to inject only `baseURL`,
1402
+ // leaving `apiKey: process.env.OPENAI_API_KEY` untouched while `init` wrote the
1403
+ // key to .env as INFERALL_API_KEY. The wizard then printed
1404
+ // "Done. Your project is wired to InferAll." and the user's very next run threw
1405
+ // OpenAIError: The OPENAI_API_KEY environment variable is missing or empty
1406
+ // i.e. the one-command installer reported success and left a broken project.
1407
+ // That is the worst possible outcome for onboarding, and it plausibly accounts
1408
+ // for part of the mint -> never-called gap.
1409
+ //
1410
+ // It survived because the wizard's own "test call ✓" uses the key DIRECTLY
1411
+ // rather than the code it just wrote; it verified a convenient proxy instead
1412
+ // of the path the user takes. Caught by running the generated project.
1413
+ //
1414
+ // The rewrite is scoped to the constructor's argument list (brace/paren depth)
1415
+ // so an unrelated OPENAI_API_KEY elsewhere in the file is left alone.
1416
+ export function repointApiKey(src, sdk, isPy) {
1417
+ const ctor = isPy
1418
+ ? new RegExp(`\\b${sdk}\\s*\\(`)
1419
+ : new RegExp(`new\\s+${sdk}\\s*\\(\\s*\\{`);
1420
+ const m = ctor.exec(src);
1421
+ if (!m) return src;
1422
+ const start = m.index + m[0].length;
1423
+ const open = isPy ? "(" : "{";
1424
+ const close = isPy ? ")" : "}";
1425
+ let depth = 1;
1426
+ let end = start;
1427
+ while (end < src.length && depth > 0) {
1428
+ const ch = src[end];
1429
+ if (ch === open) depth++;
1430
+ else if (ch === close) depth--;
1431
+ end++;
1432
+ }
1433
+ const head = src.slice(0, start);
1434
+ const region = src.slice(start, end);
1435
+ const tail = src.slice(end);
1436
+
1437
+ const want = isPy ? `os.environ["INFERALL_API_KEY"]` : `process.env.INFERALL_API_KEY`;
1438
+ const keyRe = isPy
1439
+ ? /os\.(?:environ\[\s*["'](?:OPENAI|ANTHROPIC|GOOGLE|GEMINI|OPENROUTER)_API_KEY["']\s*\]|getenv\(\s*["'](?:OPENAI|ANTHROPIC|GOOGLE|GEMINI|OPENROUTER)_API_KEY["']\s*\))/g
1440
+ : /process\.env\.(?:OPENAI|ANTHROPIC|GOOGLE|GEMINI|OPENROUTER)_API_KEY/g;
1441
+
1442
+ if (keyRe.test(region)) {
1443
+ keyRe.lastIndex = 0;
1444
+ return head + region.replace(keyRe, want) + tail;
1445
+ }
1446
+
1447
+ // 2026-07-26 tick 810: the HARDCODED-LITERAL case. Until now this function
1448
+ // only understood a credential read from an env var. A constructor written
1449
+ //
1450
+ // new OpenAI({ apiKey: "sk-live-abc123" })
1451
+ // OpenAI(api_key="sk-live-abc123")
1452
+ //
1453
+ // fell through both branches (`hasKey` is true, so we returned the region
1454
+ // untouched) and the caller then pointed baseURL at InferAll anyway. The
1455
+ // user ends up sending a foreign provider key to our gateway: the exact
1456
+ // "wired to InferAll, fails on first call" shape fixed at tick 800 for env
1457
+ // vars, still open for literals. Pre-existing in BOTH languages; found by
1458
+ // running the patcher over a module-qualified `openai.OpenAI(api_key="…")`.
1459
+ //
1460
+ // Deliberately keyed on the PROVIDER KEY SHAPE, not on "any string literal".
1461
+ // Local-inference users legitimately write api_key="EMPTY" or "ollama" for
1462
+ // vLLM/Ollama, and silently rewriting those would be us editing something we
1463
+ // do not understand. Those are left alone and reported by the post-patch
1464
+ // check instead. sk- covers OpenAI, Anthropic and OpenRouter; AIza is Google.
1465
+ const literalRe = isPy
1466
+ ? /(\bapi_key\s*=\s*)(["'])(?:sk-[A-Za-z0-9_-]{8,}|AIza[A-Za-z0-9_-]{10,})\2/
1467
+ : /(\bapiKey\s*:\s*)(["'])(?:sk-[A-Za-z0-9_-]{8,}|AIza[A-Za-z0-9_-]{10,})\2/;
1468
+ if (literalRe.test(region)) {
1469
+ return head + region.replace(literalRe, `$1${want}`) + tail;
1470
+ }
1471
+
1472
+ // No credential passed at all: the SDK falls back to the PROVIDER's env var,
1473
+ // which is just as broken once baseURL points at us. Supply ours.
1474
+ const hasKey = isPy ? /\bapi_key\s*=/.test(region) : /\bapiKey\s*:/.test(region);
1475
+ if (!hasKey) {
1476
+ return head + (isPy ? `api_key=${want}, ` : `apiKey: ${want}, `) + region + tail;
1477
+ }
1478
+ return head + region + tail;
1479
+ }
1480
+
1481
+ // `import os` is required by every Python rewrite we emit (all of them read
1482
+ // os.environ["INFERALL_API_KEY"]). Skipped when the module already imports it.
1483
+ // The shebang must stay on line 1 to keep working, so insert below it.
1484
+ export function ensureImportOs(src) {
1485
+ // `import os` and `import os.path` both bind the name `os`. `from os import
1486
+ // environ` does NOT — a file with only that line still needs the import, or
1487
+ // the os.environ[...] we emit raises NameError. Adding a redundant import is
1488
+ // harmless; omitting a required one is not.
1489
+ if (/^\s*import os(\b|\.)/m.test(src)) return src;
1490
+ const lines = src.split("\n");
1491
+ const at = lines[0]?.startsWith("#!") ? 1 : 0;
1492
+ lines.splice(at, 0, "import os");
1493
+ return lines.join("\n");
1494
+ }
1495
+
208
1496
  // Deterministic rewrite for the common zero-arg / simple-config constructor.
209
1497
  // The OpenAI SDK's baseURL includes /v1; the Anthropic SDK appends /v1 itself.
210
- function patchSite(site) {
1498
+ export function patchSite(site) {
211
1499
  const { body, isPy, sdk } = site;
212
- const base = sdk === "Anthropic" ? ANTHROPIC_BASE : API_BASE;
1500
+ const base = baseForSdk(sdk);
213
1501
  const baseLine = isPy
214
1502
  ? `base_url="${base}", api_key=os.environ["INFERALL_API_KEY"]`
215
1503
  : `baseURL: "${base}", apiKey: process.env.INFERALL_API_KEY`;
@@ -219,15 +1507,80 @@ function patchSite(site) {
219
1507
  const bare = new RegExp(`\\b${sdk}\\s*\\(\\s*\\)`);
220
1508
  if (bare.test(body)) {
221
1509
  patched = body.replace(bare, `${sdk}(${baseLine})`);
222
- if (!/^import os$/m.test(patched)) patched = `import os\n${patched}`;
1510
+ } else {
1511
+ // 2026-07-26 tick 810: the keyword-argument form. Until now the Python
1512
+ // codemod handled ONLY the bare `OpenAI()` constructor, while the
1513
+ // JavaScript branch below handled both bare AND config-object form. That
1514
+ // asymmetry was not a decision — the branch was simply never written.
1515
+ //
1516
+ // Measured before writing this, by running the real patcher over six
1517
+ // realistic Python files: 5 of 6 returned null. Every one of these is a
1518
+ // form you would find in an actual project:
1519
+ //
1520
+ // OpenAI(api_key=os.environ["OPENAI_API_KEY"]) -> null
1521
+ // OpenAI(api_key=os.getenv("OPENAI_API_KEY")) -> null
1522
+ // self.client = OpenAI(api_key=...) (in a class) -> null
1523
+ // return OpenAI(\n api_key=...,\n timeout=30,\n) -> null
1524
+ // Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])-> null
1525
+ //
1526
+ // Those users were not silently broken (the t805 fallback prints an
1527
+ // honest "couldn't patch automatically"), but they were pushed onto the
1528
+ // AI-assist path — which costs a call and is skipped entirely on a 402 —
1529
+ // to do something the deterministic codemod can do exactly.
1530
+ //
1531
+ // Guards, in order of how badly each would break a file:
1532
+ // (?<!class\s)(?<!def\s) — `class OpenAI(Base):` and `def OpenAI(x):`
1533
+ // both match a bare `\bOpenAI\s*\(`, and injecting base_url= into a
1534
+ // class or def header produces a file that will not import.
1535
+ // \b — keeps `OpenAI` from matching inside `ChatOpenAI` (t805), while
1536
+ // still allowing the `openai.OpenAI(...)` module-qualified form.
1537
+ // !site.hasBaseUrl — never touch a client already given a base_url.
1538
+ const kw = new RegExp(`(?<!class\\s)(?<!def\\s)\\b${sdk}\\s*\\([ \\t]*`);
1539
+ if (kw.test(body) && !site.hasBaseUrl) {
1540
+ // Same separator rule as the JS branch: emit the space only when
1541
+ // something follows on the SAME line, so a multiline constructor keeps
1542
+ // its newline and does not gain trailing whitespace.
1543
+ patched = body.replace(kw, (match, offset, whole) => {
1544
+ const nextCh = whole[offset + match.length];
1545
+ const sep = nextCh === "\n" || nextCh === "\r" ? "" : " ";
1546
+ return `${sdk}(base_url="${base}",${sep}`;
1547
+ });
1548
+ patched = repointApiKey(patched, sdk, true);
1549
+ }
223
1550
  }
1551
+ // Hoisted out of the bare branch so BOTH paths get the import they need —
1552
+ // every Python rewrite here emits os.environ[...]. Inserted after a shebang
1553
+ // rather than above it: `#!/usr/bin/env python3` only works on line 1, and
1554
+ // the old unconditional prepend would have silently disarmed it.
1555
+ if (patched) patched = ensureImportOs(patched);
224
1556
  } else {
225
1557
  const bare = new RegExp(`new\\s+${sdk}\\s*\\(\\s*\\)`);
226
- const obj = new RegExp(`new\\s+${sdk}\\s*\\(\\s*\\{`);
1558
+ // 2026-07-26 tick 802: consume the spaces/tabs AFTER the brace so the
1559
+ // injected `baseURL: ...,` does not collide with the original's leading
1560
+ // space and produce `, apiKey` (two spaces). Found by running init on an
1561
+ // Anthropic project and reading the patched line, not the code.
1562
+ //
1563
+ // [ \t]* deliberately, NOT \s*: a multiline constructor
1564
+ // new OpenAI({
1565
+ // apiKey: ...
1566
+ // })
1567
+ // must keep its newline and indentation. Collapsing that onto one line
1568
+ // would be real reformatting, which is a bigger sin than a double space --
1569
+ // the README promises "your code otherwise untouched".
1570
+ const obj = new RegExp(`new\\s+${sdk}\\s*\\(\\s*\\{[ \\t]*`);
227
1571
  if (bare.test(body)) {
228
1572
  patched = body.replace(bare, `new ${sdk}({ ${baseLine} })`);
229
1573
  } else if (obj.test(body) && !site.hasBaseUrl) {
230
- patched = body.replace(obj, `new ${sdk}({ baseURL: "${base}", `);
1574
+ // Emit the separating space only when something follows on the SAME
1575
+ // line. On a multiline constructor the next character is a newline, and
1576
+ // a space before it is trailing whitespace (eslint no-trailing-spaces,
1577
+ // and a red block in the diff). Two artifacts, one decision point.
1578
+ patched = body.replace(obj, (match, offset, whole) => {
1579
+ const nextCh = whole[offset + match.length];
1580
+ const sep = nextCh === "\n" || nextCh === "\r" ? "" : " ";
1581
+ return `new ${sdk}({ baseURL: "${base}",${sep}`;
1582
+ });
1583
+ patched = repointApiKey(patched, sdk, false);
231
1584
  }
232
1585
  }
233
1586
  return patched;
@@ -257,6 +1610,115 @@ function findGeminiSites(root) {
257
1610
  return sites;
258
1611
  }
259
1612
 
1613
+ /**
1614
+ * Vercel AI SDK call sites (`ai` + `@ai-sdk/<provider>`).
1615
+ *
1616
+ * 2026-07-26 tick 804: `ai` is in NODE_SDKS, so a Vercel-AI project was
1617
+ * DETECTED, but its code has no `new OpenAI({...})` constructor to rewrite --
1618
+ * the provider is `openai("gpt-4o-mini")` from `@ai-sdk/openai`. The codemod
1619
+ * found nothing, the wizard fell through to its greenfield branch, wrote a
1620
+ * hello-world, and printed "Done. Your project is wired to InferAll" while
1621
+ * src/gen.js still routed to OpenAI directly.
1622
+ *
1623
+ * Same failure family as the tick-803 monorepo bug: detected the project and
1624
+ * reported success while migrating none of it. Detection alone is not the
1625
+ * promise; the promise is that the code now points here.
1626
+ *
1627
+ * Unlike Gemini, this one IS re-pointable -- `createOpenAI({ baseURL, apiKey })`
1628
+ * is a supported factory in @ai-sdk/openai. It is left as guidance rather than
1629
+ * a codemod because the change is one line the user makes deliberately, and a
1630
+ * wrong automatic rewrite of a provider factory is worse than an accurate
1631
+ * instruction.
1632
+ */
1633
+ /**
1634
+ * Vercel AI SDK call sites, TAGGED WITH WHICH PROVIDER PACKAGE each file uses.
1635
+ *
1636
+ * 2026-07-27 tick 819: the tag is new. The guidance printed for these files was
1637
+ * hardcoded to @ai-sdk/openai, so a project importing @ai-sdk/anthropic was told
1638
+ * to install and rewrite against a package it does not have.
1639
+ */
1640
+ export function findVercelAiSites(root) {
1641
+ const sites = [];
1642
+ for (const file of walk(root)) {
1643
+ if (extname(file) === ".py") continue;
1644
+ let body;
1645
+ try { body = readFileSync(file, "utf8"); } catch { continue; }
1646
+ if (!/@ai-sdk\//.test(body)) continue;
1647
+ // 2026-07-27 tick 831: factory files used to be dropped here with the
1648
+ // comment "already re-pointable". Re-pointABLE is not re-pointED. The file
1649
+ // is now kept and tagged, because `doctor` needs to see a createOpenAI call
1650
+ // that never got a baseURL -- exactly what somebody who half-applied our
1651
+ // own snippet ends up with. `init` filters on usesFactory below and so
1652
+ // behaves as before.
1653
+ const usesFactory = /createOpenAI|createAnthropic/.test(body);
1654
+ const providers = [];
1655
+ if (/@ai-sdk\/anthropic/.test(body)) providers.push("anthropic");
1656
+ if (/@ai-sdk\/openai/.test(body)) providers.push("openai");
1657
+ // A file importing some other @ai-sdk/* provider still gets the generic
1658
+ // openai-compatible route, which is the honest fallback rather than
1659
+ // silently naming a package the file never mentions.
1660
+ sites.push({
1661
+ file,
1662
+ body,
1663
+ providers: providers.length ? providers : ["openai"],
1664
+ usesFactory,
1665
+ alreadyPointed: pointsAtInferAll(body),
1666
+ });
1667
+ }
1668
+ return sites;
1669
+ }
1670
+
1671
+ /**
1672
+ * 2026-07-27 tick 819. Two defects, both MEASURED against the real packages
1673
+ * rather than assumed, because a snippet we print is a claim.
1674
+ *
1675
+ * 1. THE SNIPPET POINTED AT AN ENDPOINT WE DO NOT SERVE. In @ai-sdk/openai v4,
1676
+ * `openai("gpt-4o")` targets the Responses API. Measured by pointing a
1677
+ * factory at a recording server:
1678
+ *
1679
+ * openai("gpt-4o") -> POST /responses
1680
+ * openai.chat("gpt-4o") -> POST /chat/completions
1681
+ *
1682
+ * and against production: /v1/responses is 404, /v1/chat/completions is 401.
1683
+ * So anybody who followed the old snippet literally got a 404 from us. The
1684
+ * fix is `.chat(...)`, which is a CLI-side change; adding a /v1/responses
1685
+ * route would be a gateway feature and a much larger decision.
1686
+ *
1687
+ * 2. IT NAMED THE WRONG PACKAGE. Every file got @ai-sdk/openai advice, so an
1688
+ * @ai-sdk/anthropic project was told to import a package it does not have.
1689
+ * createAnthropic exists and honours baseURL (verified: it reached the
1690
+ * recording server rather than silently ignoring the option, which is the
1691
+ * llamaindex api_base trap from tick 811).
1692
+ *
1693
+ * BOTH providers take API_BASE, the /v1 URL. I had assumed anthropic would need
1694
+ * the host root, as the raw @anthropic-ai/sdk does. It does not: this provider
1695
+ * appends only "/messages", so the /v1 has to already be there. Measured, and
1696
+ * the assumption was wrong.
1697
+ */
1698
+ function printVercelAiGuidance(root, sites) {
1699
+ console.log(`\n ${c.bold("Vercel AI SDK usage")} found in ${sites.length} file(s):`);
1700
+ for (const s of sites) {
1701
+ console.log(` - ${relative(root, s.file)} ${c.dim(`(${s.providers.join(" + ")})`)}`);
1702
+ }
1703
+ console.log(` These were ${c.bold("NOT changed")}. The provider helper builds its own URL, so there`);
1704
+ console.log(` is no client constructor to re-point. Swap the helper for its factory form:`);
1705
+ const wanted = [...new Set(sites.flatMap((s) => s.providers))];
1706
+ for (const prov of wanted) {
1707
+ const isAnthropic = prov === "anthropic";
1708
+ const pkg = isAnthropic ? "@ai-sdk/anthropic" : "@ai-sdk/openai";
1709
+ const factory = isAnthropic ? "createAnthropic" : "createOpenAI";
1710
+ const local = isAnthropic ? "anthropic" : "openai";
1711
+ // .chat() on the openai provider: the default export targets /responses,
1712
+ // which this gateway does not serve. See the note above.
1713
+ const call = isAnthropic ? `${local}("${FREE_MODEL}")` : `${local}.chat("${FREE_MODEL}")`;
1714
+ console.log(c.dim(` // import { ${local} } from "${pkg}";`));
1715
+ console.log(c.cyan(` import { ${factory} } from "${pkg}";`));
1716
+ console.log(c.cyan(` const ${local} = ${factory}({ baseURL: "${API_BASE}", apiKey: process.env.INFERALL_API_KEY });`));
1717
+ console.log(c.cyan(` model: ${call} ${c.dim("// $0 in/out")}`));
1718
+ }
1719
+ console.log(` Everything downstream keeps working, and you can now name any InferAll model.`);
1720
+ }
1721
+
260
1722
  function printGeminiGuidance(root, sites) {
261
1723
  console.log(`\n ${c.bold("Gemini SDK usage")} found in ${sites.length} file(s):`);
262
1724
  for (const s of sites) console.log(` - ${relative(root, s.file)}`);
@@ -271,14 +1733,32 @@ function printGeminiGuidance(root, sites) {
271
1733
  console.log(c.cyan(` const ai = new OpenAI({ baseURL: "${API_BASE}", apiKey: process.env.INFERALL_API_KEY });`));
272
1734
  console.log(c.cyan(` await ai.chat.completions.create({ model: "gemini/gemini-2.5-flash", messages });`));
273
1735
  }
274
- console.log(` Same Gemini models, one ifu_ key, $0 markup — migrate at your own pace.`);
1736
+ // ⛔ "$0 markup" CORRECTED 2026-09-03. Premium provider tokens (OpenAI,
1737
+ // Anthropic, Google) are NOT billed at the provider's rate: InferAll bills its
1738
+ // own published per-token rates, and applied-margin measures 1.30x across
1739
+ // those providers. The site published this correction on 2026-08-26 and said
1740
+ // "every live surface has been corrected" — the CLI is a live surface and was
1741
+ // missed, so the claim shipped on npm for another week.
1742
+ console.log(` Same Gemini models, one ifu_ key, one bill. Premium tokens bill at InferAll's published per-token rate; see ${BILLING_URL}. Migrate at your own pace.`);
1743
+ // ⛔ "IT BILLS" AND "YOU CANNOT USE IT" ARE DIFFERENT FACTS, and only the second
1744
+ // one is actionable for a caller who has not paid. Gemini is a PREMIUM route
1745
+ // ($0.15/$0.60 per M), so a no-card trial key is REFUSED here rather than
1746
+ // charged — it routes to free NIM models only. Following this guidance on a
1747
+ // new key turns a working Gemini SDK into a 402.
1748
+ //
1749
+ // ⭐ THE CAVEAT ALREADY EXISTED ONE SURFACE OVER. content/blog/
1750
+ // gemini-2-5-flash-api-free.mdx carries "Using a new key? A no-card trial key
1751
+ // routes to the free NIM models only" — the site said it, the CLI did not.
1752
+ // Same shape as the "$0 markup" correction directly above, which the site
1753
+ // shipped on 2026-08-26 while this file kept the wrong claim for another week.
1754
+ console.log(` ${c.yellow("~")} On a no-card trial key these premium routes are REFUSED, not billed: the trial covers the free NIM models only. Add $5 at ${BILLING_URL}, or start with model="nvidia/nemotron-3-ultra-550b-a55b" which is $0 and routes as written.`);
275
1755
  }
276
1756
 
277
1757
  // AI-assisted patch for call sites the deterministic codemod can't handle.
278
1758
  // Uses the user's own key against a $0 NIM model — the wizard dogfoods the
279
1759
  // gateway. Returns the suggested full-file replacement, or null.
280
1760
  async function aiSuggestPatch(key, site) {
281
- const base = site.sdk === "Anthropic" ? ANTHROPIC_BASE : API_BASE;
1761
+ const base = baseForSdk(site.sdk);
282
1762
  const prompt = [
283
1763
  {
284
1764
  role: "user",
@@ -294,29 +1774,118 @@ async function aiSuggestPatch(key, site) {
294
1774
  const { status, body } = await apiChat(key, prompt);
295
1775
  if (status !== 200) return { status, patched: null };
296
1776
  const text = body?.choices?.[0]?.message?.content;
297
- if (!text || Math.abs(text.length - site.body.length) > site.body.length) return { status, patched: null };
298
- return { status, patched: text.replace(/^```[a-z]*\n?/, "").replace(/\n?```\s*$/, "") };
1777
+ // Strip fences BEFORE judging the text. The old order measured the fenced
1778
+ // string and then wrote the unfenced one, so the thing checked was never the
1779
+ // thing saved.
1780
+ const cleaned = (text ?? "").replace(/^```[a-z]*\n?/, "").replace(/\n?```\s*$/, "");
1781
+ if (!isPlausiblePatch(cleaned, site, base)) return { status, patched: null };
1782
+ return { status, patched: cleaned };
1783
+ }
1784
+
1785
+ /**
1786
+ * Would this model output be a sane replacement for the whole file?
1787
+ *
1788
+ * WHY THIS IS STRICT (2026-07-31, tick 880)
1789
+ * ----------------------------------------
1790
+ * The previous check was:
1791
+ *
1792
+ * Math.abs(text.length - site.body.length) > site.body.length
1793
+ *
1794
+ * For an original of length L that accepts ANY reply from 1 to 2L characters.
1795
+ * Its failing set is empty in the only direction that destroys data. Measured:
1796
+ * a two-character reply overwrote a 250-character main.py, and the wizard then
1797
+ * printed a tick and "Done. Your project is wired to InferAll."
1798
+ *
1799
+ * The replies that actually occur in the short direction are refusals, one-line
1800
+ * apologies and responses truncated by max_tokens. All of them passed. This
1801
+ * function exists so those fail CLOSED: the site is reported as unchanged,
1802
+ * which the caller already accounts for and which suppresses the "wired"
1803
+ * closing line.
1804
+ *
1805
+ * A patch is only accepted if it did the job it was asked to do, which is a
1806
+ * stronger and more honest test than any length band on its own.
1807
+ */
1808
+ export function isPlausiblePatch(text, site, base) {
1809
+ if (!text || !text.trim()) return false;
1810
+ const orig = site.body ?? "";
1811
+ // A whole-file rewrite should be about the same size. Anything much shorter
1812
+ // is a refusal or a truncation, not a rewrite.
1813
+ if (orig.length > 0 && text.length < orig.length * 0.6) return false;
1814
+ if (orig.length > 0 && text.length > orig.length * 2) return false;
1815
+ // 2026-07-31 tick 881: a CHARACTER ratio cannot tell a whole file from a
1816
+ // single line once the file is short. Measured end to end: a one-line reply
1817
+ // containing every marker replaced a four-line file, because 105 chars is 80%
1818
+ // of 130 and so cleared the 0.6 bound. The tick 880 test for this case passed
1819
+ // only because it used a longer original, where the same fragment fell below
1820
+ // the ratio. Line count does not decay with file size, so it catches the
1821
+ // fragment at any scale. Allow one line of slack for a dropped blank.
1822
+ const lines = (s) => s.split("\n").filter((l) => l.trim() !== "").length;
1823
+ const origLines = lines(orig);
1824
+ if (origLines > 0 && lines(text) < origLines - 1) return false;
1825
+ // 2026-07-31 tick 884: the line floor above has its OWN degenerate region,
1826
+ // and it is small by construction, which is exactly where a casual test never
1827
+ // looks. At origLines = 2 the floor is 1, so a one-line reply satisfies it,
1828
+ // and the character ratio cannot help because the fragment is often LONGER
1829
+ // than the two lines it replaces (measured: ratio 1.12). A two-line client
1830
+ // module is an ordinary shape:
1831
+ //
1832
+ // import OpenAI from "openai";
1833
+ // export default new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
1834
+ //
1835
+ // and the fragment silently deletes the import.
1836
+ //
1837
+ // So stop sampling the quantity and test the INVARIANT instead. The model is
1838
+ // told to change the client constructor and NOTHING else, therefore every
1839
+ // import the original had must survive. That holds at any file size and it
1840
+ // disagrees the moment it is wrong, where a size comparison agrees by luck
1841
+ // most of the time.
1842
+ const importsOf = (s) =>
1843
+ s.split("\n").map((l) => l.trim()).filter((l) => /^(import|from)\s/.test(l));
1844
+ for (const line of importsOf(orig)) {
1845
+ if (!text.includes(line)) return false;
1846
+ }
1847
+ // It must contain what it was told to add, or it changed nothing that matters
1848
+ // and there is no reason to overwrite the file with it.
1849
+ if (!text.includes("INFERALL_API_KEY")) return false;
1850
+ if (base && !text.includes(base)) return false;
1851
+ // And it must still be recognisably the same file: the SDK it was built from
1852
+ // has to survive. Catches "here is the rewritten file:" style prose.
1853
+ if (site.sdk && !text.includes(site.sdk)) return false;
1854
+ return true;
299
1855
  }
300
1856
 
301
1857
  // ---------- fresh-install example ----------
302
1858
 
303
- function writeExample(root, kind) {
304
- if (kind === "python") {
305
- const p = join(root, "inferall_hello.py");
306
- if (existsSync(p)) return null;
307
- writeFileSync(
308
- p,
309
- `"""InferAll hello — run: python inferall_hello.py (pip install inferall-ai)"""\nfrom inferall import Inferall\n\nai = Inferall() # reads INFERALL_API_KEY from the environment\nprint(ai.text("Explain InferAll in one sentence."))\n`
310
- );
311
- return p;
312
- }
313
- const p = join(root, "inferall-hello.mjs");
314
- if (existsSync(p)) return null;
315
- writeFileSync(
316
- p,
317
- `// InferAll hello — run: node --env-file=.env inferall-hello.mjs\nconst res = await fetch("${API_BASE}/chat/completions", {\n method: "POST",\n headers: {\n Authorization: \`Bearer \${process.env.INFERALL_API_KEY}\`,\n "Content-Type": "application/json",\n },\n body: JSON.stringify({\n model: "${FREE_MODEL}",\n messages: [{ role: "user", content: "Explain InferAll in one sentence." }],\n }),\n});\nconsole.log((await res.json()).choices?.[0]?.message?.content ?? (await res.text()));\n`
318
- );
319
- return p;
1859
+ /**
1860
+ * 2026-07-28 tick 837: these two writes were the last bare ones in the wizard.
1861
+ *
1862
+ * Tick 836 guarded .env, .env.example and .gitignore, and MEASURED against a
1863
+ * read-only working directory those three now degrade correctly -- and then
1864
+ * this function threw one line later:
1865
+ *
1866
+ * ✗ could not write .env: permission denied
1867
+ * ✗ could not write .env.example: permission denied
1868
+ * ✗ could not update .gitignore: permission denied
1869
+ * ✗ EACCES: permission denied, open '/…/inferall-hello.mjs'
1870
+ *
1871
+ * Three honest lines followed by the exact raw errno they replaced. I guarded
1872
+ * the writers I happened to be looking at rather than enumerating them, which
1873
+ * is the same mistake as tick 835 (parse vs read) one surface further on.
1874
+ *
1875
+ * Returns { path, problem }: path when written, problem when it could not be.
1876
+ */
1877
+ export function writeExample(root, kind) {
1878
+ const p = join(root, kind === "python" ? "inferall_hello.py" : "inferall-hello.mjs");
1879
+ if (existsSync(p)) return { path: null, problem: null }; // never clobber theirs
1880
+ const body = kind === "python"
1881
+ ? `"""InferAll hello. Run: python inferall_hello.py (pip install inferall-ai)"""\nfrom inferall import Inferall\n\nai = Inferall() # reads INFERALL_API_KEY from the environment\nprint(ai.text("Explain InferAll in one sentence."))\n`
1882
+ : `// InferAll hello. Run: node --env-file=.env inferall-hello.mjs\nconst res = await fetch("${API_BASE}/chat/completions", {\n method: "POST",\n headers: {\n Authorization: \`Bearer \${process.env.INFERALL_API_KEY}\`,\n "Content-Type": "application/json",\n },\n body: JSON.stringify({\n model: "${FREE_MODEL}",\n messages: [{ role: "user", content: "Explain InferAll in one sentence." }],\n }),\n});\nconsole.log((await res.json()).choices?.[0]?.message?.content ?? (await res.text()));\n`;
1883
+ try {
1884
+ writeFileSync(p, body);
1885
+ return { path: p, problem: null };
1886
+ } catch (err) {
1887
+ return { path: null, problem: describeFsError(err, p, "write") };
1888
+ }
320
1889
  }
321
1890
 
322
1891
  // ---------- models ----------
@@ -334,13 +1903,32 @@ function priceLabel(p) {
334
1903
  }
335
1904
 
336
1905
  async function modelsCmd() {
337
- const res = await fetch(`${ANTHROPIC_BASE}/ai/v1/models`);
1906
+ const res = await fetch(`${ANTHROPIC_BASE}/ai/v1/models`, { signal: timeoutSignal() });
338
1907
  if (!res.ok) {
339
- console.log(`${c.red("✗")} couldn't fetch the model list (HTTP ${res.status})`);
1908
+ for (const line of httpFailureLines(res.status)) console.log(line);
1909
+ rl.close();
1910
+ exit(1);
1911
+ }
1912
+ const parsed = await readJsonBody(res);
1913
+ if (parsed.parseFailed) {
1914
+ for (const line of nonJsonLines(parsed)) console.log(line);
1915
+ rl.close();
1916
+ exit(1);
1917
+ }
1918
+ const map = parsed.body;
1919
+ // 2026-07-31 tick 886: valid JSON of the WRONG SHAPE. Measured against a stub
1920
+ // returning an ARRAY: Object.entries gives ["0", "gpt-4o"], p is a string, and
1921
+ // the renderer died on `Cannot read properties of undefined (reading
1922
+ // 'padEnd')`. The catalog is an object of {id: pricing}; anything else is a
1923
+ // different service answering at this URL, which is what a mistyped
1924
+ // INFERALL_API_BASE looks like.
1925
+ if (!isCatalogShape(map)) {
1926
+ console.log(`${c.red("✗")} the gateway returned JSON, but not a model catalog`);
1927
+ console.log(` Expected an object of model ids to pricing. Got ${Array.isArray(map) ? "an array" : typeof map}.`);
1928
+ console.log(` Check ${c.cyan("INFERALL_API_BASE")}: something else is answering at that address.`);
340
1929
  rl.close();
341
1930
  exit(1);
342
1931
  }
343
- const map = await res.json();
344
1932
  let rows = Object.entries(map).map(([id, p]) => ({
345
1933
  id,
346
1934
  type: p.type,
@@ -362,53 +1950,204 @@ async function modelsCmd() {
362
1950
  console.log(`${r.id.padEnd(w)}${r.type.padEnd(12)}${price}`);
363
1951
  }
364
1952
  const freeCount = Object.values(map).filter((p) => p.type === "token" && !p.inputPerM && !p.outputPerM).length;
365
- console.log(`\n${Object.keys(map).length} models — ${c.green(`${freeCount} at $0`)} (NVIDIA NIM). Use any with your ifu_ key.\n`);
1953
+ console.log(`\n${Object.keys(map).length} models, ${c.green(`${freeCount} at $0`)} (NVIDIA NIM). Use any with your InferAll key.\n`);
366
1954
  rl.close();
367
1955
  }
368
1956
 
369
- // ---------- status ----------
370
-
371
- // GET /ai/v1/key/status (host root, API-key auth) — tier, trial counter,
372
- // balance, savings. Answers "did my payment land?" without opening a browser.
373
- async function statusCmd() {
374
- let key = env.INFERALL_API_KEY || flagValue("key") || "";
375
- const envPath = join(cwd(), ".env");
376
- if (!key && existsSync(envPath)) {
377
- const m = readFileSync(envPath, "utf8").match(/^INFERALL_API_KEY=(.+)$/m);
378
- if (m) key = m[1].trim();
1957
+ // Resolve the API key from --key, env, or .env (in that order); exit if none.
1958
+ function resolveKey() {
1959
+ let key = flagValue("key") || env.INFERALL_API_KEY || "";
1960
+ if (!key) {
1961
+ // 2026-07-27 tick 834: shared parser (quotes, `export`, spaces, comments).
1962
+ // 2026-07-27 tick 835: shared READER too, so an unreadable .env is named
1963
+ // rather than escaping as a Node errno.
1964
+ const { text, code } = readEnvText(cwd());
1965
+ const problem = envReadProblem(code);
1966
+ if (problem) {
1967
+ console.log(`${c.red("✗")} ${problem}`);
1968
+ console.log(` ${c.dim(join(cwd(), ".env"))}`);
1969
+ console.log(` Fix it, or pass the key directly: ${c.bold("--key ifu_...")}`);
1970
+ rl.close();
1971
+ exit(1);
1972
+ }
1973
+ key = parseEnvKey(text) || "";
379
1974
  }
380
1975
  if (!key) {
381
- console.log(`${c.red("✗")} No key in INFERALL_API_KEY or .env — run ${c.bold("npx @inferall/cli init")}`);
1976
+ console.log(`${c.red("✗")} No key in INFERALL_API_KEY or .env. Run ${c.bold("npx @inferall/cli init")}`);
1977
+ rl.close();
1978
+ exit(1);
1979
+ }
1980
+ return key;
1981
+ }
1982
+
1983
+ // ---------- chat ----------
1984
+
1985
+ // One-shot chat: `inferall chat "prompt"` (or piped stdin). The fastest way to
1986
+ // confirm a key works and see a real response — dogfoods the gateway on a $0
1987
+ // NIM model by default (a taster's first "it works" moment without writing code).
1988
+ async function chatCmd() {
1989
+ const key = resolveKey();
1990
+ const model = flagValue("model") || FREE_MODEL;
1991
+ // Every flag that takes a VALUE must be listed here, or its value is
1992
+ // silently treated as part of the prompt. Adding --max-tokens without this
1993
+ // would have made `chat "hi" --max-tokens 900` send the prompt "hi 900".
1994
+ const VALUE_FLAGS = ["--key", "--model", "--max-tokens"];
1995
+ let prompt = args.filter((a, idx) => idx > 0 && !a.startsWith("-") && !VALUE_FLAGS.includes(args[idx - 1])).join(" ").trim();
1996
+ if (!prompt && !stdin.isTTY) {
1997
+ prompt = readFileSync(0, "utf8").trim(); // piped stdin
1998
+ }
1999
+ if (!prompt) {
2000
+ console.log(`Usage: npx @inferall/cli chat "your prompt" ${c.dim("(or pipe: echo \"hi\" | npx @inferall/cli chat)")}`);
382
2001
  rl.close();
383
2002
  exit(1);
384
2003
  }
2004
+ // Parsed here rather than inside apiChat so the flag is visible where the
2005
+ // error message that recommends it also lives.
2006
+ const rawMax = flagValue("max-tokens");
2007
+ const parsedMax = rawMax === undefined ? 512 : parseInt(rawMax, 10);
2008
+ const maxTokens = Number.isFinite(parsedMax) && parsedMax > 0 ? parsedMax : 512;
2009
+ const reply = await apiChat(key, [{ role: "user", content: prompt }], model, maxTokens);
2010
+ const { status, body } = reply;
2011
+ // 2026-07-27 tick 833: a body we could not parse is a FAILED call whatever
2012
+ // the status line said. Measured: a 200 whose JSON was truncated mid-stream
2013
+ // (a proxy cutting the response) printed `{}` and exited 0, so a script
2014
+ // wrapping this command saw success and an empty answer. Reporting success
2015
+ // for a call that produced nothing is the rung of the ladder this product
2016
+ // keeps landing on, and it is worth its own branch before the status checks.
2017
+ if (reply.parseFailed) {
2018
+ console.log(`${c.red("✗")} ${nonJsonSummary(reply)}`);
2019
+ console.log(` ${c.dim(`HTTP ${status}. Nothing usable came back, so this call did not succeed.`)}`);
2020
+ if (status >= 500 || status === 502 || status === 503) {
2021
+ console.log(` That is an infrastructure error, not a problem with your key. Retry shortly.`);
2022
+ }
2023
+ rl.close();
2024
+ exit(1);
2025
+ }
2026
+ if (status === 200) {
2027
+ const choice = body?.choices?.[0];
2028
+ const text = choice?.message?.content;
2029
+ if (text) {
2030
+ console.log(text);
2031
+ /*
2032
+ * Printed to STDERR, deliberately. `chat` is piped: the usage line above
2033
+ * documents `echo "hi" | npx @inferall/cli chat`, and people redirect the
2034
+ * answer into files and other commands. A counter on stdout would corrupt
2035
+ * every one of those, which is a worse bug than the blindness it fixes.
2036
+ * stderr reaches a human at a terminal and stays out of the pipe.
2037
+ */
2038
+ const countdown = trialCountdownLine(reply.trialUsage);
2039
+ if (countdown) process.stderr.write(`${countdown}\n`);
2040
+ } else if (choice?.finish_reason === "length") {
2041
+ // 2026-07-26 tick 817. Reasoning models (nemotron and friends) spend the
2042
+ // token budget on reasoning_content and return content: null with
2043
+ // finish_reason "length". Measured at tick 811: nemotron at max_tokens=64
2044
+ // returns null, at 256 it answers. The old code fell through to
2045
+ // JSON.stringify(body), so a user who asked a question got a JSON blob
2046
+ // back with no hint that the fix is simply a bigger budget.
2047
+ console.log(c.yellow("The model used its whole token budget before answering."));
2048
+ console.log(` ${c.dim("Reasoning models spend the budget thinking first. Retry with a larger")} ${c.bold("--max-tokens")}${c.dim(", or use a non-reasoning model such as")} ${c.bold(FREE_MODEL)}${c.dim(".")}`);
2049
+ rl.close();
2050
+ exit(1);
2051
+ } else {
2052
+ // 2026-07-27 tick 833: this printed a JSON blob and then fell out of the
2053
+ // function, so the process exited 0. A 200 carrying no answer is still a
2054
+ // call that produced nothing, and a caller piping this into anything had
2055
+ // no way to tell. The blob is kept -- it is the only diagnostic there is
2056
+ // -- but it is labelled and the exit code now says what happened.
2057
+ console.log(`${c.yellow("~")} the gateway returned 200 with no answer in it:`);
2058
+ console.log(` ${JSON.stringify(body).slice(0, 400)}`);
2059
+ rl.close();
2060
+ exit(1);
2061
+ }
2062
+ } else {
2063
+ // 2026-07-26 tick 817: SHOW THE SERVER'S OWN MESSAGE.
2064
+ //
2065
+ // The gateway writes careful, model-aware 402 and 429 copy (ticks 812 and
2066
+ // 815 were spent making it accurate) and this command threw it away. On 402
2067
+ // it printed "your free allowance is used up or the account isn't activated
2068
+ // yet", which conflates two different states and is simply WRONG for a user
2069
+ // who was never activated: nothing of theirs is used up. The accurate
2070
+ // sentence was in the response we discarded. On 429 it dumped raw JSON at
2071
+ // the user, so the gateway's readable "retry in 42s" arrived wrapped in
2072
+ // braces.
2073
+ //
2074
+ // Both are the same mistake: the client second-guessing a server that knows
2075
+ // more than it does.
2076
+ const serverMsg = body?.error?.message || body?.message;
2077
+ console.log(`${status === 402 ? c.yellow("402") : c.red(`HTTP ${status}`)}: ${serverMsg || JSON.stringify(body).slice(0, 300)}`);
2078
+ // Keep the pointer to `status`, which is genuinely useful and not something
2079
+ // the server can know to suggest. Only for billing states, where "where do
2080
+ // I stand" is the actual next question.
2081
+ if (status === 402) {
2082
+ console.log(` ${c.dim("check your account:")} ${c.bold("npx @inferall/cli status")}`);
2083
+ }
2084
+ rl.close();
2085
+ exit(1);
2086
+ }
2087
+ rl.close();
2088
+ }
2089
+
2090
+ // ---------- status ----------
2091
+
2092
+ // GET /ai/v1/key/status (host root, API-key auth) — tier, trial counter,
2093
+ // balance, savings. Answers "did my payment land?" without opening a browser.
2094
+ async function statusCmd() {
2095
+ const key = resolveKey();
385
2096
  const res = await fetch(`${ANTHROPIC_BASE}/ai/v1/key/status`, {
2097
+ signal: timeoutSignal(),
386
2098
  headers: { Authorization: `Bearer ${key}` },
387
2099
  });
388
2100
  if (!res.ok) {
389
- console.log(`${c.red("✗")} status check failed (HTTP ${res.status}) — check the key at ${KEYS_URL}`);
2101
+ for (const line of httpFailureLines(res.status)) console.log(line);
2102
+ rl.close();
2103
+ exit(1);
2104
+ }
2105
+ const parsedStatus = await readJsonBody(res);
2106
+ if (parsedStatus.parseFailed) {
2107
+ for (const line of nonJsonLines(parsedStatus)) console.log(line);
390
2108
  rl.close();
391
2109
  exit(1);
392
2110
  }
393
- const s = await res.json();
2111
+ const s = parsedStatus.body;
394
2112
  console.log(`\n${c.bold("InferAll account status")} ${c.dim(`(key ${key.slice(0, 8)}…)`)}\n`);
395
2113
  if (s.project) {
396
- console.log(` tier: ${c.cyan(s.tier)} ${c.dim("(project key — billed separately, no trial counter)")}\n`);
2114
+ console.log(` tier: ${c.cyan(s.tier)} ${c.dim("(project key, billed separately, no trial counter)")}\n`);
397
2115
  rl.close();
398
2116
  return;
399
2117
  }
400
2118
  const active = s.has_paid_successfully;
401
2119
  console.log(` tier: ${c.cyan(s.tier)}`);
402
2120
  console.log(` activated: ${active ? c.green("yes") : c.yellow("not yet")}${!active && s.has_card ? c.dim(" (checkout started, not settled)") : ""}`);
403
- console.log(` balance: ${s.balance_usd > 0 ? c.green(`$${s.balance_usd.toFixed(2)}`) : `$${s.balance_usd.toFixed(2)}`}`);
2121
+ // 2026-07-31 tick 882: this was a bare s.balance_usd.toFixed(2), so a
2122
+ // response missing the field killed the command with a raw
2123
+ // "Cannot read properties of undefined (reading 'toFixed')". The CLI is
2124
+ // published independently of the gateway, so an older client meeting a
2125
+ // renamed or dropped field is a version-skew case, not a hypothetical. Say
2126
+ // the number is unavailable rather than throwing Node's words at the user.
2127
+ const bal = typeof s.balance_usd === "number" && Number.isFinite(s.balance_usd) ? s.balance_usd : null;
2128
+ console.log(` balance: ${bal === null ? c.yellow("unavailable (gateway did not report it)") : bal > 0 ? c.green(`$${bal.toFixed(2)}`) : `$${bal.toFixed(2)}`}`);
404
2129
  if (s.trial) console.log(` trial: ${s.trial.remaining}/${s.trial.cap} calls remaining`);
405
2130
  const sv = s.savings_7d;
406
2131
  if (sv && (sv.vs_gpt4o_mini_usd > 0 || sv.vs_claude_sonnet_usd > 0)) {
407
2132
  console.log(` saved 7d: $${sv.vs_gpt4o_mini_usd.toFixed(2)} vs GPT-4o-mini · $${sv.vs_claude_sonnet_usd.toFixed(2)} vs Claude Sonnet`);
408
2133
  }
409
2134
  if (!active) {
410
- console.log(`\n Activate with the one-time ${c.bold("$5 starter pack")} (becomes spendable balance):`);
411
- console.log(` ${c.cyan(BILLING_URL)}`);
2135
+ // 2026-07-25: `active` is has_paid_successfully, so before this branch was
2136
+ // split EVERY unpaid user got told to buy the $5 pack -- including someone
2137
+ // holding 5 unused free calls. That is the same mistake the /keys page made
2138
+ // ("your calls will 402 until you pay") at the moment activation is decided.
2139
+ // trial.active comes from the gateway and means "this key can call RIGHT
2140
+ // NOW", so the CTA no longer has to be inferred from payment state.
2141
+ if (s.trial?.active) {
2142
+ const n = s.trial.remaining;
2143
+ console.log(`\n ${c.green("You can call right now")}: ${c.bold(`${n} free request${n === 1 ? "" : "s"}`)} on 10+ open models, no card needed:`);
2144
+ console.log(` ${c.cyan('npx @inferall/cli chat "hello"')}`);
2145
+ console.log(c.dim(` When those run out, the one-time $5 starter pack keeps you going (it becomes`));
2146
+ console.log(c.dim(` spendable balance, not a fee): ${BILLING_URL}`));
2147
+ } else {
2148
+ console.log(`\n Activate with the one-time ${c.bold("$5 starter pack")} (becomes spendable balance):`);
2149
+ console.log(` ${c.cyan(BILLING_URL)}`);
2150
+ }
412
2151
  }
413
2152
  console.log("");
414
2153
  rl.close();
@@ -417,51 +2156,189 @@ async function statusCmd() {
417
2156
  // ---------- doctor ----------
418
2157
 
419
2158
  async function doctor() {
420
- console.log(`\n${c.bold("InferAll doctor")} ${c.dim(`— checks your setup against ${API_BASE}`)}\n`);
2159
+ console.log(`\n${c.bold("InferAll doctor")} ${c.dim(`checks your setup against ${API_BASE}`)}\n`);
421
2160
  const root = cwd();
422
2161
  let ok = true;
423
2162
 
424
2163
  // 1. key present
425
2164
  let key = env.INFERALL_API_KEY || "";
426
2165
  const envPath = join(root, ".env");
427
- if (!key && existsSync(envPath)) {
428
- const m = readFileSync(envPath, "utf8").match(/^INFERALL_API_KEY=(.+)$/m);
429
- if (m) key = m[1].trim();
2166
+ if (!key) {
2167
+ // 2026-07-27 tick 834: shared parser (quotes, `export`, spaces, comments).
2168
+ // 2026-07-27 tick 835: shared reader. doctor is the command people run to
2169
+ // find out what is wrong, so "EISDIR: illegal operation on a directory,
2170
+ // read" was the least useful thing it could have said: it names no file, no
2171
+ // cause and no next step.
2172
+ const { text, code } = readEnvText(root);
2173
+ const problem = envReadProblem(code);
2174
+ if (problem) {
2175
+ console.log(` ${c.red("✗")} ${problem}`);
2176
+ console.log(` ${c.dim(envPath)}`);
2177
+ console.log(` Fix that file, or set ${c.cyan("INFERALL_API_KEY")} in your environment.`);
2178
+ rl.close();
2179
+ exit(1);
2180
+ }
2181
+ key = parseEnvKey(text) || "";
430
2182
  }
431
2183
  if (!key) {
432
- console.log(` ${c.red("✗")} INFERALL_API_KEY not found in environment or .env — run ${c.bold("npx @inferall/cli init")}`);
2184
+ // 2026-07-29 tick 850: say WHERE we looked, and whether a parent directory
2185
+ // already has one. The old line reported absence with no scope, then sent
2186
+ // the user to `init`, which in a monorepo writes a second key beside the
2187
+ // first. Naming the location turns a wrong instruction into a correct one.
2188
+ const ancestor = findAncestorEnvKey(root);
2189
+ console.log(` ${c.red("✗")} INFERALL_API_KEY not in the environment, and no key in ${c.bold(".env")} here`);
2190
+ console.log(` ${c.dim(`looked in ${root}`)}`);
2191
+ if (ancestor) {
2192
+ const rel = relative(root, ancestor.dir) || ".";
2193
+ console.log(` ${c.yellow("~")} a parent directory already has one: ${c.bold(join(rel, ".env"))}`);
2194
+ console.log(` Run this from there, or copy that key into this package.`);
2195
+ console.log(` ${c.bold("Do not run init here")} unless you want a second key for the same repo.`);
2196
+ } else {
2197
+ console.log(` Run ${c.bold("npx @inferall/cli init")}`);
2198
+ }
433
2199
  rl.close();
434
2200
  exit(1);
435
2201
  }
436
2202
  console.log(` ${c.green("✓")} key found (${key.slice(0, 8)}…)`);
437
- if (!key.startsWith("ifu_")) {
438
- console.log(` ${c.yellow("~")} key doesn't have the ifu_ prefix — is this an InferAll key?`);
2203
+ // 2026-07-25: this used to warn (and set ok=false) for ANY key without an
2204
+ // ifu_ prefix. The gateway mints ifu_ but auth.ts also accepts ifa_,
2205
+ // kr_user_ and kr_proj_ ("Legacy kr_user_... keys remain valid"), so a user
2206
+ // with a working key was told their setup "needs attention" — and then, two
2207
+ // lines later, told "key accepted by gateway". doctor contradicted itself.
2208
+ //
2209
+ // The gateway check below is authoritative; a client-side prefix guess is a
2210
+ // second copy of a rule enforced elsewhere. Only flag keys that are
2211
+ // recognisably ANOTHER provider's, where a bare 401 would not explain why.
2212
+ const foreign =
2213
+ key.startsWith("sk-ant-") ? "an Anthropic key (sk-ant-…)" :
2214
+ key.startsWith("sk-or-") ? "an OpenRouter key (sk-or-…)" :
2215
+ key.startsWith("sk-") ? "an OpenAI key (sk-…)" :
2216
+ key.startsWith("AIzaSy") ? "a Google AI Studio key (AIzaSy…)" : null;
2217
+ if (foreign) {
2218
+ console.log(` ${c.red("✗")} that looks like ${foreign}, not an InferAll key. Mint one at ${KEYS_URL}`);
439
2219
  ok = false;
440
2220
  }
441
2221
 
442
2222
  // 2. .env hygiene
443
- const giPath = join(root, ".gitignore");
444
- const giCovers =
445
- existsSync(giPath) &&
446
- readFileSync(giPath, "utf8").split("\n").some((l) => [".env", ".env*", "*.env"].includes(l.trim()));
447
- if (existsSync(envPath) && !giCovers) {
448
- console.log(` ${c.yellow("~")} .env exists but is NOT gitignored — your key could be committed`);
2223
+ //
2224
+ // 2026-07-27 tick 825: this asked ONE question (is .env gitignored?) and
2225
+ // then reported on a broader one (".env hygiene ok"). Measured: a .env that
2226
+ // is COMMITTED TO GIT, with a live key in it, got
2227
+ //
2228
+ // ✓ .env hygiene ok
2229
+ //
2230
+ // because a tracked file is usually also listed in .gitignore, and gitignore
2231
+ // has no effect on tracked files. tick 821 built envIsGitTracked() for the
2232
+ // same check in `init` and this copy never got it: the eighth time in a week
2233
+ // one fact lived in two places and went stale in one of them.
2234
+ //
2235
+ // It is worse here than in init. People run `doctor` to ASK whether their
2236
+ // setup is safe, so a green tick from it is the strongest false assurance
2237
+ // the tool can give.
2238
+ const gi = gitignoreCoversEnv(root);
2239
+ const giCovers = gi.covers === true;
2240
+ const envExists = existsSync(envPath);
2241
+ const trackStatus = envExists ? gitTrackStatus(root) : "untracked";
2242
+ if (trackStatus === "tracked") {
2243
+ // Checked FIRST: for a tracked file the gitignore answer is irrelevant,
2244
+ // and reporting the ignore status here would soften a live exposure.
2245
+ console.log(` ${c.red("✗")} .env is ${c.bold("tracked by git")} and holds your key. Untrack it: ${c.cyan("git rm --cached .env")}`);
2246
+ ok = false;
2247
+ } else if (trackStatus === "unknown") {
2248
+ // 2026-07-28 tick 839: git could not be run, so this check did not happen.
2249
+ // Reporting "hygiene ok" here would be the tick-825 false all-clear again,
2250
+ // reached through a different door: a committed .env in a container without
2251
+ // git answered exactly the same as a safe one.
2252
+ console.log(` ${c.yellow("~")} could not check whether .env is tracked by git (git is not available here)`);
2253
+ console.log(` If this project is a git repository, confirm yourself: ${c.cyan("git ls-files --error-unmatch .env")}`);
2254
+ ok = false;
2255
+ } else if (envExists && gi.covers === null) {
2256
+ // Ordered AFTER the tracked check and BEFORE the not-ignored one, because
2257
+ // "could not read .gitignore" must not be reported as "not ignored". The
2258
+ // key may well be protected; we cannot say. Saying either would be a
2259
+ // confident answer to a question this run did not get to ask.
2260
+ console.log(` ${c.yellow("~")} ${gi.problem}, so .env protection could not be checked`);
2261
+ console.log(` Check it yourself: ${c.cyan("git check-ignore -v .env")}`);
2262
+ ok = false;
2263
+ } else if (envExists && !giCovers) {
2264
+ console.log(` ${c.yellow("~")} .env exists but is NOT gitignored, so your key could be committed`);
449
2265
  ok = false;
450
2266
  } else {
451
2267
  console.log(` ${c.green("✓")} .env hygiene ok`);
452
2268
  }
453
2269
 
454
- // 3. key valid against the gateway
455
- const models = await apiGet("/models", key).catch(() => null);
456
- if (!models || (models.status !== 200 && models.status !== 402)) {
457
- console.log(` ${c.red("✗")} key rejected by gateway (${models ? `HTTP ${models.status}` : "network error"}) — check ${KEYS_URL}`);
2270
+ // 3. key valid against the gateway.
2271
+ // MUST hit an authenticated endpoint. /v1/models is public — it answers 200
2272
+ // with no key at all — so checking it made this line ("key accepted by
2273
+ // gateway") true for any string, in the very tool people run BECAUSE their
2274
+ // key isn't working.
2275
+ const models = await fetch(`${ANTHROPIC_BASE}/ai/v1/key/status`, {
2276
+ signal: timeoutSignal(),
2277
+ headers: { Authorization: `Bearer ${key}` },
2278
+ }).then((r) => readJsonBody(r)).catch((e) => ({ netErr: e }));
2279
+ // 2026-07-27 tick 832: an unreachable gateway used to be reported as
2280
+ // "key rejected by gateway (network error). Check <keys page>". The key was
2281
+ // never checked, and minting a replacement cannot reach a host that is down.
2282
+ // doctor exists to diagnose, so a confident wrong cause is the failure that
2283
+ // costs the most here.
2284
+ if (models.netErr) {
2285
+ for (const line of unreachableLines(models.netErr)) console.log(` ${line}`);
2286
+ rl.close();
2287
+ exit(1);
2288
+ }
2289
+ if (models.status !== 200 && models.status !== 402) {
2290
+ for (const line of httpFailureLines(models.status)) console.log(` ${line}`);
2291
+ rl.close();
2292
+ exit(1);
2293
+ }
2294
+ if (models.parseFailed) {
2295
+ // A login page is served with 200, so the status check above passes it.
2296
+ for (const line of nonJsonLines(models, " ")) console.log(line);
458
2297
  rl.close();
459
2298
  exit(1);
460
2299
  }
461
2300
  console.log(` ${c.green("✓")} key accepted by gateway`);
462
2301
 
2302
+ /*
2303
+ * TRIAL BALANCE, 2026-08-03 tick 927.
2304
+ *
2305
+ * `status` has always printed this; `doctor` never did, even though it
2306
+ * already fetches the very response that carries it (the /ai/v1/key/status
2307
+ * call above). So the command people run BECAUSE something is wrong was the
2308
+ * one command that could not tell them the most common reason it is wrong.
2309
+ *
2310
+ * MEASURED, server-side and synthetic-filtered, since the micro-trial:
2311
+ * 9 real users have hit the trial ceiling, 9 of 9 had made successful calls
2312
+ * first (the cap only fires after five), and SEVEN OF THE NINE burned all
2313
+ * five within 0.0 to 0.3 hours of their first call. Six of them arrived
2314
+ * through real tooling, including claude-cli and opencode, which consume
2315
+ * five calls inside a single task. Nobody saw a counter on the way down.
2316
+ *
2317
+ * A used-up trial is not a fault, so it prints as information rather than as
2318
+ * a failure and deliberately does NOT set ok = false. doctor exits non-zero
2319
+ * for things the user can fix by editing something; "you spent your trial"
2320
+ * is a true, correct state of a working key.
2321
+ *
2322
+ * The zero case gets its own line because that is the population this exists
2323
+ * for, and because "0/5 remaining" scans as a number rather than as a cause.
2324
+ */
2325
+ const trial = models.body?.trial;
2326
+ if (trial && typeof trial.remaining === "number" && typeof trial.cap === "number") {
2327
+ if (trial.remaining > 0) {
2328
+ console.log(` ${c.dim("·")} trial: ${trial.remaining}/${trial.cap} free calls remaining`);
2329
+ } else {
2330
+ console.log(` ${c.yellow("~")} trial: 0/${trial.cap} free calls remaining. This key works; the free allowance is spent.`);
2331
+ console.log(` ${c.dim("Activate at https://inferall.ai/billing to keep calling.")}`);
2332
+ }
2333
+ }
2334
+
463
2335
  // 4. call-site wiring
464
- const sites = findCallSites(root);
2336
+ // 2026-07-30 tick 854: the walk can stop early (depth and size caps), and
2337
+ // both claims below are about the WHOLE project. Saying "no constructors
2338
+ // found" over a tree we did not finish reading is the tick 830 false
2339
+ // all-clear in a new place.
2340
+ const cappedDoc = [];
2341
+ const sites = findCallSites(root, cappedDoc);
465
2342
  const pointed = sites.filter((s) => s.alreadyPointed).length;
466
2343
  const unpointed = sites.length - pointed;
467
2344
  if (sites.length === 0) {
@@ -469,14 +2346,81 @@ async function doctor() {
469
2346
  } else if (unpointed === 0) {
470
2347
  console.log(` ${c.green("✓")} all ${sites.length} client constructor site(s) point at InferAll`);
471
2348
  } else {
472
- console.log(` ${c.yellow("~")} ${unpointed} of ${sites.length} client site(s) NOT pointed at InferAll — run ${c.bold("npx @inferall/cli init")}`);
2349
+ console.log(` ${c.yellow("~")} ${unpointed} of ${sites.length} client site(s) NOT pointed at InferAll. Run ${c.bold("npx @inferall/cli init")}`);
2350
+ ok = false;
2351
+ }
2352
+ // A count is only as good as the scan behind it. Printed AFTER the verdict so
2353
+ // it qualifies whatever was just claimed, including the "none found" case.
2354
+ const capDoc = cappedSummary(cappedDoc);
2355
+ if (capDoc) {
2356
+ console.log(` ${c.yellow("~")} ${capDoc}, so this count may be incomplete`);
2357
+ ok = false;
2358
+ }
2359
+
2360
+ // 4a. Notebook cells.
2361
+ //
2362
+ // 2026-07-27 tick 830: doctor never looked inside a .ipynb. On a
2363
+ // notebook-only project it printed
2364
+ //
2365
+ // · no OpenAI/Anthropic client constructors found in this project
2366
+ // All checks passed.
2367
+ //
2368
+ // and exited 0, over a notebook calling OpenAI directly with a live key. The
2369
+ // user's traffic was going to OpenAI, billed by OpenAI, and the tool they ran
2370
+ // to check their InferAll setup told them it was fine.
2371
+ //
2372
+ // This is tick 816 reproduced one command over: t816 taught `init` about
2373
+ // notebooks and `doctor` never got it. Twelfth time one fact lived in two
2374
+ // places and went stale in one. Same severity argument as t825 -- doctor is
2375
+ // the tool people run to ASK whether they are set up, so its green tick is
2376
+ // the strongest false assurance we can hand out.
2377
+ //
2378
+ // Reported, never patched: rewriting a notebook reformats the whole file
2379
+ // (t816). doctor's job here is to say the cell is not wired, not to fix it.
2380
+ const nbSites = findNotebookSites(root);
2381
+ const nbUnpointed = nbSites.flatMap((s) => s.hits.filter((h) => !h.alreadyPointed).map((h) => ({ file: s.file, ...h })));
2382
+ if (nbUnpointed.length) {
2383
+ console.log(` ${c.yellow("~")} ${nbUnpointed.length} notebook cell(s) still call the provider directly:`);
2384
+ for (const h of nbUnpointed) {
2385
+ console.log(` ${relative(root, h.file)} ${c.dim(`cell ${h.index} (${h.sdk})`)}`);
2386
+ }
2387
+ console.log(` Notebooks are never rewritten automatically. Add to the client: ${c.cyan(`base_url="${API_BASE}"`)}`);
2388
+ ok = false;
2389
+ } else if (nbSites.length) {
2390
+ const cells = nbSites.reduce((n, s) => n + s.hits.length, 0);
2391
+ console.log(` ${c.green("✓")} all ${cells} notebook cell(s) point at InferAll`);
2392
+ }
2393
+
2394
+ // 4c. Vercel AI SDK sites.
2395
+ //
2396
+ // 2026-07-27 tick 831: doctor never called findVercelAiSites, so a Next.js
2397
+ // route doing `openai("gpt-4o")` from @ai-sdk/openai produced
2398
+ //
2399
+ // · no OpenAI/Anthropic client constructors found in this project
2400
+ // All checks passed. (exit 0)
2401
+ //
2402
+ // while every request went to OpenAI and was billed by OpenAI. `init` has
2403
+ // handled this surface since tick 804 and doctor never got it. Same shape as
2404
+ // the notebook hole fixed yesterday in tick 830, one surface over, and the
2405
+ // thirteenth time one fact lived in two places.
2406
+ //
2407
+ // Found by the command-x-surface sweep rather than by stumbling into it,
2408
+ // which is the whole reason that sweep is now a standing step.
2409
+ const vSites = findVercelAiSites(root).filter((s) => !s.alreadyPointed);
2410
+ if (vSites.length) {
2411
+ console.log(` ${c.yellow("~")} ${vSites.length} Vercel AI SDK file(s) still route to the provider directly:`);
2412
+ for (const s of vSites) {
2413
+ const why = s.usesFactory ? "factory without an InferAll baseURL" : `bare ${s.providers[0]}() import`;
2414
+ console.log(` ${relative(root, s.file)} ${c.dim(`(${why})`)}`);
2415
+ }
2416
+ console.log(` Run ${c.bold("npx @inferall/cli init")} for the exact snippet.`);
473
2417
  ok = false;
474
2418
  }
475
2419
 
476
2420
  // 4b. Gemini SDK sites — different protocol, can't be re-pointed
477
2421
  const gSites = findGeminiSites(root);
478
2422
  if (gSites.length) {
479
- console.log(` ${c.yellow("~")} ${gSites.length} Gemini SDK site(s) bypass InferAll (different protocol) — use the OpenAI-compat endpoint with model ${c.bold("gemini/gemini-2.5-flash")}; run ${c.bold("npx @inferall/cli init")} for the exact snippet`);
2423
+ console.log(` ${c.yellow("~")} ${gSites.length} Gemini SDK site(s) bypass InferAll (different protocol). Use the OpenAI-compat endpoint with model ${c.bold("gemini/gemini-2.5-flash")}; run ${c.bold("npx @inferall/cli init")} for the exact snippet`);
480
2424
  ok = false;
481
2425
  }
482
2426
 
@@ -503,26 +2447,44 @@ async function doctor() {
503
2447
 
504
2448
  async function main() {
505
2449
  if (flag("version") || cmd === "version") {
506
- const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
507
- console.log(pkg.version);
2450
+ console.log(versionLine(CLI_VERSION, BUILD_INFO));
508
2451
  rl.close();
509
2452
  return;
510
2453
  }
511
2454
  if (cmd === "doctor") return doctor();
512
2455
  if (cmd === "models") return modelsCmd();
513
2456
  if (cmd === "status") return statusCmd();
2457
+ if (cmd === "chat") return chatCmd();
514
2458
  if (cmd !== "init") {
515
- console.log(`Usage: npx @inferall/cli <init|doctor|models|status|version> [--key ifu_...] [--yes] [--no-ai] [--free] [--json]`);
2459
+ console.log(`Usage: npx @inferall/cli <init|doctor|models|status|chat|version> [--key ifu_...] [--model id] [--yes] [--no-ai] [--free] [--json]`);
516
2460
  exit(cmd === "help" || flag("help") ? 0 : 1);
517
2461
  }
518
2462
 
519
- console.log(`\n${c.bold("InferAll setup")} ${c.dim("— one API for OpenAI, Anthropic, Google, and 118+ $0 NIM models")}\n`);
2463
+ console.log(`\n${c.bold("InferAll setup")} ${c.dim("one API for OpenAI, Anthropic, Google, and 10+ $0 NIM models")}\n`);
520
2464
 
521
2465
  const root = cwd();
522
2466
  const project = detectProject(root);
2467
+ // 2026-07-28 tick 844. Deliberately NOT folded into envResult.problems: that
2468
+ // channel's closing message says either "Your key was not saved" or "your key
2469
+ // is in .env but .gitignore was not updated", and both would be false here.
2470
+ // The key saves fine; what failed is reading a manifest, so what is at risk
2471
+ // is DETECTION, and the honest warning names that instead.
2472
+ for (const problem of project.readProblems) {
2473
+ console.log(` ${c.yellow("~")} ${problem}`);
2474
+ console.log(` Your dependencies could not be read, so existing SDK calls may not be rewritten.`);
2475
+ }
523
2476
  if (!project.kind) {
524
- console.log(`${c.yellow("!")} No package.json / pyproject.toml / requirements.txt found here.`);
525
- console.log(` Run this from your project root, or continue for a bare .env + example setup.`);
2477
+ // 2026-07-27 tick 828: this used to say "Run this from your project root",
2478
+ // which was impossible advice for someone already standing in it -- see
2479
+ // detectProject. Import-based detection now covers the manifest-less Python
2480
+ // case, so reaching here means we found no manifest AND no SDK import, and
2481
+ // the honest statement is that there is nothing here to migrate rather than
2482
+ // that the user is in the wrong directory.
2483
+ console.log(`${c.yellow("!")} No project detected here: no package.json, pyproject.toml or requirements.txt,`);
2484
+ console.log(` and no Python file or notebook using an AI SDK.`);
2485
+ console.log(` ${c.dim(`(looked in ${root})`)}`);
2486
+ console.log(` If your code lives elsewhere, cd there and re-run. Otherwise continue for a`);
2487
+ console.log(` bare .env + example setup you can point at your own code.`);
526
2488
  if (!(await confirm("Continue anyway?", false))) exit(0);
527
2489
  project.kind = "node";
528
2490
  }
@@ -531,36 +2493,276 @@ async function main() {
531
2493
  // --- key ---
532
2494
  let key = flagValue("key") || env.INFERALL_API_KEY || "";
533
2495
  if (!key) {
2496
+ // 2026-07-26 tick 800 — the wizard used to dead-end the ONE person it
2497
+ // exists for: someone with no key yet.
2498
+ //
2499
+ // Pressing Enter with nothing typed sent "" to the gateway, which 401'd,
2500
+ // and we printed "The gateway does not recognise that key." That is the
2501
+ // worst available message — it tells a user their key is wrong when their
2502
+ // actual state is "I don't have one." Then it exited, so the fix was
2503
+ // re-running the whole wizard.
2504
+ //
2505
+ // Two changes: empty input is now its own case (re-prompt, never verified),
2506
+ // and we offer to open the signup page so getting a key is a keystroke
2507
+ // instead of a context switch out of the terminal and back. This is the
2508
+ // widest measured gap in the funnel — 114 arrived, 97 minted, 52 called.
534
2509
  console.log(`\n Grab a key at ${c.cyan(KEYS_URL)} (free account, key starts with ${c.bold("ifu_")})`);
535
- key = await ask(` Paste your InferAll API key: `);
2510
+ console.log(` ${c.dim("It's free, takes about 20 seconds, and includes 5 calls with no card.")}`);
2511
+
2512
+ const interactive = Boolean(process.stdin.isTTY) && !YES;
2513
+ for (let attempt = 1; !key; attempt++) {
2514
+ if (attempt === 1 && interactive && (await confirm(` Open ${KEYS_URL} in your browser now?`, true))) {
2515
+ if (openBrowser(KEYS_URL)) {
2516
+ console.log(` ${c.green("→")} opened your browser. Create a key, then paste it below.`);
2517
+ } else {
2518
+ console.log(` ${c.yellow("!")} couldn't open a browser; visit ${c.cyan(KEYS_URL)} manually.`);
2519
+ }
2520
+ }
2521
+ key = await ask(` Paste your InferAll API key: `);
2522
+ if (key) break;
2523
+
2524
+ // Non-interactive (CI, piped stdin): looping would spin forever on EOF.
2525
+ // Say what to do and leave a non-zero exit for the caller.
2526
+ if (!interactive) {
2527
+ console.log(`${c.red("✗")} No key provided.`);
2528
+ console.log(` Pass one non-interactively: ${c.bold("npx @inferall/cli init --key ifu_…")}`);
2529
+ console.log(` or set ${c.bold("INFERALL_API_KEY")} in the environment. Get one at ${c.cyan(KEYS_URL)}.`);
2530
+ exit(1);
2531
+ }
2532
+ if (attempt >= 3) {
2533
+ console.log(`${c.yellow("!")} No key entered, so nothing was changed.`);
2534
+ console.log(` Re-run ${c.bold("npx @inferall/cli init")} once you have one from ${c.cyan(KEYS_URL)}.`);
2535
+ exit(0); // 0: the user chose to stop, that is not a failure
2536
+ }
2537
+ console.log(` ${c.dim("Nothing entered. Paste the key (starts with ifu_), or Ctrl-C to stop.")}`);
2538
+ }
536
2539
  }
537
- if (!key.startsWith("ifu_")) {
538
- console.log(`${c.red("✗")} That doesn't look like an InferAll key (expected ifu_ prefix).`);
2540
+ // 2026-07-25: this used to HARD-EXIT on anything without an ifu_ prefix, which
2541
+ // was both wrong and redundant.
2542
+ //
2543
+ // Wrong: the gateway mints ifu_ today but auth.ts explicitly accepts ifu_,
2544
+ // ifa_, kr_user_ and kr_proj_ ("Legacy kr_user_... keys remain valid"). So a
2545
+ // legacy or project key — which works perfectly against the API — was told
2546
+ // "that doesn't look like an InferAll key" and the wizard quit. `doctor` only
2547
+ // warned on the same input, so the two commands disagreed about what a valid
2548
+ // key is, and `init` was stricter than the system it configures.
2549
+ //
2550
+ // Redundant: the very next line verifies the key against the gateway, which
2551
+ // is the actual authority. A client-side prefix test is a second copy of a
2552
+ // rule enforced elsewhere, and second copies drift.
2553
+ //
2554
+ // Kept: a specific, useful hint when the key is recognisably ANOTHER
2555
+ // provider's, because a bare 401 would not explain that.
2556
+ const foreignKey =
2557
+ key.startsWith("sk-ant-") ? "an Anthropic key (sk-ant-…)" :
2558
+ key.startsWith("sk-or-") ? "an OpenRouter key (sk-or-…)" :
2559
+ key.startsWith("sk-") ? "an OpenAI key (sk-…)" :
2560
+ key.startsWith("AIzaSy") ? "a Google AI Studio key (AIzaSy…)" : null;
2561
+ if (foreignKey) {
2562
+ console.log(`${c.red("✗")} That looks like ${foreignKey}, not an InferAll key.`);
2563
+ console.log(` Mint one at ${c.cyan(KEYS_URL)}. InferAll keys start with ${c.bold("ifu_")}.`);
539
2564
  exit(1);
540
2565
  }
541
2566
 
2567
+ // 2026-07-25: this used to verify against /v1/models — which is PUBLIC.
2568
+ // It returns 200 with a garbage key and 200 with no Authorization header at
2569
+ // all, so "verifying key … ok" passed for literally any string and this step
2570
+ // proved nothing. That is the vacuous-guard failure mode, in the one place a
2571
+ // developer trusts us to tell them their key works before we rewrite their
2572
+ // source files.
2573
+ //
2574
+ // /ai/v1/key/status runs authenticateRequest: 401 for a bad key, 200 for a
2575
+ // good one (verified against production, including a fake ifu_-prefixed key).
542
2576
  process.stdout.write(` verifying key against ${API_BASE} … `);
543
- const models = await apiGet("/models", key).catch(() => null);
544
- if (!models || (models.status !== 200 && models.status !== 402)) {
545
- console.log(c.red(`failed (${models ? `HTTP ${models.status}` : "network error"})`));
2577
+ // 2026-07-30 tick 854: this used to `.catch(() => null)`, discarding the error,
2578
+ // and then print "Check the key at <keys page> and re-run" for a NETWORK
2579
+ // failure. Minting a new key cannot fix DNS, a closed port or a proxy that
2580
+ // re-signs TLS. That is the tick 832 conflation, which was fixed in `doctor`
2581
+ // and never applied here, one screen away, and the tick 852 cause-naming was
2582
+ // built for `doctor` alone for the same reason. Both commands now share it.
2583
+ const verify = await fetch(`${ANTHROPIC_BASE}/ai/v1/key/status`, {
2584
+ signal: timeoutSignal(),
2585
+ headers: { Authorization: `Bearer ${key}` },
2586
+ }).then((res) => ({ res })).catch((err) => ({ err }));
2587
+ if (verify.res && verify.res.status !== 401) {
2588
+ // Same trap as doctor: a captive portal answers 200 with HTML, and the
2589
+ // wizard would otherwise print "ok" and write the key as verified.
2590
+ const parsedVerify = await readJsonBody(verify.res.clone());
2591
+ if (parsedVerify.parseFailed) {
2592
+ console.log(c.red("failed"));
2593
+ for (const line of nonJsonLines(parsedVerify, " ")) console.log(line);
2594
+ exit(1);
2595
+ }
2596
+ }
2597
+ if (verify.res?.status === 401) {
2598
+ console.log(c.red("rejected"));
2599
+ console.log(` The gateway does not recognise that key. Mint one at ${KEYS_URL} and re-run.`);
2600
+ exit(1);
2601
+ }
2602
+ if (verify.err) {
2603
+ console.log(c.red("failed"));
2604
+ // Same lines doctor prints, so the cause and its remedy are stated once.
2605
+ for (const line of unreachableLines(verify.err)) console.log(` ${line}`);
2606
+ console.log(` ${c.bold("Nothing was written.")} Your key was never checked, so this says nothing about it.`);
2607
+ exit(1);
2608
+ }
2609
+ if (!verify.res.ok) {
2610
+ console.log(c.red(`failed (HTTP ${verify.res.status})`));
546
2611
  console.log(` Check the key at ${KEYS_URL} and re-run.`);
547
2612
  exit(1);
548
2613
  }
549
2614
  console.log(c.green("ok"));
550
2615
 
551
2616
  // --- env files ---
552
- upsertEnvFile(root, key);
553
- console.log(` ${c.green("+")} INFERALL_API_KEY written to ${c.bold(".env")} (and .env.example placeholder)`);
2617
+ // tick 820: never silently replace a credential, and never call a
2618
+ // replacement an addition. --yes still replaces (the user opted in), but the
2619
+ // message says so.
2620
+ const existingKey = readEnvKey(root);
2621
+ let writeKey = true;
2622
+ if (existingKey && existingKey !== key) {
2623
+ writeKey = await confirm(` .env already has a DIFFERENT INFERALL_API_KEY (${maskKey(existingKey)}). Replace it?`);
2624
+ }
2625
+ // 2026-07-29 tick 850. Measured in a monorepo with the key at the repo root
2626
+ // and the code in packages/api: running init from the sub-package wrote a
2627
+ // SECOND .env with a different key and said nothing about the first. Two
2628
+ // credentials for one repo, and which one the app uses depends on the
2629
+ // directory it starts in.
2630
+ //
2631
+ // Deliberately a warning and not a refusal. A monorepo where each package
2632
+ // genuinely has its own key is a real setup, and this command was given an
2633
+ // explicit --key, so blocking it would break a legitimate case to prevent a
2634
+ // confusing one. Naming the other file is enough to make the choice informed.
2635
+ // Printed BEFORE the write so the ordering matches what happens on disk.
2636
+ if (writeKey) {
2637
+ const ancestor = findAncestorEnvKey(root);
2638
+ if (ancestor) {
2639
+ const rel = relative(root, ancestor.dir) || ".";
2640
+ console.log(` ${c.yellow("~")} ${c.bold(join(rel, ".env"))} already holds a key for this repo`);
2641
+ console.log(` Writing another one here. Your app will use whichever .env matches the`);
2642
+ console.log(` directory it runs from, so delete one if that is not what you intended.`);
2643
+ }
2644
+ }
2645
+ const envResult = upsertEnvFile(root, key, { writeKey });
2646
+ // 2026-07-27 tick 836: report what actually happened on disk. Previously a
2647
+ // failing step threw a bare errno from inside upsertEnvFile, after the key had
2648
+ // already been written, so the user got no file name, no cause, and no notice
2649
+ // that a live credential was sitting unprotected.
2650
+ for (const p of envResult.problems) {
2651
+ console.log(` ${c.red("✗")} ${p}`);
2652
+ }
2653
+ if (envResult.keyWritten && !envResult.gitignoreOk) {
2654
+ // The security-relevant combination, and the reason this is not merely a
2655
+ // tidier error message: the secret is on disk and nothing is ignoring it.
2656
+ console.log(` ${c.red("!")} your key is in ${c.bold(".env")} but ${c.bold(".gitignore")} could not be updated.`);
2657
+ console.log(` Add ${c.cyan(".env")} to .gitignore yourself before committing.`);
2658
+ }
2659
+ if (!envResult.keyWritten && writeKey) {
2660
+ const dirBad = dirIsUnwritable(root);
2661
+ console.log(
2662
+ ` ${c.dim("The key was not saved. Pass it per run with")} ${c.bold("--key")}${c.dim(
2663
+ dirBad ? ", or make this DIRECTORY writable: nothing can be created here." : ", or fix the file above.",
2664
+ )}`,
2665
+ );
2666
+ }
2667
+ if (existingKey && existingKey === key) {
2668
+ console.log(` ${c.green("=")} INFERALL_API_KEY in ${c.bold(".env")} already matches (unchanged)`);
2669
+ } else if (existingKey && writeKey) {
2670
+ console.log(` ${c.yellow("!")} REPLACED a different INFERALL_API_KEY in ${c.bold(".env")} ${c.dim(`(was ${maskKey(existingKey)})`)}`);
2671
+ } else if (existingKey) {
2672
+ console.log(` ${c.yellow("~")} kept your existing INFERALL_API_KEY in ${c.bold(".env")} ${c.dim("(this run used the key you supplied)")}`);
2673
+ } else if (envResult.keyWritten) {
2674
+ // tick 836: guarded. This line used to print unconditionally, so a failed
2675
+ // write was announced as a success on the very next line after the error.
2676
+ console.log(` ${c.green("+")} INFERALL_API_KEY written to ${c.bold(".env")} (and .env.example placeholder)`);
2677
+ }
554
2678
 
555
2679
  // --- integration ---
556
- const usesSdk = project.sdks.some((s) => ["openai", "@anthropic-ai/sdk", "anthropic"].includes(s));
557
- if (usesSdk) {
558
- const sites = findCallSites(root).filter((s) => !s.alreadyPointed);
2680
+ // Computed before the branch: a Vercel-AI project has no client constructor
2681
+ // to rewrite, so without this the wizard would treat it as greenfield.
2682
+ // .filter(!usesFactory) preserves the pre-tick-831 behaviour exactly: this
2683
+ // list drives the "paste this snippet" guidance, and a file that already
2684
+ // calls createOpenAI does not need the snippet, only a baseURL. doctor reads
2685
+ // the same finder with a different filter (alreadyPointed).
2686
+ const allVercelSites = project.kind === "node" ? findVercelAiSites(root) : [];
2687
+ const vercelAiSites = allVercelSites.filter((s) => !s.usesFactory);
2688
+ // Factory files that never got our baseURL. Separate list because the remedy
2689
+ // is one argument, not the snippet, but they count the same in the sum below.
2690
+ const vercelFactoryUnpointed = allVercelSites.filter((s) => s.usesFactory && !s.alreadyPointed);
2691
+ // 2026-07-26 tick 805: this was a THIRD hardcoded list, narrower than the two
2692
+ // detection lists above it. Fixing detection to recognise langchain-openai
2693
+ // was not enough -- this gate still said "no SDK", the wizard still took the
2694
+ // greenfield branch, and it still printed "Done. Your project is wired to
2695
+ // InferAll" over an unmigrated file.
2696
+ //
2697
+ // That is the lesson from the whole t803-t805 run in one line: a claim can
2698
+ // stay false after you fix the thing you thought was making it false. I
2699
+ // verified detection (the step) and only caught this by running the flow end
2700
+ // to end and reading the file afterwards.
2701
+ //
2702
+ // Derived from one place now. Anything whose constructors findCallSites can
2703
+ // actually rewrite belongs here, so the two cannot drift apart again.
2704
+ const CODEMODDABLE = ["openai", "@anthropic-ai/sdk", "anthropic",
2705
+ "langchain-openai", "langchain_openai", "langchain-anthropic", "langchain_anthropic"];
2706
+ const usesSdk = project.sdks.some((s) => CODEMODDABLE.includes(s));
2707
+ // 2026-07-26 tick 816: computed BEFORE the green tick below, because the tick
2708
+ // is exactly what was wrong. A notebook project lists `openai` in
2709
+ // requirements.txt, so usesSdk is true, but findCallSites never opens .ipynb
2710
+ // and returned zero, so the wizard reported "nothing to migrate" over a
2711
+ // notebook still calling OpenAI directly.
2712
+ // 2026-07-27 tick 830: filtered to cells that are NOT already pointed, which
2713
+ // is what the findCallSites line below has always done. Without it, a user
2714
+ // who had already made the manual edit was told on their next run to "add
2715
+ // these two arguments" that were already there, and the cell still counted
2716
+ // toward the sum that suppresses "Done. Your project is wired to InferAll" --
2717
+ // so a fully wired project was reported as unfinished. Mirror image of the
2718
+ // doctor bug fixed in the same tick: doctor could not see notebooks at all,
2719
+ // init could not see that they were already done.
2720
+ const notebookSites = findNotebookSites(root)
2721
+ .map((s) => ({ ...s, hits: s.hits.filter((h) => !h.alreadyPointed) }))
2722
+ .filter((s) => s.hits.length);
2723
+ // Populated inside the codemod branch below; read by the closing line.
2724
+ let unwritableCount = 0;
2725
+ const cappedInit = [];
2726
+ // 2026-08-01 tick 896. `usesSdk` reads the MANIFEST, and it used to gate the
2727
+ // source scan entirely. Every earlier fix in this family (t803 monorepo, t804
2728
+ // Vercel AI, t805 LangChain) added another NAME to the manifest scan, which by
2729
+ // construction cannot reach the case where the SDK is not declared at all.
2730
+ //
2731
+ // MEASURED against the installed 0.2.1 artifact, on a project whose
2732
+ // package.json had no dependencies and whose src/index.js read
2733
+ // `new OpenAI({ apiKey: process.env.OPENAI_API_KEY })`:
2734
+ //
2735
+ // detected: node project
2736
+ // Done. Your project is wired to InferAll.
2737
+ //
2738
+ // Nothing was rewritten and it reported success. Running `inferall init`
2739
+ // before `npm install openai` is an ordinary ordering, so this is reachable
2740
+ // rather than a corner.
2741
+ //
2742
+ // findCallSites reads the SOURCE, so it is the authoritative answer to "is
2743
+ // there anything to rewrite" and it now decides. The manifest survives as a
2744
+ // hint: an SDK that IS declared but has no constructors still gets its honest
2745
+ // "no un-migrated client constructors found" line, which is why usesSdk stays
2746
+ // in the condition. The tree is already walked unconditionally for notebooks
2747
+ // a few lines above, so this adds no new cost class.
2748
+ const sites = findCallSites(root, cappedInit).filter((s) => !s.alreadyPointed);
2749
+ if (usesSdk || sites.length > 0) {
559
2750
  if (sites.length === 0) {
560
- console.log(` ${c.green("✓")} AI SDK present but no un-migrated client constructors found.`);
2751
+ // Only claim there is nothing to do when there is genuinely nothing to
2752
+ // do. Notebooks are un-migrated code we simply cannot rewrite safely.
2753
+ if (notebookSites.length === 0) {
2754
+ console.log(` ${c.green("✓")} AI SDK present but no un-migrated client constructors found.`);
2755
+ } else {
2756
+ console.log(` ${c.yellow("~")} No rewritable client constructors, but notebooks below still call the provider directly.`);
2757
+ }
561
2758
  } else {
562
2759
  console.log(`\n Found ${c.bold(sites.length)} client constructor site(s):`);
563
2760
  for (const s of sites) console.log(` - ${relative(root, s.file)} ${c.dim(`(${s.sdk})`)}`);
2761
+ // Files we detected, tried to rewrite, and could NOT write. They belong
2762
+ // in the same bucket as Vercel/Gemini/notebook sites: detected, not
2763
+ // changed, and therefore must suppress the "your project is wired"
2764
+ // closing line (tick 816).
2765
+ let unwritable = 0;
564
2766
  if (await confirm(` Point them at the InferAll gateway?`)) {
565
2767
  for (const s of sites) {
566
2768
  let patched = patchSite(s);
@@ -569,7 +2771,7 @@ async function main() {
569
2771
  process.stdout.write(` ${c.dim("AI-assist")} ${relative(root, s.file)} … `);
570
2772
  const r = await aiSuggestPatch(key, s);
571
2773
  if (r.status === 402) {
572
- console.log(c.yellow("skipped (account not activated yet — see verify step below)"));
2774
+ console.log(c.yellow("skipped (account not activated yet, see verify step below)"));
573
2775
  continue;
574
2776
  }
575
2777
  patched = r.patched;
@@ -577,25 +2779,105 @@ async function main() {
577
2779
  console.log(patched ? c.green("patch ready") : c.yellow("no confident patch"));
578
2780
  }
579
2781
  if (!patched) {
580
- const manualBase = s.sdk === "Anthropic" ? ANTHROPIC_BASE : API_BASE;
581
- console.log(` ${c.yellow("~")} ${relative(root, s.file)}: couldn't patch automatically — set ${s.isPy ? `base_url="${manualBase}"` : `baseURL: "${manualBase}"`} on your ${s.sdk} client manually.`);
2782
+ const manualBase = baseForSdk(s.sdk);
2783
+ console.log(` ${c.yellow("~")} ${relative(root, s.file)}: couldn't patch automatically. Set ${s.isPy ? `base_url="${manualBase}"` : `baseURL: "${manualBase}"`} on your ${s.sdk} client manually.`);
2784
+ // 2026-07-31 tick 880: this `continue` did not count the site, so a
2785
+ // file we detected and left untouched still ended with "Done. Your
2786
+ // project is wired to InferAll." Only the EACCES branch below was
2787
+ // counted, though the comment above this block already says
2788
+ // detected-but-not-changed belongs in the same bucket. Tightening
2789
+ // the AI patch check made this path common rather than rare.
2790
+ unwritable++;
2791
+ continue;
2792
+ }
2793
+ // Declining the rewrite also leaves the site unchanged, so it counts
2794
+ // for the same reason.
2795
+ if (via === "AI" && !(await confirm(` Apply AI-suggested rewrite to ${relative(root, s.file)}?`))) {
2796
+ unwritable++;
2797
+ continue;
2798
+ }
2799
+ // 2026-07-27 tick 824: guarded. This write was bare, so a single
2800
+ // unwritable file killed the whole wizard with a raw Node exception:
2801
+ //
2802
+ // ✗ EACCES: permission denied, open '/abs/path/svc.ts'
2803
+ //
2804
+ // and it did so AFTER .env and .gitignore had been written, leaving a
2805
+ // half-configured project, no summary, and no test call. One vendored
2806
+ // read-only file in a monorepo cost the user the entire setup.
2807
+ //
2808
+ // To its credit it never claimed success, which is the failure this
2809
+ // codebase keeps making. But "abort everything" is the wrong response
2810
+ // to a per-file problem when a per-file fallback already exists three
2811
+ // lines above. Route into it: report this file honestly, keep going.
2812
+ try {
2813
+ writeFileSync(s.file, preserveTrailingNewline(s.body ?? "", patched));
2814
+ } catch (err) {
2815
+ const why = err && err.code === "EACCES" ? "no write permission" : (err?.code || "write failed");
2816
+ console.log(` ${c.yellow("~")} ${relative(root, s.file)}: ${c.bold(why)}, left unchanged. Make it writable and re-run, or set ${s.isPy ? `base_url="${baseForSdk(s.sdk)}"` : `baseURL: "${baseForSdk(s.sdk)}"`} by hand.`);
2817
+ unwritable++;
582
2818
  continue;
583
2819
  }
584
- if (via === "AI" && !(await confirm(` Apply AI-suggested rewrite to ${relative(root, s.file)}?`))) continue;
585
- writeFileSync(s.file, patched);
2820
+ // Post-patch self-check. A rewrite that points the client at InferAll
2821
+ // but leaves a FOREIGN provider credential produces a project that
2822
+ // fails on its first run — while this wizard prints "Done." That
2823
+ // exact combination shipped until 2026-07-25 and is invisible to the
2824
+ // verify step below, which calls the API with the key directly rather
2825
+ // than through the code we just wrote. Never claim success on a file
2826
+ // we can see is still mis-wired.
2827
+ const stillForeign = /(?:process\.env\.|os\.environ\[\s*["']|os\.getenv\(\s*["'])(?:OPENAI|ANTHROPIC|GOOGLE|GEMINI|OPENROUTER)_API_KEY/.test(patched);
2828
+ // tick 810: the same check, for a credential written inline. The env-var
2829
+ // form above was the only one this warned about, so a client left
2830
+ // holding apiKey: "sk-…", or a local-inference placeholder like
2831
+ // "EMPTY" that we deliberately do not rewrite, printed a bare ✓ over
2832
+ // a project that cannot authenticate.
2833
+ const stillLiteral = /\b(?:api_key\s*=|apiKey\s*:)\s*(["'])(?!\s*\1)[^"']*\1/.test(patched)
2834
+ && !/(?:os\.environ\[|os\.getenv\(|process\.env\.)\s*["']?INFERALL_API_KEY/.test(patched);
586
2835
  console.log(` ${c.green("✓")} ${relative(root, s.file)} ${c.dim(`(${via})`)}`);
2836
+ if (stillForeign) {
2837
+ console.log(` ${c.yellow("!")} ${relative(root, s.file)} still reads another provider's API key env var.`);
2838
+ console.log(` Point it at ${c.bold(s.isPy ? 'os.environ["INFERALL_API_KEY"]' : "process.env.INFERALL_API_KEY")} or the first run will fail.`);
2839
+ } else if (stillLiteral) {
2840
+ console.log(` ${c.yellow("!")} ${relative(root, s.file)} still passes a hardcoded API key literal.`);
2841
+ console.log(` Replace it with ${c.bold(s.isPy ? 'os.environ["INFERALL_API_KEY"]' : "process.env.INFERALL_API_KEY")} or the first run will fail.`);
2842
+ }
587
2843
  }
2844
+ unwritableCount = unwritable;
588
2845
  }
589
2846
  }
590
- } else {
2847
+ } else if (vercelAiSites.length === 0 && notebookSites.length === 0) {
2848
+ // Only treat this as a greenfield project when there is genuinely no AI
2849
+ // code we recognised. Writing a hello-world next to un-migrated calls is
2850
+ // what made the tick-804 report misleading. Notebooks count as recognised
2851
+ // AI code (tick 816): a .ipynb calling OpenAI is not a blank slate.
591
2852
  const example = writeExample(root, project.kind);
592
- if (example) console.log(` ${c.green("+")} example written: ${c.bold(relative(root, example))}`);
2853
+ if (example.path) console.log(` ${c.green("+")} example written: ${c.bold(relative(root, example.path))}`);
2854
+ // tick 837: an unwritable example is a per-file degradation, not a crash.
2855
+ // It also counts toward envResult.problems so the closing line cannot claim
2856
+ // a clean setup while a step it announced never happened.
2857
+ if (example.problem) {
2858
+ console.log(` ${c.red("✗")} ${example.problem}`);
2859
+ envResult.problems.push(example.problem);
2860
+ }
593
2861
  if (project.kind === "node") console.log(` ${c.dim("optional:")} npm install @inferall/sdk`);
594
2862
  else console.log(` ${c.dim("optional:")} pip install inferall-ai`);
595
2863
  }
596
2864
 
2865
+ if (vercelAiSites.length) printVercelAiGuidance(root, vercelAiSites);
2866
+ // 2026-07-27 tick 831: a file that calls createOpenAI WITHOUT an InferAll
2867
+ // baseURL routes to the provider, and init used to say nothing about it and
2868
+ // then print "Done. Your project is wired to InferAll". Measured: a one-file
2869
+ // project in exactly that state got a clean Done while every request went to
2870
+ // OpenAI. These need one argument, not the whole snippet, so they get their
2871
+ // own line rather than being folded into the guidance above.
2872
+ if (vercelFactoryUnpointed.length) {
2873
+ console.log(`\n ${c.bold("Vercel AI SDK factory")} without an InferAll baseURL in ${vercelFactoryUnpointed.length} file(s):`);
2874
+ for (const s of vercelFactoryUnpointed) console.log(` - ${relative(root, s.file)}`);
2875
+ console.log(` These were ${c.bold("NOT changed")}. Add one argument to the factory call:`);
2876
+ console.log(c.cyan(` baseURL: "${API_BASE}"`));
2877
+ }
597
2878
  const geminiSites = findGeminiSites(root);
598
2879
  if (geminiSites.length) printGeminiGuidance(root, geminiSites);
2880
+ if (notebookSites.length) printNotebookGuidance(root, notebookSites);
599
2881
 
600
2882
  // --- verify with a live call ---
601
2883
  const testModel = flagValue("model") || FREE_MODEL;
@@ -603,33 +2885,162 @@ async function main() {
603
2885
  const test = await apiChat(key, [{ role: "user", content: "Reply with the single word: ready" }], testModel);
604
2886
  if (test.status === 200) {
605
2887
  console.log(c.green("success ✓"));
606
- console.log(`\n${c.green(c.bold("Done."))} Your project is wired to InferAll. Models list: ${API_BASE}/models\n`);
2888
+ // 2026-07-26 tick 804: do not claim the project is wired when we have just
2889
+ // printed guidance saying specific files were NOT changed. Saying "Done.
2890
+ // Your project is wired to InferAll" directly under a list of un-migrated
2891
+ // call sites is the same false-success the monorepo bug produced, only
2892
+ // this time contradicted by our own output three lines earlier.
2893
+ // 2026-07-26 tick 816: notebooks were missing from this sum, so the wizard
2894
+ // printed "Done. Your project is wired to InferAll" three lines under its
2895
+ // own "These were NOT changed" notebook list. The t804 fix was correct and
2896
+ // simply did not know about a surface added later.
2897
+ //
2898
+ // THIS SUM MUST INCLUDE EVERY DETECTED-BUT-NOT-REWRITTEN SURFACE. It has
2899
+ // now drifted once already, and it is the fourth time in this codebase that
2900
+ // one fact spread across several places went stale in one of them
2901
+ // (t811 base URLs, t813 the metrics filter, t815 three model call sites).
2902
+ // Anything that prints "NOT changed" guidance belongs here, or the closing
2903
+ // line contradicts it.
2904
+ // 2026-07-30 tick 854. MEASURED: three identical call sites, one past the
2905
+ // depth cap and one over the size cap, produced "Found 1 client constructor
2906
+ // site(s)" and then "Done. Your project is wired to InferAll." while two
2907
+ // files still called OpenAI directly. That is the tick 805 outcome exactly.
2908
+ // A cap the user cannot see becomes a claim the tool cannot support, so an
2909
+ // incomplete scan counts as outstanding work like any unwritable file.
2910
+ const capInit = cappedSummary(cappedInit);
2911
+ if (capInit) {
2912
+ console.log(` ${c.yellow("~")} ${capInit}, so there may be client code this did not see.`);
2913
+ console.log(` Wire any client in those paths by hand, or move it within 6 directory levels and re-run.`);
2914
+ }
2915
+ const manual = vercelAiSites.length + geminiSites.length + notebookSites.length + unwritableCount
2916
+ + vercelFactoryUnpointed.length + (capInit ? 1 : 0); // tick 831, see the note above this sum
2917
+ // 2026-07-27 tick 836: a filesystem problem outranks the file counts. With
2918
+ // .env unwritable the key never reached disk, so "Done. Your project is
2919
+ // wired to InferAll" was flatly false -- the next run finds no key at all.
2920
+ // With .gitignore unwritable the key IS on disk and unprotected, which is
2921
+ // not "done" either. Both used to print Done and exit 0.
2922
+ if (envResult.problems.length) {
2923
+ const what = envResult.keyWritten
2924
+ ? `Your key is in .env, but ${c.bold(".gitignore")} was not updated.`
2925
+ : `${c.bold("Your key was not saved.")} Pass it with ${c.bold("--key")} until ${dirIsUnwritable(root) ? "this directory is writable" : "the file above is fixed"}.`;
2926
+ console.log(`\n${c.yellow(c.bold("Setup incomplete."))} ${what}`);
2927
+ console.log(` The gateway accepted your key, so the account side is fine. Models list: ${API_BASE}/models\n`);
2928
+ rl.close();
2929
+ exit(1);
2930
+ }
2931
+ if (manual > 0) {
2932
+ console.log(`\n${c.green(c.bold("Key verified and configured."))} ${c.yellow(`${manual} file(s) still need the one-line change above.`)}`);
2933
+ console.log(` Your key works and .env is set. Models list: ${API_BASE}/models\n`);
2934
+ } else {
2935
+ console.log(`\n${c.green(c.bold("Done."))} Your project is wired to InferAll. Models list: ${API_BASE}/models\n`);
2936
+ }
607
2937
  } else if (test.status === 402) {
608
2938
  console.log(c.yellow("blocked (402)"));
609
2939
  console.log(`\n Everything is wired up, but your account isn't activated yet.`);
610
- // Real account state beats a generic message — trial counter + whether a
2940
+ // Real account state beats a generic message: trial counter + whether a
611
2941
  // checkout was already started (settle vs start are different next steps).
612
2942
  const st = await fetch(`${ANTHROPIC_BASE}/ai/v1/key/status`, {
2943
+ signal: timeoutSignal(),
613
2944
  headers: { Authorization: `Bearer ${key}` },
614
2945
  }).then((r) => (r.ok ? r.json() : null)).catch(() => null);
615
2946
  if (st?.trial) {
616
- console.log(` Trial: ${st.trial.used}/${st.trial.cap} free calls used${st.has_card ? c.dim(" · checkout started but not settled — finishing it activates you") : ""}.`);
2947
+ console.log(` Trial: ${st.trial.used}/${st.trial.cap} free calls used. ${st.trial.remaining === 0 ? "none left" : `${st.trial.remaining} still available`}${st.has_card ? c.dim(" · checkout started but not settled, and finishing it activates you") : ""}.`);
617
2948
  }
618
- console.log(` InferAll charges ${c.bold("$0 markup")} — a one-time ${c.bold("$5 activation")} (it becomes spendable`);
619
- console.log(` balance, not a fee) unlocks every model, including the 118+ free NIM models.`);
2949
+ // Same correction as printGeminiGuidance above; see the note there.
2950
+ console.log(` The 10+ free NIM models are ${c.bold("$0 in/out")}; premium tokens bill at InferAll's published per-token rate. A one-time ${c.bold("$5 activation")} (it becomes spendable`);
2951
+ console.log(` balance, not a fee) unlocks every model, including the 10+ free NIM models.`);
620
2952
  console.log(` Activate: ${c.cyan(BILLING_URL)}`);
621
- console.log(` The code changes above are done — the first call will work the moment you activate.`);
2953
+ console.log(` The code changes above are done. The first call will work the moment you activate.`);
622
2954
  console.log(` Check anytime: ${c.bold("npx @inferall/cli status")}\n`);
2955
+ } else if (test.status === 401 || test.status === 403) {
2956
+ // 2026-07-27 tick 826: this fell into the generic branch below, which
2957
+ // printed a bare "HTTP 401", advised re-running init, and exited 0.
2958
+ //
2959
+ // Three separate things were wrong with that, in increasing order of harm.
2960
+ // The wizard printed "verifying key ... ok" four lines earlier, so it
2961
+ // contradicted its own output with no acknowledgement. Retrying an
2962
+ // authentication failure with the same credential cannot succeed, so the
2963
+ // remedy we printed was a dead end. And exiting 0 told every calling
2964
+ // script that setup had worked, while the codemod had already repointed a
2965
+ // working project at a gateway that rejects its key. A tool that breaks a
2966
+ // project and reports success is the worst rung of the severity ladder,
2967
+ // and this is the second time the exit status has been the tell (t824).
2968
+ //
2969
+ // The pre-flight hits /ai/v1/key/status and this hits /v1/chat/completions,
2970
+ // so a key revoked between the two, or valid for a different account,
2971
+ // produces exactly this split. Measured against a stub that answers 200 on
2972
+ // status and 401 on chat.
2973
+ console.log(c.red("rejected (401)"));
2974
+ console.log(`\n The gateway accepted this key a moment ago and has now rejected it.`);
2975
+ console.log(` That usually means the key was revoked or rotated between the two checks,`);
2976
+ console.log(` or that it belongs to a different account than the one you are setting up.`);
2977
+ console.log(`\n ${c.bold("Your files were already changed.")} The code and .env now point at InferAll,`);
2978
+ console.log(` so calls will keep failing until the key works. Re-running with the same key`);
2979
+ console.log(` cannot help.`);
2980
+ console.log(`\n Mint a fresh key at ${c.cyan(KEYS_URL)}, then re-run:`);
2981
+ console.log(` ${c.bold("npx @inferall/cli init --key ifu_your_new_key")}\n`);
2982
+ rl.close();
2983
+ exit(1);
623
2984
  } else {
2985
+ // Transient or unexpected. Retrying IS the right advice here, unlike the
2986
+ // 401 case above, but the run still failed to verify so it must not exit 0.
624
2987
  console.log(c.red(`HTTP ${test.status}`));
625
2988
  console.log(` ${JSON.stringify(test.body).slice(0, 300)}`);
626
2989
  console.log(` Setup files were written; re-run ${c.bold("npx @inferall/cli init")} to retry the verify step.\n`);
2990
+ rl.close();
2991
+ exit(1);
627
2992
  }
628
2993
  rl.close();
629
2994
  }
630
2995
 
631
- main().catch((err) => {
632
- console.error(`${c.red("✗")} ${err?.message || err}`);
633
- rl.close();
634
- exit(1);
635
- });
2996
+ // Run only when invoked as the entry point. Before this guard the file executed
2997
+ // main() on import, so the codemod could not be unit-tested at all -- which is
2998
+ // exactly how the 2026-07-25 bug shipped: `init` rewrote a client to point at
2999
+ // InferAll while leaving the caller's OPENAI_API_KEY in place, printed "Done",
3000
+ // and left a project that threw on its first run.
3001
+ /**
3002
+ * True when this file is being RUN, as opposed to imported by a test.
3003
+ *
3004
+ * argv[1] must be resolved through realpath first. npm installs a bin as a
3005
+ * SYMLINK at node_modules/.bin/inferall, and when the shell execs that symlink
3006
+ * via the shebang, argv[1] is the symlink path while import.meta.url is always
3007
+ * the resolved real file. Comparing them unresolved is false for every
3008
+ * npm-installed invocation, so main() never ran and the process exited 0 in
3009
+ * silence: `npx @inferall/cli` did nothing at all.
3010
+ *
3011
+ * The tests could not catch it. They spawn `node <real path to bin/inferall.js>`,
3012
+ * where argv[1] IS the real file and the comparison happens to hold. The guard
3013
+ * was added in #114 to make this module importable BY those tests, so the change
3014
+ * made for testability broke the only path a user actually takes.
3015
+ */
3016
+ function isRunningAsEntrypoint(p) {
3017
+ if (!p) return false;
3018
+ try {
3019
+ return import.meta.url === pathToFileURL(realpathSync(p)).href;
3020
+ } catch {
3021
+ // A missing or unreadable argv[1] must not silently disable the CLI. Fall
3022
+ // back to the unresolved comparison rather than returning false.
3023
+ return import.meta.url === pathToFileURL(p).href;
3024
+ }
3025
+ }
3026
+ const isEntrypoint = isRunningAsEntrypoint(argv[1]);
3027
+ if (isEntrypoint) {
3028
+ main().catch((err) => {
3029
+ // 2026-07-27 tick 832: an unreachable gateway reached here and printed
3030
+ // Node's own words -- a bare "✗ fetch failed" with no URL, no cause and no
3031
+ // next step. `models` offline was the measured case.
3032
+ if (isNetworkError(err)) for (const line of unreachableLines(err)) console.error(line);
3033
+ else if (err instanceof InputEndedError) {
3034
+ // Naming the working command matters more than naming the fault. This
3035
+ // exact invocation was verified end to end: it writes .env, gitignores
3036
+ // it, rewrites the client constructors and makes a test call.
3037
+ console.error(`${c.red("✗")} setup needs an answer but there is no more input to read.`);
3038
+ console.error(` Anything reported above already happened and is on disk.`);
3039
+ console.error(` For a non-interactive setup, pass the key and accept the defaults:`);
3040
+ console.error(` ${c.cyan("npx @inferall/cli init --key ifu_... --yes")}`);
3041
+ console.error(` Or run ${c.cyan("npx @inferall/cli init")} in a terminal to answer the prompts.`);
3042
+ } else console.error(`${c.red("✗")} ${err?.message || err}`);
3043
+ rl.close();
3044
+ exit(1);
3045
+ });
3046
+ }