@phantomcodes/cli 1.2.4 → 1.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -62,9 +62,11 @@ The Phantom CLI detects supported agents, previews the configuration changes, th
62
62
  applying them. It connects their model calls to Phantom AI and wires up the saved
63
63
  key. Restart your agent, or open a new terminal, to use the connection.
64
64
 
65
- Codex, Claude Code, opencode, Continue, Goose, Aider, GitHub Copilot CLI and Cline
66
- support saved setup. Cursor gets instructions for its settings screen; pi gets
67
- the agent skill.
65
+ Saved setup covers Claude Code, Codex, opencode, Continue, Goose, Aider, GitHub
66
+ Copilot CLI, Cline, Gemini CLI, Crush, Qwen Code, Kilo Code, Kimi Code, Droid,
67
+ Mistral Vibe and Zed. Cursor, VS Code, JetBrains AI Assistant, Warp and Trae
68
+ keep these settings on their own settings screens, so setup prints the steps;
69
+ pi gets the agent skill.
68
70
 
69
71
  Preview without changing files with `phantom setup --dry-run`. The Phantom CLI keeps backups;
70
72
  undo the changes with `phantom setup --restore`.
@@ -79,9 +81,11 @@ phantom workspace status
79
81
  phantom codex
80
82
  ```
81
83
 
82
- Both setup options require **Node 24+** and your coding agent installed. Connect, fund your
83
- workspace, then launch **Codex** (`phantom codex`), **Claude Code** (`phantom claude`),
84
- **pi** (`phantom pi`), or **any other compatible agent**.
84
+ Both setup options require **Node 24+** and your coding agent installed. Connect, then
85
+ launch **Codex** (`phantom codex`), **Claude Code** (`phantom claude`), **pi**
86
+ (`phantom pi`), or **any other compatible agent**. Each runs phantom-1 unless you
87
+ pick a model (`--model` after `--`), and phantom-1 runs on free models until you add
88
+ credit. Free calls leave out web search, which costs money on any model.
85
89
 
86
90
  Login opens a device approval page and saves a key privately. Funding opens
87
91
  checkout; check your workspace balance after payment. Agent runs use temporary
@@ -6,7 +6,10 @@ import { die, NO_KEY } from "../core/errors.mjs";
6
6
  import { DEFAULT_BASE_URL, httpsOnly, request } from "../core/request.mjs";
7
7
  import { keyId, readNamedKey, resolveApiKey } from "../keys/saved.mjs";
8
8
  import { workspaceLogin } from "../workspace/command.mjs";
9
- const DEFAULT_MODEL = 'auto';
9
+ /** The model an agent runs when the user names none: phantom-1, which runs free on a key with no credit. */
10
+ const DEFAULT_MODEL = 'phantom-1';
11
+ /** phantom-1 as Claude Code names it: its picker keeps only ids that say "claude", so Phantom lists them as `claude-code/<id>`. */
12
+ const CLAUDE_DEFAULT_MODEL = `claude-code/${DEFAULT_MODEL}`;
10
13
  /** The key's spend and balance, or null when the answer has no numbers, such as a sign-in page that came back with status 200. */
11
14
  async function keyStatus(apiKey, baseUrl) {
12
15
  const s = await request('GET', '/key/balance', apiKey, undefined, baseUrl);
@@ -23,11 +26,16 @@ export const AGENTS = {
23
26
  // --settings outranks settings.json, so an env block or apiKeyHelper
24
27
  // `phantom setup` wrote can't send Claude Code past the proxy. The helper
25
28
  // reads the token from the environment, so it stays off the command line.
29
+ // It runs with this phantom's own Node: the curl installer puts none on PATH.
30
+ const helper = `${JSON.stringify(process.execPath)} -p "process.env.PHANTOM_AGENT_TOKEN || ''"`;
31
+ // phantom-1 unless the user picks a model. It goes in the environment, which
32
+ // Claude Code ranks below --model and below a model in its settings file.
33
+ const model = hasModel(args) || env.ANTHROPIC_MODEL ? {} : { ANTHROPIC_MODEL: CLAUDE_DEFAULT_MODEL };
26
34
  const vars = { ANTHROPIC_BASE_URL: proxy, CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: '1', ENABLE_TOOL_SEARCH: 'true' };
27
35
  const { ANTHROPIC_AUTH_TOKEN: _auth, ...rest } = env;
28
36
  return {
29
- args: ['--settings', JSON.stringify({ apiKeyHelper: 'node -p "process.env.PHANTOM_AGENT_TOKEN || \'\'"', env: vars }), ...args],
30
- env: { ...rest, ANTHROPIC_BASE_URL: proxy, ANTHROPIC_API_KEY: token, PHANTOM_AGENT_TOKEN: token },
37
+ args: ['--settings', JSON.stringify({ apiKeyHelper: helper, env: vars }), ...args],
38
+ env: { ...rest, ...model, ANTHROPIC_BASE_URL: proxy, ANTHROPIC_API_KEY: token, PHANTOM_AGENT_TOKEN: token },
31
39
  };
32
40
  },
33
41
  },
package/dist/cli/run.mjs CHANGED
@@ -21,16 +21,21 @@ import { LEGACY_SETUP_HINT, legacySetupFound } from "../setup/legacy.mjs";
21
21
  * @param node The Node version it runs on; one too old to reject forged
22
22
  * eMudhra certificates gets a warning on stderr first.
23
23
  */
24
+ /** Retired flags, accepted so the command can say what replaced them, and never suggested for a typo. */
25
+ const RETIRED_FLAGS = ['pay', 'mpp'];
26
+ /** The flags every command takes, and the retired ones. */
27
+ const EVERY_COMMAND_FLAGS = ['table', 'json', 'help', 'all', 'version', 'mcp', ...RETIRED_FLAGS];
24
28
  /**
25
- * Every flag phantom reads: each one a command's help names, plus the flags
26
- * every command takes and two retired ones that explain themselves. A typo
27
- * such as --yse is refused instead of quietly ignored, which in CI would
28
- * otherwise drop the flag without a word.
29
+ * The flags a command reads: each one its help names, plus those of the
30
+ * command its subcommand names (`workspace fund` reads `fund`'s), plus
31
+ * {@link EVERY_COMMAND_FLAGS}. A typo such as --yse, or a flag only another
32
+ * command takes (balance --key), is refused instead of quietly ignored, which
33
+ * in CI would otherwise drop the flag without a word.
29
34
  */
30
- const KNOWN_FLAGS = new Set([
31
- ...COMMANDS.flatMap((command) => [...command.help.matchAll(/--([a-z][a-z0-9-]*)/g)].map((m) => m[1])),
32
- 'table', 'json', 'help', 'all', 'version', 'mcp', 'pay', 'mpp',
33
- ]);
35
+ function flagsOf(command, subcommand) {
36
+ const helps = [command.help, COMMANDS.find((c) => c.name === subcommand)?.help ?? ''].join('\n');
37
+ return new Set([...[...helps.matchAll(/--([a-z][a-z0-9-]*)/g)].map((m) => m[1]), ...EVERY_COMMAND_FLAGS]);
38
+ }
34
39
  export async function run(argv = process.argv.slice(2), env = process.env, io = {
35
40
  stdout: (s) => process.stdout.write(s),
36
41
  stderr: (s) => process.stderr.write(s),
@@ -92,10 +97,11 @@ export async function run(argv = process.argv.slice(2), env = process.env, io =
92
97
  const ownFlags = parseFlags(flagArgs.includes('--') ? flagArgs.slice(0, flagArgs.indexOf('--')) : flagArgs);
93
98
  // claude, codex and pi hand their arguments to the agent, so they check their own.
94
99
  const passesArgs = command.handler === COMMANDS.find((c) => c.name === 'claude')?.handler;
100
+ const known = flagsOf(command, subcommand);
95
101
  for (const name of passesArgs ? [] : Object.keys(ownFlags)) {
96
- if (KNOWN_FLAGS.has(name))
102
+ if (known.has(name))
97
103
  continue;
98
- const guess = closestCommand(name, [...KNOWN_FLAGS]);
104
+ const guess = closestCommand(name, [...known].filter((flag) => !RETIRED_FLAGS.includes(flag)));
99
105
  die(`Unknown flag --${name} for phantom ${argv[0]}.${guess ? ` Did you mean --${guess}?` : ''} See phantom ${argv[0]} --help.`, 'unknown_flag');
100
106
  }
101
107
  // A person at a terminal reads each command's screen; a pipe (scripts, agents) or --json gets JSON.
@@ -109,15 +109,19 @@ export function spendScreen(s, style = {}) {
109
109
  lines.push(...(s.scope === 'workspace' ? next(p, 'Pick the models phantom-1 runs:', 'phantom lineup') : next(p, 'Every key in the workspace:', 'phantom spend --workspace')));
110
110
  return lines.join('\n');
111
111
  }
112
+ /** `text` cut to `width` characters, ending in … when it was longer. */
113
+ const clip = (text, width) => (text.length > width ? `${text.slice(0, width - 1)}…` : text);
112
114
  /** The keys, one row each: name, the start of the key, what it spent, and its limit. */
113
115
  function keyRows(p, keys) {
114
116
  if (!keys.length)
115
117
  return [` ${p.dim('No keys yet.')}`];
116
118
  return keys.map((k) => {
117
119
  const limit = k.limit_usd != null ? `limit ${usd(k.limit_usd)}` : k.budget_usd != null ? `${usd(k.budget_usd)} every ${plural(k.period_days ?? 30, 'day')}` : 'no limit';
118
- const mark = k.is_active === false ? p.dim('off') : p.lime('●');
120
+ // A revoked or rotated key gets a hollow mark and the word off at the end, so the columns stay in line.
121
+ const off = k.is_active === false;
122
+ const mark = off ? p.dim('○') : p.lime('●');
119
123
  // The id's first 8 characters: enough for --id, which also takes the name.
120
- return ` ${mark} ${(k.name ?? 'Key').slice(0, 15).padEnd(16)}${p.dim((k.id ?? '').slice(0, 8).padEnd(10))}${p.dim((k.prefix ?? '').slice(0, 18).padEnd(20))}${usd(k.spent_usd ?? 0).padStart(8)} ${p.dim(limit)}`;
124
+ return ` ${mark} ${clip(k.name ?? 'Key', 20).padEnd(21)}${p.dim((k.id ?? '').slice(0, 8).padEnd(10))}${p.dim((k.prefix ?? '').slice(0, 18).padEnd(20))}${usd(k.spent_usd ?? 0).padStart(8)} ${p.dim(limit)}${off ? ` ${p.dim('off')}` : ''}`;
121
125
  });
122
126
  }
123
127
  /** `phantom workspace`: the shared balance, each key, and the last few charges. */
@@ -161,7 +165,7 @@ export function routeScreen(r, style = {}) {
161
165
  const lines = ['', ...caption(p, w, 'ROUTING', p.dim('how model "auto" picks for this key'))];
162
166
  const policy = r.route_policy;
163
167
  if (!policy) {
164
- lines.push(' No route policy, so requests for model "auto" are refused.', ` ${p.dim('phantom-1 picks a model for you without one.')}`);
168
+ lines.push(' No route policy, so model "auto" runs phantom-1, which picks a model for each request.');
165
169
  lines.push(...next(p, 'Make one:', 'phantom route set --models a,b,c'));
166
170
  return lines.join('\n');
167
171
  }
@@ -317,7 +321,8 @@ export function savedKeysScreen(keys, style = {}) {
317
321
  lines.push(` ${p.dim('None saved yet.')}`);
318
322
  for (const k of keys) {
319
323
  const credit = k.credit_balance_usd === undefined ? '' : usd(k.credit_balance_usd).padStart(10);
320
- lines.push(` ${k.active === false ? p.dim('off') : p.lime('●')} ${k.name.slice(0, 19).padEnd(20)}${p.dim(k.id.slice(0, 12).padEnd(14))}${credit}`);
324
+ const off = k.active === false;
325
+ lines.push(` ${off ? p.dim('○') : p.lime('●')} ${clip(k.name, 20).padEnd(21)}${p.dim(k.id.slice(0, 12).padEnd(14))}${credit}${off ? ` ${p.dim('off')}` : ''}`);
321
326
  }
322
327
  lines.push(...next(p, 'Save another:', 'phantom keys save <name>'));
323
328
  return lines.join('\n');
@@ -12,7 +12,7 @@ export function pickIo(input) {
12
12
  if (!(io.keys ? io.isTTY : canPick(env, io.isTTY)))
13
13
  return null;
14
14
  // A command started from the menu goes back to it on Esc; one typed on its own quits.
15
- return { write: io.stderr, env, keys: io.keys, esc: io.fromMenu ? 'back' : 'quit', columns: process.stderr.columns };
15
+ return { write: io.stderr, env, keys: io.keys, esc: io.fromMenu ? 'back' : 'cancel', columns: process.stderr.columns };
16
16
  }
17
17
  /**
18
18
  * Whether a command should draw its screen rather than print JSON: with
@@ -96,6 +96,8 @@ function block(write) {
96
96
  }
97
97
  /** The question line: a lime ? and the question in bold, then the answer once there is one. */
98
98
  const asked = (p, question, answer = '') => ` ${p.lime('?')} ${p.bold(question)}${answer ? ` ${p.cyan(answer)}` : ''}`;
99
+ /** The answer a question shows after Esc: back, quit, or cancelled. */
100
+ const escAnswer = (esc) => (esc === 'quit' ? 'quit' : esc === 'cancel' ? 'cancelled' : 'back');
99
101
  /** The hint under a picker: which keys do what. */
100
102
  const keysHint = (p, words) => ` ${p.dim(words)}`;
101
103
  /**
@@ -129,7 +131,7 @@ export async function choose(io, question, choices, start = 0) {
129
131
  return choices[at].value;
130
132
  }
131
133
  else if (key === 'escape' || key === 'q' || key === 'end') {
132
- draw([asked(p, question, io.esc === 'quit' ? 'quit' : 'back')]);
134
+ draw([asked(p, question, escAnswer(io.esc))]);
133
135
  return null;
134
136
  }
135
137
  else if (key === 'ctrl-c') {
@@ -190,7 +192,7 @@ export async function checklist(io, question, items) {
190
192
  }
191
193
  }
192
194
  else if (key === 'escape' || key === 'q' || key === 'end') {
193
- draw([asked(p, question, io.esc === 'quit' ? 'quit' : 'back')]);
195
+ draw([asked(p, question, escAnswer(io.esc))]);
194
196
  return null;
195
197
  }
196
198
  else if (key === 'ctrl-c') {
@@ -270,7 +272,7 @@ export async function askText(io, question, opts = {}) {
270
272
  problem = why;
271
273
  }
272
274
  else if (key === 'escape' || key === 'end') {
273
- draw([asked(p, question, io.esc === 'quit' ? 'quit' : 'back')]);
275
+ draw([asked(p, question, escAnswer(io.esc))]);
274
276
  return null;
275
277
  }
276
278
  else if (key === 'ctrl-c') {
@@ -1,2 +1,2 @@
1
1
  /** This release of phantom. `npm version` keeps it equal to the version in package.json. */
2
- export const VERSION = '1.2.4';
2
+ export const VERSION = '1.2.6';
@@ -6,6 +6,13 @@ import { die } from "../core/errors.mjs";
6
6
  import { resolveApiKey } from "../keys/saved.mjs";
7
7
  import { SETUP_MODEL, agentBaseUrl, readText, configHome, onPath, phantomCommand, offResult, unreadable, SETUP_MARK, SETUP_CONTEXT_WINDOW, } from "./shared.mjs";
8
8
  import { jsonObject, jsonEdit, tomlSet, tomlRemoveOurs, yamlSetTop, yamlAddModel, yamlRemoveOurs } from "./editors.mjs";
9
+ /** The most output a model call may ask for, for agents that need a number. */
10
+ const SETUP_OUTPUT_TOKENS = 32_000;
11
+ /** A JSON edit's result, or '' when only `$schema` (or nothing) is left, so `off` deletes a file phantom made. */
12
+ function unlessEmpty(file, after) {
13
+ const left = jsonObject(file, after);
14
+ return Object.keys(left).every((k) => k === '$schema') ? '' : after;
15
+ }
9
16
  // ── Manual Provider Helper ───────────────────────────────────────────────────
10
17
  /** An agent whose model settings live on its own settings screen: setup prints the steps. */
11
18
  export function manualProvider(where, steps, note) {
@@ -88,7 +95,8 @@ export function claudePlan(env, opts) {
88
95
  ANTHROPIC_API_KEY: '',
89
96
  ENABLE_TOOL_SEARCH: 'true',
90
97
  CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: '1',
91
- ...(opts.model ? { ANTHROPIC_MODEL: opts.model } : {}),
98
+ // phantom-1 unless a model is named, as Codex gets: it runs free until there is credit.
99
+ ANTHROPIC_MODEL: opts.model ?? 'claude-code/phantom-1',
92
100
  };
93
101
  const changes = [[['apiKeyHelper'], `${phantomCommand(opts.onPath ?? onPath)} key show`], ...Object.entries(set).map(([k, v]) => [['env', k], v])];
94
102
  // A token outranks apiKeyHelper, and would send a non-Phantom key.
@@ -311,6 +319,312 @@ export function clineProvider(ctx, off) {
311
319
  },
312
320
  };
313
321
  }
322
+ // ── Gemini CLI ───────────────────────────────────────────────────────────────
323
+ /** Gemini CLI's folder: `.gemini` in `$GEMINI_CLI_HOME`, else in the home folder. */
324
+ export function geminiDir(env, home) {
325
+ return path.join(env.GEMINI_CLI_HOME?.trim() ? env.GEMINI_CLI_HOME : home, '.gemini');
326
+ }
327
+ /**
328
+ * Gemini CLI speaks Google's API, which Phantom AI serves at `/gemini-cli`.
329
+ * The shell block sets GOOGLE_GEMINI_BASE_URL to it and GEMINI_API_KEY to
330
+ * the Phantom key; `settings.json` in Gemini CLI's folder picks API key
331
+ * sign-in (`security.auth.selectedType`) and the model (`model.name`).
332
+ * `--provider off` takes out a phantom model; the sign-in choice stays, and
333
+ * `phantom setup --restore` puts the file back as it was.
334
+ */
335
+ export function geminiProvider(ctx, off) {
336
+ const file = path.join(geminiDir(ctx.env, ctx.home), 'settings.json');
337
+ const text = readText(file);
338
+ const settings = jsonObject(file, text);
339
+ if (off) {
340
+ const ours = typeof settings.model?.name === 'string' && settings.model.name.startsWith('phantom-');
341
+ const alone = Object.keys(settings.model ?? {}).length === 1;
342
+ return offResult('Gemini CLI', file, text, ours ? jsonEdit(file, text, [[alone ? ['model'] : ['model', 'name'], undefined]]) : text);
343
+ }
344
+ const model = ctx.model ?? SETUP_MODEL;
345
+ const changes = [
346
+ [['security', 'auth', 'selectedType'], 'gemini-api-key'],
347
+ [['model', 'name'], model],
348
+ ];
349
+ return {
350
+ edits: [{ file, after: jsonEdit(file, text, changes) }],
351
+ result: {
352
+ settings: file,
353
+ on: true,
354
+ base_url: agentBaseUrl(ctx.env, 'gemini-cli'),
355
+ model,
356
+ warnings: ['Gemini CLI reads its key from the shell; open a new terminal before running it. Other programs that read GEMINI_API_KEY get the Phantom key too'],
357
+ },
358
+ };
359
+ }
360
+ // ── Crush ────────────────────────────────────────────────────────────────────
361
+ /** Crush's config folder: `$CRUSH_GLOBAL_CONFIG`, else `crush` in the config folder. */
362
+ export function crushDir(env, home) {
363
+ return env.CRUSH_GLOBAL_CONFIG?.trim() ? env.CRUSH_GLOBAL_CONFIG : path.join(configHome(env, home), 'crush');
364
+ }
365
+ /**
366
+ * Crush: `crush.json` in Crush's config folder gets a `phantom` provider at
367
+ * `/coding-agent/v1` whose key is `$PHANTOM_API_KEY`, which Crush expands
368
+ * when it loads, and phantom-1 as both its large and small model. Crush 0.99
369
+ * reads this file beside a `crushrc`.
370
+ */
371
+ export function crushProvider(ctx, off) {
372
+ const file = path.join(crushDir(ctx.env, ctx.home), 'crush.json');
373
+ const text = readText(file);
374
+ const config = jsonObject(file, text);
375
+ const sizes = ['large', 'small'];
376
+ if (off) {
377
+ if (!config.providers?.phantom)
378
+ return offResult('Crush', file, text, text);
379
+ const changes = [Object.keys(config.providers).length === 1 ? [['providers'], undefined] : [['providers', 'phantom'], undefined]];
380
+ const mine = Object.keys(config.models ?? {}).filter((k) => sizes.includes(k) && config.models?.[k]?.provider === 'phantom');
381
+ if (mine.length && mine.length === Object.keys(config.models ?? {}).length)
382
+ changes.push([['models'], undefined]);
383
+ else
384
+ for (const k of mine)
385
+ changes.push([['models', k], undefined]);
386
+ return offResult('Crush', file, text, unlessEmpty(file, jsonEdit(file, text, changes)));
387
+ }
388
+ const base = agentBaseUrl(ctx.env, 'coding-agent');
389
+ const model = ctx.model ?? SETUP_MODEL;
390
+ const changes = [
391
+ ...(!text?.trim() ? [[['$schema'], 'https://charm.land/crush.json']] : []),
392
+ [
393
+ ['providers', 'phantom'],
394
+ {
395
+ name: 'Phantom AI',
396
+ type: 'openai-compat',
397
+ base_url: base,
398
+ api_key: '$PHANTOM_API_KEY',
399
+ models: [{ id: model, name: model, context_window: SETUP_CONTEXT_WINDOW, default_max_tokens: SETUP_OUTPUT_TOKENS }],
400
+ },
401
+ ],
402
+ ...sizes.map((size) => [['models', size], { provider: 'phantom', model }]),
403
+ ];
404
+ return { edits: [{ file, after: jsonEdit(file, text, changes) }], result: { settings: file, on: true, base_url: base, model, warnings: [] } };
405
+ }
406
+ // ── Qwen Code ────────────────────────────────────────────────────────────────
407
+ /** Qwen Code's folder: `$QWEN_HOME`, else `~/.qwen`. */
408
+ export function qwenDir(env, home) {
409
+ return env.QWEN_HOME?.trim() ? env.QWEN_HOME : path.join(home, '.qwen');
410
+ }
411
+ /**
412
+ * Qwen Code: `settings.json` in its folder gets a model under
413
+ * `modelProviders.openai` at `/coding-agent/v1` that reads its key from
414
+ * PHANTOM_API_KEY (`envKey`), is picked as the model (`model.name`), and the
415
+ * OpenAI-compatible sign-in (`security.auth.selectedType`), so Qwen Code
416
+ * starts without asking. Its other models stay.
417
+ */
418
+ export function qwenProvider(ctx, off) {
419
+ const file = path.join(qwenDir(ctx.env, ctx.home), 'settings.json');
420
+ const text = readText(file);
421
+ const config = jsonObject(file, text);
422
+ const list = config.modelProviders?.openai ?? [];
423
+ if (!Array.isArray(list))
424
+ unreadable(file, 'has modelProviders.openai as something other than a list');
425
+ const base = agentBaseUrl(ctx.env, 'coding-agent');
426
+ const ours = (m) => m?.baseUrl === base;
427
+ const others = list.filter((m) => !ours(m));
428
+ const mineIds = list.filter(ours).map((m) => m.id);
429
+ if (off) {
430
+ if (others.length === list.length)
431
+ return offResult('Qwen Code', file, text, text);
432
+ const changes = [];
433
+ if (others.length)
434
+ changes.push([['modelProviders', 'openai'], others]);
435
+ else
436
+ changes.push(Object.keys(config.modelProviders ?? {}).length === 1 ? [['modelProviders'], undefined] : [['modelProviders', 'openai'], undefined]);
437
+ if (mineIds.includes(config.model?.name))
438
+ changes.push(Object.keys(config.model ?? {}).length === 1 ? [['model'], undefined] : [['model', 'name'], undefined]);
439
+ return offResult('Qwen Code', file, text, unlessEmpty(file, jsonEdit(file, text, changes)));
440
+ }
441
+ const model = ctx.model ?? SETUP_MODEL;
442
+ const entry = { id: model, name: `Phantom AI ${model}`, baseUrl: base, envKey: 'PHANTOM_API_KEY', generationConfig: { contextWindowSize: SETUP_CONTEXT_WINDOW } };
443
+ const changes = [
444
+ [['modelProviders', 'openai'], [entry, ...others]],
445
+ [['security', 'auth', 'selectedType'], 'openai'],
446
+ [['model', 'name'], model],
447
+ ];
448
+ return { edits: [{ file, after: jsonEdit(file, text, changes) }], result: { settings: file, on: true, base_url: base, model, warnings: [] } };
449
+ }
450
+ // ── Kilo Code ────────────────────────────────────────────────────────────────
451
+ /** Kilo Code's config file: `kilo.jsonc` (or `kilo.json`) in `$XDG_CONFIG_HOME/kilo`, else `~/.config/kilo`. */
452
+ export function kiloFile(env, home) {
453
+ const dir = path.join(configHome(env, home), 'kilo');
454
+ return existsSync(path.join(dir, 'kilo.jsonc')) ? path.join(dir, 'kilo.jsonc') : path.join(dir, 'kilo.json');
455
+ }
456
+ /**
457
+ * Kilo Code's CLI, a fork of opencode: its global config gets the same
458
+ * `phantom` provider opencode does, with the key as `{env:PHANTOM_API_KEY}`,
459
+ * and `phantom/<model>` as the model. Kilo resolves `{env:...}` only in the
460
+ * global file, which is the one written here.
461
+ */
462
+ export function kiloProvider(ctx, off) {
463
+ const file = kiloFile(ctx.env, ctx.home);
464
+ const text = readText(file);
465
+ const config = jsonObject(file, text);
466
+ if (off) {
467
+ if (!config.provider?.phantom)
468
+ return offResult('Kilo Code', file, text, text);
469
+ const changes = [Object.keys(config.provider).length === 1 ? [['provider'], undefined] : [['provider', 'phantom'], undefined]];
470
+ if (typeof config.model === 'string' && config.model.startsWith('phantom/'))
471
+ changes.push([['model'], undefined]);
472
+ return offResult('Kilo Code', file, text, unlessEmpty(file, jsonEdit(file, text, changes)));
473
+ }
474
+ const base = agentBaseUrl(ctx.env, 'coding-agent');
475
+ const model = ctx.model ?? SETUP_MODEL;
476
+ const changes = [
477
+ ...(!text?.trim() ? [[['$schema'], 'https://app.kilo.ai/config.json']] : []),
478
+ [
479
+ ['provider', 'phantom'],
480
+ {
481
+ npm: '@ai-sdk/openai-compatible',
482
+ name: 'Phantom AI',
483
+ options: { baseURL: base, apiKey: '{env:PHANTOM_API_KEY}' },
484
+ // Kilo turns off compaction for a model without limits.
485
+ models: { [model]: { name: model, tool_call: true, reasoning: true, limit: { context: SETUP_CONTEXT_WINDOW, output: SETUP_OUTPUT_TOKENS } } },
486
+ },
487
+ ],
488
+ [['model'], `phantom/${model}`],
489
+ ];
490
+ return { edits: [{ file, after: jsonEdit(file, text, changes) }], result: { settings: file, on: true, base_url: base, model: `phantom/${model}`, warnings: [] } };
491
+ }
492
+ // ── Kimi Code ────────────────────────────────────────────────────────────────
493
+ /** Kimi Code's folder: `$KIMI_CODE_HOME`, else `~/.kimi-code`. */
494
+ export function kimiDir(env, home) {
495
+ return env.KIMI_CODE_HOME?.trim() ? env.KIMI_CODE_HOME : path.join(home, '.kimi-code');
496
+ }
497
+ /**
498
+ * Kimi Code CLI: `config.toml` in its folder gets a `phantom` provider at
499
+ * `/coding-agent/v1` that reads its key from PHANTOM_API_KEY
500
+ * (`api_key_env`), a `phantom/<model>` model, and that model as
501
+ * `default_model`. Each run starts from the file without phantom's lines, so
502
+ * a new `--model` replaces the old one.
503
+ */
504
+ export function kimiProvider(ctx, off) {
505
+ const file = path.join(kimiDir(ctx.env, ctx.home), 'config.toml');
506
+ const text = readText(file);
507
+ const before = text === null ? null : tomlRemoveOurs(file, text);
508
+ if (off)
509
+ return offResult('Kimi Code', file, text, before);
510
+ const base = agentBaseUrl(ctx.env, 'coding-agent');
511
+ const model = ctx.model ?? SETUP_MODEL;
512
+ const alias = `phantom/${model}`;
513
+ let t = tomlSet(file, before ?? '', [], 'default_model', JSON.stringify(alias));
514
+ for (const [k, v] of [['type', '"openai"'], ['base_url', JSON.stringify(base)], ['api_key_env', '"PHANTOM_API_KEY"']])
515
+ t = tomlSet(file, t, ['providers', 'phantom'], k, v);
516
+ for (const [k, v] of [['provider', '"phantom"'], ['model', JSON.stringify(model)], ['max_context_size', String(SETUP_CONTEXT_WINDOW)], ['capabilities', '["thinking", "tool_use"]']]) {
517
+ t = tomlSet(file, t, ['models', alias], k, v);
518
+ }
519
+ return { edits: [{ file, after: t }], result: { settings: file, on: true, base_url: base, model: alias, warnings: [] } };
520
+ }
521
+ // ── Droid ────────────────────────────────────────────────────────────────────
522
+ /** The name phantom gives its model in Droid. Droid calls it `custom:Phantom-AI-<n>`. */
523
+ const DROID_NAME = 'Phantom AI';
524
+ /**
525
+ * Factory's Droid: `~/.factory/settings.json` gets a custom model at
526
+ * `/coding-agent/v1` (chat completions) with the key as `${PHANTOM_API_KEY}`,
527
+ * which Droid expands, and that model as `model`. Droid names a custom
528
+ * model by its place in `customModels`, so phantom's goes last, or stays
529
+ * where it was, and the user's own keep their names.
530
+ */
531
+ export function droidProvider(ctx, off) {
532
+ const file = path.join(ctx.home, '.factory', 'settings.json');
533
+ const text = readText(file);
534
+ const config = jsonObject(file, text);
535
+ const list = config.customModels ?? [];
536
+ if (!Array.isArray(list))
537
+ unreadable(file, 'has customModels as something other than a list');
538
+ const base = agentBaseUrl(ctx.env, 'coding-agent');
539
+ const at = list.findIndex((m) => m?.baseUrl === base);
540
+ const mine = (m) => typeof m === 'string' && m.startsWith(`custom:${DROID_NAME.replace(/ /g, '-')}-`);
541
+ if (off) {
542
+ if (at === -1)
543
+ return offResult('Droid', file, text, text);
544
+ const rest = list.filter((_, i) => i !== at);
545
+ const changes = [[['customModels'], rest.length ? rest : undefined]];
546
+ if (mine(config.model))
547
+ changes.push([['model'], undefined]);
548
+ return offResult('Droid', file, text, unlessEmpty(file, jsonEdit(file, text, changes)));
549
+ }
550
+ const model = ctx.model ?? SETUP_MODEL;
551
+ const entry = { model, displayName: DROID_NAME, baseUrl: base, apiKey: '${PHANTOM_API_KEY}', provider: 'generic-chat-completion-api', maxOutputTokens: SETUP_OUTPUT_TOKENS };
552
+ const index = at === -1 ? list.length : at;
553
+ const next = [...list];
554
+ next[index] = entry;
555
+ const id = `custom:${DROID_NAME.replace(/ /g, '-')}-${index}`;
556
+ const changes = [
557
+ [['customModels'], next],
558
+ [['model'], id],
559
+ ];
560
+ return { edits: [{ file, after: jsonEdit(file, text, changes) }], result: { settings: file, on: true, base_url: base, model: id, warnings: [] } };
561
+ }
562
+ // ── Mistral Vibe ─────────────────────────────────────────────────────────────
563
+ /**
564
+ * Mistral Vibe: `~/.vibe/config.toml` gets a `phantom` provider at
565
+ * `/coding-agent/v1` that reads its key from PHANTOM_API_KEY
566
+ * (`api_key_env_var`), a model on it, and that model as `active_model`.
567
+ * Providers and models are lists of tables (`[[providers]]`), so phantom's
568
+ * go at the end as marked lines, which `--provider off` takes out again.
569
+ */
570
+ export function vibeProvider(ctx, off) {
571
+ const file = path.join(ctx.home, '.vibe', 'config.toml');
572
+ const text = readText(file);
573
+ const before = text === null ? null : tomlRemoveOurs(file, text);
574
+ if (off)
575
+ return offResult('Mistral Vibe', file, text, before);
576
+ const base = agentBaseUrl(ctx.env, 'coding-agent');
577
+ const model = ctx.model ?? SETUP_MODEL;
578
+ const block = [
579
+ '[[providers]]',
580
+ 'name = "phantom"',
581
+ `api_base = ${JSON.stringify(base)}`,
582
+ 'api_key_env_var = "PHANTOM_API_KEY"',
583
+ 'backend = "generic"',
584
+ 'api_style = "openai"',
585
+ '',
586
+ '[[models]]',
587
+ `name = ${JSON.stringify(model)}`,
588
+ 'provider = "phantom"',
589
+ `alias = ${JSON.stringify(model)}`,
590
+ ].map((l) => (l ? `${l} ${SETUP_MARK}` : l));
591
+ const body = (before ?? '').replace(/\s*$/, '');
592
+ const t = tomlSet(file, `${body ? `${body}\n\n` : ''}${block.join('\n')}\n`, [], 'active_model', JSON.stringify(model));
593
+ return { edits: [{ file, after: t }], result: { settings: file, on: true, base_url: base, model, warnings: [] } };
594
+ }
595
+ // ── Zed ──────────────────────────────────────────────────────────────────────
596
+ /**
597
+ * Zed's agent panel: `settings.json` in Zed's config folder gets an OpenAI
598
+ * compatible provider named `phantom` at `/coding-agent/v1`, and its model
599
+ * as the agent's default. Zed reads the key for a provider named `phantom`
600
+ * from PHANTOM_API_KEY by itself, so no key goes in the file.
601
+ */
602
+ export function zedProvider(ctx, off) {
603
+ const file = path.join(configHome(ctx.env, ctx.home), 'zed', 'settings.json');
604
+ const text = readText(file);
605
+ const config = jsonObject(file, text);
606
+ if (off) {
607
+ if (!config.language_models?.openai_compatible?.phantom)
608
+ return offResult('Zed', file, text, text);
609
+ const changes = [[['language_models', 'openai_compatible', 'phantom'], undefined]];
610
+ if (config.agent?.default_model?.provider === 'phantom')
611
+ changes.push([['agent', 'default_model'], undefined]);
612
+ return offResult('Zed', file, text, jsonEdit(file, text, changes));
613
+ }
614
+ const base = agentBaseUrl(ctx.env, 'coding-agent');
615
+ const model = ctx.model ?? SETUP_MODEL;
616
+ const changes = [
617
+ [
618
+ ['language_models', 'openai_compatible', 'phantom'],
619
+ { api_url: base, available_models: [{ name: model, display_name: `Phantom AI ${model}`, max_tokens: SETUP_CONTEXT_WINDOW, capabilities: { tools: true } }] },
620
+ ],
621
+ [['agent', 'default_model'], { provider: 'phantom', model }],
622
+ ];
623
+ return {
624
+ edits: [{ file, after: jsonEdit(file, text, changes) }],
625
+ result: { settings: file, on: true, base_url: base, model, warnings: ['Zed reads PHANTOM_API_KEY from your login shell; quit and reopen Zed'] },
626
+ };
627
+ }
314
628
  // ── Registry ─────────────────────────────────────────────────────────────────
315
629
  /**
316
630
  * The agents `phantom setup` knows, in the order setup runs them. Adding an agent
@@ -418,6 +732,136 @@ export const SETUP_AGENTS = {
418
732
  home: '.cline',
419
733
  provider: clineProvider,
420
734
  },
735
+ /** Gemini CLI. Its address and key come from the shell block; settings.json picks the sign-in and model. */
736
+ gemini: {
737
+ label: 'Gemini CLI',
738
+ /** ~/.gemini, or under $GEMINI_CLI_HOME */
739
+ home: '.gemini',
740
+ detect: (env, home) => existsSync(geminiDir(env, home)),
741
+ /** ~/.agents/skills, which Gemini CLI reads as well as ~/.gemini/skills */
742
+ skills: '.agents/skills',
743
+ provider: geminiProvider,
744
+ shellEnv: (ctx) => ({
745
+ GOOGLE_GEMINI_BASE_URL: agentBaseUrl(ctx.env, 'gemini-cli'),
746
+ GEMINI_API_KEY: '$PHANTOM_API_KEY',
747
+ }),
748
+ },
749
+ /** Crush. */
750
+ crush: {
751
+ label: 'Crush',
752
+ /** ~/.config/crush, or $CRUSH_GLOBAL_CONFIG */
753
+ home: '.config/crush',
754
+ detect: (env, home) => existsSync(crushDir(env, home)),
755
+ /** ~/.agents/skills */
756
+ skills: '.agents/skills',
757
+ provider: crushProvider,
758
+ shellEnv: () => ({}),
759
+ },
760
+ /** Qwen Code. */
761
+ qwen: {
762
+ label: 'Qwen Code',
763
+ /** ~/.qwen, or $QWEN_HOME */
764
+ home: '.qwen',
765
+ detect: (env, home) => existsSync(qwenDir(env, home)),
766
+ /** ~/.agents/skills */
767
+ skills: '.agents/skills',
768
+ provider: qwenProvider,
769
+ shellEnv: () => ({}),
770
+ },
771
+ /** Kilo Code's CLI. */
772
+ kilo: {
773
+ label: 'Kilo Code',
774
+ /** ~/.config/kilo, or under $XDG_CONFIG_HOME */
775
+ home: '.config/kilo',
776
+ detect: (env, home) => existsSync(path.join(configHome(env, home), 'kilo')),
777
+ /** ~/.agents/skills */
778
+ skills: '.agents/skills',
779
+ provider: kiloProvider,
780
+ shellEnv: () => ({}),
781
+ },
782
+ /** Kimi Code CLI. */
783
+ kimi: {
784
+ label: 'Kimi Code',
785
+ /** ~/.kimi-code, or $KIMI_CODE_HOME */
786
+ home: '.kimi-code',
787
+ detect: (env, home) => existsSync(kimiDir(env, home)),
788
+ /** ~/.agents/skills */
789
+ skills: '.agents/skills',
790
+ provider: kimiProvider,
791
+ shellEnv: () => ({}),
792
+ },
793
+ /** Factory's Droid. */
794
+ droid: {
795
+ label: 'Droid',
796
+ /** ~/.factory */
797
+ home: '.factory',
798
+ /** ~/.factory/skills */
799
+ skills: '.factory/skills',
800
+ provider: droidProvider,
801
+ shellEnv: () => ({}),
802
+ },
803
+ /** Mistral Vibe. */
804
+ vibe: {
805
+ label: 'Mistral Vibe',
806
+ /** ~/.vibe */
807
+ home: '.vibe',
808
+ provider: vibeProvider,
809
+ shellEnv: () => ({}),
810
+ },
811
+ /** Zed's agent panel. It reads PHANTOM_API_KEY from the login shell. */
812
+ zed: {
813
+ label: 'Zed',
814
+ /** ~/.config/zed, or under $XDG_CONFIG_HOME */
815
+ home: '.config/zed',
816
+ detect: (env, home) => existsSync(path.join(configHome(env, home), 'zed')),
817
+ /** ~/.agents/skills */
818
+ skills: '.agents/skills',
819
+ provider: zedProvider,
820
+ shellEnv: () => ({}),
821
+ },
822
+ /** VS Code's chat. Its Custom Endpoint models are added on its Language Models screen. */
823
+ vscode: {
824
+ label: 'VS Code',
825
+ /** ~/.vscode */
826
+ home: '.vscode',
827
+ provider: manualProvider('VS Code, Chat: Manage Language Models', (ctx) => [
828
+ 'In VS Code, run Chat: Manage Language Models from the Command Palette, then Add Models, Custom Endpoint.',
829
+ 'Name the group Phantom AI, paste your Phantom AI key (copy it with "Copy my key" in phantom\'s menu, or phantom keys show --copy), and pick Chat Completions.',
830
+ `In the chatLanguageModels.json file VS Code opens, set the model's id to ${ctx.model ?? SETUP_MODEL}, its url to ${agentBaseUrl(ctx.env, 'coding-agent')}/chat/completions, and toolCalling to true, then save.`,
831
+ ]),
832
+ },
833
+ /** JetBrains AI Assistant, in IntelliJ IDEA, PyCharm and the other JetBrains editors. */
834
+ jetbrains: {
835
+ label: 'JetBrains AI Assistant',
836
+ /** ~/Library/Application Support/JetBrains on macOS, ~/.config/JetBrains elsewhere */
837
+ home: 'Library/Application Support/JetBrains',
838
+ detect: (env, home) => existsSync(path.join(home, 'Library', 'Application Support', 'JetBrains')) || existsSync(path.join(configHome(env, home), 'JetBrains')),
839
+ provider: manualProvider('Settings, Tools, AI Assistant, Providers & API keys', (ctx) => [
840
+ 'In your JetBrains editor, open Settings, then Tools, AI Assistant, Providers & API keys.',
841
+ `Under OpenAI-compatible, set the URL to ${agentBaseUrl(ctx.env, 'coding-agent')}, paste your Phantom AI key (copy it with "Copy my key" in phantom's menu, or phantom keys show --copy), and turn on Tool calling.`,
842
+ `Pick ${ctx.model ?? SETUP_MODEL} in the AI Assistant chat.`,
843
+ ]),
844
+ },
845
+ /** Warp. Its custom inference endpoint is set on its settings screen. */
846
+ warp: {
847
+ label: 'Warp',
848
+ /** ~/.warp */
849
+ home: '.warp',
850
+ provider: manualProvider('Warp Settings', (ctx) => [
851
+ 'In Warp, open Settings and search for "inference endpoint".',
852
+ `Set the endpoint to ${agentBaseUrl(ctx.env, 'coding-agent')}, paste your Phantom AI key (copy it with "Copy my key" in phantom's menu, or phantom keys show --copy), and add the model ${ctx.model ?? SETUP_MODEL}.`,
853
+ ], 'Warp uses a custom endpoint for agents that run on this computer only.'),
854
+ },
855
+ /** Trae. Its custom models are added on its settings screen. */
856
+ trae: {
857
+ label: 'Trae',
858
+ /** ~/.trae */
859
+ home: '.trae',
860
+ provider: manualProvider('Trae Settings, Models', (ctx) => [
861
+ 'In Trae, open Settings, then Models, Add Model, Custom Model.',
862
+ `Pick the OpenAI API format, set the request URL to ${agentBaseUrl(ctx.env, 'coding-agent')}, the model id to ${ctx.model ?? SETUP_MODEL}, and paste your Phantom AI key (copy it with "Copy my key" in phantom's menu, or phantom keys show --copy).`,
863
+ ]),
864
+ },
421
865
  };
