@phantomcodes/cli 1.2.3 → 1.2.4

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.
@@ -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.
@@ -354,13 +355,12 @@ export const SETUP_AGENTS = {
354
355
  home: '.cursor',
355
356
  /** ~/.cursor/skills */
356
357
  skills: '.cursor/skills',
358
+ // Three short steps; the key gets copied without being shown (phantom's menu has "Copy my key").
357
359
  provider: manualProvider('Cursor Settings, Models', (ctx) => [
358
360
  '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
- ]),
361
+ `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')}`,
362
+ `Under Add model, add ${ctx.model ?? SETUP_MODEL}.`,
363
+ ], "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
364
  shellEnv: () => ({}),
365
365
  },
366
366
  /** opencode. */
@@ -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' : 'quit' }, '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 [
@@ -33,6 +33,8 @@ export function planSetup(env, opts = {}) {
33
33
  picked = names.filter((a) => SETUP_AGENTS[a].detect?.(env, home) ?? existsSync(path.join(home, SETUP_AGENTS[a].home)));
34
34
  if (picked.length === 0)
35
35
  die(`Found no agent to set up. Pass --agent ${names.join('|')}`);
36
+ if (opts.only)
37
+ picked = picked.filter((a) => opts.only.includes(a));
36
38
  }
37
39
  else if (names.includes(opts.agent)) {
38
40
  picked = [opts.agent];
@@ -55,8 +57,8 @@ export function planSetup(env, opts = {}) {
55
57
  }
56
58
  if (provider === true && !resolveApiKey(env)) {
57
59
  if (!opts.guided)
58
- die('No API key to give the agents. Run: phantom login', 'no_key');
59
- notes.push('No Phantom AI key yet, so only the skills are installed. Run phantom login, then phantom setup again.');
60
+ die('No Phantom key yet to give the agents. Run phantom login to sign in, then run this again.', 'no_key');
61
+ notes.push('No Phantom key yet, so only the skills are installed. Run phantom setup in a terminal to sign in and set up the rest.');
60
62
  provider = false;
61
63
  }
62
64
  const ctx = { env, home, onPath: opts.onPath, model: opts.model, shellRc: shellRcFile(env, home) };
@@ -0,0 +1,237 @@
1
+ // How `phantom setup` looks in a terminal, drawn in the site's style: the
2
+ // ghost and a title, an uppercase caption over a thin rule, one row per agent
3
+ // with its files lined up in columns, and after the changes a ✓ per agent and
4
+ // the next step in corner brackets, like the site's hero.
5
+ // The JSON result (setupCommand without a terminal) never goes through here.
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import { paint, unstyled } from "../core/style.mjs";
9
+ import { brandHeader } from "../core/brand.mjs";
10
+ import { inApp } from "../cli/screens.mjs";
11
+ import { SETUP_AGENTS } from "./agents.mjs";
12
+ /** `text` broken at spaces into lines of at most `width` characters; a single longer word stays whole. */
13
+ export function wrapWords(text, width) {
14
+ const lines = [];
15
+ let line = '';
16
+ for (const word of text.split(' ')) {
17
+ if (line && (line + ' ' + word).length > width) {
18
+ lines.push(line);
19
+ line = word;
20
+ }
21
+ else
22
+ line = line ? line + ' ' + word : word;
23
+ }
24
+ return [...lines, line];
25
+ }
26
+ /** The name a person knows an agent by: "Claude Code", or "Your shell" for the startup file. */
27
+ const label = (agent) => (agent === 'shell' ? 'Your shell' : (SETUP_AGENTS[agent]?.label ?? agent));
28
+ /** How many lines a diff from `lineDiff` adds and removes. */
29
+ function counts(diff) {
30
+ const rows = (diff ?? '').split('\n');
31
+ return { added: rows.filter((r) => r.startsWith('+')).length, removed: rows.filter((r) => r.startsWith('-')).length };
32
+ }
33
+ /** An uppercase caption over a thin rule, like the site's section labels (SPEND, CALLS). */
34
+ function caption(p, text, extra, width) {
35
+ return [` ${p.bold(text)} ${extra}`.trimEnd(), ` ${p.dim('─'.repeat(width))}`];
36
+ }
37
+ /** Text framed by corner brackets, the site's hero frame, for the one thing to do next. */
38
+ function framed(p, text, columns) {
39
+ const inner = unstyled(text).length + 4;
40
+ // Too wide for the window: the corners would wrap away from the text, so it's a plain line instead.
41
+ if (columns && inner + 4 > columns)
42
+ return [` ${text}`];
43
+ return [` ${p.lime('┌')}${' '.repeat(inner)}${p.lime('┐')}`, ` ${text}`, ` ${p.lime('└')}${' '.repeat(inner)}${p.lime('┘')}`];
44
+ }
45
+ /** `phantom setup`'s screen for one result. See {@link ScreenMode} for the moments it shows. */
46
+ export function setupScreen(r, opts = {}) {
47
+ const env = opts.env ?? {};
48
+ const color = Boolean(opts.color);
49
+ const p = paint(env, color);
50
+ const mode = opts.mode ?? (r.dry_run ? 'dry' : r.applied === false ? 'declined' : 'done');
51
+ // The list is already on screen above the question, so a no only needs saying.
52
+ if (mode === 'declined')
53
+ return '\n Nothing was changed.';
54
+ const home = env.HOME || os.homedir();
55
+ const short = (file) => (file === home || file.startsWith(home + path.sep) ? '~' + file.slice(home.length) : file);
56
+ // Rows run in the order setup found the agents, then the shell file last.
57
+ const agents = [...r.agents.map((a) => a.agent), ...(r.files.some((f) => f.agent === 'shell') ? ['shell'] : [])];
58
+ const skipped = (r.skipped ?? []).filter((s) => !agents.includes(s.agent));
59
+ const names = [...agents, ...skipped.map((s) => s.agent)];
60
+ const width = Math.max(12, ...names.map((a) => label(a).length + 2));
61
+ const pad = ' '.repeat(width + 4);
62
+ const name = (agent) => p.bold(label(agent).padEnd(width));
63
+ // Every path is padded to the longest, so the line counts form one column,
64
+ // unless that's wider than the window: then rows stay short and unaligned.
65
+ const longest = Math.max(0, ...r.files.map((f) => short(f.file).length));
66
+ const columns = opts.columns ?? process.stdout.columns ?? 100;
67
+ const fits = width + 4 + 10 + longest + 2 + 8 + 4 <= columns;
68
+ const pathWidth = fits ? longest : 0;
69
+ // 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));
71
+ // After the prompt, a blank line sets the result apart from the answer typed above it.
72
+ const lines = [''];
73
+ const found = r.agents.length;
74
+ if (mode !== 'done') {
75
+ lines.push(...brandHeader(p, color, 'setup'), ...caption(p, 'AGENTS FOUND', p.lime(p.bold(String(found))), rule));
76
+ }
77
+ else {
78
+ lines.push(...caption(p, 'DONE', '', rule));
79
+ }
80
+ for (const agent of agents) {
81
+ const a = r.agents.find((x) => x.agent === agent);
82
+ // The agent's settings before its skill: the settings are what change how it runs.
83
+ const files = r.files.filter((f) => f.agent === agent).sort((x, y) => Number(!x.diff) - Number(!y.diff));
84
+ const skip = (r.skipped ?? []).filter((s) => s.agent === agent);
85
+ const manual = a?.provider?.manual ?? [];
86
+ const warnings = a?.provider?.warnings ?? [];
87
+ const detail = [];
88
+ if (mode === 'done') {
89
+ // Turning Phantom off for an agent that was never on changed nothing, so it gets no check mark.
90
+ const unchanged = !files.length && a?.provider && !a.provider.on;
91
+ const mark = skip.length ? p.red('✗') : manual.length ? p.yellow('!') : unchanged ? p.dim('·') : p.lime('✓');
92
+ lines.push(` ${mark} ${name(agent)}${doneSummary(agent, a, files.length, skip.length > 0, p)}`);
93
+ }
94
+ else {
95
+ // The files first, so the eye reads down one column of paths.
96
+ const [first, ...rest] = files.map((f) => fileRow(f, p, short, pathWidth));
97
+ lines.push(` ${name(agent)}${first ?? p.dim(manual.length ? 'nothing to write' : 'already set up')}`);
98
+ for (const row of rest)
99
+ detail.push(pad + row);
100
+ if (opts.diff) {
101
+ for (const f of files.filter((x) => x.diff)) {
102
+ detail.push('', ...f.diff.split('\n').map((row) => pad + ' ' + diffRow(row, p)), '');
103
+ }
104
+ }
105
+ }
106
+ for (const s of skip)
107
+ detail.push(pad + p.red(`skipped: ${s.reason}`));
108
+ for (const w of warnings)
109
+ detail.push(pad + p.yellow(w));
110
+ // Steps to do by hand show once, after the changes, when they can be done.
111
+ if (manual.length && mode !== 'done')
112
+ detail.push(pad + p.yellow(`then a few steps by hand in ${a?.provider?.settings}, shown after`));
113
+ else if (manual.length) {
114
+ detail.push(pad + p.yellow(`In ${a?.provider?.settings}:`));
115
+ // Each step wraps under its own first word, not under the number.
116
+ const room = Math.max(30, (opts.columns ?? 100) - pad.length - 5);
117
+ manual.forEach((step, k) => {
118
+ const [first, ...rest] = wrapWords(step, room);
119
+ detail.push(`${pad} ${p.dim(`${k + 1}.`)} ${first}`, ...rest.map((l) => `${pad} ${l}`));
120
+ });
121
+ }
122
+ lines.push(...detail);
123
+ }
124
+ for (const s of skipped)
125
+ lines.push(` ${mode === 'done' ? p.red('✗') + ' ' : ' '}${name(s.agent)}${p.red(`skipped: ${s.reason}`)}`);
126
+ const notes = r.notes ?? [];
127
+ if (notes.length)
128
+ lines.push('', ...notes.map((n) => ` ${n}`));
129
+ if (mode === 'ask' || mode === 'dry') {
130
+ // The file list says where; these lines say what it means, in plain words, before anyone reads a diff.
131
+ const routes = r.files.some((f) => f.diff && f.agent !== 'shell');
132
+ const skills = r.files.some((f) => !f.diff);
133
+ if (routes || skills)
134
+ lines.push('');
135
+ if (routes) {
136
+ 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.`);
138
+ }
139
+ if (skills)
140
+ lines.push(` ${p.dim('skill:')} a short note that teaches the agent the phantom commands.`);
141
+ // Most people only see the 'ask' screen, so its hint is the plain command they just ran, plus --diff.
142
+ if (!opts.diff && opts.hint !== false && r.files.some((f) => f.diff)) {
143
+ lines.push('', mode === 'ask' ? ` To see every line first, answer n and run: ${p.cyan('phantom setup --diff')}` : ` To see every line, add ${p.cyan('--diff')}.`);
144
+ }
145
+ if (mode === 'dry')
146
+ lines.push(p.dim(' This was a preview. Nothing was changed.'));
147
+ }
148
+ else if (!r.files.length && !(r.skipped ?? []).length && !r.agents.some((a) => a.provider?.manual?.length)) {
149
+ const off = r.agents.length > 0 && r.agents.every((a) => a.provider && !a.provider.on);
150
+ lines.push(off ? ` ${p.dim('·')} Phantom AI was already off for these agents. Nothing to change.` : ` ${p.lime('✓')} Everything is already set up. Nothing to change.`);
151
+ }
152
+ else if (r.files.length) {
153
+ lines.push('', ...nextStep(r, p, opts.columns));
154
+ lines.push('', inApp() ? ` ${p.dim('Undo any time: More, then Undo setup.')}` : ` Undo any time: ${p.cyan('phantom setup --restore')}`);
155
+ }
156
+ return lines.join('\n');
157
+ }
158
+ /** What a file is, in the words the screen uses: its settings (an agent's, or the shell's ~/.zshrc), or its skill. */
159
+ function kind(f) {
160
+ if (!f.diff)
161
+ return 'skill';
162
+ // ~/.zshrc is the shell's own settings file, and the row already says "Your shell".
163
+ return 'settings';
164
+ }
165
+ /** What happens to a file, in one short word. */
166
+ const ACTION = { create: 'new', update: 'update', remove: 'remove' };
167
+ /**
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`.
171
+ */
172
+ function fileRow(f, p, short, pathWidth) {
173
+ const action = ACTION[f.action].padEnd(6);
174
+ const { added, removed } = counts(f.diff);
175
+ const change = [added ? p.green(`+${added}`) : '', removed ? p.red(`-${removed}`) : ''].filter(Boolean).join(' ');
176
+ return [
177
+ p.dim(kind(f).padEnd(8)),
178
+ short(f.file).padEnd(pathWidth),
179
+ f.action === 'remove' ? p.red(action) : p.cyan(action),
180
+ change + (f.backup ? p.dim(' copy kept') : ''),
181
+ ]
182
+ .join(' ')
183
+ .trimEnd();
184
+ }
185
+ /** One line of a diff, colored by whether it adds, removes or only shows context. */
186
+ function diffRow(row, p) {
187
+ if (row.startsWith('+'))
188
+ return p.green(row);
189
+ if (row.startsWith('-'))
190
+ return p.red(row);
191
+ return p.dim(row);
192
+ }
193
+ /** After the changes: one plain sentence per agent on what it does now. */
194
+ function doneSummary(agent, a, files, skipped, p) {
195
+ if (skipped)
196
+ return p.red('left as it was.');
197
+ if (agent === 'shell')
198
+ return 'loads your Phantom AI key in new terminals.';
199
+ const prov = a?.provider;
200
+ if (prov?.manual?.length)
201
+ return p.yellow('not finished: 2 minutes by hand:');
202
+ if (prov && !prov.on)
203
+ return !files ? p.dim('already off: nothing to remove.') : agent === 'claude' ? 'back on Anthropic.' : 'Phantom AI settings removed.';
204
+ if (prov?.on) {
205
+ const model = prov.model ? ` ${p.pill(` ${prov.model} `)}` : '';
206
+ return `runs on Phantom AI${model} ${p.dim(`restart ${label(agent)} to pick it up`)}`;
207
+ }
208
+ return files ? 'skill installed.' : p.dim('already set up.');
209
+ }
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' };
212
+ /**
213
+ * The one thing to do after setup, in corner brackets: run the agent. When an
214
+ * agent reads its key from the shell (Codex, Goose and others), the step says
215
+ * to open a new terminal first, and names that agent; otherwise it doesn't.
216
+ */
217
+ function nextStep(r, p, columns) {
218
+ const on = r.agents.filter((a) => a.provider?.on || (!a.provider && r.files.some((f) => f.agent === a.agent)));
219
+ const shell = r.files.some((f) => f.agent === 'shell');
220
+ const readsShell = on.filter((a) => SETUP_AGENTS[a.agent]?.shellEnv);
221
+ const first = (readsShell.length && shell ? readsShell : on).find((a) => START[a.agent]) ?? on[0];
222
+ if (!first)
223
+ return [];
224
+ const cmd = START[first.agent];
225
+ const name = label(first.agent);
226
+ const step = cmd
227
+ ? shell && readsShell.some((a) => a.agent === first.agent)
228
+ ? `${p.lime('→')} ${p.bold('Open a new terminal')}, then run ${p.cyan(cmd)}`
229
+ : `${p.lime('→')} ${p.bold('Next:')} run ${p.cyan(cmd)}`
230
+ : `${p.lime('→')} ${p.bold('Next:')} open ${name}`;
231
+ const lines = framed(p, step, columns);
232
+ if (cmd && ['claude', 'codex', 'pi'].includes(first.agent))
233
+ lines.push(p.dim(` or start it through phantom: phantom ${first.agent}`));
234
+ if (shell && readsShell.length)
235
+ lines.push(p.dim(` ${readsShell.map((a) => label(a.agent)).join(', ')} ${readsShell.length === 1 ? 'reads' : 'read'} your Phantom AI key from the shell, so a new terminal is needed.`));
236
+ return lines;
237
+ }