@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.
@@ -0,0 +1,330 @@
1
+ import { paint } from "../core/style.mjs";
2
+ /** The window's width: given, or the terminal's, or 100 for a pipe. */
3
+ const widthOf = (style) => style.columns ?? process.stdout.columns ?? 100;
4
+ /** How long a caption's rule is: 66, or less in a narrow window. */
5
+ const ruleWidth = (style) => Math.max(20, Math.min(66, widthOf(style) - 4));
6
+ /** Dollars as people read them: two decimals, or four under a cent so small spend doesn't read as $0.00. */
7
+ export function usd(n) {
8
+ const abs = Math.abs(n);
9
+ return `${n < 0 ? '-' : ''}$${abs > 0 && abs < 0.01 ? abs.toFixed(4) : abs.toFixed(2)}`;
10
+ }
11
+ const plural = (n, one) => `${n} ${one}${n === 1 ? '' : 's'}`;
12
+ /** Spend rows line up: a label column, then a bar, then the dollars; both shrink to fit a narrow window. */
13
+ const spendColumns = (style) => (widthOf(style) < 80 ? { LABEL: 22, BAR: 10 } : { LABEL: 30, BAR: 20 });
14
+ /** An uppercase caption, an optional detail beside it, and a thin rule under both. */
15
+ function caption(p, w, text, detail = '') {
16
+ const width = w;
17
+ // The heading in bold, so it's the easiest thing to find, not the quietest.
18
+ return [` ${p.bold(text)}${detail ? ` ${detail}` : ''}`, ` ${p.dim('─'.repeat(width))}`];
19
+ }
20
+ /** True inside the app (phantom alone), which offers next steps as choices, so the typed hint isn't needed. */
21
+ let hintsHidden = false;
22
+ /** True while the app runs, so a screen can point at a menu item instead of a command. */
23
+ export const inApp = () => hintsHidden;
24
+ /** Hides or shows every screen's "→ type this next" line, and returns what it was, to put back after. The app hides them while it runs. */
25
+ export function hideHints(hidden) {
26
+ const was = hintsHidden;
27
+ hintsHidden = hidden;
28
+ return was;
29
+ }
30
+ /** The last line of a screen: the one command to type next, in cyan. Not shown inside the app. */
31
+ function next(p, words, command) {
32
+ return hintsHidden ? [] : ['', ` ${p.lime('→')} ${words} ${p.cyan(command)}`];
33
+ }
34
+ /** A thin bar `width` cells long at `share` of full (0 to 1), in `fill`, on a dimmed track: the site's cost bars. */
35
+ export function bar(p, share, width, fill) {
36
+ const cells = Math.max(0, Math.min(width, Math.round(share * width)));
37
+ // A share above zero always shows at least a sliver, so a small spender isn't drawn as nothing.
38
+ const shown = share > 0 && cells === 0 ? 1 : cells;
39
+ return fill('━'.repeat(shown)) + p.dim('─'.repeat(width - shown));
40
+ }
41
+ /** A date as `Nov 1, 2026`, read as UTC, or the text as given when it isn't one. */
42
+ function day(iso) {
43
+ const d = new Date(iso.length === 10 ? `${iso}T00:00:00Z` : iso);
44
+ return Number.isNaN(d.getTime()) ? iso : d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' });
45
+ }
46
+ /** `phantom balance`: the credit in bold, then what it spent and when the key stops working. */
47
+ export function balanceScreen(b, style = {}) {
48
+ const p = paint(style.env ?? {}, Boolean(style.color));
49
+ const w = ruleWidth(style);
50
+ const lines = ['', ...caption(p, w, 'BALANCE'), ` ${p.bold(usd(b.credit_balance_usd))} ${p.dim('this key can spend')}`, ` ${p.dim('the workspace balance, or less when the key has a limit')}`, ''];
51
+ lines.push(` ${p.dim('spent'.padEnd(10))}${usd(b.credit_spent_usd)}`);
52
+ lines.push(` ${p.dim('expires'.padEnd(10))}${day(b.expires_at)}`);
53
+ if (!b.active)
54
+ lines.push('', ` ${p.red('✗')} This key is switched off or past its expiry.`);
55
+ if (b.credit_balance_usd <= 0) {
56
+ lines.push('', ` ${p.lime('✓')} phantom-1 still runs on free models while you have no credit.`);
57
+ lines.push(...next(p, 'Add credit:', 'phantom fund --amount 10 --card'));
58
+ }
59
+ else {
60
+ lines.push(...next(p, 'See what it went on:', 'phantom spend'));
61
+ }
62
+ return lines.join('\n');
63
+ }
64
+ /**
65
+ * `phantom spend`: the total, what phantom-1 saved, the site's two bars (what
66
+ * the calls cost against what the same calls cost on the frontier model), then
67
+ * each model and each key with a bar for its share.
68
+ */
69
+ export function spendScreen(s, style = {}) {
70
+ const p = paint(style.env ?? {}, Boolean(style.color));
71
+ const w = ruleWidth(style);
72
+ const { LABEL, BAR } = spendColumns(style);
73
+ const range = `${day(s.from)} to ${day(s.to)}, UTC`;
74
+ const lines = ['', ...caption(p, w, s.scope === 'workspace' ? 'WORKSPACE SPEND' : 'SPEND', p.dim(range))];
75
+ lines.push(` ${p.bold(usd(s.total.spent_usd))} ${p.dim(`spent on ${plural(s.total.calls, 'call')}`)}`);
76
+ if (s.total.plan_usd > 0)
77
+ lines.push(` ${p.dim(`${usd(s.total.plan_usd)} of it paid by your plan`)}`);
78
+ const r = s.routing;
79
+ if (r.available && r.calls > 0) {
80
+ // The site's comparison: what phantom-1 cost, in lime, against the same calls on the frontier model.
81
+ const most = Math.max(r.spent_usd, r.baseline_usd) || 1;
82
+ lines.push('', ` ${p.lime(p.bold(`phantom-1 saved ${usd(r.saved_usd)}`))} ${p.dim(`on ${plural(r.calls, 'call')}`)}`);
83
+ // Labelled by what they mean, with the model the comparison uses said once underneath.
84
+ lines.push(` ${'You paid'.padEnd(LABEL)}${bar(p, r.spent_usd / most, BAR, p.lime)} ${usd(r.spent_usd).padStart(8)}`);
85
+ lines.push(` ${'Without phantom-1'.padEnd(LABEL)}${bar(p, r.baseline_usd / most, BAR, p.white)} ${usd(r.baseline_usd).padStart(8)}`);
86
+ lines.push(` ${p.dim(`without phantom-1: the same calls, all on ${r.baseline_model}`)}`);
87
+ }
88
+ else if (!r.available) {
89
+ lines.push('', ` ${p.dim(`phantom-1 savings not shown: ${r.reason}`)}`);
90
+ }
91
+ if (s.models.length) {
92
+ const top = s.models[0].spent_usd || 1;
93
+ lines.push('', ...caption(p, w, 'BY MODEL'));
94
+ for (const m of s.models.slice(0, 8)) {
95
+ lines.push(` ${m.model.slice(0, LABEL - 1).padEnd(LABEL)}${bar(p, m.spent_usd / top, BAR, p.cyan)} ${usd(m.spent_usd).padStart(8)} ${p.dim(plural(m.calls, 'call'))}`);
96
+ }
97
+ if (s.models.length > 8)
98
+ lines.push(` ${p.dim(`and ${plural(s.models.length - 8, 'more model')}`)}`);
99
+ }
100
+ if (s.keys.length > 1) {
101
+ lines.push('', ...caption(p, w, 'BY KEY'));
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')}`);
105
+ }
106
+ }
107
+ if (!s.total.calls)
108
+ lines.push('', ` ${p.dim('No calls in this range yet.')}`);
109
+ lines.push(...(s.scope === 'workspace' ? next(p, 'Pick the models phantom-1 runs:', 'phantom lineup') : next(p, 'Every key in the workspace:', 'phantom spend --workspace')));
110
+ return lines.join('\n');
111
+ }
112
+ /** The keys, one row each: name, the start of the key, what it spent, and its limit. */
113
+ function keyRows(p, keys) {
114
+ if (!keys.length)
115
+ return [` ${p.dim('No keys yet.')}`];
116
+ return keys.map((k) => {
117
+ const limit = k.limit_usd != null ? `limit ${usd(k.limit_usd)}` : k.budget_usd != null ? `${usd(k.budget_usd)} every ${plural(k.period_days ?? 30, 'day')}` : 'no limit';
118
+ const mark = k.is_active === false ? p.dim('off') : p.lime('●');
119
+ // The id's first 8 characters: enough for --id, which also takes the name.
120
+ return ` ${mark} ${(k.name ?? 'Key').slice(0, 15).padEnd(16)}${p.dim((k.id ?? '').slice(0, 8).padEnd(10))}${p.dim((k.prefix ?? '').slice(0, 18).padEnd(20))}${usd(k.spent_usd ?? 0).padStart(8)} ${p.dim(limit)}`;
121
+ });
122
+ }
123
+ /** `phantom workspace`: the shared balance, each key, and the last few charges. */
124
+ export function workspaceScreen(view, style = {}) {
125
+ const p = paint(style.env ?? {}, Boolean(style.color));
126
+ const w = ruleWidth(style);
127
+ const lines = ['', ...caption(p, w, 'WORKSPACE', p.dim(`${view.name ?? ''}${view.account_linked ? ' · signed-in account' : ' · guest'}`))];
128
+ lines.push(` ${p.bold(usd(view.balance_usd))} ${p.dim('shared balance')}${view.held_usd ? p.dim(` ${usd(view.held_usd)} held for requests running now`) : ''}`);
129
+ lines.push('', ...caption(p, w, 'KEYS', p.dim(String(view.keys.length))), ...keyRows(p, view.keys));
130
+ const charges = (view.charges ?? []).slice(0, 5);
131
+ if (charges.length) {
132
+ lines.push('', ...caption(p, w, 'LATEST CALLS'));
133
+ // Single calls cost fractions of a cent, so this column always shows four places and lines up.
134
+ for (const c of charges)
135
+ lines.push(` ${(c.model ?? '').slice(0, 33).padEnd(34)}${`$${(c.cost_usd ?? 0).toFixed(4)}`.padStart(8)} ${p.dim(c.why ?? '')}`.trimEnd());
136
+ }
137
+ lines.push(...next(p, 'Make a key for another agent:', 'phantom keys create --save <name>'));
138
+ return lines.join('\n');
139
+ }
140
+ /** `phantom keys`: the workspace's keys alone. */
141
+ export function keysScreen(keys, style = {}) {
142
+ const p = paint(style.env ?? {}, Boolean(style.color));
143
+ const w = ruleWidth(style);
144
+ return ['', ...caption(p, w, 'KEYS', p.dim(String(keys.length))), ...keyRows(p, keys), ...next(p, 'Set a spending limit:', 'phantom keys set --id <name or id> --limit <USD>')].join('\n');
145
+ }
146
+ /** After `phantom login`: signed in, where the key is kept, and the next step. */
147
+ export function signedInScreen(saved, style = {}) {
148
+ const p = paint(style.env ?? {}, Boolean(style.color));
149
+ const w = ruleWidth(style);
150
+ const where = saved.saved_as === 'default' ? 'Your key is' : `Key "${saved.saved_as}" is`;
151
+ 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');
152
+ }
153
+ /** A ✓ line with an optional dim detail, for a change that worked. */
154
+ function done(p, words, detail = '') {
155
+ return ` ${p.lime('✓')} ${words}${detail ? ` ${p.dim(detail)}` : ''}`;
156
+ }
157
+ /** `phantom route`: which models "auto" picks from, what it does at the edges, and its rules, in sentences. */
158
+ export function routeScreen(r, style = {}) {
159
+ const p = paint(style.env ?? {}, Boolean(style.color));
160
+ const w = ruleWidth(style);
161
+ const lines = ['', ...caption(p, w, 'ROUTING', p.dim('how model "auto" picks for this key'))];
162
+ const policy = r.route_policy;
163
+ if (!policy) {
164
+ lines.push(' No route policy, so requests for model "auto" are refused.', ` ${p.dim('phantom-1 picks a model for you without one.')}`);
165
+ lines.push(...next(p, 'Make one:', 'phantom route set --models a,b,c'));
166
+ return lines.join('\n');
167
+ }
168
+ const row = (label, value) => ` ${p.dim(label.padEnd(14))}${value}`;
169
+ // One model a line, in the order "auto" tries them, so a long list doesn't wrap.
170
+ policy.models.forEach((m, k) => lines.push(row(k === 0 ? 'models' : '', `${p.dim(`${k + 1}.`)} ${k === 0 ? `${p.bold(m)} ${p.dim('the default')}` : m}`)));
171
+ lines.push(row('applies to', policy.applies_to === 'all' ? 'every request' : 'requests for model "auto"'));
172
+ lines.push(row('cap used up', policy.on_empty === 'cheapest' ? 'keep going on the cheapest model' : 'stop and refuse requests'));
173
+ lines.push(row('a model fails', policy.fallback_on_error ? 'try the next one' : 'stop there'));
174
+ const stick = policy.stick_minutes ?? 5;
175
+ lines.push(row('conversations', stick ? `stay on their model for ${plural(stick, 'minute')}${policy.stick_by_prompt === false ? ' (by x-phantom-session only)' : ''}` : 'can change model every request'));
176
+ const rules = policy.rules ?? [];
177
+ lines.push('', ...caption(p, w, 'RULES', p.dim(rules.length ? 'checked in order; the first that matches picks' : '')));
178
+ if (!rules.length)
179
+ lines.push(` ${p.dim('None: "auto" runs the first model.')}`);
180
+ rules.forEach((rule, k) => {
181
+ const when = Object.entries(rule.if).map(([name, value]) => `${name} = ${value}`).join(' and ');
182
+ lines.push(` ${p.dim(`${k + 1}.`)} if ${when}, use ${p.bold(rule.use)}`);
183
+ });
184
+ lines.push(...next(p, 'See what a request would get now:', 'phantom route test'));
185
+ return lines.join('\n');
186
+ }
187
+ /** `phantom route test`: the model a request would run on right now, and why. */
188
+ export function routeTestScreen(t, style = {}) {
189
+ const p = paint(style.env ?? {}, Boolean(style.color));
190
+ const w = ruleWidth(style);
191
+ const lines = [''];
192
+ if (!t.model)
193
+ lines.push(` ${p.red('✗')} A request now would be refused: this key's cap is used up.`);
194
+ else
195
+ lines.push(` ${p.lime('→')} A request now would run on ${p.bold(t.model)}`, ` ${p.dim(t.routed === false ? 'it names its own model, so the route policy is not used' : `because: ${t.reason ?? 'default'}`)}`);
196
+ if (t.fallbacks?.length)
197
+ lines.push(` ${p.dim(`then, if it fails: ${t.fallbacks.join(', ')}`)}`);
198
+ return lines.join('\n');
199
+ }
200
+ /** What each tier is for, in a few words. */
201
+ export const TIER_ABOUT = { economy: 'short, easy requests', standard: 'everyday coding', frontier: 'the hardest work' };
202
+ /** `phantom lineup`: the model phantom-1 runs in each tier, whether it's the default or your pick, and its fallback. */
203
+ export function lineupScreen(l, style = {}) {
204
+ const p = paint(style.env ?? {}, Boolean(style.color));
205
+ const w = ruleWidth(style);
206
+ const lines = ['', ...caption(p, w, 'LINEUP', p.dim('the model phantom-1 runs in each tier'))];
207
+ for (const tier of ['economy', 'standard', 'frontier']) {
208
+ const mine = Boolean(l.lineup?.[tier]);
209
+ const fallback = l.lineup?.fallbacks?.[tier];
210
+ lines.push(` ${p.bold(tier.toUpperCase().padEnd(10))}${l.tiers[tier].model.padEnd(36)}${mine ? p.lime('your pick') : p.dim('default')}`);
211
+ lines.push(` ${' '.repeat(10)}${p.dim(TIER_ABOUT[tier])}${fallback ? p.dim(` · falls back to ${fallback}`) : ''}`);
212
+ }
213
+ lines.push(...next(p, 'Pick a model for a tier:', 'phantom lineup set'));
214
+ return lines.join('\n');
215
+ }
216
+ /** A model's price per million tokens, in and out, for a picker's hint. */
217
+ export function priceHint(c) {
218
+ const price = c.input_per_m === null || c.output_per_m === null ? 'price not listed now' : `$${c.input_per_m.toFixed(2)} in · $${c.output_per_m.toFixed(2)} out per 1M`;
219
+ const context = c.context ? ` · ${Math.round(c.context / 1000)}k context` : '';
220
+ return `${price}${context}${c.default ? ' · default' : ''}`;
221
+ }
222
+ /** `phantom plan`: the plan, as a lime pill, and how much of this week it has used, as a bar. */
223
+ export function planScreen(pl, style = {}) {
224
+ const p = paint(style.env ?? {}, Boolean(style.color));
225
+ const w = ruleWidth(style);
226
+ const lines = ['', ...caption(p, w, 'PLAN')];
227
+ if (!pl.plan) {
228
+ if (!pl.available)
229
+ return [...lines, ' No plan, and plans are not open right now.'].join('\n');
230
+ lines.push(' No plan. A plan pays for phantom-1 each week, in place of credit, for every key in this workspace.');
231
+ if (pl.prices) {
232
+ lines.push('');
233
+ for (const name of ['pro', 'max']) {
234
+ const o = pl.prices[name];
235
+ lines.push(` ${p.bold((name === 'max' ? 'Max' : 'Pro').padEnd(6))}$${o.usd_per_month} a month ${p.dim(`pays for $${o.week_limit_usd} of model cost a week; past that, credit`)}`);
236
+ }
237
+ }
238
+ lines.push(...next(p, 'Start one:', 'phantom plan pro'), ...(hintsHidden ? [] : [` ${p.dim(' or')} ${p.cyan('phantom plan max')}`]));
239
+ return lines.join('\n');
240
+ }
241
+ const share = pl.week_limit_usd ? pl.week_used_usd / pl.week_limit_usd : 0;
242
+ lines.push(` ${p.pill(` ${pl.plan === 'max' ? 'Max' : 'Pro'} `)} ${pl.status === 'active' ? p.lime('active') : p.yellow(pl.status ?? '')}`, '');
243
+ lines.push(` ${p.dim('this week'.padEnd(12))}${bar(p, share, 24, share > 0.9 ? p.yellow : p.lime)} ${usd(pl.week_used_usd)} of ${usd(pl.week_limit_usd)}`);
244
+ if (pl.resets_at)
245
+ lines.push(` ${p.dim('room frees'.padEnd(12))}from ${day(pl.resets_at)}`);
246
+ if (pl.renews_at)
247
+ lines.push(` ${p.dim('renews'.padEnd(12))}${day(pl.renews_at)}`);
248
+ lines.push(...next(p, 'Card, switch or cancel:', 'phantom plan manage'));
249
+ return lines.join('\n');
250
+ }
251
+ /** A link to open in the browser (Stripe Checkout, billing, a wallet), with what it's for and the next step. */
252
+ export function linkScreen(words, url, after, style = {}) {
253
+ const p = paint(style.env ?? {}, Boolean(style.color));
254
+ const w = ruleWidth(style);
255
+ return ['', ` ${p.lime('◆')} ${p.bold(words)}`, ` ${p.dim(style.opened ? 'Opened in your browser:' : 'Open:')} ${url}`, ...(after ? next(p, after[0], after[1]) : [])].join('\n');
256
+ }
257
+ /** `phantom fund`: the card checkout link, or the wallet payment link with its id to check on. */
258
+ export function fundScreen(f, style = {}) {
259
+ if (f.checkout_url) {
260
+ const total = f.amount_cents ? ` (${usd(f.amount_cents / 100)} with the card fee)` : '';
261
+ return linkScreen(`Pay by card on Stripe${total}`, f.checkout_url, ['Once it goes through:', 'phantom balance'], style);
262
+ }
263
+ const p = paint(style.env ?? {}, Boolean(style.color));
264
+ const w = ruleWidth(style);
265
+ return [
266
+ linkScreen(`Pay ${f.amount ?? ''} ${(f.coin ?? '').toUpperCase()} from your wallet`.replace(/\s+/g, ' '), f.solana_pay_url ?? '', null, style),
267
+ ` ${p.dim('payment')} ${f.payment_id ?? ''}${f.expires_at ? p.dim(` · good until ${day(f.expires_at)}`) : ''}`,
268
+ ...next(p, 'Check it landed:', `phantom workspace payment --id ${f.payment_id ?? '<id>'}`),
269
+ ].join('\n');
270
+ }
271
+ /** `phantom workspace payment`: whether a payment has come in. */
272
+ export function paymentScreen(pay, style = {}) {
273
+ const p = paint(style.env ?? {}, Boolean(style.color));
274
+ const w = ruleWidth(style);
275
+ const landed = pay.status === 'completed';
276
+ return ['', landed ? done(p, `Paid. ${usd(pay.credit_usd ?? 0)} of credit is in your workspace.`) : ` ${p.yellow('…')} Not in yet ${p.dim(`(${pay.status})`)}. Payments can take a minute.`, ...(landed ? next(p, 'See it:', 'phantom balance') : [])].join('\n');
277
+ }
278
+ /** What a key change did: created, rotated, revoked or updated, and what to do next. */
279
+ export function keyChangeScreen(change, name, style = {}) {
280
+ const p = paint(style.env ?? {}, Boolean(style.color));
281
+ const w = ruleWidth(style);
282
+ const named = name && name !== 'default' ? `"${name}"` : 'your default key';
283
+ const lines = [''];
284
+ if (change === 'created') {
285
+ lines.push(done(p, `Key ${named} created${style.limit != null ? ` with a ${usd(style.limit)} limit` : ''}.`, `saved in your ${style.storage ?? 'macOS Keychain'}`));
286
+ lines.push(...next(p, 'Run an agent on it:', `phantom codex --key ${name ?? 'default'}`));
287
+ }
288
+ else if (change === 'rotated') {
289
+ lines.push(done(p, `Key rotated. ${named} is the new one; the old one stops working.`));
290
+ }
291
+ else if (change === 'revoked') {
292
+ lines.push(done(p, 'Key revoked. Its credit stays in the workspace.'), ...next(p, 'See your keys:', 'phantom keys'));
293
+ }
294
+ else {
295
+ lines.push(done(p, 'Key updated.'), ...next(p, 'See it:', 'phantom keys'));
296
+ }
297
+ return lines.join('\n');
298
+ }
299
+ /** `phantom logout`: signed out on this computer, or nothing to sign out of. */
300
+ export function logoutScreen(removed, style = {}) {
301
+ const p = paint(style.env ?? {}, Boolean(style.color));
302
+ const w = ruleWidth(style);
303
+ const lines = ['', removed ? done(p, 'Signed out on this computer.', 'your workspace, keys and credit are unchanged') : ` ${p.dim('Not signed in on this computer, so nothing changed.')}`];
304
+ if (style.removedNamed?.length)
305
+ lines.push(` ${p.lime('✓')} Forgot the keys saved here: ${style.removedNamed.join(', ')}. ${p.dim('They still work wherever else they are.')}`);
306
+ if (style.kept?.length)
307
+ lines.push(` ${p.dim(`Still saved here: ${plural(style.kept.length, 'key')} by name (${style.kept.join(', ')}). To forget them too:`)} ${p.cyan('phantom logout --all')}`);
308
+ lines.push(...next(p, 'Sign in again:', 'phantom login'));
309
+ return lines.join('\n');
310
+ }
311
+ /** `phantom keys saved`: the keys saved on this computer, by name. */
312
+ export function savedKeysScreen(keys, style = {}) {
313
+ const p = paint(style.env ?? {}, Boolean(style.color));
314
+ const w = ruleWidth(style);
315
+ const lines = ['', ...caption(p, w, 'KEYS ON THIS COMPUTER', p.dim(String(keys.length)))];
316
+ if (!keys.length)
317
+ lines.push(` ${p.dim('None saved yet.')}`);
318
+ for (const k of keys) {
319
+ const credit = k.credit_balance_usd === undefined ? '' : usd(k.credit_balance_usd).padStart(10);
320
+ lines.push(` ${k.active === false ? p.dim('off') : p.lime('●')} ${k.name.slice(0, 19).padEnd(20)}${p.dim(k.id.slice(0, 12).padEnd(14))}${credit}`);
321
+ }
322
+ lines.push(...next(p, 'Save another:', 'phantom keys save <name>'));
323
+ return lines.join('\n');
324
+ }
325
+ /** `phantom keys save <name>`: the key is saved under its name, and how to run an agent on it. */
326
+ export function keySavedScreen(name, style = {}) {
327
+ const p = paint(style.env ?? {}, Boolean(style.color));
328
+ const w = ruleWidth(style);
329
+ return ['', done(p, `Key "${name}" saved on this computer.`), ...next(p, 'Run an agent on it:', `phantom codex --key ${name}`)].join('\n');
330
+ }
@@ -0,0 +1,142 @@
1
+ // What `phantom` alone prints: the way in. With no key yet, one command to
2
+ // start, `phantom setup`. Once signed in, the handful of commands people use
3
+ // day to day. It reads no network, so it prints at once, even offline.
4
+ import { brandHeader } from "../core/brand.mjs";
5
+ import { paint } from "../core/style.mjs";
6
+ /** One row: a command in cyan, padded, then what it does. */
7
+ const row = (p, command, about) => ` ${p.cyan(command.padEnd(18))}${about}`;
8
+ /** An uppercase caption over a thin rule, like the site's section labels. */
9
+ const caption = (p, text) => [` ${p.bold(text)}`, ` ${p.dim('─'.repeat(56))}`];
10
+ /**
11
+ * The start screen. `signedIn` is whether a key is saved or set, so it shows
12
+ * the first step or the everyday commands.
13
+ */
14
+ export function startScreen(env, color, signedIn) {
15
+ const p = paint(env, color);
16
+ const lines = ['', ...brandHeader(p, color, '')];
17
+ 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
+ }
20
+ else {
21
+ 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'));
22
+ }
23
+ lines.push('', ` All commands: ${p.cyan('phantom --help')}`, '');
24
+ return lines.join('\n');
25
+ }
26
+ /**
27
+ * How many single-letter edits turn `a` into `b`: a letter added, removed or
28
+ * changed, or two neighbours swapped ("yse" is one edit from "yes").
29
+ */
30
+ export function editDistance(a, b) {
31
+ const d = Array.from({ length: a.length + 1 }, (_, i) => Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)));
32
+ for (let i = 1; i <= a.length; i++) {
33
+ for (let j = 1; j <= b.length; j++) {
34
+ d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
35
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1])
36
+ d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);
37
+ }
38
+ }
39
+ return d[a.length][b.length];
40
+ }
41
+ /**
42
+ * The command `typed` was most likely meant to be: the nearest name at most
43
+ * two edits away, or null. On a tie, the one that starts with the same letter.
44
+ */
45
+ export function closestCommand(typed, names) {
46
+ const t = typed.toLowerCase();
47
+ let best = null;
48
+ let bestScore = 3;
49
+ for (const name of names) {
50
+ // A different first letter counts a little against a guess, so "yse" means "yes", not "use".
51
+ const score = editDistance(t, name) + (name[0] === t[0] ? 0 : 0.5);
52
+ if (score < bestScore)
53
+ [best, bestScore] = [name, score];
54
+ }
55
+ return best;
56
+ }
57
+ /** The title block over the start menu: the ghost and PHANTOM. */
58
+ export function startHeader(env, color) {
59
+ return ['', ...brandHeader(paint(env, color), color, '')].join('\n');
60
+ }
61
+ /**
62
+ * The start menu's choices, each the command it runs. Without a key, setup
63
+ * comes first. Signed in but never set up, finishing setup comes first.
64
+ * Once set up, the agents and the money do, with undo near the end.
65
+ */
66
+ export function startChoices(signedIn, setUp = signedIn) {
67
+ const quit = { label: 'Quit', value: [] };
68
+ if (!signedIn) {
69
+ return [
70
+ { label: 'Set up my coding agents', hint: 'sign in and connect them (phantom-1 is free to start)', value: ['setup'] },
71
+ { label: 'Sign in only', hint: 'or save a Phantom key you already have', value: ['login'] },
72
+ { label: 'See every command', value: ['--help'] },
73
+ quit,
74
+ ];
75
+ }
76
+ const finish = setUp ? [] : [{ label: 'Finish setting up my agents', hint: "you're signed in, but no agent uses Phantom yet", value: ['setup'] }];
77
+ return [
78
+ ...finish,
79
+ { label: 'Start Claude Code', hint: 'on Phantom AI', value: ['claude'] },
80
+ { label: 'Start Codex', hint: 'on Phantom AI', value: ['codex'] },
81
+ { label: 'Start pi', hint: 'on Phantom AI', value: ['pi'] },
82
+ { label: 'Balance', hint: 'your credit', value: ['balance'] },
83
+ { label: 'Spend', hint: 'what you spent, and what phantom-1 saved', value: ['spend'] },
84
+ { label: 'Add credit', hint: 'by card or crypto', value: ['fund'] },
85
+ ...(setUp ? [{ label: 'Set up more agents', hint: 'or change how they run', value: ['setup'] }] : []),
86
+ ...(setUp ? [{ label: 'Undo setup', hint: 'put your agents back how they were', value: ['setup', '--restore'] }] : []),
87
+ { label: 'See every command', value: ['--help'] },
88
+ quit,
89
+ ];
90
+ }
91
+ /** "Unknown <command> subcommand" with the nearest real one, or the list when nothing is close. */
92
+ export function unknownSub(command, typed, subs) {
93
+ const guess = closestCommand(typed, subs);
94
+ return guess
95
+ ? `Unknown phantom ${command} subcommand "${typed}". Did you mean phantom ${command} ${guess}?`
96
+ : `Unknown phantom ${command} subcommand "${typed}". It takes: ${subs.join(', ')}.`;
97
+ }
98
+ /**
99
+ * Words people type instead of a command, and the command they mean. These
100
+ * run as that command, and stay out of --help.
101
+ */
102
+ export const ALIASES = {
103
+ help: ['--help'],
104
+ undo: ['setup', '--restore'],
105
+ uninstall: ['setup', '--restore'],
106
+ signin: ['login'],
107
+ 'sign-in': ['login'],
108
+ signout: ['logout'],
109
+ 'sign-out': ['logout'],
110
+ add: ['fund'],
111
+ topup: ['fund'],
112
+ 'top-up': ['fund'],
113
+ credit: ['fund'],
114
+ savings: ['spend'],
115
+ usage: ['spend'],
116
+ costs: ['spend'],
117
+ status: ['workspace'],
118
+ start: [],
119
+ menu: [],
120
+ };
121
+ /** Words in a sentence typed as a command ("how do i add money"), and the command they point at. */
122
+ const HINTS = [
123
+ [/^(money|credit|fund|pay|topup|top-up|card)$/, 'fund'],
124
+ [/^(charged|charge|cost|costs|spent|spend|saving|savings|bill|billing)$/, 'spend'],
125
+ [/^(undo|uninstall|remove|revert|reset)$/, 'setup --restore'],
126
+ [/^(setup|connect|install|configure)$/, 'setup'],
127
+ [/^(balance|left)$/, 'balance'],
128
+ [/^(key|keys)$/, 'keys'],
129
+ [/^(claude|codex)$/, 'claude'],
130
+ [/^(plan|subscription|subscribe)$/, 'plan'],
131
+ [/^(login|signin|sign)$/, 'login'],
132
+ ];
133
+ /** The command a sentence most likely means, from the first word in it that points at one, or null. */
134
+ export function commandFromWords(words) {
135
+ for (const word of words) {
136
+ const w = word.toLowerCase().replace(/[^a-z-]/g, '');
137
+ const hit = HINTS.find(([re]) => re.test(w));
138
+ if (hit)
139
+ return hit[1];
140
+ }
141
+ return null;
142
+ }
@@ -0,0 +1,31 @@
1
+ /** The ghost in block characters: white, eyes set to the right, a wavy hem, and a thin mint edge on its right side. */
2
+ export const GHOST = [
3
+ [' ▄████▄', ''],
4
+ ['████ █ ', '▌'],
5
+ ['███████', '▌'],
6
+ ['▀█▀█▀█▀', ''],
7
+ ];
8
+ /** PHANTOM in three rows of half blocks. Each letter is its three rows, top to bottom. */
9
+ const LETTERS = {
10
+ P: ['█▀▀▄', '█▀▀ ', '▀ '],
11
+ H: ['█ █', '█▀▀█', '▀ ▀'],
12
+ A: ['▄▀▀▄', '█▀▀█', '▀ ▀'],
13
+ N: ['█▄ █', '█ ▀█', '▀ ▀'],
14
+ T: ['▀█▀', ' █ ', ' ▀ '],
15
+ O: ['▄▀▀▄', '█ █', ' ▀▀ '],
16
+ M: ['█▄ ▄█', '█ ▀ █', '▀ ▀'],
17
+ };
18
+ export const WORDMARK = [0, 1, 2].map((row) => [...'PHANTOM'].map((c) => LETTERS[c][row]).join(' '));
19
+ /** The one line that says what phantom does, everywhere the CLI describes itself. */
20
+ export const TAGLINE = 'Points your coding agents at Phantom AI.';
21
+ /**
22
+ * A screen's title block: the ghost, PHANTOM beside it, and `label` in lime
23
+ * with `line` along the ghost's hem. For example `setup Points your coding
24
+ * agents at Phantom AI.` Without color, a plain one-line title instead.
25
+ */
26
+ export function brandHeader(p, color, label, line = TAGLINE) {
27
+ if (!color)
28
+ return [` ${p.bold(`phantom ${label}`.trim())} ${line}`, ''];
29
+ const side = [...WORDMARK.map((w) => p.white(w)), `${label ? `${p.bold(p.lime(label))} ` : ''}${line}`];
30
+ return [...GHOST.map(([body, rim], k) => ` ${p.white(body)}${p.lime(rim)}${rim ? '' : ' '} ${side[k]}`.trimEnd()), ''];
31
+ }
@@ -34,6 +34,6 @@ export function die(msg, code = 'cli_error', exitCode = 1) {
34
34
  throw new CliError(msg, code, exitCode);
35
35
  }
