@phantomcodes/cli 1.2.5 → 1.2.7

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,11 +62,15 @@ 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, Cline
66
- and Gemini CLI support saved setup. Cursor gets instructions for its settings screen; pi gets
67
- the agent skill.
68
-
69
- Preview without changing files with `phantom setup --dry-run`. The Phantom CLI keeps backups;
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.
70
+
71
+ Preview without changing files with `phantom setup --dry-run`. To leave your
72
+ shell startup file alone, untick "Your shell" in the checklist or pass
73
+ `--no-shell`; setup then prints the lines to add yourself. The Phantom CLI keeps backups;
70
74
  undo the changes with `phantom setup --restore`.
71
75
 
72
76
  ## Connect for one run
package/dist/cli/app.mjs CHANGED
@@ -6,6 +6,7 @@ import { resolveApiKey } from "../keys/saved.mjs";
6
6
  import { hideHints } from "./screens.mjs";
7
7
  import { startHeader } from "./start.mjs";
8
8
  import { COMMANDS } from "./commands.mjs";
9
+ import { FREE_LINE } from "../core/free.mjs";
9
10
  /** The agents the menu can start, and the program each needs on this computer. */
10
11
  const AGENTS = [
11
12
  { label: 'Start Claude Code', bin: 'claude', run: ['claude'] },
@@ -17,7 +18,7 @@ export function mainMenu(s) {
17
18
  const quit = { label: 'Quit', value: { run: [] } };
18
19
  if (!s.signedIn) {
19
20
  return [
20
- { label: 'Set up my coding agents', hint: 'sign in and connect them; phantom-1 is free to start', value: { run: ['setup'] } },
21
+ { label: 'Set up my coding agents', hint: 'sign in and connect them, free to start', value: { run: ['setup'] } },
21
22
  { label: 'Sign in only', hint: 'or save a Phantom key you already have', value: { run: ['login'] } },
22
23
  { label: 'Every command', hint: 'pick one to run', value: { open: 'commands' } },
23
24
  quit,
@@ -26,7 +27,7 @@ export function mainMenu(s) {
26
27
  // Only agents that are installed here, so the menu never offers one that can't start.
27
28
  const agents = s.setUp ? AGENTS.filter((a) => s.installed(a.bin)).map((a) => ({ label: a.label, hint: 'on Phantom AI', value: { run: a.run } })) : [];
28
29
  return [
29
- ...(s.setUp ? [] : [{ label: 'Finish setting up my agents', hint: "you're signed in, but no agent uses Phantom yet", value: { run: ['setup'] } }]),
30
+ ...(s.setUp ? [] : [{ label: 'Finish setting up my agents', hint: 'no agent uses Phantom yet', value: { run: ['setup'] } }]),
30
31
  ...agents,
31
32
  { label: 'Balance', hint: 'what this key can spend', value: { run: ['balance'] } },
32
33
  { label: 'Spend', hint: 'what you spent, and what phantom-1 saved', value: { run: ['spend'] } },
@@ -105,7 +106,9 @@ export async function runApp(env, pick, write, runCommand, installed = onPath) {
105
106
  // A clean screen for each visit to the menu: a title, then where you are.
106
107
  write('\x1b[2J\x1b[H' + startHeader(env, color) + '\n');
107
108
  const s = state();
108
- write(` ${p.dim(s.signedIn ? (s.setUp ? 'Signed in · your agents use Phantom AI' : 'Signed in · no agent set up yet') : 'Not signed in yet')}\n\n`);
109
+ write(` ${p.dim(s.signedIn ? (s.setUp ? 'Signed in · your agents use Phantom AI' : 'Signed in · no agent set up yet') : 'Not signed in yet')}\n`);
110
+ // Before signing in, the one line that says what free means, whole, since it is why most people came.
111
+ write(s.signedIn ? '\n' : ` ${p.lime('Free to start:')} ${FREE_LINE}.\n\n`);
109
112
  let action = await choose({ ...pick, esc: 'quit' }, 'What would you like to do?', mainMenu(s));
110
113
  if (action === null)
111
114
  return 0;
@@ -160,6 +160,8 @@ export const COMMANDS = [
160
160
  [--no-browser] print the sign-in link instead of opening it
161
161
  [--dry-run] preview the changes without making them
162
162
  [--diff] show every line each file gains or loses
163
+ [--no-shell] leave your shell startup file alone and list
164
+ the lines to add yourself
163
165
  [--json] print JSON in a terminal too (a pipe always gets JSON)
164
166
  [--model <id>] the model each agent starts on (default ${SETUP_MODEL})
165
167
  setup --agent <name> install the skill for one agent, without asking:
package/dist/cli/run.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { VERSION } from "../core/version.mjs";
2
2
  import { nodeTrustWarning } from "../core/node-trust.mjs";
3
3
  import { die, NO_KEY } from "../core/errors.mjs";
4
- import { colorOn } from "../core/style.mjs";
4
+ import { colorOn, fitText } from "../core/style.mjs";
5
5
  import { ALIASES, closestCommand, commandFromWords, startScreen } from "./start.mjs";
6
6
  import { BackToMenu } from "../core/pick.mjs";
7
7
  import { runApp } from "./app.mjs";
@@ -41,7 +41,17 @@ export async function run(argv = process.argv.slice(2), env = process.env, io =
41
41
  stderr: (s) => process.stderr.write(s),
42
42
  isTTY: Boolean(process.stderr.isTTY),
43
43
  stdoutIsTTY: Boolean(process.stdout.isTTY),
44
+ columns: process.stdout.columns || process.stderr.columns,
44
45
  }, node = process.versions.node) {
46
+ // A line wider than the terminal breaks at a word, under its own column, rather than wherever the edge falls.
47
+ if (io.columns) {
48
+ const { columns, stdout, stderr } = io;
49
+ io = {
50
+ ...io,
51
+ ...(io.stdoutIsTTY ? { stdout: (s) => stdout(fitText(s, columns)) } : {}),
52
+ ...(io.isTTY ? { stderr: (s) => stderr(fitText(s, columns)) } : {}),
53
+ };
54
+ }
45
55
  const unsafe = nodeTrustWarning(node);
46
56
  if (unsafe)
47
57
  io.stderr(unsafe);
@@ -100,8 +100,12 @@ export function spendScreen(s, style = {}) {
100
100
  if (s.keys.length > 1) {
101
101
  lines.push('', ...caption(p, w, 'BY KEY'));
102
102
  for (const k of s.keys) {
103
- const saved = k.saved_usd ? p.lime(` saved ${usd(k.saved_usd)}`) : '';
104
- lines.push(` ${k.name.slice(0, LABEL - 1).padEnd(LABEL + BAR + 2)}${usd(k.spent_usd).padStart(8)} ${p.dim(plural(k.calls, 'call'))}${saved}${k.active ? '' : p.dim(' revoked or rotated')}`);
103
+ const savedText = k.saved_usd ? ` saved ${usd(k.saved_usd)}` : '';
104
+ const offText = k.active ? '' : ' revoked or rotated';
105
+ // The dollars line up with the model rows above, unless that pushes the row past the window's edge.
106
+ const tail = 8 + 2 + plural(k.calls, 'call').length + savedText.length + offText.length;
107
+ const pad = Math.max(LABEL, Math.min(LABEL + BAR + 2, widthOf(style) - 2 - tail));
108
+ lines.push(` ${k.name.slice(0, LABEL - 1).padEnd(pad)}${usd(k.spent_usd).padStart(8)} ${p.dim(plural(k.calls, 'call'))}${savedText ? p.lime(savedText) : ''}${offText ? p.dim(offText) : ''}`);
105
109
  }
106
110
  }
107
111
  if (!s.total.calls)
@@ -116,12 +120,12 @@ function keyRows(p, keys) {
116
120
  if (!keys.length)
117
121
  return [` ${p.dim('No keys yet.')}`];
118
122
  return keys.map((k) => {
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';
123
+ const limit = k.limit_usd != null ? `limit ${usd(k.limit_usd)}` : k.budget_usd != null ? `${usd(k.budget_usd)} per ${plural(k.period_days ?? 30, 'day')}` : 'no limit';
120
124
  // A revoked or rotated key gets a hollow mark and the word off at the end, so the columns stay in line.
121
125
  const off = k.is_active === false;
122
126
  const mark = off ? p.dim('○') : p.lime('●');
123
127
  // The id's first 8 characters: enough for --id, which also takes the name.
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')}` : ''}`;
128
+ return ` ${mark} ${clip(k.name ?? 'Key', 20).padEnd(21)}${p.dim((k.id ?? '').slice(0, 8).padEnd(9))}${p.dim((k.prefix ?? '').slice(0, 18).padEnd(20))}${usd(k.spent_usd ?? 0).padStart(8)} ${p.dim(limit)}${off ? ` ${p.dim('off')}` : ''}`;
125
129
  });
126
130
  }
127
131
  /** `phantom workspace`: the shared balance, each key, and the last few charges. */
@@ -154,6 +158,16 @@ export function signedInScreen(saved, style = {}) {
154
158
  const where = saved.saved_as === 'default' ? 'Your key is' : `Key "${saved.saved_as}" is`;
155
159
  return ['', ` ${p.lime('✓')} ${p.bold('Signed in.')} ${p.dim(`${where} saved in your ${saved.storage}.`)}`, ...next(p, 'Connect your coding agents:', 'phantom setup')].join('\n');
156
160
  }
161
+ /** `phantom login` when this computer already has a key: say so, then how to switch or add one. */
162
+ export function alreadySignedInScreen(keyIdShown, style = {}) {
163
+ const p = paint(style.env ?? {}, Boolean(style.color));
164
+ return [
165
+ '',
166
+ ` ${p.lime('✓')} ${p.bold('You are already signed in on this computer.')}${keyIdShown ? ` ${p.dim(`key ${keyIdShown}`)}` : ''}`,
167
+ ...next(p, 'Use another workspace: run phantom logout, then', 'phantom login'),
168
+ ...next(p, 'Add a second key here:', 'phantom login --save <name>'),
169
+ ].join('\n');
170
+ }
157
171
  /** A ✓ line with an optional dim detail, for a change that worked. */
158
172
  function done(p, words, detail = '') {
159
173
  return ` ${p.lime('✓')} ${words}${detail ? ` ${p.dim(detail)}` : ''}`;
@@ -3,6 +3,7 @@
3
3
  // day to day. It reads no network, so it prints at once, even offline.
4
4
  import { brandHeader } from "../core/brand.mjs";
5
5
  import { paint } from "../core/style.mjs";
6
+ import { FREE_LINE } from "../core/free.mjs";
6
7
  /** One row: a command in cyan, padded, then what it does. */
7
8
  const row = (p, command, about) => ` ${p.cyan(command.padEnd(18))}${about}`;
8
9
  /** An uppercase caption over a thin rule, like the site's section labels. */
@@ -15,7 +16,7 @@ export function startScreen(env, color, signedIn) {
15
16
  const p = paint(env, color);
16
17
  const lines = ['', ...brandHeader(p, color, '')];
17
18
  if (!signedIn) {
18
- lines.push(...caption(p, 'START HERE'), row(p, 'phantom setup', 'signs you in and connects your coding agents'), ` ${' '.repeat(18)}${p.dim('phantom-1 is free to start: no credit needed')}`, '', ` Then use your agent as usual, or start it with ${p.cyan('phantom claude')}, ${p.cyan('phantom codex')} or ${p.cyan('phantom pi')}.`);
19
+ lines.push(...caption(p, 'START HERE'), row(p, 'phantom setup', 'signs you in and connects your coding agents'), ` ${' '.repeat(18)}${p.dim(FREE_LINE)}`, '', ` Then use your agent as usual, or start it with ${p.cyan('phantom claude')}, ${p.cyan('phantom codex')} or ${p.cyan('phantom pi')}.`);
19
20
  }
20
21
  else {
21
22
  lines.push(` ${p.lime('✓')} Signed in.`, '', ...caption(p, 'EVERYDAY'), row(p, 'phantom claude', 'run Claude Code on Phantom AI (also codex, pi)'), row(p, 'phantom balance', 'your credit'), row(p, 'phantom spend', 'what you spent, and what phantom-1 saved'), row(p, 'phantom setup', 'connect more agents, or change how they run'));
@@ -67,7 +68,7 @@ export function startChoices(signedIn, setUp = signedIn) {
67
68
  const quit = { label: 'Quit', value: [] };
68
69
  if (!signedIn) {
69
70
  return [
70
- { label: 'Set up my coding agents', hint: 'sign in and connect them (phantom-1 is free to start)', value: ['setup'] },
71
+ { label: 'Set up my coding agents', hint: 'sign in and connect them, free to start', value: ['setup'] },
71
72
  { label: 'Sign in only', hint: 'or save a Phantom key you already have', value: ['login'] },
72
73
  { label: 'See every command', value: ['--help'] },
73
74
  quit,
@@ -0,0 +1,9 @@
1
+ // What phantom-1 gives a key for free, said the same way on every screen that mentions it.
2
+ /**
3
+ * Free phantom-1 requests a workspace gets each UTC day, before it has had $10
4
+ * of credit. Phantom's own limit is `FREE_ROUTER_CAPS.perDay` in the Phantom
5
+ * repo's `lib/config.ts`; change both together.
6
+ */
7
+ export const FREE_REQUESTS_A_DAY = 50;
8
+ /** The free offer in one line short enough for an 80-column terminal. */
9
+ export const FREE_LINE = `phantom-1 runs free models, ${FREE_REQUESTS_A_DAY} requests a day, with no card`;
@@ -34,3 +34,65 @@ export function paint(env, on) {
34
34
  }
35
35
  /** `text` without color codes, to measure how wide it prints. */
36
36
  export const unstyled = (text) => text.replace(/\x1b\[[0-9;]*m/g, '');
37
+ /**
38
+ * One line of a screen, fitted to a terminal `columns` wide: a longer line
39
+ * breaks at spaces, and each piece after the first starts under the text it
40
+ * continues. That is the second column for a table row or a help entry
41
+ * (text after a run of two spaces), the words after a mark such as `✓ ` or
42
+ * `→ `, or else the line's own indent. A word longer than the room, such as
43
+ * a URL, stays whole. Color codes don't count toward the width.
44
+ */
45
+ export function fitLine(line, columns) {
46
+ const seen = unstyled(line);
47
+ if (seen.length <= columns)
48
+ return line;
49
+ const lead = seen.length - seen.trimStart().length;
50
+ const column = /^(\s*\S.*?\s{2,})\S/.exec(seen)?.[1].length;
51
+ const mark = /^\s*[✓✗→·!◆?❯] /.exec(seen)?.[0].length;
52
+ const hang = column !== undefined && column <= columns / 2 ? column : (mark ?? lead);
53
+ const rows = [];
54
+ let row = '';
55
+ let width = 0;
56
+ // A command stays on one line to copy: `phantom` keeps the word after it, and a flag the word before.
57
+ const words = [];
58
+ for (const word of line.split(' ')) {
59
+ const last = words.length ? unstyled(words[words.length - 1]) : '';
60
+ if (words.length && word && (/(^|[^\w-])phantom$/.test(last) || /^-{1,2}[a-z]/.test(unstyled(word))))
61
+ words[words.length - 1] += ' ' + word;
62
+ else
63
+ words.push(word);
64
+ }
65
+ words.forEach((word, i) => {
66
+ const w = unstyled(word).length;
67
+ const blank = unstyled(row).trim() === '';
68
+ if (i > 0 && !blank && width + 1 + w > columns) {
69
+ rows.push(row.trimEnd());
70
+ row = ' '.repeat(hang) + word;
71
+ width = hang + w;
72
+ }
73
+ else if (i > 0 && blank && rows.length) {
74
+ row += word;
75
+ width += w;
76
+ }
77
+ else {
78
+ row += (i > 0 ? ' ' : '') + word;
79
+ width += (i > 0 ? 1 : 0) + w;
80
+ }
81
+ });
82
+ return [...rows, row.trimEnd()].join('\n');
83
+ }
84
+ /** Moves the cursor or redraws: text drawn in place (a menu, a spinner), which must not be wrapped. */
85
+ const DRAWN = /\r|\x1b\[[0-9;?]*[A-HJKSTfhlsu]/;
86
+ /**
87
+ * Text fitted to `columns` with {@link fitLine}, line by line. JSON (text
88
+ * that starts with `{` or `[`) and anything drawn in place come back as they
89
+ * are, since a break there would change what they mean.
90
+ */
91
+ export function fitText(text, columns) {
92
+ if (!columns || DRAWN.test(text) || /^\s*[{[]/.test(text))
93
+ return text;
94
+ return text
95
+ .split('\n')
96
+ .map((l) => fitLine(l, columns))
97
+ .join('\n');
98
+ }
@@ -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.5';
2
+ export const VERSION = '1.2.7';
@@ -65,7 +65,7 @@ export async function planCommand(input) {
65
65
  const pick = pickIo(input);
66
66
  if (pick) {
67
67
  const offer = (await getPlan(apiKey, baseUrl)).prices?.[subcommand];
68
- const what = offer ? `$${offer.usd_per_month} a month, paying for $${offer.week_limit_usd} of model cost a week, for every key in this workspace` : 'for every key in this workspace';
68
+ const what = offer ? `$${offer.usd_per_month} a month for $${offer.week_limit_usd} of model cost a week, every key` : 'for every key in this workspace';
69
69
  const go = await choose(pick, `Start ${subcommand === 'max' ? 'Max' : 'Pro'}?`, [
70
70
  { label: 'Open Stripe to pay', hint: what, value: true },
71
71
  { label: 'Cancel', hint: 'nothing is charged', value: false },
@@ -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) {
@@ -350,6 +357,274 @@ export function geminiProvider(ctx, off) {
350
357
  },
351
358
  };
352
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
+ }
353
628
  // ── Registry ─────────────────────────────────────────────────────────────────
354
629
  /**
355
630
  * The agents `phantom setup` knows, in the order setup runs them. Adding an agent
@@ -471,6 +746,122 @@ export const SETUP_AGENTS = {
471
746
  GEMINI_API_KEY: '$PHANTOM_API_KEY',
472
747
  }),
473
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
+ },
474
865
  };
475
866
  export const SETUP_NAMES = Object.keys(SETUP_AGENTS);
476
867
  /** `a, b or c`. */
@@ -119,19 +119,25 @@ export async function setupCommand(input) {
119
119
  provider: guided || provider === true ? true : provider === 'off' ? 'off' : false,
120
120
  model: typeof model === 'string' ? model : undefined,
121
121
  guided,
122
+ shell: !flags['no-shell'],
122
123
  };
123
124
  const found = planSetup(env, options);
124
125
  let plan = found;
125
126
  // At a terminal, plain setup asks which of the agents it found to connect (all ticked, Space unticks),
126
127
  // then whether to make the changes. Esc on the question goes back to the checklist.
127
128
  const pick = guided && !flags['yes'] && !dryRun ? pickIo(input) : null;
128
- const ticks = Boolean(pick) && found.agents.length > 1;
129
+ const ticks = Boolean(pick) && (found.agents.length > 1 || found.files.some((f) => f.agent === 'shell'));
129
130
  /** Shows the checklist, or returns false when Esc there means leave. */
131
+ // The shell file is a row of its own, so it can be left out like an agent.
132
+ const shellRow = found.files.some((f) => f.agent === 'shell');
130
133
  const tick = async () => {
131
- const only = await checklist(pick, 'Which agents should use Phantom AI?', found.agents.map((a) => ({ label: SETUP_AGENTS[a.agent].label, hint: a.provider ? 'skill and model settings' : 'skill', value: a.agent, checked: plan.agents.some((p) => p.agent === a.agent) })));
134
+ const only = await checklist(pick, 'Which agents should use Phantom AI?', [
135
+ ...found.agents.map((a) => ({ label: SETUP_AGENTS[a.agent].label, hint: a.provider ? 'skill and model settings' : 'skill', value: a.agent, checked: plan.agents.some((p) => p.agent === a.agent) })),
136
+ ...(shellRow ? [{ label: 'Your shell', hint: 'new terminals get your key; untick to add it yourself', value: 'shell', checked: plan.files.some((f) => f.agent === 'shell') }] : []),
137
+ ]);
132
138
  if (!only)
133
139
  return false;
134
- plan = planSetup(env, { ...options, only });
140
+ plan = planSetup(env, { ...options, only: only.filter((a) => a !== 'shell'), shell: shellRow ? only.includes('shell') : options.shell });
135
141
  return true;
136
142
  };
137
143
  /** Esc on setup's first step: back to the menu when it started setup, otherwise stop with nothing changed. */
@@ -109,7 +109,12 @@ export function planSetup(env, opts = {}) {
109
109
  }
110
110
  agents.push({ agent, skill, ...(result ? { provider: result } : {}) });
111
111
  }
112
- if (shellOn.size || shellOff.length) {
112
+ if (shellOn.size && opts.shell === false) {
113
+ const block = withShellBlock(env, ctx.shellRc, '', shellOn, [], opts.onPath) ?? '';
114
+ const short = ctx.shellRc.startsWith(home + path.sep) ? '~' + ctx.shellRc.slice(home.length) : ctx.shellRc;
115
+ notes.push(`Your shell file was left alone. So ${orList([...shellOn.keys()].map((a) => SETUP_AGENTS[a].label))} can find your key, add these lines to ${short}:`, '', ...block.trimEnd().split('\n').map((l) => ` ${l}`));
116
+ }
117
+ else if (shellOn.size || shellOff.length) {
113
118
  try {
114
119
  add('shell', ctx.shellRc, withShellBlock(env, ctx.shellRc, readText(ctx.shellRc), shellOn, shellOff, opts.onPath));
115
120
  }
@@ -5,10 +5,11 @@
5
5
  // The JSON result (setupCommand without a terminal) never goes through here.
6
6
  import os from 'node:os';
7
7
  import path from 'node:path';
8
- import { paint, unstyled } from "../core/style.mjs";
8
+ import { fitText, paint, unstyled } from "../core/style.mjs";
9
9
  import { brandHeader } from "../core/brand.mjs";
10
10
  import { inApp } from "../cli/screens.mjs";
11
11
  import { SETUP_AGENTS } from "./agents.mjs";
12
+ import { FREE_LINE } from "../core/free.mjs";
12
13
  /** `text` broken at spaces into lines of at most `width` characters; a single longer word stays whole. */
13
14
  export function wrapWords(text, width) {
14
15
  const lines = [];
@@ -64,10 +65,12 @@ export function setupScreen(r, opts = {}) {
64
65
  // unless that's wider than the window: then rows stay short and unaligned.
65
66
  const longest = Math.max(0, ...r.files.map((f) => short(f.file).length));
66
67
  const columns = opts.columns ?? process.stdout.columns ?? 100;
67
- const fits = width + 4 + 10 + longest + 2 + 8 + 4 <= columns;
68
+ // The widest "what happens" phrase (`new file, 12 lines`), plus room for "copy kept".
69
+ const said = Math.max(8, ...r.files.map((f) => change(f).length + (f.backup ? 11 : 0)));
70
+ const fits = width + 4 + 10 + longest + 2 + said <= columns;
68
71
  const pathWidth = fits ? longest : 0;
69
72
  // As wide as a file row (name, kind, path, action and a line count), and never wider than the window.
70
- const rule = Math.max(20, Math.min(width + 2 + 10 + longest + 2 + 8 + 4, columns - 4));
73
+ const rule = Math.max(20, Math.min(width + 2 + 10 + longest + 2 + said, columns - 4));
71
74
  // After the prompt, a blank line sets the result apart from the answer typed above it.
72
75
  const lines = [''];
73
76
  const found = r.agents.length;
@@ -134,7 +137,16 @@ export function setupScreen(r, opts = {}) {
134
137
  lines.push('');
135
138
  if (routes) {
136
139
  lines.push(` ${p.dim('What this does:')} these agents send their model requests to Phantom AI, and phantom-1 picks the model.`);
137
- lines.push(` ${p.dim('Nothing is bought:')} phantom-1 runs free until you add credit.`);
140
+ lines.push(` ${p.dim('Free to start:')} ${FREE_LINE}.`);
141
+ // Claude Code's settings file applies in every folder, so its own login stops being used.
142
+ if (r.agents.some((a) => a.agent === 'claude' && a.provider?.on)) {
143
+ lines.push(` ${p.dim('Claude Code:')} uses Phantom AI in place of your Anthropic login, in every folder. To keep your login, leave Claude Code out and start it with ${p.cyan('phantom claude')} when you want Phantom.`);
144
+ }
145
+ const readers = r.agents.filter((a) => a.provider?.on && SETUP_AGENTS[a.agent]?.shellEnv);
146
+ if (readers.length && r.files.some((f) => f.agent === 'shell')) {
147
+ lines.push(` ${p.dim('Your shell:')} new terminals get your key as PHANTOM_API_KEY, for ${readers.map((a) => label(a.agent)).join(', ')}. The file runs ${p.cyan('phantom key show')}; the key itself isn't written in it.`);
148
+ }
149
+ lines.push(` ${p.dim('Undo:')} phantom keeps a copy of each file it changes, and ${p.cyan('phantom setup --restore')} puts them back.`);
138
150
  }
139
151
  if (skills)
140
152
  lines.push(` ${p.dim('skill:')} a short note that teaches the agent the phantom commands.`);
@@ -153,7 +165,8 @@ export function setupScreen(r, opts = {}) {
153
165
  lines.push('', ...nextStep(r, p, opts.columns));
154
166
  lines.push('', inApp() ? ` ${p.dim('Undo any time: More, then Undo setup.')}` : ` Undo any time: ${p.cyan('phantom setup --restore')}`);
155
167
  }
156
- return lines.join('\n');
168
+ // Long sentences break at a word under their own indent, at the terminal's width.
169
+ return fitText(lines.join('\n'), columns);
157
170
  }
158
171
  /** What a file is, in the words the screen uses: its settings (an agent's, or the shell's ~/.zshrc), or its skill. */
159
172
  function kind(f) {
@@ -162,22 +175,32 @@ function kind(f) {
162
175
  // ~/.zshrc is the shell's own settings file, and the row already says "Your shell".
163
176
  return 'settings';
164
177
  }
165
- /** What happens to a file, in one short word. */
166
- const ACTION = { create: 'new', update: 'update', remove: 'remove' };
178
+ /** `n lines`, or `1 line`. */
179
+ const lineCount = (n) => `${n} line${n === 1 ? '' : 's'}`;
167
180
  /**
168
- * One file under an agent, in columns: what the file is, where it is, what
169
- * happens to it, and how many lines change. For example
170
- * `settings ~/.codex/config.toml new +12`.
181
+ * What happens to a file, in words: `new file, 12 lines`, `adds 3 lines`,
182
+ * `adds 3, removes 1`, `updated` (a skill) or `deleted`.
171
183
  */
172
- function fileRow(f, p, short, pathWidth) {
173
- const action = ACTION[f.action].padEnd(6);
184
+ function change(f) {
174
185
  const { added, removed } = counts(f.diff);
175
- const change = [added ? p.green(`+${added}`) : '', removed ? p.red(`-${removed}`) : ''].filter(Boolean).join(' ');
186
+ if (f.action === 'remove')
187
+ return 'deleted';
188
+ if (f.action === 'create')
189
+ return added ? `new file, ${lineCount(added)}` : 'new file';
190
+ if (!f.diff)
191
+ return 'updated';
192
+ return removed ? `adds ${added}, removes ${removed}` : `adds ${lineCount(added)}`;
193
+ }
194
+ /**
195
+ * One file under an agent, in columns: what the file is, where it is, and
196
+ * what happens to it. For example `settings ~/.codex/config.toml new file, 12 lines`.
197
+ */
198
+ function fileRow(f, p, short, pathWidth) {
199
+ const what = change(f);
176
200
  return [
177
201
  p.dim(kind(f).padEnd(8)),
178
202
  short(f.file).padEnd(pathWidth),
179
- f.action === 'remove' ? p.red(action) : p.cyan(action),
180
- change + (f.backup ? p.dim(' copy kept') : ''),
203
+ (f.action === 'remove' ? p.red(what) : p.cyan(what)) + (f.backup ? p.dim(' copy kept') : ''),
181
204
  ]
182
205
  .join(' ')
183
206
  .trimEnd();
@@ -208,7 +231,7 @@ function doneSummary(agent, a, files, skipped, p) {
208
231
  return files ? 'skill installed.' : p.dim('already set up.');
209
232
  }
210
233
  /** 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', gemini: 'gemini' };
234
+ 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
235
  /**
213
236
  * The one thing to do after setup, in corner brackets: run the agent. When an
214
237
  * agent reads its key from the shell (Codex, Goose and others), the step says
@@ -174,7 +174,7 @@ async function lineupPicker(input, pick, show) {
174
174
  io.stdout(show(read) + '\n\n');
175
175
  const tier = await choose({ ...pick, esc: 'back' }, 'Which tier do you want to change?', [
176
176
  ...LINEUP_TIERS.map((t) => ({ label: t[0].toUpperCase() + t.slice(1), hint: `${TIER_ABOUT[t]} · now ${read.tiers[t].model}`, value: t })),
177
- { label: 'Put every tier back to its default', value: 'reset' },
177
+ { label: 'Reset every tier', hint: 'back to its default', value: 'reset' },
178
178
  ], last);
179
179
  if (tier === null)
180
180
  return 0;
@@ -12,8 +12,8 @@ import { BackToMenu, Cancelled, askText, cancelKeys, choose, keyboard } from "..
12
12
  import { spawnSync } from 'node:child_process';
13
13
  import { onPath } from "../setup/shared.mjs";
14
14
  import { colorOn, paint } from "../core/style.mjs";
15
- import { fundScreen, keyChangeScreen, keysScreen, paymentScreen, signedInScreen, workspaceScreen } from "../cli/screens.mjs";
16
- import { keyPath, listNamedKeys, namedKeyPath, readNamedKey, saveApiKey, saveNamedKey } from "../keys/saved.mjs";
15
+ import { alreadySignedInScreen, fundScreen, keyChangeScreen, keysScreen, paymentScreen, signedInScreen, workspaceScreen } from "../cli/screens.mjs";
16
+ import { keyId, keyPath, listNamedKeys, namedKeyPath, readNamedKey, resolveApiKey, saveApiKey, saveNamedKey } from "../keys/saved.mjs";
17
17
  const credentialPath = (env) => path.join(stateDir(env), 'workspace');
18
18
  const base = (input) => httpsOnly(input.baseUrl || input.env.PHANTOM_BASE_URL || DEFAULT_BASE_URL).replace(/\/$/, '');
19
19
  async function wire(url, method, endpoint, body, token) {
@@ -52,8 +52,12 @@ export async function workspaceLogin(input, opts = {}) {
52
52
  die('--save requires a key name.');
53
53
  const name = typeof flags.save === 'string' ? flags.save : 'default';
54
54
  const target = name === 'default' ? keyPath(env) : namedKeyPath(env, name);
55
- if (existsSync(target))
56
- die(`Saved key ${name} already exists. Use --save <new-name>; no key was overwritten.`, 'key_exists');
55
+ if (existsSync(target)) {
56
+ // Signing in again is a question ("am I signed in?"), so it gets an answer, not an error.
57
+ if (name === 'default')
58
+ return alreadySignedIn(input);
59
+ die(`A key named ${name} is already saved here. Pick another name with --save <name>; nothing was changed.`, 'key_exists');
60
+ }
57
61
  // At a terminal, how to sign in is a choice; --no-browser or a pipe skips it.
58
62
  const pick = flags['no-browser'] ? null : pickIo(input);
59
63
  let noBrowser = Boolean(flags['no-browser']);
@@ -101,6 +105,17 @@ export async function workspaceLogin(input, opts = {}) {
101
105
  cancel?.close();
102
106
  }
103
107
  }
108
+ /** `phantom login` on a computer that already has a key: which key, and how to use another. Exit 0. */
109
+ function alreadySignedIn(input) {
110
+ const { env, io } = input;
111
+ const key = resolveApiKey(env);
112
+ const result = { signed_in: true, already: true, key_id: key ? keyId(key) : null };
113
+ if (showsScreen(input))
114
+ io.stdout(alreadySignedInScreen(result.key_id, { env, color: colorOn(env, io.stdoutIsTTY) }) + '\n');
115
+ else
116
+ io.stdout(JSON.stringify(result) + '\n');
117
+ return 0;
118
+ }
104
119
  /** A spinner on one line: `⠋ Waiting for you to approve...` until stopped, then `✓ Approved.` or nothing. */
105
120
  function waiting(write, p, words = 'Waiting for you to approve...', doneWords = 'Approved.') {
106
121
  const frames = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏';
@@ -324,7 +339,7 @@ export async function workspaceCommand(input) {
324
339
  }
325
340
  if (pick && !method) {
326
341
  method = (await choose({ ...pick, esc: 'back' }, 'How do you want to pay?', [
327
- { label: 'Card', hint: "on Stripe's page; Stripe's fee is added, and the next screen shows the total", value: 'card' },
342
+ { label: 'Card', hint: "on Stripe's page, plus Stripe's card fee", value: 'card' },
328
343
  { label: 'USDC', hint: 'from a Solana wallet', value: 'usdc' },
329
344
  { label: 'USDT', hint: 'from a Solana wallet', value: 'usdt' },
330
345
  { label: 'SOL', hint: 'from a Solana wallet', value: 'sol' },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phantomcodes/cli",
3
- "version": "1.2.5",
3
+ "version": "1.2.7",
4
4
  "description": "AI inference for your coding agents. Route models, control spending, and fund your workspace.",
5
5
  "keywords": [
6
6
  "phantom",