@phantomcodes/cli 1.2.2 → 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.
@@ -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');
@@ -36,8 +41,12 @@ export async function workspaceGet(input, path) {
36
41
  const saved = credentials(input);
37
42
  return wire(saved.base_url, 'GET', '/workspaces/' + encodeURIComponent(saved.workspace_id) + path, undefined, saved.management_credential);
38
43
  }
39
- /** Device authorization for accounts or guests, saving keys locally without copying a secret through the browser. */
40
- export async function workspaceLogin(input) {
44
+ /**
45
+ * Device authorization for accounts or guests, saving keys locally without copying a secret through the browser.
46
+ * With `inSetup`, `phantom setup` carries on once it is signed in, so it says that instead of printing the
47
+ * saved key's details and the next commands.
48
+ */
49
+ export async function workspaceLogin(input, opts = {}) {
41
50
  const { env, io, flags } = input;
42
51
  if (flags.save === true)
43
52
  die('--save requires a key name.');
@@ -45,17 +54,90 @@ export async function workspaceLogin(input) {
45
54
  const target = name === 'default' ? keyPath(env) : namedKeyPath(env, name);
46
55
  if (existsSync(target))
47
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
+ }
48
79
  const url = base(input);
49
80
  const start = await wire(url, 'POST', '/auth/device', { client_name: `phantom on ${hostname()}` });
50
81
  if (new URL(start.verification_uri_complete).origin !== new URL(url).origin)
51
82
  die('The approval URL belongs to another site.', 'device_origin');
52
- io.stderr(`Open ${start.verification_uri_complete}\nConfirm code ${start.user_code}\n`);
53
- 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)
54
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));
55
128
  let interval = Math.max(1, start.interval) * 1000;
56
129
  const deadline = Date.now() + Math.min(600, start.expires_in) * 1000;
57
130
  while (Date.now() < deadline) {
58
- 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
+ }
59
141
  let token;
60
142
  try {
61
143
  token = await wire(url, 'POST', '/auth/device/token', { device_code: start.device_code });
@@ -70,6 +152,7 @@ export async function workspaceLogin(input) {
70
152
  }
71
153
  throw error;
72
154
  }
155
+ spinner?.done();
73
156
  const saved = { version: 1, workspace_id: token.workspace_id, management_credential: token.management_token, base_url: url, recovery: token.recovery };
74
157
  try {
75
158
  writeSecret(env, credentialPath(env), JSON.stringify(saved));
@@ -82,7 +165,16 @@ export async function workspaceLogin(input) {
82
165
  io.stderr('phantom could not save the connection. Sign in or restore your workspace to revoke the device key and reconnect.\n');
83
166
  throw error;
84
167
  }
85
- 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');
168
+ if (opts.inSetup) {
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');
175
+ return 0;
176
+ }
177
+ io.stdout(JSON.stringify(result) + '\n');
86
178
  io.stderr('Ready. Run phantom claude, phantom codex or phantom pi. Guests can export recovery with phantom workspace export --file <path>.\n');
87
179
  return 0;
88
180
  }
@@ -93,7 +185,8 @@ export async function workspaceCommand(input) {
93
185
  const { subcommand, flags, io, env, argv } = input;
94
186
  if (argv[0] === 'keys') {
95
187
  if (subcommand === 'saved' || subcommand === 'save' || subcommand === 'show' || subcommand === 'rm') {
96
- 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 });
97
190
  }
98
191
  const remoteCommands = new Set(['list', 'create', 'rotate', 'revoke', 'set', undefined]);
