@inferall/cli 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +55 -0
  2. package/bin/inferall.js +635 -0
  3. package/package.json +32 -0
package/README.md ADDED
@@ -0,0 +1,55 @@
1
+ # @inferall/cli
2
+
3
+ Install [InferAll](https://inferall.ai) into your project in one command:
4
+
5
+ ```bash
6
+ npx @inferall/cli init
7
+ ```
8
+
9
+ The wizard:
10
+
11
+ 1. **Detects your project** — Node or Python, and whether you already use the
12
+ `openai`, `@anthropic-ai/sdk`, or `@google/generative-ai` SDKs.
13
+ 2. **Sets up your key** — writes `INFERALL_API_KEY` to `.env`, adds a
14
+ placeholder to `.env.example`, and makes sure `.env` is gitignored.
15
+ 3. **Migrates existing code** — points `OpenAI` and `Anthropic` client
16
+ constructors at the InferAll gateway (the OpenAI SDK gets
17
+ `https://api.inferall.ai/v1`; the Anthropic SDK gets the host root since it
18
+ appends `/v1/messages` itself). Simple constructors are rewritten
19
+ deterministically; anything ambiguous gets an AI-suggested patch (generated
20
+ by InferAll's own $0 NIM models — the wizard dogfoods the gateway) that you
21
+ review before it's applied. Google Gemini SDK usage is detected too — that
22
+ SDK speaks its own protocol, so instead of a risky rewrite you get the exact
23
+ OpenAI-compat snippet (`model: "gemini/gemini-2.5-flash"`) to migrate at
24
+ your own pace.
25
+ 4. **Verifies with a live call** — and if your account isn't activated yet, it
26
+ says exactly that, with the link, instead of failing opaquely.
27
+
28
+ ## Commands
29
+
30
+ - `init` — the wizard above (default).
31
+ - `doctor` — health-check an existing setup: key present + valid, `.env`
32
+ gitignore hygiene, whether every client call site points at the gateway, and
33
+ a live call that reports your real account state (active vs `$5` activation
34
+ pending). Exit code 0 only when everything passes — CI-friendly.
35
+ - `models` — the live model list with real prices, straight from the gateway.
36
+ `--free` shows only the $0 models; `--json` gives the raw pricing map.
37
+ - `status` — your account state from the key's point of view: tier, activation,
38
+ balance, trial calls remaining, and what the free models saved you this week.
39
+ The fast answer to "did my payment land?"
40
+
41
+ ## Flags
42
+
43
+ | Flag | Effect |
44
+ | --- | --- |
45
+ | `--key ifu_...` | Skip the key prompt |
46
+ | `--yes` / `-y` | Accept all defaults (non-interactive) |
47
+ | `--no-ai` | Deterministic codemod only, no AI-suggested patches |
48
+
49
+ ## Why this exists
50
+
51
+ Most gateways make you read docs to change one line. This makes the one-line
52
+ change for you, tells you the truth about the $5 activation gate, and leaves
53
+ your code otherwise untouched — no reformatting, no renames.
54
+
55
+ Zero runtime dependencies. Node 18+.
@@ -0,0 +1,635 @@
1
+ #!/usr/bin/env node
2
+ // InferAll install wizard — `npx @inferall/cli init`
3
+ //
4
+ // Gets a project from zero to a working InferAll call in one command:
5
+ // 1. Detects the project (Node / Python) and any existing AI SDK usage.
6
+ // 2. Sets up INFERALL_API_KEY in .env (+ .env.example, .gitignore).
7
+ // 3. Points existing OpenAI-SDK call sites at the InferAll gateway
8
+ // (deterministic codemod; AI-assisted patch suggestions for call
9
+ // sites the codemod can't confidently rewrite — powered by
10
+ // InferAll's own $0 NIM models, so the wizard dogfoods the product).
11
+ // 4. Verifies with a live call and explains exactly what state the
12
+ // account is in (including the $5 activation gate) instead of
13
+ // failing opaquely.
14
+ //
15
+ // Zero runtime dependencies. Node 18+ (built-in fetch).
16
+
17
+ import { createInterface } from "node:readline/promises";
18
+ import { stdin, stdout, exit, argv, env, cwd } from "node:process";
19
+ import {
20
+ readFileSync,
21
+ writeFileSync,
22
+ appendFileSync,
23
+ existsSync,
24
+ readdirSync,
25
+ statSync,
26
+ } from "node:fs";
27
+ import { join, relative, extname } from "node:path";
28
+
29
+ const API_BASE = env.INFERALL_API_BASE || "https://api.inferall.ai/v1";
30
+ // The Anthropic SDK appends /v1/messages itself, so its baseURL is the host root.
31
+ const ANTHROPIC_BASE = API_BASE.replace(/\/v1$/, "");
32
+ const KEYS_URL = "https://inferall.ai/keys";
33
+ const BILLING_URL = "https://inferall.ai/billing?src=cli_init";
34
+ const FREE_MODEL = "meta/llama-3.1-8b-instruct";
35
+
36
+ const c = {
37
+ dim: (s) => `\x1b[2m${s}\x1b[0m`,
38
+ bold: (s) => `\x1b[1m${s}\x1b[0m`,
39
+ green: (s) => `\x1b[32m${s}\x1b[0m`,
40
+ yellow: (s) => `\x1b[33m${s}\x1b[0m`,
41
+ red: (s) => `\x1b[31m${s}\x1b[0m`,
42
+ cyan: (s) => `\x1b[36m${s}\x1b[0m`,
43
+ };
44
+
45
+ const args = argv.slice(2);
46
+ const cmd = args.find((a) => !a.startsWith("-")) || "init";
47
+ const flag = (name) => args.includes(`--${name}`);
48
+ const flagValue = (name) => {
49
+ const i = args.indexOf(`--${name}`);
50
+ return i >= 0 && args[i + 1] && !args[i + 1].startsWith("-") ? args[i + 1] : undefined;
51
+ };
52
+ const YES = flag("yes") || flag("y");
53
+ const NO_AI = flag("no-ai");
54
+
55
+ const rl = createInterface({ input: stdin, output: stdout });
56
+ async function ask(q, fallback) {
57
+ if (YES) return fallback ?? "y";
58
+ const a = (await rl.question(q)).trim();
59
+ return a === "" ? (fallback ?? "") : a;
60
+ }
61
+ async function confirm(q, defaultYes = true) {
62
+ const hint = defaultYes ? "Y/n" : "y/N";
63
+ const a = (await ask(`${q} ${c.dim(`[${hint}]`)} `, defaultYes ? "y" : "n")).toLowerCase();
64
+ return defaultYes ? a !== "n" : a === "y";
65
+ }
66
+
67
+ // ---------- project detection ----------
68
+
69
+ function detectProject(root) {
70
+ const p = { root, kind: null, sdks: [], pkg: null };
71
+ const pkgPath = join(root, "package.json");
72
+ if (existsSync(pkgPath)) {
73
+ p.kind = "node";
74
+ try {
75
+ 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
+ } catch {
81
+ /* unparseable package.json — treat as bare node */
82
+ }
83
+ } else if (existsSync(join(root, "pyproject.toml")) || existsSync(join(root, "requirements.txt"))) {
84
+ p.kind = "python";
85
+ const reqs = ["pyproject.toml", "requirements.txt"]
86
+ .filter((f) => existsSync(join(root, f)))
87
+ .map((f) => readFileSync(join(root, f), "utf8"))
88
+ .join("\n");
89
+ for (const name of ["openai", "anthropic", "google-generativeai", "google-genai", "inferall-ai"]) {
90
+ if (new RegExp(`^\\s*"?${name}`, "m").test(reqs)) p.sdks.push(name);
91
+ }
92
+ }
93
+ return p;
94
+ }
95
+
96
+ // ---------- env / key setup ----------
97
+
98
+ function upsertEnvFile(root, key) {
99
+ const envPath = join(root, ".env");
100
+ 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`);
107
+ }
108
+ } else {
109
+ writeFileSync(envPath, `${line}\n`);
110
+ }
111
+
112
+ const examplePath = join(root, ".env.example");
113
+ 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`);
118
+ }
119
+ } else {
120
+ writeFileSync(examplePath, `${exampleLine}\n`);
121
+ }
122
+
123
+ 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`);
129
+ }
130
+ } else {
131
+ writeFileSync(giPath, ".env\n");
132
+ console.log(` ${c.green("+")} created .gitignore with .env`);
133
+ }
134
+ }
135
+
136
+ // ---------- gateway API helpers ----------
137
+
138
+ async function apiGet(path, key) {
139
+ const res = await fetch(`${API_BASE}${path}`, {
140
+ headers: { Authorization: `Bearer ${key}` },
141
+ });
142
+ return { status: res.status, body: await res.json().catch(() => ({})) };
143
+ }
144
+
145
+ async function apiChat(key, messages, model = FREE_MODEL) {
146
+ const res = await fetch(`${API_BASE}/chat/completions`, {
147
+ method: "POST",
148
+ headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
149
+ body: JSON.stringify({ model, messages, max_tokens: 512 }),
150
+ });
151
+ return { status: res.status, body: await res.json().catch(() => ({})) };
152
+ }
153
+
154
+ // ---------- codemod: point OpenAI SDK at InferAll ----------
155
+
156
+ const SOURCE_EXTS = new Set([".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx", ".py"]);
157
+ const SKIP_DIRS = new Set(["node_modules", ".git", ".next", "dist", "build", "venv", ".venv", "__pycache__", ".turbo"]);
158
+
159
+ function* walk(dir, depth = 0) {
160
+ if (depth > 6) return;
161
+ let entries = [];
162
+ try {
163
+ entries = readdirSync(dir);
164
+ } catch {
165
+ return;
166
+ }
167
+ for (const name of entries) {
168
+ if (SKIP_DIRS.has(name) || name.startsWith(".")) continue;
169
+ const full = join(dir, name);
170
+ let st;
171
+ try {
172
+ st = statSync(full);
173
+ } catch {
174
+ continue;
175
+ }
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;
178
+ }
179
+ }
180
+
181
+ function findCallSites(root) {
182
+ const sites = [];
183
+ for (const file of walk(root)) {
184
+ let body;
185
+ try {
186
+ body = readFileSync(file, "utf8");
187
+ } catch {
188
+ continue;
189
+ }
190
+ const isPy = file.endsWith(".py");
191
+ const baseKey = isPy ? "base_url" : "baseURL";
192
+ for (const sdk of ["OpenAI", "Anthropic"]) {
193
+ const ctorRe = isPy ? new RegExp(`\\b${sdk}\\s*\\(`) : new RegExp(`new\\s+${sdk}\\s*\\(`);
194
+ if (!ctorRe.test(body)) continue;
195
+ sites.push({
196
+ file,
197
+ body,
198
+ isPy,
199
+ sdk,
200
+ alreadyPointed: body.includes(new URL(API_BASE).host),
201
+ hasBaseUrl: new RegExp(`${baseKey}\\s*[:=]`).test(body),
202
+ });
203
+ }
204
+ }
205
+ return sites;
206
+ }
207
+
208
+ // Deterministic rewrite for the common zero-arg / simple-config constructor.
209
+ // The OpenAI SDK's baseURL includes /v1; the Anthropic SDK appends /v1 itself.
210
+ function patchSite(site) {
211
+ const { body, isPy, sdk } = site;
212
+ const base = sdk === "Anthropic" ? ANTHROPIC_BASE : API_BASE;
213
+ const baseLine = isPy
214
+ ? `base_url="${base}", api_key=os.environ["INFERALL_API_KEY"]`
215
+ : `baseURL: "${base}", apiKey: process.env.INFERALL_API_KEY`;
216
+
217
+ let patched = null;
218
+ if (isPy) {
219
+ const bare = new RegExp(`\\b${sdk}\\s*\\(\\s*\\)`);
220
+ if (bare.test(body)) {
221
+ patched = body.replace(bare, `${sdk}(${baseLine})`);
222
+ if (!/^import os$/m.test(patched)) patched = `import os\n${patched}`;
223
+ }
224
+ } else {
225
+ const bare = new RegExp(`new\\s+${sdk}\\s*\\(\\s*\\)`);
226
+ const obj = new RegExp(`new\\s+${sdk}\\s*\\(\\s*\\{`);
227
+ if (bare.test(body)) {
228
+ patched = body.replace(bare, `new ${sdk}({ ${baseLine} })`);
229
+ } else if (obj.test(body) && !site.hasBaseUrl) {
230
+ patched = body.replace(obj, `new ${sdk}({ baseURL: "${base}", `);
231
+ }
232
+ }
233
+ return patched;
234
+ }
235
+
236
+ // Gemini SDKs (@google/generative-ai, @google/genai, google-generativeai) speak
237
+ // their own wire protocol (generateContent), which the gateway doesn't serve —
238
+ // Gemini models are reached through the OpenAI-compat endpoint with a gemini/
239
+ // model prefix. These sites get exact guidance, never a baseURL patch, and no
240
+ // AI rewrite either: a cross-protocol reshape (response shapes differ) is too
241
+ // invasive to automate safely.
242
+ function findGeminiSites(root) {
243
+ const sites = [];
244
+ for (const file of walk(root)) {
245
+ let body;
246
+ try {
247
+ body = readFileSync(file, "utf8");
248
+ } catch {
249
+ continue;
250
+ }
251
+ const isPy = file.endsWith(".py");
252
+ const hit = isPy
253
+ ? /\bgenai\.(configure|GenerativeModel|Client)\s*\(/.test(body)
254
+ : /new\s+(GoogleGenerativeAI|GoogleGenAI)\s*\(/.test(body);
255
+ if (hit) sites.push({ file, isPy });
256
+ }
257
+ return sites;
258
+ }
259
+
260
+ function printGeminiGuidance(root, sites) {
261
+ console.log(`\n ${c.bold("Gemini SDK usage")} found in ${sites.length} file(s):`);
262
+ for (const s of sites) console.log(` - ${relative(root, s.file)}`);
263
+ console.log(` The Google SDK speaks its own protocol, so it can't be re-pointed with a base URL.`);
264
+ console.log(` Gemini models work through InferAll's OpenAI-compatible endpoint instead:`);
265
+ if (sites.some((s) => s.isPy)) {
266
+ console.log(c.dim(` # pip install openai`));
267
+ console.log(c.cyan(` ai = OpenAI(base_url="${API_BASE}", api_key=os.environ["INFERALL_API_KEY"])`));
268
+ console.log(c.cyan(` ai.chat.completions.create(model="gemini/gemini-2.5-flash", messages=messages)`));
269
+ } else {
270
+ console.log(c.dim(` // npm install openai`));
271
+ console.log(c.cyan(` const ai = new OpenAI({ baseURL: "${API_BASE}", apiKey: process.env.INFERALL_API_KEY });`));
272
+ console.log(c.cyan(` await ai.chat.completions.create({ model: "gemini/gemini-2.5-flash", messages });`));
273
+ }
274
+ console.log(` Same Gemini models, one ifu_ key, $0 markup — migrate at your own pace.`);
275
+ }
276
+
277
+ // AI-assisted patch for call sites the deterministic codemod can't handle.
278
+ // Uses the user's own key against a $0 NIM model — the wizard dogfoods the
279
+ // gateway. Returns the suggested full-file replacement, or null.
280
+ async function aiSuggestPatch(key, site) {
281
+ const base = site.sdk === "Anthropic" ? ANTHROPIC_BASE : API_BASE;
282
+ const prompt = [
283
+ {
284
+ role: "user",
285
+ content:
286
+ `Rewrite this ${site.isPy ? "Python" : "JavaScript/TypeScript"} file so the ${site.sdk} SDK client sends requests to the InferAll gateway.\n` +
287
+ `Rules:\n` +
288
+ `- Set the client's ${site.isPy ? `base_url to "${base}" and api_key to os.environ["INFERALL_API_KEY"]` : `baseURL to "${base}" and apiKey to process.env.INFERALL_API_KEY`}.\n` +
289
+ `- Change NOTHING else: no reformatting, no renames, no comment changes.\n` +
290
+ `- Reply with ONLY the complete rewritten file, no code fences, no commentary.\n\n` +
291
+ site.body,
292
+ },
293
+ ];
294
+ const { status, body } = await apiChat(key, prompt);
295
+ if (status !== 200) return { status, patched: null };
296
+ 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*$/, "") };
299
+ }
300
+
301
+ // ---------- fresh-install example ----------
302
+
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;
320
+ }
321
+
322
+ // ---------- models ----------
323
+
324
+ // Pricing lives on the gateway's public metadata endpoint (host root, not the
325
+ // OpenAI-compat surface): { "<id>": { type, inputPerM|perImage|perSecond, … } }.
326
+ function priceLabel(p) {
327
+ if (p.type === "token") {
328
+ if (!p.inputPerM && !p.outputPerM) return "$0";
329
+ return `$${p.inputPerM}/M in · $${p.outputPerM}/M out`;
330
+ }
331
+ if (p.perImage != null) return `$${p.perImage}/image`;
332
+ if (p.perSecond != null) return `$${p.perSecond}/s`;
333
+ return p.type;
334
+ }
335
+
336
+ async function modelsCmd() {
337
+ const res = await fetch(`${ANTHROPIC_BASE}/ai/v1/models`);
338
+ if (!res.ok) {
339
+ console.log(`${c.red("✗")} couldn't fetch the model list (HTTP ${res.status})`);
340
+ rl.close();
341
+ exit(1);
342
+ }
343
+ const map = await res.json();
344
+ let rows = Object.entries(map).map(([id, p]) => ({
345
+ id,
346
+ type: p.type,
347
+ free: p.type === "token" && !p.inputPerM && !p.outputPerM,
348
+ price: priceLabel(p),
349
+ sort: p.type === "token" ? (p.inputPerM || 0) : Infinity,
350
+ }));
351
+ if (flag("free")) rows = rows.filter((r) => r.free);
352
+ if (flag("json")) {
353
+ console.log(JSON.stringify(flag("free") ? Object.fromEntries(rows.map((r) => [r.id, map[r.id]])) : map, null, 2));
354
+ rl.close();
355
+ return;
356
+ }
357
+ rows.sort((a, b) => (a.free !== b.free ? (a.free ? -1 : 1) : a.sort - b.sort || a.id.localeCompare(b.id)));
358
+ const w = Math.min(48, Math.max(...rows.map((r) => r.id.length)) + 2);
359
+ console.log(`\n${c.bold("MODEL".padEnd(w))}${c.bold("TYPE".padEnd(12))}${c.bold("PRICE")}`);
360
+ for (const r of rows) {
361
+ const price = r.free ? c.green("$0") : r.price;
362
+ console.log(`${r.id.padEnd(w)}${r.type.padEnd(12)}${price}`);
363
+ }
364
+ 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`);
366
+ rl.close();
367
+ }
368
+
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();
379
+ }
380
+ if (!key) {
381
+ console.log(`${c.red("✗")} No key in INFERALL_API_KEY or .env — run ${c.bold("npx @inferall/cli init")}`);
382
+ rl.close();
383
+ exit(1);
384
+ }
385
+ const res = await fetch(`${ANTHROPIC_BASE}/ai/v1/key/status`, {
386
+ headers: { Authorization: `Bearer ${key}` },
387
+ });
388
+ if (!res.ok) {
389
+ console.log(`${c.red("✗")} status check failed (HTTP ${res.status}) — check the key at ${KEYS_URL}`);
390
+ rl.close();
391
+ exit(1);
392
+ }
393
+ const s = await res.json();
394
+ console.log(`\n${c.bold("InferAll account status")} ${c.dim(`(key ${key.slice(0, 8)}…)`)}\n`);
395
+ if (s.project) {
396
+ console.log(` tier: ${c.cyan(s.tier)} ${c.dim("(project key — billed separately, no trial counter)")}\n`);
397
+ rl.close();
398
+ return;
399
+ }
400
+ const active = s.has_paid_successfully;
401
+ console.log(` tier: ${c.cyan(s.tier)}`);
402
+ 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)}`}`);
404
+ if (s.trial) console.log(` trial: ${s.trial.remaining}/${s.trial.cap} calls remaining`);
405
+ const sv = s.savings_7d;
406
+ if (sv && (sv.vs_gpt4o_mini_usd > 0 || sv.vs_claude_sonnet_usd > 0)) {
407
+ 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
+ }
409
+ 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)}`);
412
+ }
413
+ console.log("");
414
+ rl.close();
415
+ }
416
+
417
+ // ---------- doctor ----------
418
+
419
+ async function doctor() {
420
+ console.log(`\n${c.bold("InferAll doctor")} ${c.dim(`— checks your setup against ${API_BASE}`)}\n`);
421
+ const root = cwd();
422
+ let ok = true;
423
+
424
+ // 1. key present
425
+ let key = env.INFERALL_API_KEY || "";
426
+ 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();
430
+ }
431
+ if (!key) {
432
+ console.log(` ${c.red("✗")} INFERALL_API_KEY not found in environment or .env — run ${c.bold("npx @inferall/cli init")}`);
433
+ rl.close();
434
+ exit(1);
435
+ }
436
+ 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?`);
439
+ ok = false;
440
+ }
441
+
442
+ // 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`);
449
+ ok = false;
450
+ } else {
451
+ console.log(` ${c.green("✓")} .env hygiene ok`);
452
+ }
453
+
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}`);
458
+ rl.close();
459
+ exit(1);
460
+ }
461
+ console.log(` ${c.green("✓")} key accepted by gateway`);
462
+
463
+ // 4. call-site wiring
464
+ const sites = findCallSites(root);
465
+ const pointed = sites.filter((s) => s.alreadyPointed).length;
466
+ const unpointed = sites.length - pointed;
467
+ if (sites.length === 0) {
468
+ console.log(` ${c.dim("·")} no OpenAI/Anthropic client constructors found in this project`);
469
+ } else if (unpointed === 0) {
470
+ console.log(` ${c.green("✓")} all ${sites.length} client constructor site(s) point at InferAll`);
471
+ } else {
472
+ console.log(` ${c.yellow("~")} ${unpointed} of ${sites.length} client site(s) NOT pointed at InferAll — run ${c.bold("npx @inferall/cli init")}`);
473
+ ok = false;
474
+ }
475
+
476
+ // 4b. Gemini SDK sites — different protocol, can't be re-pointed
477
+ const gSites = findGeminiSites(root);
478
+ 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`);
480
+ ok = false;
481
+ }
482
+
483
+ // 5. live account state
484
+ process.stdout.write(` live call (${FREE_MODEL}) … `);
485
+ const test = await apiChat(key, [{ role: "user", content: "Reply with the single word: ready" }]);
486
+ if (test.status === 200) {
487
+ console.log(c.green("account active ✓"));
488
+ } else if (test.status === 402) {
489
+ console.log(c.yellow("account not activated (402)"));
490
+ console.log(` One-time ${c.bold("$5 activation")} (becomes spendable balance): ${c.cyan(BILLING_URL)}`);
491
+ ok = false;
492
+ } else {
493
+ console.log(c.red(`HTTP ${test.status}`));
494
+ ok = false;
495
+ }
496
+
497
+ console.log(ok ? `\n${c.green(c.bold("All checks passed."))}\n` : `\n${c.yellow(c.bold("Some checks need attention (see above)."))}\n`);
498
+ rl.close();
499
+ exit(ok ? 0 : 1);
500
+ }
501
+
502
+ // ---------- main ----------
503
+
504
+ async function main() {
505
+ if (flag("version") || cmd === "version") {
506
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
507
+ console.log(pkg.version);
508
+ rl.close();
509
+ return;
510
+ }
511
+ if (cmd === "doctor") return doctor();
512
+ if (cmd === "models") return modelsCmd();
513
+ if (cmd === "status") return statusCmd();
514
+ if (cmd !== "init") {
515
+ console.log(`Usage: npx @inferall/cli <init|doctor|models|status|version> [--key ifu_...] [--yes] [--no-ai] [--free] [--json]`);
516
+ exit(cmd === "help" || flag("help") ? 0 : 1);
517
+ }
518
+
519
+ console.log(`\n${c.bold("InferAll setup")} ${c.dim("— one API for OpenAI, Anthropic, Google, and 118+ $0 NIM models")}\n`);
520
+
521
+ const root = cwd();
522
+ const project = detectProject(root);
523
+ 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.`);
526
+ if (!(await confirm("Continue anyway?", false))) exit(0);
527
+ project.kind = "node";
528
+ }
529
+ console.log(` detected: ${c.cyan(project.kind)} project${project.sdks.length ? ` using ${c.cyan(project.sdks.join(", "))}` : ""}`);
530
+
531
+ // --- key ---
532
+ let key = flagValue("key") || env.INFERALL_API_KEY || "";
533
+ if (!key) {
534
+ 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: `);
536
+ }
537
+ if (!key.startsWith("ifu_")) {
538
+ console.log(`${c.red("✗")} That doesn't look like an InferAll key (expected ifu_ prefix).`);
539
+ exit(1);
540
+ }
541
+
542
+ 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"})`));
546
+ console.log(` Check the key at ${KEYS_URL} and re-run.`);
547
+ exit(1);
548
+ }
549
+ console.log(c.green("ok"));
550
+
551
+ // --- env files ---
552
+ upsertEnvFile(root, key);
553
+ console.log(` ${c.green("+")} INFERALL_API_KEY written to ${c.bold(".env")} (and .env.example placeholder)`);
554
+
555
+ // --- 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);
559
+ if (sites.length === 0) {
560
+ console.log(` ${c.green("✓")} AI SDK present but no un-migrated client constructors found.`);
561
+ } else {
562
+ console.log(`\n Found ${c.bold(sites.length)} client constructor site(s):`);
563
+ for (const s of sites) console.log(` - ${relative(root, s.file)} ${c.dim(`(${s.sdk})`)}`);
564
+ if (await confirm(` Point them at the InferAll gateway?`)) {
565
+ for (const s of sites) {
566
+ let patched = patchSite(s);
567
+ let via = "codemod";
568
+ if (!patched && !NO_AI) {
569
+ process.stdout.write(` ${c.dim("AI-assist")} ${relative(root, s.file)} … `);
570
+ const r = await aiSuggestPatch(key, s);
571
+ if (r.status === 402) {
572
+ console.log(c.yellow("skipped (account not activated yet — see verify step below)"));
573
+ continue;
574
+ }
575
+ patched = r.patched;
576
+ via = "AI";
577
+ console.log(patched ? c.green("patch ready") : c.yellow("no confident patch"));
578
+ }
579
+ 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.`);
582
+ continue;
583
+ }
584
+ if (via === "AI" && !(await confirm(` Apply AI-suggested rewrite to ${relative(root, s.file)}?`))) continue;
585
+ writeFileSync(s.file, patched);
586
+ console.log(` ${c.green("✓")} ${relative(root, s.file)} ${c.dim(`(${via})`)}`);
587
+ }
588
+ }
589
+ }
590
+ } else {
591
+ const example = writeExample(root, project.kind);
592
+ if (example) console.log(` ${c.green("+")} example written: ${c.bold(relative(root, example))}`);
593
+ if (project.kind === "node") console.log(` ${c.dim("optional:")} npm install @inferall/sdk`);
594
+ else console.log(` ${c.dim("optional:")} pip install inferall-ai`);
595
+ }
596
+
597
+ const geminiSites = findGeminiSites(root);
598
+ if (geminiSites.length) printGeminiGuidance(root, geminiSites);
599
+
600
+ // --- verify with a live call ---
601
+ const testModel = flagValue("model") || FREE_MODEL;
602
+ process.stdout.write(`\n test call (${testModel}${testModel === FREE_MODEL ? ", $0 model" : ""}) … `);
603
+ const test = await apiChat(key, [{ role: "user", content: "Reply with the single word: ready" }], testModel);
604
+ if (test.status === 200) {
605
+ 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`);
607
+ } else if (test.status === 402) {
608
+ console.log(c.yellow("blocked (402)"));
609
+ 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
611
+ // checkout was already started (settle vs start are different next steps).
612
+ const st = await fetch(`${ANTHROPIC_BASE}/ai/v1/key/status`, {
613
+ headers: { Authorization: `Bearer ${key}` },
614
+ }).then((r) => (r.ok ? r.json() : null)).catch(() => null);
615
+ 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") : ""}.`);
617
+ }
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.`);
620
+ 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.`);
622
+ console.log(` Check anytime: ${c.bold("npx @inferall/cli status")}\n`);
623
+ } else {
624
+ console.log(c.red(`HTTP ${test.status}`));
625
+ console.log(` ${JSON.stringify(test.body).slice(0, 300)}`);
626
+ console.log(` Setup files were written; re-run ${c.bold("npx @inferall/cli init")} to retry the verify step.\n`);
627
+ }
628
+ rl.close();
629
+ }
630
+
631
+ main().catch((err) => {
632
+ console.error(`${c.red("✗")} ${err?.message || err}`);
633
+ rl.close();
634
+ exit(1);
635
+ });
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@inferall/cli",
3
+ "version": "0.2.0",
4
+ "description": "Install InferAll into your project in one command: npx @inferall/cli init",
5
+ "bin": {
6
+ "inferall": "bin/inferall.js"
7
+ },
8
+ "type": "module",
9
+ "engines": {
10
+ "node": ">=18"
11
+ },
12
+ "files": [
13
+ "bin",
14
+ "README.md"
15
+ ],
16
+ "keywords": [
17
+ "inferall",
18
+ "ai",
19
+ "gateway",
20
+ "openai",
21
+ "anthropic",
22
+ "nim",
23
+ "cli",
24
+ "wizard"
25
+ ],
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/kindlyrobotics/inferall.ai.git",
29
+ "directory": "packages/cli"
30
+ },
31
+ "license": "MIT"
32
+ }