36
36
  /** How to get a key, for every message about a missing one. */
37
- export const GET_A_KEY = 'Set PHANTOM_API_KEY, or run: phantom login to connect a workspace';
37
+ export const GET_A_KEY = 'Run phantom login to sign in, or set PHANTOM_API_KEY.';
38
38
  /** The message for a command that needs a key and has none. */
39
- export const NO_KEY = `No API key. ${GET_A_KEY}`;
39
+ export const NO_KEY = `No Phantom key yet. ${GET_A_KEY}`;
@@ -1,26 +1,59 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { PhantomApiError, CliError, die } from "./errors.mjs";
3
+ import { colorOn, paint } from "./style.mjs";
4
+ import { Cancelled, canPick } from "./pick.mjs";
5
+ /**
6
+ * Where a picker draws and reads, when this run can show one: a person at a
7
+ * terminal (or keys a test scripted), not CI. Null means use flags and the
8
+ * plain prompts instead.
9
+ */
10
+ export function pickIo(input) {
11
+ const { io, env } = input;
12
+ if (!(io.keys ? io.isTTY : canPick(env, io.isTTY)))
13
+ return null;
14
+ // A command started from the menu goes back to it on Esc; one typed on its own quits.
15
+ return { write: io.stderr, env, keys: io.keys, esc: io.fromMenu ? 'back' : 'quit', columns: process.stderr.columns };
16
+ }
17
+ /**
18
+ * Whether a command should draw its screen rather than print JSON: with
19
+ * `--table`, or when stdout is a terminal, unless `--json` asks for JSON.
20
+ * Scripts and agents read stdout through a pipe, so they keep getting JSON.
21
+ */
22
+ export function showsScreen(input) {
23
+ return input.table || (Boolean(input.io.stdoutIsTTY) && !input.flags['json']);
24
+ }
25
+ /**
26
+ * A phantom command inside a sentence: `phantom`, its name, a second word for
27
+ * the commands that take one (`phantom key show`), then any flags. For
28
+ * example "Run phantom setup to sign in" matches `phantom setup` only.
29
+ */
30
+ const COMMAND_IN_TEXT = /\bphantom [a-z][a-z-]*(?:(?<=(?:key|keys|workspace|plan|route|route rule)) [a-z][a-z-]*)?(?: --?[a-z][a-z-]*(?: <[^>]+>)?)*/g;
3
31
  /**
4
32
  * Prints an error to stderr as one line of JSON, `{"error":{...}}`, and
5
33
  * returns the exit code: 2 for a key the API rejected (401 or 403), the
6
34
  * {@link CliError}'s own code, or 1 for anything else.
7
35
  */
