@phantomcodes/cli 1.2.3 → 1.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,10 +5,15 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
5
5
  import { DEFAULT_BASE_URL, httpsOnly, apiError } from "../core/request.mjs";
6
6
  import { stateDir } from "../core/state.mjs";
7
7
  import { writeSecret, readSecret, removeSecret, keychainOn } from "../core/secrets.mjs";
8
- import { keyCommand } from "../keys/command.mjs";
8
+ import { keyCommand, loginCommand } from "../keys/command.mjs";
9
9
  import { die } from "../core/errors.mjs";
10
- import { openUrl, flagNum } from "../core/helpers.mjs";
11
- import { keyPath, namedKeyPath, saveApiKey, saveNamedKey } from "../keys/saved.mjs";
10
+ import { openUrl, flagNum, pickIo, showsScreen } from "../core/helpers.mjs";
11
+ import { BackToMenu, Cancelled, askText, cancelKeys, choose, keyboard } from "../core/pick.mjs";
12
+ import { spawnSync } from 'node:child_process';
13
+ import { onPath } from "../setup/shared.mjs";
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";
12
17
  const credentialPath = (env) => path.join(stateDir(env), 'workspace');
13
18
  const base = (input) => httpsOnly(input.baseUrl || input.env.PHANTOM_BASE_URL || DEFAULT_BASE_URL).replace(/\/$/, '');