99
192
  if (!remoteCommands.has(subcommand)) {
@@ -109,7 +202,7 @@ export async function workspaceCommand(input) {
109
202
  die('Invalid recovery file.');
110
203
  await wire(base(input), 'GET', '/workspaces/' + encodeURIComponent(recovery.workspace_id), undefined, recovery.management_credential);
111
204
  writeSecret(env, credentialPath(env), JSON.stringify({ ...recovery, base_url: base(input), recovery }));
112
- 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');
113
206
  return 0;
114
207
  }
115
208
  const saved = credentials(input);
@@ -120,37 +213,72 @@ export async function workspaceCommand(input) {
120
213
  if (typeof flags.file !== 'string')
121
214
  die('Use phantom workspace export --file <path>.');
122
215
  writeFileSync(flags.file, JSON.stringify(saved.recovery, null, 2), { mode: 0o600, flag: 'wx' });
123
- 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');
124
217
  return 0;
125
218
  }
126
219
  if (command === 'create' || command === 'rotate') {
127
- if (command === 'rotate' && typeof flags.id !== 'string')
128
- die('Use --id <key-id> from phantom keys list.');
129
- const name = typeof flags.save === 'string' ? flags.save : 'default';
130
220
  if (flags.save === true)
131
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');
132
235
  const target = name === 'default' ? keyPath(env) : namedKeyPath(env, name);
133
236
  const replacing = existsSync(target);
134
- if (replacing && (command !== 'rotate' || createHash('sha256').update(readSecret(env, target).trim()).digest('hex') !== flags.id))
135
- die('That saved key already exists. Use --save <new-name>.', 'key_exists');
136
- 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);
137
241
  if (name === 'default')
138
242
  saveApiKey(env, result.api_key);
139
243
  else
140
244
  saveNamedKey(env, name, result.api_key, { replace: replacing });
141
- 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');
142
246
  return 0;
143
247
  }
144
248
  if (command === 'revoke') {
145
- if (typeof flags.id !== 'string')
146
- die('Use phantom keys revoke --id <key-id>.');
147
- await wire(saved.base_url, 'DELETE', endpoint + '/keys/' + flags.id, undefined, saved.management_credential);
148
- 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');
149
266
  return 0;
150
267
  }
151
268
  if (command === 'set') {
152
- if (typeof flags.id !== 'string')
153
- 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
+ }
154
282
  const body = {};
155
283
  const controls = { limit_usd: flags.limit, budget_usd: flags.budget, period_days: flags.period, rate_usd_per_min: flags.rate };
156
284
  for (const [field, value] of Object.entries(controls))
@@ -162,47 +290,75 @@ export async function workspaceCommand(input) {
162
290
  body.route_policy = JSON.parse(readFileSync(flags['policy-file'], 'utf8'));
163
291
  if (flags['clear-policy'])
164
292
  body.route_policy = null;
165
- const result = await wire(saved.base_url, 'PATCH', endpoint + '/keys/' + flags.id, body, saved.management_credential);
166
- 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');
167
295
  return 0;
168
296
  }
169
297
  if (command === 'revoke-access') {
170
298
  if (typeof flags.id !== 'string')
171
299
  die('Use --id <management-id> from phantom workspace status.');
172
300
  await wire(saved.base_url, 'DELETE', endpoint + '/access/' + flags.id, undefined, saved.management_credential);
173
- 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');
174
302
  return 0;
175
303
  }
176
304
  if (command === 'payment') {
177
305
  if (typeof flags.id !== 'string')
178
306
  die('Use --id <payment-id> from phantom workspace fund.');
179
- 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');
180
309
  return 0;
181
310
  }
182
311
  if (command === 'fund') {
183
312
  if (flags.pay)
184
313
  die('--pay has been removed. Use --card or --coin.');
185
- if (!flags.card && !flags.coin)
186
- die('fund requires --card or --coin.');
187
- const amount = flagNum(flags, 'amount');
188
- if (amount === undefined)
189
- die('fund requires --amount <USD>.');
190
- if (amount <= 0)
191
- die('--amount must be greater than 0.');
192
- 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);
193
- 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');
194
339
  if (result.checkout_url && !flags['no-browser'])
195
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);
196
345
  return 0;
197
346
  }
198
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
+ }
199
353
  io.stdout(JSON.stringify(argv[0] === 'keys' ? view.keys : view, null, 2) + '\n');
200
354
  return 0;
201
355
  }
202
- /** 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. */
203
357
  export async function forgetWorkspace(env, baseUrl) {
204
358
  const file = credentialPath(env);
205
- if (existsSync(file)) {
359
+ if (!existsSync(file))
360
+ return false;
361
+ {
206
362
  try {
207
363
  const saved = JSON.parse(readSecret(env, file));
208
364
  if (saved?.workspace_id && saved?.management_credential) {
@@ -215,4 +371,171 @@ export async function forgetWorkspace(env, baseUrl) {
215
371
  }
216
372
  removeSecret(file);
217
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
+ }
218
541
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phantomcodes/cli",
3
- "version": "1.2.2",
3
+ "version": "1.2.4",
4
4
  "description": "AI inference for your coding agents. Route models, control spending, and fund your workspace.",
5
5
  "keywords": [
6
6
  "phantom",
@@ -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