8
- export function handleError(err, stderr = (s) => process.stderr.write(s)) {
9
- if (err instanceof PhantomApiError) {
10
- stderr(JSON.stringify({
11
- error: { status: err.status, code: err.code, message: err.message },
12
- }) + '\n');
13
- return err.status === 401 || err.status === 403 ? 2 : 1;
36
+ export function handleError(err, stderr = (s) => process.stderr.write(s), opts = {}) {
37
+ // Ctrl+C in a picker: stop, with the code shells use for it (128 + SIGINT), and no error to read.
38
+ if (err instanceof Cancelled)
39
+ return 130;
40
+ const api = err instanceof PhantomApiError ? err : null;
41
+ const code = api ? api.code : err instanceof CliError ? err.code : 'unknown';
42
+ const message = err instanceof Error ? err.message : String(err);
43
+ if (opts.human) {
44
+ // A person at a terminal reads a sentence; the code stays for support, dimmed.
45
+ const p = paint(opts.env ?? {}, colorOn(opts.env ?? {}, true));
46
+ const lit = message.replace(COMMAND_IN_TEXT, (cmd) => p.cyan(cmd));
47
+ // The code only when it says more than "an error": HTTP 401, invalid_key helps support; cli_error doesn't.
48
+ const detail = api ? `HTTP ${api.status}, ${code}` : code === 'cli_error' || code === 'unknown' ? '' : code;
49
+ stderr(`\n ${p.red('✗')} ${lit}\n${detail ? p.dim(` ${detail}`) + '\n' : ''}\n`);
14
50
  }
15
- if (err instanceof CliError) {
16
- stderr(JSON.stringify({
17
- error: { code: err.code, message: err.message },
18
- }) + '\n');
19
- return err.exitCode;
51
+ else {
52
+ stderr(JSON.stringify({ error: { ...(api ? { status: api.status } : {}), code, message } }) + '\n');
20
53
  }
21
- const msg = err instanceof Error ? err.message : String(err);
22
- stderr(JSON.stringify({ error: { code: 'unknown', message: msg } }) + '\n');
23
- return 1;
54
+ if (api)
55
+ return api.status === 401 || api.status === 403 ? 2 : 1;
56
+ return err instanceof CliError ? err.exitCode : 1;
24
57
  }
25
58
  /**
26
59
  * Reads `--name value`, `--name=value` and bare `--name` (true) from the