@phantomcodes/cli 1.2.3 → 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.
@@ -1,7 +1,11 @@
1
+ import { unknownSub } from "../cli/start.mjs";
1
2
  import { createInterface } from 'node:readline/promises';
2
- import { die } from "../core/errors.mjs";
3
+ import { spawnSync } from 'node:child_process';
4
+ import { die, NO_KEY } from "../core/errors.mjs";
3
5
  import { getBalance } from "../core/request.mjs";
4
- import { out } from "../core/helpers.mjs";
6
+ import { out, showsScreen } from "../core/helpers.mjs";
7
+ import { colorOn, paint } from "../core/style.mjs";
8
+ import { balanceScreen, keySavedScreen, logoutScreen, savedKeysScreen } from "../cli/screens.mjs";
5
9
  import { resolveApiKey, readNamedKey, saveNamedKey, removeNamedKey, listNamedKeys, saveApiKey, removeApiKey } from "./saved.mjs";
6
10
  /** `--table` output for `balance`. */
7
11
  export function tableBalance(d) {
@@ -27,9 +31,10 @@ export function tableKeys(d) {
27
31
  .join('\n');
28
32
  }
29
33
  /** `phantom balance`: the key's credit and expiry. Run by `run()`. */
30
- export async function balanceCommand({ io, apiKey, table, baseUrl }) {
34
+ export async function balanceCommand(input) {
35
+ const { io, apiKey, baseUrl, env } = input;
31
36
  const result = await getBalance(apiKey, baseUrl);
32
- out(result, table, tableBalance, io.stdout);
37
+ out(result, showsScreen(input), (d) => balanceScreen(d, { env, color: colorOn(env, io.stdoutIsTTY) }), io.stdout);
33
38
  return 0;
34
39
  }
35
40
  /** `phantom key list|save|show|rm`: keys saved by name. Run by `run()`. */
@@ -47,10 +52,10 @@ export async function keyCommand({ argv, env, io, subcommand, flags, table, base
47
52
  return { ...k, active: false };
48
53
  }
49
54
  }));
50
- out({ keys: withBalance }, table, tableKeys, io.stdout);
55
+ out({ keys: withBalance }, table, (d) => savedKeysScreen(d.keys, { env, color: colorOn(env, io.stdoutIsTTY) }), io.stdout);
51
56
  }
52
57
  else {
53
- out(list, table, tableKeys, io.stdout);
58
+ out(list, table, (d) => savedKeysScreen(d.keys, { env, color: colorOn(env, io.stdoutIsTTY) }), io.stdout);
54
59
  }
55
60
  }