422
866
  export const SETUP_NAMES = Object.keys(SETUP_AGENTS);
423
867
  /** `a, b or c`. */
@@ -168,7 +168,7 @@ export async function setupCommand(input) {
168
168
  // At a terminal: a choice instead of typing y or n, with a look at every line first if wanted.
169
169
  if (pick && !io.ask) {
170
170
  for (;;) {
171
- const answer = await choose({ ...pick, esc: ticks || io.fromMenu ? 'back' : 'quit' }, 'Make these changes?', [
171
+ const answer = await choose({ ...pick, esc: ticks || io.fromMenu ? 'back' : 'cancel' }, 'Make these changes?', [
172
172
  { label: 'Make these changes', value: 'yes' },
173
173
  { label: 'Show every line first', value: 'diff' },
174
174
  { label: 'Cancel', hint: 'change nothing', value: 'no' },
@@ -208,7 +208,7 @@ function doneSummary(agent, a, files, skipped, p) {
208
208
  return files ? 'skill installed.' : p.dim('already set up.');
209
209
  }
210
210
  /** The command that starts each agent that runs in a terminal. Editors (Cursor, Continue, Cline) are opened, not run. */
211
- const START = { claude: 'claude', codex: 'codex', pi: 'pi', opencode: 'opencode', goose: 'goose', aider: 'aider', copilot: 'copilot' };
211
+ const START = { claude: 'claude', codex: 'codex', pi: 'pi', opencode: 'opencode', goose: 'goose', aider: 'aider', copilot: 'copilot', gemini: 'gemini', crush: 'crush', qwen: 'qwen', kilo: 'kilo', kimi: 'kimi', droid: 'droid', vibe: 'vibe' };
212
212
  /**
213
213
  * The one thing to do after setup, in corner brackets: run the agent. When an
214
214
  * agent reads its key from the shell (Codex, Goose and others), the step says
@@ -11,13 +11,14 @@ export function onPath(bin) {
11
11
  export const SETUP_MODEL = 'phantom-1';
12
12
  /**
13
13
  * The address Phantom AI serves a coding agent at, from PHANTOM_BASE_URL
14
- * without its `/v1`: `/claude-code` (Claude Code adds `/v1` itself), or
15
- * `/codex/v1`, `/cursor/v1` and `/coding-agent/v1` for the others.
14
+ * without its `/v1`: `/claude-code` (Claude Code adds `/v1` itself),
15
+ * `/gemini-cli` (Gemini CLI adds Google's `/v1beta`), or `/codex/v1`,
16
+ * `/cursor/v1` and `/coding-agent/v1` for the others.
16
17
  * @throws {@link CliError} when PHANTOM_BASE_URL is not https ({@link httpsOnly}).
17
18
  */
18
19
  export function agentBaseUrl(env, agent) {
19
20
  const origin = httpsOnly(env.PHANTOM_BASE_URL || DEFAULT_BASE_URL).replace(/\/+$/, '').replace(/\/v1$/, '');
20
- return agent === 'claude-code' ? `${origin}/claude-code` : `${origin}/${agent}/v1`;
21
+ return agent === 'claude-code' || agent === 'gemini-cli' ? `${origin}/${agent}` : `${origin}/${agent}/v1`;
21
22
  }
22
23
  /** The end of every line phantom adds to a TOML, YAML or shell file. */
23
24
  export const SETUP_MARK = '# phantom setup';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phantomcodes/cli",
3
- "version": "1.2.4",
3
+ "version": "1.2.6",
4
4
  "description": "AI inference for your coding agents. Route models, control spending, and fund your workspace.",
5
5
  "keywords": [
6
6
  "phantom",
@@ -66,10 +66,10 @@
66
66
  "license": "SEE LICENSE IN LICENSE",
67
67
  "author": "Harbor Financial LLC",
68
68
  "devDependencies": {
69
- "@types/node": "^24.19.1",
69
+ "@types/node": "^24.19.2",
70
70
  "@vitest/coverage-v8": "^5.0.3",
71
71
  "typescript": "^6.0.3",
72
- "vitest": "^5.0.2"
72
+ "vitest": "^5.0.5"
73
73
  },
74
74
  "publishConfig": {
75
75
  "access": "public"