14
19
  async function wire(url, method, endpoint, body, token) {
@@ -21,7 +26,7 @@ async function wire(url, method, endpoint, body, token) {
21
26
  function credentials(input) {
22
27
  const file = credentialPath(input.env);
23
28
  if (!existsSync(file))
24
- die('No workspace connection. Run phantom login.', 'workspace_missing');
29
+ die('Not signed in to a workspace on this computer. Run phantom login to sign in.', 'workspace_missing');
25
30
  const saved = JSON.parse(readSecret(input.env, file));
26
31
  if (saved.base_url !== base(input))
27
32
  die('This workspace belongs to a different API address. Run phantom login for this address.', 'workspace_origin');
@@ -49,19 +54,90 @@ export async function workspaceLogin(input, opts = {}) {
49
54
  const target = name === 'default' ? keyPath(env) : namedKeyPath(env, name);
50
55
  if (existsSync(target))
51
56
  die(`Saved key ${name} already exists. Use --save <new-name>; no key was overwritten.`, 'key_exists');
57
+ // At a terminal, how to sign in is a choice; --no-browser or a pipe skips it.
58
+ const pick = flags['no-browser'] ? null : pickIo(input);
59
+ let noBrowser = Boolean(flags['no-browser']);
60
+ if (pick) {
61
+ const how = await choose(pick, 'How do you want to sign in?', [
62
+ { label: 'Open the browser', hint: 'approve there, and phantom saves your key', value: 'browser' },
63
+ { label: 'Just show the link', hint: 'to open it yourself, or on another device', value: 'link' },
64
+ { label: 'Paste a key I already have', hint: 'starts with sk-phantom-', value: 'paste' },
65
+ ]);
66
+ // Esc: back to the menu when it started this, otherwise stop without saving anything.
67
+ if (how === null && input.io.fromMenu)
68
+ throw new BackToMenu();
69
+ if (how === null) {
70
+ input.io.stderr(' Sign-in cancelled. Nothing was saved.\n');
71
+ return 0;
72
+ }
73
+ if (how === 'paste') {
74
+ await loginCommand({ ...input, subcommand: undefined, flags: {}, io: opts.inSetup ? { ...input.io, stdout: input.io.stderr } : input.io });
75
+ return 0;
76
+ }
77
+ noBrowser = how === 'link';
78
+ }
52
79
  const url = base(input);
53
80
  const start = await wire(url, 'POST', '/auth/device', { client_name: `phantom on ${hostname()}` });
54
81
  if (new URL(start.verification_uri_complete).origin !== new URL(url).origin)
55
82
  die('The approval URL belongs to another site.', 'device_origin');
56
- io.stderr(`Sign in at ${start.verification_uri_complete}\n` +
57
- `Check that the page shows code ${start.user_code}, then approve.\n` +
58
- 'Waiting for you to approve...\n');
59
- if (!flags['no-browser'])
83
+ const lit = colorOn(env, io.isTTY);
84
+ const p = paint(env, lit);
85
+ io.stderr(`\n ${p.lime('◆')} ${p.bold('Sign in to Phantom AI')}\n\n` +
86
+ ` ${p.dim('Open')} ${p.bold(start.verification_uri_complete)}\n` +
87
+ ` ${p.dim('Code')} ${p.lime(p.bold(start.user_code))} ${p.dim('check that the page shows the same code, then approve')}\n\n` +
88
+ // In color the spinner below draws this line, so it moves while sign-in waits.
89
+ (lit ? '' : ` ${p.dim('Waiting for you to approve...')}\n`) +
90
+ (pick ? ` ${p.dim('esc or ctrl+c to cancel')}\n` : ''));
91
+ if (!noBrowser)
60
92
  openUrl(start.verification_uri_complete);
93
+ const spinner = lit ? waiting(io.stderr, p) : null;
94
+ // At a terminal, Esc or Ctrl+C stops the wait, as they do everywhere else.
95
+ const cancel = pick ? cancelKeys((pick.keys ?? keyboard)()) : null;
96
+ try {
97
+ return await pollForApproval(input, opts, { url, start, name, spinner, cancel: cancel?.pressed ?? null });
98
+ }
99
+ finally {
100
+ spinner?.stop();
101
+ cancel?.close();
102
+ }
103
+ }
104
+ /** A spinner on one line: `⠋ Waiting for you to approve...` until stopped, then `✓ Approved.` or nothing. */
105
+ function waiting(write, p, words = 'Waiting for you to approve...', doneWords = 'Approved.') {
106
+ const frames = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏';
107
+ let k = 0;
108
+ const draw = () => write(`\r ${p.lime(frames[k++ % frames.length])} ${p.dim(words)}`);
109
+ draw();
110
+ const timer = setInterval(draw, 80);
111
+ // The spinner never keeps the process alive on its own.
112
+ timer.unref?.();
113
+ let stopped = false;
114
+ const stop = (line = '') => {
115
+ if (stopped)
116
+ return;
117
+ stopped = true;
118
+ clearInterval(timer);
119
+ write(`\r\x1b[2K${line}`);
120
+ };
121
+ return { stop: () => stop(), done: () => stop(` ${p.lime('✓')} ${doneWords}\n`) };
122
+ }
123
+ /** Asks the server for the approved key until it comes, the code expires, or it fails. */
124
+ async function pollForApproval(input, opts, ctx) {
125
+ const { env, io } = input;
126
+ const { url, start, name, spinner, cancel } = ctx;
127
+ const p = paint(env, colorOn(env, io.isTTY));
61
128
  let interval = Math.max(1, start.interval) * 1000;
62
129
  const deadline = Date.now() + Math.min(600, start.expires_in) * 1000;
63
130
  while (Date.now() < deadline) {
64
- await new Promise((r) => setTimeout(r, interval));
131
+ const key = await Promise.race([new Promise((r) => setTimeout(() => r(null), interval)), cancel ?? new Promise(() => { })]);
132
+ if (key) {
133
+ spinner?.stop();
134
+ if (key === 'ctrl-c')
135
+ throw new Cancelled();
136
+ if (input.io.fromMenu)
137
+ throw new BackToMenu();
138
+ io.stderr(' Sign-in cancelled. Nothing was saved.\n');
139
+ return 0;
140
+ }
65
141
  let token;
66
142
  try {
67
143
  token = await wire(url, 'POST', '/auth/device/token', { device_code: start.device_code });
@@ -76,6 +152,7 @@ export async function workspaceLogin(input, opts = {}) {
76
152
  }
77
153
  throw error;
78
154
  }
155
+ spinner?.done();
79
156
  const saved = { version: 1, workspace_id: token.workspace_id, management_credential: token.management_token, base_url: url, recovery: token.recovery };
80
157
  try {
81
158
  writeSecret(env, credentialPath(env), JSON.stringify(saved));
@@ -89,10 +166,15 @@ export async function workspaceLogin(input, opts = {}) {
89
166
  throw error;
90
167
  }
91
168
  if (opts.inSetup) {
92
- io.stderr('Signed in. Setting up your agents next.\n\n');
169
+ io.stderr(` ${p.lime('✓')} Signed in. Setting up your agents next.\n\n`);
170
+ return 0;
171
+ }
172
+ const result = { saved_as: name, key_name: token.key_name, workspace_id: token.workspace_id, storage: keychainOn(env) ? 'macOS Keychain' : 'private files' };
173
+ if (showsScreen(input)) {
174
+ io.stdout(signedInScreen(result, { env, color: colorOn(env, io.stdoutIsTTY) }) + '\n');
93
175
  return 0;
94
176
  }
95
- io.stdout(JSON.stringify({ saved_as: name, key_name: token.key_name, workspace_id: token.workspace_id, storage: keychainOn(env) ? 'macOS Keychain' : 'private files' }) + '\n');
177
+ io.stdout(JSON.stringify(result) + '\n');
96
178
  io.stderr('Ready. Run phantom claude, phantom codex or phantom pi. Guests can export recovery with phantom workspace export --file <path>.\n');
97
179
  return 0;
98
180
  }
@@ -103,7 +185,8 @@ export async function workspaceCommand(input) {
103
185
  const { subcommand, flags, io, env, argv } = input;
104
186
  if (argv[0] === 'keys') {
105
187
  if (subcommand === 'saved' || subcommand === 'save' || subcommand === 'show' || subcommand === 'rm') {
106
- return keyCommand({ ...input, flags: { ...(flags.balance ? { balance: flags.balance } : {}) }, subcommand: subcommand === 'saved' ? 'list' : subcommand });
188
+ // Only the flags the key command reads pass through; --copy must, or `keys show --copy` would print the key.
189
+ return keyCommand({ ...input, flags: { ...(flags.balance ? { balance: flags.balance } : {}), ...(flags.copy ? { copy: flags.copy } : {}) }, subcommand: subcommand === 'saved' ? 'list' : subcommand });
107
190
  }
108
191
  const remoteCommands = new Set(['list', 'create', 'rotate', 'revoke', 'set', undefined]);
109
192
  if (!remoteCommands.has(subcommand)) {
@@ -119,7 +202,7 @@ export async function workspaceCommand(input) {
119
202
  die('Invalid recovery file.');
120
203
  await wire(base(input), 'GET', '/workspaces/' + encodeURIComponent(recovery.workspace_id), undefined, recovery.management_credential);
121
204
  writeSecret(env, credentialPath(env), JSON.stringify({ ...recovery, base_url: base(input), recovery }));
122
- io.stdout('Workspace restored. Use phantom keys create to save a new API key.\n');
205
+ io.stdout(showsScreen(input) ? `\n ${paint(env, colorOn(env, io.stdoutIsTTY)).lime('✓')} Workspace restored. Make a new key with phantom keys create.\n` : 'Workspace restored. Use phantom keys create to save a new API key.\n');
123
206
  return 0;
124
207
  }
125
208
  const saved = credentials(input);
@@ -130,37 +213,72 @@ export async function workspaceCommand(input) {
130
213
  if (typeof flags.file !== 'string')
131
214
  die('Use phantom workspace export --file <path>.');
132
215
  writeFileSync(flags.file, JSON.stringify(saved.recovery, null, 2), { mode: 0o600, flag: 'wx' });
133
- io.stdout('Recovery exported. Keep that file private; it grants workspace management.\n');
216
+ io.stdout(showsScreen(input) ? `\n ${paint(env, colorOn(env, io.stdoutIsTTY)).lime('✓')} Recovery saved to ${flags.file}. Keep that file private: it can manage this workspace.\n` : 'Recovery exported. Keep that file private; it grants workspace management.\n');
134
217
  return 0;
135
218
  }
136
219
  if (command === 'create' || command === 'rotate') {
137
- if (command === 'rotate' && typeof flags.id !== 'string')
138
- die('Use --id <key-id> from phantom keys list.');
139
- const name = typeof flags.save === 'string' ? flags.save : 'default';
140
220
  if (flags.save === true)
141
221
  die('--save requires a key name.');
222
+ // keys create at a terminal with nothing given: a short form (name, then limit), and the key to hand over.
223
+ if (command === 'create' && flags.save === undefined && flags.limit === undefined) {
224
+ const pick = pickIo(input);
225
+ if (pick)
226
+ return newKeyForm(input, pick, saved, endpoint);
227
+ }
228
+ const which = command === 'rotate' ? await whichKey(input, saved, endpoint, 'rotate') : null;
229
+ if (command === 'rotate' && which === null)
230
+ return 0;
231
+ const id = which?.id ?? null;
232
+ // Rotating a key saved on this computer replaces that saved copy, whatever its name.
233
+ const mine = id ? savedNameFor(env, id) : null;
234
+ const name = typeof flags.save === 'string' ? flags.save : (mine ?? 'default');
142
235
  const target = name === 'default' ? keyPath(env) : namedKeyPath(env, name);
143
236
  const replacing = existsSync(target);
144
- if (replacing && (command !== 'rotate' || createHash('sha256').update(readSecret(env, target).trim()).digest('hex') !== flags.id))
145
- die('That saved key already exists. Use --save <new-name>.', 'key_exists');
146
- const result = await wire(saved.base_url, 'POST', command === 'create' ? endpoint + '/keys' : endpoint + '/keys/' + String(flags.id), command === 'create' ? { name, limit_usd: flags.limit === undefined ? null : Number(flags.limit) } : { action: 'rotate' }, saved.management_credential);
237
+ if (replacing && name !== mine)
238
+ die(`A key is already saved here as "${name}". Pick another name with --save <name>.`, 'key_exists');
239
+ const limit = flags.limit === undefined ? null : Number(flags.limit);
240
+ const result = await wire(saved.base_url, 'POST', command === 'create' ? endpoint + '/keys' : endpoint + '/keys/' + String(id), command === 'create' ? { name, limit_usd: limit } : { action: 'rotate' }, saved.management_credential);
147
241
  if (name === 'default')
148
242
  saveApiKey(env, result.api_key);
149
243
  else
150
244
  saveNamedKey(env, name, result.api_key, { replace: replacing });
151
- io.stdout(JSON.stringify({ saved_as: name }) + '\n');
245
+ io.stdout(showsScreen(input) ? keyChangeScreen(command === 'create' ? 'created' : 'rotated', name, { env, color: colorOn(env, io.stdoutIsTTY), storage: keychainOn(env) ? 'macOS Keychain' : 'private files', limit }) + '\n' : JSON.stringify({ saved_as: name }) + '\n');
152
246
  return 0;
153
247
  }
154
248
  if (command === 'revoke') {
155
- if (typeof flags.id !== 'string')
156
- die('Use phantom keys revoke --id <key-id>.');
157
- await wire(saved.base_url, 'DELETE', endpoint + '/keys/' + flags.id, undefined, saved.management_credential);
158
- io.stdout('Key revoked.\n');
249
+ const which = await whichKey(input, saved, endpoint, 'revoke');
250
+ if (which === null)
251
+ return 0;
252
+ // At a terminal, revoking asks first and names the key: it can't be undone.
253
+ const pick = flags['yes'] ? null : pickIo(input);
254
+ if (pick) {
255
+ const go = await choose({ ...pick, esc: 'back' }, `Revoke ${which.name}?`, [
256
+ { label: `Revoke ${which.name}`, hint: 'anything using it stops working at once; its credit stays', value: true },
257
+ { label: 'Cancel', hint: 'keep it', value: false },
258
+ ], 1);
259
+ if (!go) {
260
+ io.stderr(' Nothing was changed.\n');
261
+ return 0;
262
+ }
263
+ }
264
+ await wire(saved.base_url, 'DELETE', endpoint + '/keys/' + which.id, undefined, saved.management_credential);
265
+ io.stdout(showsScreen(input) ? keyChangeScreen('revoked', null, { env, color: colorOn(env, io.stdoutIsTTY) }) + '\n' : 'Key revoked.\n');
159
266
  return 0;
160
267
  }
161
268
  if (command === 'set') {
162
- if (typeof flags.id !== 'string')
163
- die('Use phantom keys set --id <key-id>.');
269
+ const which = await whichKey(input, saved, endpoint, 'change');
270
+ if (which === null)
271
+ return 0;
272
+ const id = which.id;
273
+ // No change given, at a terminal: the usual change, a spending limit, as a picker.
274
+ const anyChange = ['limit', 'budget', 'period', 'rate', 'name', 'policy-file', 'clear-policy'].some((k) => flags[k] !== undefined);
275
+ const pick = anyChange ? null : pickIo(input);
276
+ if (pick) {
277
+ const limit = await chooseLimit(pick);
278
+ if (limit === undefined)
279
+ return 0;
280
+ flags.limit = limit === null ? 'none' : String(limit);
281
+ }
164
282
  const body = {};
165
283
  const controls = { limit_usd: flags.limit, budget_usd: flags.budget, period_days: flags.period, rate_usd_per_min: flags.rate };
166
284
  for (const [field, value] of Object.entries(controls))
@@ -172,47 +290,75 @@ export async function workspaceCommand(input) {
172
290
  body.route_policy = JSON.parse(readFileSync(flags['policy-file'], 'utf8'));
173
291
  if (flags['clear-policy'])
174
292
  body.route_policy = null;
175
- const result = await wire(saved.base_url, 'PATCH', endpoint + '/keys/' + flags.id, body, saved.management_credential);
176
- io.stdout(JSON.stringify(result) + '\n');
293
+ const result = await wire(saved.base_url, 'PATCH', endpoint + '/keys/' + id, body, saved.management_credential);
294
+ io.stdout(showsScreen(input) ? keyChangeScreen('updated', null, { env, color: colorOn(env, io.stdoutIsTTY) }) + '\n' : JSON.stringify(result) + '\n');
177
295
  return 0;
178
296
  }
179
297
  if (command === 'revoke-access') {
180
298
  if (typeof flags.id !== 'string')
181
299
  die('Use --id <management-id> from phantom workspace status.');
182
300
  await wire(saved.base_url, 'DELETE', endpoint + '/access/' + flags.id, undefined, saved.management_credential);
183
- io.stdout('Management access revoked.\n');
301
+ io.stdout(showsScreen(input) ? `\n ${paint(env, colorOn(env, io.stdoutIsTTY)).lime('✓')} That sign-in can no longer manage this workspace.\n` : 'Management access revoked.\n');
184
302
  return 0;
185
303
  }
186
304
  if (command === 'payment') {
187
305
  if (typeof flags.id !== 'string')
188
306
  die('Use --id <payment-id> from phantom workspace fund.');
189
- io.stdout(JSON.stringify(await wire(saved.base_url, 'GET', endpoint + '/payments/' + flags.id, undefined, saved.management_credential)) + '\n');
307
+ const payment = await wire(saved.base_url, 'GET', endpoint + '/payments/' + flags.id, undefined, saved.management_credential);
308
+ io.stdout((showsScreen(input) ? paymentScreen(payment, { env, color: colorOn(env, io.stdoutIsTTY) }) : JSON.stringify(payment)) + '\n');
190
309
  return 0;
191
310
  }
192
311
  if (command === 'fund') {
193
312
  if (flags.pay)
194
313
  die('--pay has been removed. Use --card or --coin.');
195
- if (!flags.card && !flags.coin)
196
- die('fund requires --card or --coin.');
197
- const amount = flagNum(flags, 'amount');
198
- if (amount === undefined)
199
- die('fund requires --amount <USD>.');
200
- if (amount <= 0)
201
- die('--amount must be greater than 0.');
202
- const result = await wire(saved.base_url, 'POST', endpoint + '/fund', { method: flags.card ? 'card' : 'crypto', amount_usd: amount, coin: flags.coin ?? 'usdc' }, saved.management_credential);
203
- io.stdout(JSON.stringify(result) + '\n');
314
+ if (flags.coin === true)
315
+ die(`--coin needs usdc, usdt or sol. ${FUND_EXAMPLE}`);
316
+ let amount = dollarsFlag(flags);
317
+ let method = flags.card ? 'card' : flags.coin;
318
+ // At a terminal, whatever is missing is asked for: first how much, then how to pay.
319
+ const pick = amount === undefined || !method ? pickIo(input) : null;
320
+ if (pick && amount === undefined) {
321
+ amount = (await choose(pick, 'How much credit do you want to add?', [5, 10, 25, 50, 100].map((n) => ({ label: `$${n}`, value: n })), 1)) ?? undefined;
322
+ if (amount === undefined)
323
+ return leaveFund(input);
324
+ }
325
+ if (pick && !method) {
326
+ 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' },
328
+ { label: 'USDC', hint: 'from a Solana wallet', value: 'usdc' },
329
+ { label: 'USDT', hint: 'from a Solana wallet', value: 'usdt' },
330
+ { label: 'SOL', hint: 'from a Solana wallet', value: 'sol' },
331
+ ])) ?? undefined;
332
+ if (!method)
333
+ return leaveFund(input);
334
+ }
335
+ if (amount === undefined || !method)
336
+ die(`fund needs how much and how to pay. ${FUND_EXAMPLE}`, 'fund_usage');
337
+ const result = await wire(saved.base_url, 'POST', endpoint + '/fund', { method: method === 'card' ? 'card' : 'crypto', amount_usd: amount, coin: method === 'card' ? 'usdc' : method }, saved.management_credential);
338
+ io.stdout((showsScreen(input) ? fundScreen(result, { env, color: colorOn(env, io.stdoutIsTTY), opened: Boolean(result.checkout_url) && !flags['no-browser'] }) : JSON.stringify(result)) + '\n');
204
339
  if (result.checkout_url && !flags['no-browser'])
205
340
  openUrl(result.checkout_url);
341
+ // At a terminal, wait for the payment to land, so nobody wonders whether it worked.
342
+ const watch = pickIo(input);
343
+ if (watch && result.payment_id)
344
+ await waitForPayment(input, watch, saved, endpoint, result.payment_id);
206
345
  return 0;
207
346
  }
208
347
  const view = await wire(saved.base_url, 'GET', endpoint, undefined, saved.management_credential);
348
+ if (showsScreen(input)) {
349
+ const style = { env, color: colorOn(env, io.stdoutIsTTY) };
350
+ io.stdout((argv[0] === 'keys' ? keysScreen(view.keys, style) : workspaceScreen(view, style)) + '\n');
351
+ return 0;
352
+ }
209
353
  io.stdout(JSON.stringify(argv[0] === 'keys' ? view.keys : view, null, 2) + '\n');
210
354
  return 0;
211
355
  }
212
- /** Forget local management credentials and revoke server management access, leaving server-side keys and recovery files valid. */
356
+ /** Forget local management credentials and revoke server management access, leaving server-side keys and recovery files valid. True when there was a workspace sign-in to forget. */
213
357
  export async function forgetWorkspace(env, baseUrl) {
214
358
  const file = credentialPath(env);
215
- if (existsSync(file)) {
359
+ if (!existsSync(file))
360
+ return false;
361
+ {
216
362
  try {
217
363
  const saved = JSON.parse(readSecret(env, file));
218
364
  if (saved?.workspace_id && saved?.management_credential) {
@@ -225,4 +371,171 @@ export async function forgetWorkspace(env, baseUrl) {
225
371
  }
226
372
  removeSecret(file);
227
373
  }
374
+ return true;
375
+ }
376
+ /** The working fund command every fund error shows, so one try is enough. */
377
+ const FUND_EXAMPLE = 'For example: phantom fund --amount 10 --card (or --coin usdc).';
378
+ /**
379
+ * `--amount` as dollars: `10`, `10.50` or `$10`. A bare `--amount` is
380
+ * usually `--amount $5` after the shell has replaced `$5` with nothing, so
381
+ * the message says to leave out the $.
382
+ */
383
+ export function dollarsFlag(flags) {
384
+ const raw = flags['amount'];
385
+ if (raw === undefined)
386
+ return undefined;
387
+ if (raw === true || raw.trim() === '')
388
+ die(`--amount needs a number of dollars, like --amount 10. Leave out the $: the shell drops "$5". ${FUND_EXAMPLE}`, 'fund_usage');
389
+ const n = Number(raw.trim().replace(/^\$/, ''));
390
+ if (!Number.isFinite(n) || n <= 0)
391
+ die(`--amount must be a number of dollars above 0, not "${raw}". ${FUND_EXAMPLE}`, 'fund_usage');
392
+ return n;
393
+ }
394
+ /** Esc out of fund's pickers: back to the menu if it started this, otherwise say nothing was charged. */
395
+ function leaveFund(input) {
396
+ if (input.io.fromMenu)
397
+ throw new BackToMenu();
398
+ input.io.stderr(' Nothing was charged.\n');
399
+ return 0;
400
+ }
401
+ /**
402
+ * The key a key command acts on. --id may be the id, its first few
403
+ * characters, the key's name, or the start of the key, the way people
404
+ * remember it. With no --id at a terminal, it asks with a picker. Null means
405
+ * Esc: nothing was done.
406
+ */
407
+ async function whichKey(input, saved, endpoint, verb) {
408
+ const { flags } = input;
409
+ const view = await wire(saved.base_url, 'GET', endpoint, undefined, saved.management_credential);
410
+ const named = (id) => ({ id, name: view.keys.find((k) => k.id === id)?.name ?? 'Key' });
411
+ if (typeof flags.id === 'string')
412
+ return named(findKey(view.keys, flags.id));
413
+ const pick = pickIo(input);
414
+ if (!pick)
415
+ die(`Say which key with --id: its id, name or start. For example: phantom keys ${verb === 'change' ? 'set' : verb} --id CI${verb === 'change' ? ' --limit 5' : ''}`, 'key_usage');
416
+ const chosen = await choose(pick, `Which key do you want to ${verb}?`, view.keys.map((k) => ({ label: k.name ?? 'Key', hint: `${k.prefix ?? ''} · id ${k.id.slice(0, 8)} · spent $${(k.spent_usd ?? 0).toFixed(2)}`, value: k.id })));
417
+ if (chosen === null && input.io.fromMenu)
418
+ throw new BackToMenu();
419
+ if (chosen === null)
420
+ input.io.stderr(' Nothing was changed.\n');
421
+ return chosen === null ? null : named(chosen);
422
+ }
423
+ /** Finds one key by id, the start of its id, its name, or the start of the key itself. Refuses when none or more than one matches. */
424
+ export function findKey(keys, typed) {
425
+ const t = typed.trim().toLowerCase();
426
+ const hits = keys.filter((k) => k.id.toLowerCase() === t);
427
+ const loose = hits.length ? hits : keys.filter((k) => (t.length >= 4 && k.id.toLowerCase().startsWith(t)) || k.name?.toLowerCase() === t || (t.length >= 8 && k.prefix?.toLowerCase().startsWith(t)));
428
+ const list = keys.map((k) => `${k.name ?? 'Key'} (${k.id.slice(0, 8)})`).join(', ');
429
+ if (loose.length === 1)
430
+ return loose[0].id;
431
+ if (!loose.length)
432
+ die(`No key matches "${typed}". Your keys: ${list || 'none yet'}.`, 'key_not_found');
433
+ die(`"${typed}" matches more than one key: ${loose.map((k) => `${k.name ?? 'Key'} (${k.id.slice(0, 8)})`).join(', ')}. Use more of the id.`, 'key_ambiguous');
434
+ }
435
+ /** The name a key with this id is saved under on this computer, if any: `default` for the login key. */
436
+ function savedNameFor(env, id) {
437
+ const hash = (key) => createHash('sha256').update(key.trim()).digest('hex');
438
+ if (existsSync(keyPath(env)) && hash(readSecret(env, keyPath(env))) === id)
439
+ return 'default';
440
+ for (const k of listNamedKeys(env).keys) {
441
+ try {
442
+ if (hash(readNamedKey(env, k.name)) === id)
443
+ return k.name;
444
+ }
445
+ catch {
446
+ // A saved key that can't be read is skipped: it can't be the one being rotated.
447
+ }
448
+ }
449
+ return null;
450
+ }
451
+ /** A limit picker: no limit, or a few round amounts. undefined means Esc. */
452
+ async function chooseLimit(pick) {
453
+ const limit = await choose({ ...pick, esc: 'back' }, 'Spending limit for this key?', [
454
+ { label: 'No limit', hint: 'it can spend the whole workspace balance', value: 'none' },
455
+ ...[5, 10, 25, 50, 100].map((n) => ({ label: `$${n}`, hint: 'then it stops working until you raise it', value: n })),
456
+ ]);
457
+ return limit === null ? undefined : limit === 'none' ? null : limit;
458
+ }
459
+ /**
460
+ * keys create at a terminal: name the key, pick a limit, create it, and save
461
+ * it here. Then, on a Mac, offer to copy it to the clipboard to hand to a
462
+ * teammate. The key is never printed: only keys show and rotate print one.
463
+ */
464
+ async function newKeyForm(input, pick, saved, endpoint) {
465
+ const { env, io } = input;
466
+ const name = await askText({ ...pick, esc: 'back' }, 'Name this key', {
467
+ placeholder: 'for example ci or dana',
468
+ check: (text) => (!/^[a-z0-9][a-z0-9_-]{0,31}$/i.test(text) ? 'Start with a letter or number; then letters, numbers, - and _, up to 32.' : existsSync(namedKeyPath(env, text)) ? `A key is already saved here as "${text}". Type another name.` : null),
469
+ });
470
+ if (name === null)
471
+ return leaveKeys(input);
472
+ const limit = await chooseLimit(pick);
473
+ if (limit === undefined)
474
+ return leaveKeys(input);
475
+ const result = await wire(saved.base_url, 'POST', endpoint + '/keys', { name, limit_usd: limit }, saved.management_credential);
476
+ saveNamedKey(env, name, result.api_key);
477
+ const style = { env, color: colorOn(env, io.stdoutIsTTY), storage: keychainOn(env) ? 'macOS Keychain' : 'private files', limit };
478
+ io.stdout(keyChangeScreen('created', name, style) + '\n');
479
+ if (process.platform === 'darwin' && onPath('pbcopy')) {
480
+ const copy = await choose({ ...pick, esc: 'back' }, 'Hand it to someone?', [
481
+ { label: 'Copy the key to the clipboard', hint: 'then paste it to your teammate; it is not shown here', value: true },
482
+ { label: 'No, keep it saved here only', value: false },
483
+ ]);
484
+ if (copy) {
485
+ spawnSync('pbcopy', { input: result.api_key });
486
+ io.stderr(` ${paint(env, colorOn(env, io.isTTY)).lime('✓')} Copied. Paste it somewhere private: it works like a password.\n`);
487
+ }
488
+ }
489
+ return 0;
490
+ }
491
+ /** Esc out of a key form: back to the menu if it started this, otherwise say nothing changed. */
492
+ function leaveKeys(input) {
493
+ if (input.io.fromMenu)
494
+ throw new BackToMenu();
495
+ input.io.stderr(' Nothing was changed.\n');
496
+ return 0;
497
+ }
498
+ /** How often fund checks whether a payment has landed, and for how long, in milliseconds. */
499
+ const PAYMENT_POLL_MS = 2000;
500
+ const PAYMENT_WAIT_MS = 15 * 60 * 1000;
501
+ /**
502
+ * After fund opens the payment page: a spinner until the payment lands, then
503
+ * the paid screen. Esc stops waiting (the payment still goes through if it's
504
+ * finished); Ctrl+C stops everything.
505
+ */
506
+ async function waitForPayment(input, pick, saved, endpoint, paymentId) {
507
+ const { env, io } = input;
508
+ const p = paint(env, colorOn(env, io.isTTY));
509
+ io.stderr(` ${p.dim('esc to stop waiting')}\n`);
510
+ const spinner = colorOn(env, io.isTTY) ? waiting(io.stderr, p, 'Waiting for your payment...', 'Payment received.') : null;
511
+ const cancel = cancelKeys((pick.keys ?? keyboard)());
512
+ const deadline = Date.now() + PAYMENT_WAIT_MS;
513
+ try {
514
+ while (Date.now() < deadline) {
515
+ const payment = await wire(saved.base_url, 'GET', endpoint + '/payments/' + encodeURIComponent(paymentId), undefined, saved.management_credential);
516
+ if (payment.status === 'completed') {
517
+ spinner?.done();
518
+ io.stdout(paymentScreen(payment, { env, color: colorOn(env, io.stdoutIsTTY) }) + '\n');
519
+ // The new balance right away, so nobody has to go and check that it arrived.
520
+ const view = await wire(saved.base_url, 'GET', endpoint, undefined, saved.management_credential);
521
+ if (typeof view.balance_usd === 'number')
522
+ io.stdout(` ${p.dim('Workspace balance now')} ${p.bold(`$${view.balance_usd.toFixed(2)}`)}\n`);
523
+ return;
524
+ }
525
+ const key = await Promise.race([new Promise((r) => setTimeout(() => r(null), PAYMENT_POLL_MS)), cancel.pressed]);
526
+ if (key === 'ctrl-c')
527
+ throw new Cancelled();
528
+ if (key) {
529
+ spinner?.stop();
530
+ io.stderr(` ${p.dim('Stopped waiting. If you finish paying, the credit still lands: check Balance.')}\n`);
531
+ return;
532
+ }
533
+ }
534
+ spinner?.stop();
535
+ io.stderr(` ${p.dim('No payment yet. If you finish paying, the credit still lands: check Balance.')}\n`);
536
+ }
537
+ finally {
538
+ spinner?.stop();
539
+ cancel.close();
540
+ }
228
541
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phantomcodes/cli",
3
- "version": "1.2.3",
3
+ "version": "1.2.5",
4
4
  "description": "AI inference for your coding agents. Route models, control spending, and fund your workspace.",
5
5
  "keywords": [
6
6
  "phantom",
@@ -66,10 +66,10 @@
66
66
  "license": "SEE LICENSE IN LICENSE",
67
67
  "author": "Harbor Financial LLC",
68
68
  "devDependencies": {
69
- "@types/node": "^24.19.1",
69
+ "@types/node": "^24.19.2",
70
70
  "@vitest/coverage-v8": "^5.0.3",
71
71
  "typescript": "^6.0.3",
72
- "vitest": "^5.0.2"
72
+ "vitest": "^5.0.5"
73
73
  },
74
74
  "publishConfig": {
75
75
  "access": "public"
@@ -7,8 +7,9 @@ description: Use the phantom CLI to connect and run coding agents through Phanto
7
7
 
8
8
  Phantom AI serves model inference at `https://phantom.codes/v1`. The Phantom CLI connects
9
9
  existing coding agents to that API, manages model routing to control costs, and
10
- handles workspace funding and controls. Management commands
11
- print JSON; add `--table` for supported human-readable output.
10
+ handles workspace funding and controls. Run from an agent (output piped),
11
+ commands print JSON; add `--table` for the human-readable screen. Unknown
12
+ flags are refused, so check `phantom <command> --help` for the flag names.
12
13
 
13
14
  ## Connect and run
14
15