56
61
  else if (subcommand === 'save') {
@@ -68,14 +73,22 @@ export async function keyCommand({ argv, env, io, subcommand, flags, table, base
68
73
  if (!key.startsWith('sk-phantom-'))
69
74
  die('That is not a Phantom AI key. Keys start with sk-phantom-');
70
75
  await getBalance(key, baseUrl);
71
- out(saveNamedKey(env, name, key), table, (d) => `saved ${d.name}`, io.stdout);
76
+ out(saveNamedKey(env, name, key), table, (d) => keySavedScreen(d.name, { env, color: colorOn(env, io.stdoutIsTTY) }), io.stdout);
72
77
  }
73
78
  else if (subcommand === 'show') {
74
79
  // Raw, for PHANTOM_API_KEY=$(phantom key show <name>). With no name, the
75
80
  // key phantom itself would use, which is what Claude Code's apiKeyHelper runs.
76
81
  const key = name ? readNamedKey(env, name) : resolveApiKey(env);
77
82
  if (!key)
78
- die('No API key. Set PHANTOM_API_KEY or run: phantom login');
83
+ die(NO_KEY);
84
+ // --copy: to the clipboard instead of the screen, for pasting into an app's settings (Cursor's API key box).
85
+ if (flags['copy']) {
86
+ const copied = process.platform === 'darwin' ? spawnSync('pbcopy', { input: key }).status === 0 : false;
87
+ if (!copied)
88
+ die('Copying needs pbcopy, which is on every Mac. Elsewhere: phantom keys show | your clipboard tool.', 'copy_unavailable');
89
+ io.stderr(` ${paint(env, colorOn(env, io.isTTY)).lime('✓')} Copied your key. Paste it where it's asked for; it works like a password.\n`);
90
+ return 0;
91
+ }
79
92
  io.stdout(key + '\n');
80
93
  }
81
94
  else if (subcommand === 'rm') {
@@ -84,7 +97,7 @@ export async function keyCommand({ argv, env, io, subcommand, flags, table, base
84
97
  out(removeNamedKey(env, name), table, (d) => `removed ${d.removed}`, io.stdout);
85
98
  }
86
99
  else {
87
- die(`Unknown key subcommand: ${subcommand}`);
100
+ die(unknownSub('key', String(subcommand), ['list', 'save', 'show', 'rm']), 'key_usage');
88
101
  }
89
102
  return 0;
90
103
  }
@@ -106,11 +119,22 @@ export async function loginCommand({ env, io, subcommand, table, baseUrl }) {
106
119
  // Check the key works before saving it.
107
120
  const balance = await getBalance(key, baseUrl);
108
121
  const saved = saveApiKey(env, key);
109
- out({ ...saved, ...balance }, table, (d) => `saved ${d.saved}\n${tableBalance(d)}`, io.stdout);
122
+ out({ ...saved, ...balance }, table, (d) => `\n ${paint(env, colorOn(env, io.stdoutIsTTY)).lime('✓')} Key saved.\n${balanceScreen(d, { env, color: colorOn(env, io.stdoutIsTTY) })}`, io.stdout);
110
123
  return 0;
111
124
  }
112
- /** `phantom logout`: forgets the login key. Run by `run()`. */
113
- export async function logoutCommand({ env, io, table }) {
114
- out(removeApiKey(env), table, (d) => `removed ${d.removed}`, io.stdout);
125
+ /**
126
+ * `phantom logout`: forgets the login key. `forgotWorkspace` says whether the
127
+ * workspace sign-in was forgotten too, so the screen doesn't say nothing changed. Run by `run()`.
128
+ */
129
+ export async function logoutCommand({ env, io, table, flags }, forgotWorkspace = false) {
130
+ const result = removeApiKey(env);
131
+ // --all also forgets the keys saved by name; without it they stay, and the screen says so.
132
+ const named = listNamedKeys(env).keys.map((k) => k.name);
133
+ if (flags['all'])
134
+ for (const name of named)
135
+ removeNamedKey(env, name);
136
+ const kept = flags['all'] ? [] : named;
137
+ const removedNamed = flags['all'] ? named : [];
138
+ out({ ...result, removed_named: removedNamed, kept_named: kept }, table, (d) => logoutScreen(d.removed || forgotWorkspace || removedNamed.length > 0, { env, color: colorOn(env, io.stdoutIsTTY), kept, removedNamed }), io.stdout);
115
139
  return 0;
116
140
  }
package/dist/phantom.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * phantom: prepaid AI inference for coding agents with Phantom AI.
3
+ * phantom: points your coding agents at Phantom AI.
4
4
  *
5
5
  * Keys, money and setup for any AI agent: a Phantom AI key, model routing,
6
6
  * and receipts that show which model answered. It runs as a CLI and through
@@ -1,5 +1,9 @@
1
+ import { unknownSub } from "../cli/start.mjs";
2
+ import { colorOn } from "../core/style.mjs";
3
+ import { linkScreen, planScreen } from "../cli/screens.mjs";
1
4
  import { die } from "../core/errors.mjs";
2
- import { openUrl, out } from "../core/helpers.mjs";
5
+ import { pickIo, showsScreen, openUrl, out } from "../core/helpers.mjs";
6
+ import { choose } from "../core/pick.mjs";
3
7
  import { request } from "../core/request.mjs";
4
8
  import { savedManagement } from "../workspace/command.mjs";
5
9
  /** The plan of the key's workspace and its week (GET /plan). */
@@ -37,8 +41,6 @@ export function tablePlan(d) {
37
41
  lines.push(`period ends ${p.renews_at}`);
38
42
  return lines.join('\n');
39
43
  }
40
- /** What `phantom plan` accepts after `plan`. */
41
- const USAGE = 'Use phantom plan, phantom plan pro, phantom plan max or phantom plan manage.';
42
44
  /**
43
45
  * `phantom plan`: the plan of the key's workspace and how much of the week is
44
46
  * used. `phantom plan pro|max` prints a Stripe Checkout link that puts the
@@ -48,26 +50,42 @@ const USAGE = 'Use phantom plan, phantom plan pro, phantom plan max or phantom p
48
50
  * login` saved, since a key can't manage billing. Run by `run()`.
49
51
  */
50
52
  export async function planCommand(input) {
51
- const { io, apiKey, table, baseUrl, flags, subcommand } = input;
53
+ const { io, apiKey, baseUrl, flags, subcommand, env } = input;
54
+ const table = showsScreen(input);
55
+ const style = { env, color: colorOn(env, io.stdoutIsTTY), opened: !flags['no-browser'] };
52
56
  const open = (url) => {
53
57
  if (!flags['no-browser'])
54
58
  openUrl(url);
55
59
  };
56
60
  if (subcommand === undefined || subcommand === 'status') {
57
- out(await getPlan(apiKey, baseUrl), table, tablePlan, io.stdout);
61
+ out(await getPlan(apiKey, baseUrl), table, (d) => planScreen(d, style), io.stdout);
58
62
  }
59
63
  else if (subcommand === 'pro' || subcommand === 'max') {
64
+ // At a terminal, say what the plan costs and covers, and ask, before sending anyone to pay.
65
+ const pick = pickIo(input);
66
+ if (pick) {
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';
69
+ const go = await choose(pick, `Start ${subcommand === 'max' ? 'Max' : 'Pro'}?`, [
70
+ { label: 'Open Stripe to pay', hint: what, value: true },
71
+ { label: 'Cancel', hint: 'nothing is charged', value: false },
72
+ ]);
73
+ if (!go) {
74
+ io.stderr(' Nothing was charged.\n');
75
+ return 0;
76
+ }
77
+ }
60
78
  const result = await planCheckout(apiKey, subcommand, baseUrl);
61
- out(result, table, (d) => `Open this link to pay: ${d.checkout_url}`, io.stdout);
79
+ out(result, table, (d) => linkScreen(`Start ${subcommand === 'max' ? 'Max' : 'Pro'} on Stripe`, d.checkout_url, ['Once it goes through:', 'phantom plan'], style), io.stdout);
62
80
  open(result.checkout_url);
63
81
  }
64
82
  else if (subcommand === 'manage') {
65
83
  const result = await planPortal(savedManagement(input) ?? apiKey, baseUrl);
66
- out(result, table, (d) => `Open this link to manage billing: ${d.portal_url}`, io.stdout);
84
+ out(result, table, (d) => linkScreen('Manage billing on Stripe: card, switch plan, cancel', d.portal_url, null, style), io.stdout);
67
85
  open(result.portal_url);
68
86
  }
69
87
  else {
70
- die(`Unknown plan command "${subcommand}". ${USAGE}`, 'plan_usage');
88
+ die(unknownSub('plan', String(subcommand), ['status', 'pro', 'max', 'manage']), 'plan_usage');
71
89
  }
72
90
  return 0;
73
91
  }
@@ -8,10 +8,11 @@ import { SETUP_MODEL, agentBaseUrl, readText, configHome, onPath, phantomCommand
8
8
  import { jsonObject, jsonEdit, tomlSet, tomlRemoveOurs, yamlSetTop, yamlAddModel, yamlRemoveOurs } from "./editors.mjs";
9
9
  // ── Manual Provider Helper ───────────────────────────────────────────────────
10
10
  /** An agent whose model settings live on its own settings screen: setup prints the steps. */
11
- export function manualProvider(where, steps) {
11
+ export function manualProvider(where, steps, note) {
12
12
  return (ctx, off) => ({
13
13
  edits: [],
14
- result: { settings: where, on: false, warnings: [], manual: off ? [`Remove the Phantom AI settings in ${where}.`] : steps(ctx) },
14
+ // The note is something to know, not a step to do, so it shows apart from the numbered steps.
15
+ result: { settings: where, on: false, warnings: off || !note ? [] : [note], manual: off ? [`Remove the Phantom AI settings in ${where}.`] : steps(ctx) },
15
16
  });
16
17
  }
17
18
  // ── Codex ────────────────────────────────────────────────────────────────────
@@ -76,7 +77,7 @@ export function claudePlan(env, opts) {
76
77
  };
77
78
  }
78
79
  if (!resolveApiKey(env))
79
- die('No API key to give Claude Code. Run: phantom login', 'no_key');
80
+ die('No Phantom key yet to give Claude Code. Run phantom login to sign in, then run this again.', 'no_key');
80
81
  const baseUrl = agentBaseUrl(env, 'claude-code');
81
82
  // `phantom` if it's installed, else this very phantom by absolute path: Claude Code
82
83
  // runs the helper outside this shell, so a relative or npx path won't do.
@@ -87,7 +88,8 @@ export function claudePlan(env, opts) {
87
88
  ANTHROPIC_API_KEY: '',
88
89
  ENABLE_TOOL_SEARCH: 'true',
89
90
  CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: '1',
90
- ...(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',
91
93
  };
92
94
  const changes = [[['apiKeyHelper'], `${phantomCommand(opts.onPath ?? onPath)} key show`], ...Object.entries(set).map(([k, v]) => [['env', k], v])];
93
95
  // A token outranks apiKeyHelper, and would send a non-Phantom key.
@@ -310,6 +312,44 @@ export function clineProvider(ctx, off) {
310
312
  },
311
313
  };
312
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
+ }
313
353
  // ── Registry ─────────────────────────────────────────────────────────────────
314
354
  /**
315
355
  * The agents `phantom setup` knows, in the order setup runs them. Adding an agent
@@ -354,13 +394,12 @@ export const SETUP_AGENTS = {
354
394
  home: '.cursor',
355
395
  /** ~/.cursor/skills */
356
396
  skills: '.cursor/skills',
397
+ // Three short steps; the key gets copied without being shown (phantom's menu has "Copy my key").
357
398
  provider: manualProvider('Cursor Settings, Models', (ctx) => [
358
399
  'Open Cursor Settings (Cmd+Shift+J on macOS), then Models.',
359
- `Under OpenAI API Key, paste your Phantom AI key, then turn on Override OpenAI Base URL and set it to ${agentBaseUrl(ctx.env, 'cursor')}`,
360
- 'Copy the key without showing it: phantom key show | pbcopy',
361
- `Use Add model to add ${ctx.model ?? SETUP_MODEL} and any other Phantom AI model ids you want in the picker.`,
362
- 'While the override is on, Cursor\'s own non-OpenAI models stop working; turn it off to go back. Tab completions never use your own key.',
363
- ]),
400
+ `Paste your Phantom AI key under OpenAI API Key (copy it with "Copy my key" in phantom's menu, or phantom keys show --copy), then turn on Override OpenAI Base URL and set it to ${agentBaseUrl(ctx.env, 'cursor')}`,
401
+ `Under Add model, add ${ctx.model ?? SETUP_MODEL}.`,
402
+ ], "While the override is on, Cursor's own non-OpenAI models stop working; turn it off to go back. Tab completions never use your key."),
364
403
  shellEnv: () => ({}),
365
404
  },
366
405
  /** opencode. */
@@ -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`. */
@@ -1,6 +1,7 @@
1
+ import os from 'node:os';
1
2
  import path from 'node:path';
2
3
  import { createHash } from 'node:crypto';
3
- import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmdirSync, rmSync, writeFileSync } from 'node:fs';
4
5
  import { stateDir } from "../core/state.mjs";
5
6
  import { readText } from "./shared.mjs";
6
7
  import { jsonText } from "./editors.mjs";
@@ -52,28 +53,6 @@ export function lineDiff(before, after) {
52
53
  }
53
54
  return out.join('\n');
54
55
  }
55
- /** A plan as people read it: each file with its diff (keys masked), the skill in one line, then commands, skips and steps to do by hand. */
56
- export function setupPreview(plan) {
57
- const parts = [];
58
- for (const f of plan.files) {
59
- if (f.skill)
60
- parts.push(`${f.action === 'create' ? 'install' : 'update'} skill ${f.file}`);
61
- else if (f.action === 'remove')
62
- parts.push(`remove ${f.file}`);
63
- else
64
- parts.push(`${f.action} ${f.file}\n${maskKeys(lineDiff(f.before, f.after))}`);
65
- }
66
- for (const s of plan.skipped)
67
- parts.push(`skip ${s.agent}: ${s.reason}`);
68
- for (const a of plan.agents) {
69
- const steps = a.provider?.manual ?? [];
70
- if (steps.length)
71
- parts.push([`${SETUP_AGENTS[a.agent].label}, by hand:`, ...steps.map((s, k) => ` ${k + 1}. ${s}`)].join('\n'));
72
- }
73
- for (const n of plan.notes)
74
- parts.push(n);
75
- return parts.length ? parts.join('\n\n') : 'Nothing to change.';
76
- }
77
56
  function backupsFile(env) {
78
57
  return path.join(stateDir(env), 'setup-backups.json');
79
58
  }
@@ -89,6 +68,10 @@ function writeBackups(env, entries) {
89
68
  mkdirSync(stateDir(env), { recursive: true, mode: 0o700 });
90
69
  writeFileSync(backupsFile(env), jsonText(entries), { mode: 0o600 });
91
70
  }
71
+ /** Whether setup has written anything on this computer that undo could put back: it keeps a record of each file. */
72
+ export function isSetUp(env) {
73
+ return Object.keys(readBackups(env)).length > 0;
74
+ }
92
75
  const sha256 = (text) => createHash('sha256').update(text).digest('hex');
93
76
  /**
94
77
  * Writes a plan from {@link planSetup}. Before it edits a file it copies it
@@ -213,5 +196,25 @@ export function restoreSetup(env, opts = {}) {
213
196
  }
214
197
  if (!opts.dryRun && restored.length)
215
198
  writeBackups(env, backups);
199
+ // The phantom-ai skill is phantom's own file, kept out of the backups, so undo removes it here.
200
+ const home = env.HOME || os.homedir();
201
+ for (const [agent, spec] of Object.entries(SETUP_AGENTS)) {
202
+ if (!spec.skills || (opts.agent && opts.agent !== 'all' && agent !== opts.agent))
203
+ continue;
204
+ const skill = path.join(home, spec.skills, 'phantom-ai', 'SKILL.md');
205
+ // Two agents can share a skills folder (pi and Codex use ~/.agents/skills), so each file is listed once.
206
+ if (!existsSync(skill) || restored.some((r) => r.file === skill) || !/^name: phantom-ai$/m.test(readText(skill) ?? ''))
207
+ continue;
208
+ if (!opts.dryRun) {
209
+ rmSync(skill, { force: true });
210
+ try {
211
+ rmdirSync(path.dirname(skill));
212
+ }
213
+ catch {
214
+ // Not empty: something else lives there, so the folder stays.
215
+ }
216
+ }
217
+ restored.push({ file: skill, from: null });
218
+ }
216
219
  return { restored, skipped, ...(opts.dryRun ? { dry_run: true } : {}) };
217
220
  }
@@ -1,68 +1,68 @@
1
+ import path from 'node:path';
2
+ import { existsSync } from 'node:fs';
1
3
  import { createInterface } from 'node:readline/promises';
2
4
  import { die } from "../core/errors.mjs";
3
- import { out } from "../core/helpers.mjs";
5
+ import { out, pickIo } from "../core/helpers.mjs";
6
+ import { BackToMenu, checklist, choose } from "../core/pick.mjs";
4
7
  import { resolveApiKey } from "../keys/saved.mjs";
5
8
  import { workspaceLogin } from "../workspace/command.mjs";
6
9
  import { SETUP_AGENTS, SETUP_NAMES } from "./agents.mjs";
7
10
  import { planSetup } from "./plan.mjs";
8
- import { setupPreview, applySetup, previewSetup, restoreSetup } from "./apply.mjs";
9
- /** `--table` output for `setup`. */
10
- export function tableSetup(d) {
11
- const r = d;
12
- const pad = ''.padEnd(16);
13
- const lines = r.agents.map((a) => {
14
- const label = SETUP_AGENTS[a.agent]?.label ?? a.agent;
15
- const p = a.provider;
16
- return [
17
- `${a.agent.padEnd(16)}${a.skill ? `skill ${r.dry_run || r.applied === false ? 'goes' : 'installed'} at ${a.skill}` : 'no skill (this agent does not read skills)'}`,
18
- ...(!p
19
- ? []
20
- : p.manual
21
- ? [`${pad}set up by hand in ${p.settings}:`, ...p.manual.map((step, k) => `${pad} ${k + 1}. ${step}`)]
22
- : p.on
23
- ? [
24
- `${pad}models via Phantom AI at ${p.base_url}${p.model ? `, model ${p.model}` : ''} (${p.settings})`,
25
- `${pad}restart ${label} to pick it up; undo with: phantom setup --agent ${a.agent} --provider off`,
26
- ]
27
- : [a.agent === 'claude' ? `${pad}models back to Anthropic (${p.settings})` : `${pad}Phantom AI settings removed (${p.settings})`]),
28
- ...(p?.warnings ?? []).map((w) => `${pad}warning: ${w}`),
29
- ].join('\n');
30
- });
31
- const files = (r.files ?? []).filter((f) => f.diff !== undefined);
32
- if (files.length) {
33
- lines.push('');
34
- for (const f of files) {
35
- lines.push(`${r.dry_run || r.applied === false ? `would ${f.action}` : `${f.action}d`} ${f.file}${f.backup ? ` (copy before: ${f.backup})` : ''}`);
36
- if (f.diff && (r.dry_run || r.applied === false))
37
- lines.push(f.diff.replace(/^/gm, ' '));
38
- }
39
- }
40
- for (const s of r.skipped ?? [])
41
- lines.push(`skipped ${s.agent}: ${s.reason}`);
42
- for (const n of r.notes ?? [])
43
- lines.push(n);
44
- if (r.applied === false)
45
- lines.push('Nothing was changed.');
46
- // Agents that read the key from the shell only see it in a terminal opened after setup.
47
- else if (!r.dry_run && (r.files ?? []).some((f) => f.agent === 'shell')) {
48
- lines.push('', 'Open a new terminal before you start your agents, so they can read your Phantom AI key.');
49
- }
50
- return lines.join('\n');
11
+ import { applySetup, previewSetup, restoreSetup } from "./apply.mjs";
12
+ import { setupScreen } from "./screen.mjs";
13
+ import { colorOn, paint, unstyled } from "../core/style.mjs";
14
+ import { inApp } from "../cli/screens.mjs";
15
+ import { brandIntro, motionOn, play, reveal } from "../core/motion.mjs";
16
+ /** `--table` output for `setup`, and what a terminal shows: see {@link setupScreen}. */
17
+ export function tableSetup(d, opts = {}) {
18
+ return setupScreen(d, opts);
51
19
  }
52
- /** `--table` output for `setup --restore`. */
53
- export function tableRestore(d) {
20
+ /** `--table` output for `setup --restore`, and what a terminal shows: a ✓ per file put back, a ✗ per file left alone. */
21
+ export function tableRestore(d, opts = {}) {
54
22
  const r = d;
23
+ const p = paint(opts.env ?? {}, Boolean(opts.color));
24
+ const home = opts.env?.HOME;
25
+ const short = (file) => (home && file.startsWith(home + '/') ? '~' + file.slice(home.length) : file);
26
+ if (!r.restored.length && !r.skipped.length)
27
+ return 'Nothing to restore.';
55
28
  const lines = [
56
- ...r.restored.map((x) => `${r.dry_run ? 'would restore' : 'restored'} ${x.file}${x.from ? ` from ${x.from}` : ' (phantom created it, so it is removed)'}`),
57
- ...r.skipped.map((x) => `skipped ${x.file}: ${x.reason}`),
29
+ ...r.restored.map((x) => {
30
+ const verb = x.from ? (r.dry_run ? 'puts back' : 'put back') : r.dry_run ? 'deletes' : 'removed';
31
+ const what = x.from ? `${verb} ${short(x.file)} as it was before setup` : `${verb} ${short(x.file)} (phantom made this file)`;
32
+ return ` ${r.dry_run ? p.dim('·') : p.lime('✓')} ${what}`;
33
+ }),
34
+ ...r.skipped.map((x) => ` ${p.red('✗')} left ${short(x.file)}: ${x.reason}`),
58
35
  ];
59
- return lines.length ? lines.join('\n') : 'Nothing to restore.';
36
+ if (!r.dry_run && r.restored.length) {
37
+ // What undo leaves alone, so nobody thinks they're back to nothing when they're still signed in.
38
+ lines.push('', inApp() ? ` ${p.dim('Still here: your sign-in and saved keys. To remove those too, pick Sign out below.')}` : ` ${p.dim('Still here: your sign-in and saved keys. To remove those too:')} ${p.cyan('phantom logout --all')}`);
39
+ // Cursor was set up by hand, so undo can't reach it; say what to do there.
40
+ if (opts.env?.HOME && existsSync(path.join(opts.env.HOME, '.cursor')))
41
+ lines.push(` ${p.dim("Cursor: if you set it up by hand, turn off Override OpenAI Base URL in Cursor Settings, Models.")}`);
42
+ lines.push(` ${p.lime('→')} Restart your agents (and open a new terminal) so they go back to how they were.`);
43
+ }
44
+ return lines.join('\n');
60
45
  }
61
46
  /** `phantom setup`: plans, previews, asks, applies or restores agent setup. Plain setup with no key yet signs in first when it runs in a terminal. Run by `run()`. */
62
47
  export async function setupCommand(input) {
63
48
  const { env, io, flags } = input;
64
49
  // A person at a terminal gets the short summary; a pipe, or --json, gets JSON.
65
50
  const table = input.table || (Boolean(io.stdoutIsTTY) && !flags['json']);
51
+ const diff = Boolean(flags['diff']);
52
+ // The summary goes to stdout and the question to stderr, so each gets color only when it is a terminal.
53
+ const shown = (opts = {}) => (d) => tableSetup(d, { env, diff, color: colorOn(env, io.stdoutIsTTY), columns: process.stdout.columns, ...opts });
54
+ /**
55
+ * Prints a result to stdout: JSON for a pipe, the screen otherwise, played
56
+ * as a flipbook when motion is on. The header draws in on a preview; after
57
+ * the changes, each ✓ ticks in after the one before.
58
+ */
59
+ const emit = async (result) => {
60
+ if (!table || !motionOn(env, io.stdoutIsTTY))
61
+ return out(result, table, shown(), io.stdout);
62
+ const lines = shown()(result).split('\n');
63
+ const book = result.dry_run ? brandIntro(lines) : reveal(lines, (line) => (/^\s+\S*[✓!✗]/.test(unstyled(line)) ? 60 : 0));
64
+ await play(io.stdout, book, { rows: process.stdout.rows, skipOnKey: true });
65
+ };
66
66
  const agent = flags['agent'];
67
67
  if (agent === true)
68
68
  die(`--agent requires ${SETUP_NAMES.join(', ')} or all`);
@@ -76,8 +76,29 @@ export async function setupCommand(input) {
76
76
  die('--mcp has been removed. Phantom inference runs directly through phantom claude, phantom codex, or phantom pi.');
77
77
  const dryRun = Boolean(flags['dry-run']);
78
78
  if (flags['restore']) {
79
+ // At a terminal, undo shows what it will put back, and asks, the way setup asks before it changes anything.
80
+ const pick = !dryRun && !flags['yes'] ? pickIo(input) : null;
81
+ if (pick) {
82
+ const preview = restoreSetup(env, { agent, dryRun: true });
83
+ if (!preview.restored.length && !preview.skipped.length) {
84
+ io.stdout(' Nothing to undo: setup has no changes on this computer.\n');
85
+ return 0;
86
+ }
87
+ io.stderr(`\n${tableRestore(preview, { env, color: colorOn(env, io.isTTY) })}\n\n`);
88
+ // The marker starts on Cancel: undo can't itself be undone, so it takes a deliberate move.
89
+ const go = await choose(pick, 'Undo these changes?', [
90
+ { label: 'Undo them', hint: 'your agents go back to how they were', value: true },
91
+ { label: 'Cancel', hint: 'change nothing', value: false },
92
+ ], 1);
93
+ if (!go) {
94
+ if (go === null && io.fromMenu)
95
+ throw new BackToMenu();
96
+ io.stderr(' Nothing was changed.\n');
97
+ return 0;
98
+ }
99
+ }
79
100
  const result = restoreSetup(env, { agent, dryRun });
80
- out(result, table, tableRestore, io.stdout);
101
+ out(result, table, (d) => tableRestore(d, { env, color: colorOn(env, io.stdoutIsTTY) }), io.stdout);
81
102
  return result.skipped.length ? 1 : 0;
82
103
  }
83
104
  // Plain `phantom setup` sets up everything it finds and asks first. Any of
@@ -87,19 +108,90 @@ export async function setupCommand(input) {
87
108
  // get one in this run. Its words go to stderr; stdout stays setup's result.
88
109
  if (guided && !dryRun && io.isTTY && !resolveApiKey(env)) {
89
110
  await workspaceLogin({ ...input, io: { ...io, stdout: io.stderr }, flags: flags['no-browser'] ? { 'no-browser': true } : {} }, { inSetup: true });
111
+ // Sign-in was cancelled: stop here rather than set up the agents without a key.
112
+ if (!resolveApiKey(env)) {
113
+ io.stderr(' Nothing was changed.\n');
114
+ return 0;
115
+ }
90
116
  }
91
- const plan = planSetup(env, {
117
+ const options = {
92
118
  agent,
93
119
  provider: guided || provider === true ? true : provider === 'off' ? 'off' : false,
94
120
  model: typeof model === 'string' ? model : undefined,
95
121
  guided,
96
- });
122
+ };
123
+ const found = planSetup(env, options);
124
+ let plan = found;
125
+ // At a terminal, plain setup asks which of the agents it found to connect (all ticked, Space unticks),
126
+ // then whether to make the changes. Esc on the question goes back to the checklist.
127
+ const pick = guided && !flags['yes'] && !dryRun ? pickIo(input) : null;
128
+ const ticks = Boolean(pick) && found.agents.length > 1;
129
+ /** Shows the checklist, or returns false when Esc there means leave. */
130
+ 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) })));
132
+ if (!only)
133
+ return false;
134
+ plan = planSetup(env, { ...options, only });
135
+ return true;
136
+ };
137
+ /** Esc on setup's first step: back to the menu when it started setup, otherwise stop with nothing changed. */
138
+ const leave = () => {
139
+ if (io.fromMenu)
140
+ throw new BackToMenu();
141
+ io.stderr(' Nothing was changed.\n');
142
+ return 0;
143
+ };
144
+ if (ticks && !(await tick()))
145
+ return leave();
146
+ /**
147
+ * Prints the result and gives the exit code: 1 when a file was skipped, or
148
+ * when there was no key so the agents weren't pointed at Phantom AI. A script
149
+ * checking only the exit code then can't mistake half a setup for all of it.
150
+ */
151
+ const finish = async (result) => {
152
+ const partial = guided && provider !== 'off' && !resolveApiKey(env);
153
+ await emit(partial ? { ...result, complete: false } : result);
154
+ return result.skipped || partial ? 1 : 0;
155
+ };
97
156
  if (dryRun) {
98
- out(previewSetup(plan), table, tableSetup, io.stdout);
157
+ await emit(previewSetup(plan));
99
158
  return 0;
100
159
  }
101
160
  if (guided && !flags['yes'] && plan.files.length) {
102
- io.stderr(setupPreview(plan) + '\n\n');
161
+ const preview = setupScreen(previewSetup(plan), { env, diff, color: colorOn(env, io.isTTY), columns: process.stderr.columns, mode: 'ask', hint: !(pick && !io.ask) });
162
+ // The question waits for the header and rows to finish drawing in.
163
+ if (motionOn(env, io.isTTY))
164
+ await play(io.stderr, brandIntro(preview.split('\n')), { rows: process.stderr.rows, skipOnKey: true });
165
+ else
166
+ io.stderr(preview + '\n');
167
+ io.stderr('\n');
168
+ // At a terminal: a choice instead of typing y or n, with a look at every line first if wanted.
169
+ if (pick && !io.ask) {
170
+ for (;;) {
171
+ const answer = await choose({ ...pick, esc: ticks || io.fromMenu ? 'back' : 'cancel' }, 'Make these changes?', [
172
+ { label: 'Make these changes', value: 'yes' },
173
+ { label: 'Show every line first', value: 'diff' },
174
+ { label: 'Cancel', hint: 'change nothing', value: 'no' },
175
+ ]);
176
+ if (answer === 'diff') {
177
+ io.stderr(setupScreen(previewSetup(plan), { env, diff: true, color: colorOn(env, io.isTTY), columns: process.stderr.columns, mode: 'ask' }) + '\n\n');
178
+ continue;
179
+ }
180
+ // Esc: back to the checklist, then the preview again; or out, when there was no checklist.
181
+ if (answer === null) {
182
+ if (!ticks || !(await tick()))
183
+ return leave();
184
+ io.stderr('\n' + setupScreen(previewSetup(plan), { env, diff, color: colorOn(env, io.isTTY), columns: process.stderr.columns, mode: 'ask', hint: false }) + '\n\n');
185
+ continue;
186
+ }
187
+ if (answer !== 'yes') {
188
+ await emit({ ...previewSetup(plan), dry_run: undefined, applied: false });
189
+ return 0;
190
+ }
191
+ break;
192
+ }
193
+ return finish(applySetup(env, plan));
194
+ }
103
195
  const ask = io.ask ??
104
196
  (process.stdin.isTTY
105
197
  ? async (q) => {
@@ -114,13 +206,12 @@ export async function setupCommand(input) {
114
206
  : null);
115
207
  if (!ask)
116
208
  die('phantom setup changes the files above. Run it in a terminal to confirm, or add --yes (or --dry-run to only look)', 'confirm_needed');
117
- const answer = await ask('Make these changes? [y/N] ');
209
+ const q = paint(env, colorOn(env, io.isTTY));
210
+ const answer = await ask(` ${q.lime('?')} ${q.bold('Make these changes?')} ${q.dim('[y/N]')} `);
118
211
  if (!/^y(es)?$/i.test(answer.trim())) {
119
- out({ ...previewSetup(plan), dry_run: undefined, applied: false }, table, tableSetup, io.stdout);
212
+ await emit({ ...previewSetup(plan), dry_run: undefined, applied: false });
120
213
  return 0;
121
214
  }
122
215
  }
123
- const result = applySetup(env, plan);
124
- out(result, table, tableSetup, io.stdout);
125
- return result.skipped ? 1 : 0;
216
+ return finish(applySetup(env, plan));
126
217
  }
@@ -373,7 +373,7 @@ export function shellBlock(env, fish, state, onPathFn = onPath) {
373
373
  '# Written by phantom setup. Sets PHANTOM_API_KEY to the key phantom uses when the',
374
374
  '# shell starts, for coding agents that read it. The key is not stored here.',
375
375
  `# agents: ${state.agents.join(' ')}`,
376
- '# Undo: phantom setup --provider off',
376
+ '# Undo: phantom setup --restore',
377
377
  ];
378
378
  if (fish) {
379
379
  return [