@phantomcodes/cli 1.2.4 → 1.2.5

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,8 +62,8 @@ 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
65
+ Codex, Claude Code, opencode, Continue, Goose, Aider, GitHub Copilot CLI, Cline
66
+ and Gemini CLI support saved setup. Cursor gets instructions for its settings screen; pi gets
67
67
  the agent skill.
68
68
 
69
69
  Preview without changing files with `phantom setup --dry-run`. The Phantom CLI keeps backups;
@@ -79,9 +79,11 @@ phantom workspace status
79
79
  phantom codex
80
80
  ```
81
81
 
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**.
82
+ Both setup options require **Node 24+** and your coding agent installed. Connect, then
83
+ launch **Codex** (`phantom codex`), **Claude Code** (`phantom claude`), **pi**
84
+ (`phantom pi`), or **any other compatible agent**. Each runs phantom-1 unless you
85
+ pick a model (`--model` after `--`), and phantom-1 runs on free models until you add
86
+ credit. Free calls leave out web search, which costs money on any model.
85
87
 
86
88
  Login opens a device approval page and saves a key privately. Funding opens
87
89
  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.5';
@@ -88,7 +88,8 @@ export function claudePlan(env, opts) {
88
88
  ANTHROPIC_API_KEY: '',
89
89
  ENABLE_TOOL_SEARCH: 'true',
90
90
  CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: '1',
91
- ...(opts.model ? { ANTHROPIC_MODEL: opts.model } : {}),
91
+ // phantom-1 unless a model is named, as Codex gets: it runs free until there is credit.
92
+ ANTHROPIC_MODEL: opts.model ?? 'claude-code/phantom-1',
92
93
  };
93
94
  const changes = [[['apiKeyHelper'], `${phantomCommand(opts.onPath ?? onPath)} key show`], ...Object.entries(set).map(([k, v]) => [['env', k], v])];
94
95
  // A token outranks apiKeyHelper, and would send a non-Phantom key.
@@ -311,6 +312,44 @@ export function clineProvider(ctx, off) {
311
312
  },
312
313
  };
313
314
  }
315
+ // ── Gemini CLI ───────────────────────────────────────────────────────────────
316
+ /** Gemini CLI's folder: `.gemini` in `$GEMINI_CLI_HOME`, else in the home folder. */
317
+ export function geminiDir(env, home) {
318
+ return path.join(env.GEMINI_CLI_HOME?.trim() ? env.GEMINI_CLI_HOME : home, '.gemini');
319
+ }
320
+ /**
321
+ * Gemini CLI speaks Google's API, which Phantom AI serves at `/gemini-cli`.
322
+ * The shell block sets GOOGLE_GEMINI_BASE_URL to it and GEMINI_API_KEY to
323
+ * the Phantom key; `settings.json` in Gemini CLI's folder picks API key
324
+ * sign-in (`security.auth.selectedType`) and the model (`model.name`).
325
+ * `--provider off` takes out a phantom model; the sign-in choice stays, and
326
+ * `phantom setup --restore` puts the file back as it was.
327
+ */
328
+ export function geminiProvider(ctx, off) {
329
+ const file = path.join(geminiDir(ctx.env, ctx.home), 'settings.json');
330
+ const text = readText(file);
331
+ const settings = jsonObject(file, text);
332
+ if (off) {
333
+ const ours = typeof settings.model?.name === 'string' && settings.model.name.startsWith('phantom-');
334
+ const alone = Object.keys(settings.model ?? {}).length === 1;
335
+ return offResult('Gemini CLI', file, text, ours ? jsonEdit(file, text, [[alone ? ['model'] : ['model', 'name'], undefined]]) : text);
336
+ }
337
+ const model = ctx.model ?? SETUP_MODEL;
338
+ const changes = [
339
+ [['security', 'auth', 'selectedType'], 'gemini-api-key'],
340
+ [['model', 'name'], model],
341
+ ];
342
+ return {
343
+ edits: [{ file, after: jsonEdit(file, text, changes) }],
344
+ result: {
345
+ settings: file,
346
+ on: true,
347
+ base_url: agentBaseUrl(ctx.env, 'gemini-cli'),
348
+ model,
349
+ 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'],
350
+ },
351
+ };
352
+ }
314
353
  // ── Registry ─────────────────────────────────────────────────────────────────
315
354
  /**
316
355
  * The agents `phantom setup` knows, in the order setup runs them. Adding an agent
@@ -418,6 +457,20 @@ export const SETUP_AGENTS = {
418
457
  home: '.cline',
419
458
  provider: clineProvider,
420
459
  },
460
+ /** Gemini CLI. Its address and key come from the shell block; settings.json picks the sign-in and model. */
461
+ gemini: {
462
+ label: 'Gemini CLI',
463
+ /** ~/.gemini, or under $GEMINI_CLI_HOME */
464
+ home: '.gemini',
465
+ detect: (env, home) => existsSync(geminiDir(env, home)),
466
+ /** ~/.agents/skills, which Gemini CLI reads as well as ~/.gemini/skills */
467
+ skills: '.agents/skills',
468
+ provider: geminiProvider,
469
+ shellEnv: (ctx) => ({
470
+ GOOGLE_GEMINI_BASE_URL: agentBaseUrl(ctx.env, 'gemini-cli'),
471
+ GEMINI_API_KEY: '$PHANTOM_API_KEY',
472
+ }),
473
+ },
421
474
  };
422
475
  export const SETUP_NAMES = Object.keys(SETUP_AGENTS);
423
476
  /** `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' };
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.5",
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"