@phantomcodes/cli 1.2.6 → 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
@@ -68,7 +68,9 @@ Mistral Vibe and Zed. Cursor, VS Code, JetBrains AI Assistant, Warp and Trae
68
68
  keep these settings on their own settings screens, so setup prints the steps;
69
69
  pi gets the agent skill.
70
70
 
71
- Preview without changing files with `phantom setup --dry-run`. The Phantom CLI keeps backups;
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;
72
74
  undo the changes with `phantom setup --restore`.
73
75
 
74
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.6';
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 },
@@ -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();
@@ -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.6",
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",