@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.
- package/README.md +10 -5
- package/dist/agents/command.mjs +15 -5
- package/dist/cli/app.mjs +162 -0
- package/dist/cli/commands.mjs +37 -14
- package/dist/cli/help.mjs +26 -5
- package/dist/cli/run.mjs +54 -27
- package/dist/cli/screens.mjs +330 -0
- package/dist/cli/start.mjs +142 -0
- package/dist/core/brand.mjs +31 -0
- package/dist/core/errors.mjs +2 -2
- package/dist/core/helpers.mjs +47 -14
- package/dist/core/motion.mjs +156 -0
- package/dist/core/pick.mjs +293 -0
- package/dist/core/style.mjs +36 -0
- package/dist/core/version.mjs +1 -1
- package/dist/keys/command.mjs +37 -13
- package/dist/phantom.mjs +1 -1
- package/dist/plan/command.mjs +26 -8
- package/dist/setup/agents.mjs +8 -8
- package/dist/setup/apply.mjs +26 -23
- package/dist/setup/command.mjs +150 -59
- package/dist/setup/editors.mjs +1 -1
- package/dist/setup/plan.mjs +4 -2
- package/dist/setup/screen.mjs +237 -0
- package/dist/spend/command.mjs +75 -38
- package/dist/spend/report.mjs +12 -3
- package/dist/workspace/command.mjs +355 -42
- package/package.json +1 -1
- package/skills/phantom-ai/SKILL.md +3 -2
|
@@ -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 {
|
|
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('
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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(
|
|
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
|
-
|
|
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 &&
|
|
145
|
-
die(
|
|
146
|
-
const
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
163
|
-
|
|
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/' +
|
|
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
|
-
|
|
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 (
|
|
196
|
-
die(
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
@@ -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.
|
|
11
|
-
print JSON; add `--table` for
|
|
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
|
|