@phantomcodes/cli 1.2.6 → 1.2.8
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 +6 -1
- package/dist/cli/app.mjs +6 -3
- package/dist/cli/commands.mjs +6 -0
- package/dist/cli/run.mjs +11 -1
- package/dist/cli/screens.mjs +26 -8
- package/dist/cli/start.mjs +3 -2
- package/dist/core/free.mjs +9 -0
- package/dist/core/style.mjs +62 -0
- package/dist/core/version.mjs +1 -1
- package/dist/plan/command.mjs +1 -1
- package/dist/setup/command.mjs +58 -5
- package/dist/setup/plan.mjs +6 -1
- package/dist/setup/screen.mjs +38 -15
- package/dist/spend/command.mjs +22 -5
- package/dist/spend/routing.mjs +2 -0
- package/dist/workspace/command.mjs +20 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -68,7 +68,11 @@ Mistral Vibe and Zed. Cursor, VS Code, JetBrains AI Assistant, Warp and Trae
|
|
|
68
68
|
keep these settings on their own settings screens, so setup prints the steps;
|
|
69
69
|
pi gets the agent skill.
|
|
70
70
|
|
|
71
|
-
Preview without changing files with `phantom setup --dry-run`.
|
|
71
|
+
Preview without changing files with `phantom setup --dry-run`. To leave your
|
|
72
|
+
shell startup file alone, untick "Your shell" in the checklist or pass
|
|
73
|
+
`--no-shell`; setup then prints the lines to add yourself. Cap what the key can spend each
|
|
74
|
+
month with `phantom setup --limit 20` (`--limit none` removes it); at a terminal,
|
|
75
|
+
setup asks once when the key has no cap. The Phantom CLI keeps backups;
|
|
72
76
|
undo the changes with `phantom setup --restore`.
|
|
73
77
|
|
|
74
78
|
## Connect for one run
|
|
@@ -178,6 +182,7 @@ it cancels. A named model, not `phantom-1`, still spends credit.
|
|
|
178
182
|
| --- | --- |
|
|
179
183
|
| Connect | `phantom login`, `phantom logout` |
|
|
180
184
|
| Connect existing agents | `phantom setup`, `phantom setup --dry-run`, `phantom setup --restore` |
|
|
185
|
+
| Providers that answer soonest | `phantom route set --sort fast` (`--sort default` for cheapest first) |
|
|
181
186
|
| Run inference | `phantom claude`, `phantom codex`, `phantom pi` |
|
|
182
187
|
| Fund and check spending | `phantom fund`, `phantom balance`, `phantom spend`, `phantom workspace status` |
|
|
183
188
|
| Manage workspace keys | `phantom keys create`, `phantom keys set`, `phantom keys rotate`, `phantom keys revoke` |
|
package/dist/cli/app.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { resolveApiKey } from "../keys/saved.mjs";
|
|
|
6
6
|
import { hideHints } from "./screens.mjs";
|
|
7
7
|
import { startHeader } from "./start.mjs";
|
|
8
8
|
import { COMMANDS } from "./commands.mjs";
|
|
9
|
+
import { FREE_LINE } from "../core/free.mjs";
|
|
9
10
|
/** The agents the menu can start, and the program each needs on this computer. */
|
|
10
11
|
const AGENTS = [
|
|
11
12
|
{ label: 'Start Claude Code', bin: 'claude', run: ['claude'] },
|
|
@@ -17,7 +18,7 @@ export function mainMenu(s) {
|
|
|
17
18
|
const quit = { label: 'Quit', value: { run: [] } };
|
|
18
19
|
if (!s.signedIn) {
|
|
19
20
|
return [
|
|
20
|
-
{ label: 'Set up my coding agents', hint: 'sign in and connect them
|
|
21
|
+
{ label: 'Set up my coding agents', hint: 'sign in and connect them, free to start', value: { run: ['setup'] } },
|
|
21
22
|
{ label: 'Sign in only', hint: 'or save a Phantom key you already have', value: { run: ['login'] } },
|
|
22
23
|
{ label: 'Every command', hint: 'pick one to run', value: { open: 'commands' } },
|
|
23
24
|
quit,
|
|
@@ -26,7 +27,7 @@ export function mainMenu(s) {
|
|
|
26
27
|
// Only agents that are installed here, so the menu never offers one that can't start.
|
|
27
28
|
const agents = s.setUp ? AGENTS.filter((a) => s.installed(a.bin)).map((a) => ({ label: a.label, hint: 'on Phantom AI', value: { run: a.run } })) : [];
|
|
28
29
|
return [
|
|
29
|
-
...(s.setUp ? [] : [{ label: 'Finish setting up my agents', hint:
|
|
30
|
+
...(s.setUp ? [] : [{ label: 'Finish setting up my agents', hint: 'no agent uses Phantom yet', value: { run: ['setup'] } }]),
|
|
30
31
|
...agents,
|
|
31
32
|
{ label: 'Balance', hint: 'what this key can spend', value: { run: ['balance'] } },
|
|
32
33
|
{ label: 'Spend', hint: 'what you spent, and what phantom-1 saved', value: { run: ['spend'] } },
|
|
@@ -105,7 +106,9 @@ export async function runApp(env, pick, write, runCommand, installed = onPath) {
|
|
|
105
106
|
// A clean screen for each visit to the menu: a title, then where you are.
|
|
106
107
|
write('\x1b[2J\x1b[H' + startHeader(env, color) + '\n');
|
|
107
108
|
const s = state();
|
|
108
|
-
write(` ${p.dim(s.signedIn ? (s.setUp ? 'Signed in · your agents use Phantom AI' : 'Signed in · no agent set up yet') : 'Not signed in yet')}\n
|
|
109
|
+
write(` ${p.dim(s.signedIn ? (s.setUp ? 'Signed in · your agents use Phantom AI' : 'Signed in · no agent set up yet') : 'Not signed in yet')}\n`);
|
|
110
|
+
// Before signing in, the one line that says what free means, whole, since it is why most people came.
|
|
111
|
+
write(s.signedIn ? '\n' : ` ${p.lime('Free to start:')} ${FREE_LINE}.\n\n`);
|
|
109
112
|
let action = await choose({ ...pick, esc: 'quit' }, 'What would you like to do?', mainMenu(s));
|
|
110
113
|
if (action === null)
|
|
111
114
|
return 0;
|
package/dist/cli/commands.mjs
CHANGED
|
@@ -119,6 +119,9 @@ export const COMMANDS = [
|
|
|
119
119
|
name: 'route', handler: routeCommand,
|
|
120
120
|
group: 'Routing', summary: 'which models "auto" runs, and rules for picking',
|
|
121
121
|
help: ` route [get] show which model "auto" runs, and why
|
|
122
|
+
route set --sort fast|cheap|throughput|default
|
|
123
|
+
which providers serve this key's models first: fast
|
|
124
|
+
starts answering soonest; cheap is the default
|
|
122
125
|
route set --models a,b,c models for "auto", first is the default
|
|
123
126
|
[--applies-to auto|all] [--on-empty stop|cheapest] [--fallback]
|
|
124
127
|
[--stick-minutes n] keep a conversation's model this long (default 5)
|
|
@@ -160,6 +163,9 @@ export const COMMANDS = [
|
|
|
160
163
|
[--no-browser] print the sign-in link instead of opening it
|
|
161
164
|
[--dry-run] preview the changes without making them
|
|
162
165
|
[--diff] show every line each file gains or loses
|
|
166
|
+
[--no-shell] leave your shell startup file alone and list
|
|
167
|
+
the lines to add yourself
|
|
168
|
+
[--limit <USD|none>] cap what this key can spend each month
|
|
163
169
|
[--json] print JSON in a terminal too (a pipe always gets JSON)
|
|
164
170
|
[--model <id>] the model each agent starts on (default ${SETUP_MODEL})
|
|
165
171
|
setup --agent <name> install the skill for one agent, without asking:
|
package/dist/cli/run.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { VERSION } from "../core/version.mjs";
|
|
2
2
|
import { nodeTrustWarning } from "../core/node-trust.mjs";
|
|
3
3
|
import { die, NO_KEY } from "../core/errors.mjs";
|
|
4
|
-
import { colorOn } from "../core/style.mjs";
|
|
4
|
+
import { colorOn, fitText } from "../core/style.mjs";
|
|
5
5
|
import { ALIASES, closestCommand, commandFromWords, startScreen } from "./start.mjs";
|
|
6
6
|
import { BackToMenu } from "../core/pick.mjs";
|
|
7
7
|
import { runApp } from "./app.mjs";
|
|
@@ -41,7 +41,17 @@ export async function run(argv = process.argv.slice(2), env = process.env, io =
|
|
|
41
41
|
stderr: (s) => process.stderr.write(s),
|
|
42
42
|
isTTY: Boolean(process.stderr.isTTY),
|
|
43
43
|
stdoutIsTTY: Boolean(process.stdout.isTTY),
|
|
44
|
+
columns: process.stdout.columns || process.stderr.columns,
|
|
44
45
|
}, node = process.versions.node) {
|
|
46
|
+
// A line wider than the terminal breaks at a word, under its own column, rather than wherever the edge falls.
|
|
47
|
+
if (io.columns) {
|
|
48
|
+
const { columns, stdout, stderr } = io;
|
|
49
|
+
io = {
|
|
50
|
+
...io,
|
|
51
|
+
...(io.stdoutIsTTY ? { stdout: (s) => stdout(fitText(s, columns)) } : {}),
|
|
52
|
+
...(io.isTTY ? { stderr: (s) => stderr(fitText(s, columns)) } : {}),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
45
55
|
const unsafe = nodeTrustWarning(node);
|
|
46
56
|
if (unsafe)
|
|
47
57
|
io.stderr(unsafe);
|
package/dist/cli/screens.mjs
CHANGED
|
@@ -100,8 +100,12 @@ export function spendScreen(s, style = {}) {
|
|
|
100
100
|
if (s.keys.length > 1) {
|
|
101
101
|
lines.push('', ...caption(p, w, 'BY KEY'));
|
|
102
102
|
for (const k of s.keys) {
|
|
103
|
-
const
|
|
104
|
-
|
|
103
|
+
const savedText = k.saved_usd ? ` saved ${usd(k.saved_usd)}` : '';
|
|
104
|
+
const offText = k.active ? '' : ' revoked or rotated';
|
|
105
|
+
// The dollars line up with the model rows above, unless that pushes the row past the window's edge.
|
|
106
|
+
const tail = 8 + 2 + plural(k.calls, 'call').length + savedText.length + offText.length;
|
|
107
|
+
const pad = Math.max(LABEL, Math.min(LABEL + BAR + 2, widthOf(style) - 2 - tail));
|
|
108
|
+
lines.push(` ${k.name.slice(0, LABEL - 1).padEnd(pad)}${usd(k.spent_usd).padStart(8)} ${p.dim(plural(k.calls, 'call'))}${savedText ? p.lime(savedText) : ''}${offText ? p.dim(offText) : ''}`);
|
|
105
109
|
}
|
|
106
110
|
}
|
|
107
111
|
if (!s.total.calls)
|
|
@@ -116,12 +120,12 @@ function keyRows(p, keys) {
|
|
|
116
120
|
if (!keys.length)
|
|
117
121
|
return [` ${p.dim('No keys yet.')}`];
|
|
118
122
|
return keys.map((k) => {
|
|
119
|
-
const limit = k.limit_usd != null ? `limit ${usd(k.limit_usd)}` : k.budget_usd != null ? `${usd(k.budget_usd)}
|
|
123
|
+
const limit = k.limit_usd != null ? `limit ${usd(k.limit_usd)}` : k.budget_usd != null ? `${usd(k.budget_usd)} per ${plural(k.period_days ?? 30, 'day')}` : 'no limit';
|
|
120
124
|
// A revoked or rotated key gets a hollow mark and the word off at the end, so the columns stay in line.
|
|
121
125
|
const off = k.is_active === false;
|
|
122
126
|
const mark = off ? p.dim('○') : p.lime('●');
|
|
123
127
|
// The id's first 8 characters: enough for --id, which also takes the name.
|
|
124
|
-
return ` ${mark} ${clip(k.name ?? 'Key', 20).padEnd(21)}${p.dim((k.id ?? '').slice(0, 8).padEnd(
|
|
128
|
+
return ` ${mark} ${clip(k.name ?? 'Key', 20).padEnd(21)}${p.dim((k.id ?? '').slice(0, 8).padEnd(9))}${p.dim((k.prefix ?? '').slice(0, 18).padEnd(20))}${usd(k.spent_usd ?? 0).padStart(8)} ${p.dim(limit)}${off ? ` ${p.dim('off')}` : ''}`;
|
|
125
129
|
});
|
|
126
130
|
}
|
|
127
131
|
/** `phantom workspace`: the shared balance, each key, and the last few charges. */
|
|
@@ -154,6 +158,16 @@ export function signedInScreen(saved, style = {}) {
|
|
|
154
158
|
const where = saved.saved_as === 'default' ? 'Your key is' : `Key "${saved.saved_as}" is`;
|
|
155
159
|
return ['', ` ${p.lime('✓')} ${p.bold('Signed in.')} ${p.dim(`${where} saved in your ${saved.storage}.`)}`, ...next(p, 'Connect your coding agents:', 'phantom setup')].join('\n');
|
|
156
160
|
}
|
|
161
|
+
/** `phantom login` when this computer already has a key: say so, then how to switch or add one. */
|
|
162
|
+
export function alreadySignedInScreen(keyIdShown, style = {}) {
|
|
163
|
+
const p = paint(style.env ?? {}, Boolean(style.color));
|
|
164
|
+
return [
|
|
165
|
+
'',
|
|
166
|
+
` ${p.lime('✓')} ${p.bold('You are already signed in on this computer.')}${keyIdShown ? ` ${p.dim(`key ${keyIdShown}`)}` : ''}`,
|
|
167
|
+
...next(p, 'Use another workspace: run phantom logout, then', 'phantom login'),
|
|
168
|
+
...next(p, 'Add a second key here:', 'phantom login --save <name>'),
|
|
169
|
+
].join('\n');
|
|
170
|
+
}
|
|
157
171
|
/** A ✓ line with an optional dim detail, for a change that worked. */
|
|
158
172
|
function done(p, words, detail = '') {
|
|
159
173
|
return ` ${p.lime('✓')} ${words}${detail ? ` ${p.dim(detail)}` : ''}`;
|
|
@@ -164,12 +178,15 @@ export function routeScreen(r, style = {}) {
|
|
|
164
178
|
const w = ruleWidth(style);
|
|
165
179
|
const lines = ['', ...caption(p, w, 'ROUTING', p.dim('how model "auto" picks for this key'))];
|
|
166
180
|
const policy = r.route_policy;
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
181
|
+
const row = (label, value) => ` ${p.dim(label.padEnd(14))}${value}`;
|
|
182
|
+
// Which providers serve first holds for every request, routed or not.
|
|
183
|
+
const sortWords = { cost: 'cheapest first', ttft: 'fastest to start answering first', tps: 'most tokens a second first' };
|
|
184
|
+
const providers = row('providers', `${sortWords[policy?.sort ?? 'cost']}${policy?.sort ? '' : p.dim(' (the default)')}${policy?.zdr ? ', zero data retention only' : ''}${policy?.no_training ? ', no training on prompts' : ''}`);
|
|
185
|
+
if (!policy || !policy.models.length) {
|
|
186
|
+
lines.push(' No route policy, so model "auto" runs phantom-1, which picks a model for each request.', '', providers);
|
|
187
|
+
lines.push(...(policy?.sort ? next(p, 'Back to cheapest first:', 'phantom route set --sort default') : next(p, 'Providers that start answering soonest:', 'phantom route set --sort fast')));
|
|
170
188
|
return lines.join('\n');
|
|
171
189
|
}
|
|
172
|
-
const row = (label, value) => ` ${p.dim(label.padEnd(14))}${value}`;
|
|
173
190
|
// One model a line, in the order "auto" tries them, so a long list doesn't wrap.
|
|
174
191
|
policy.models.forEach((m, k) => lines.push(row(k === 0 ? 'models' : '', `${p.dim(`${k + 1}.`)} ${k === 0 ? `${p.bold(m)} ${p.dim('the default')}` : m}`)));
|
|
175
192
|
lines.push(row('applies to', policy.applies_to === 'all' ? 'every request' : 'requests for model "auto"'));
|
|
@@ -177,6 +194,7 @@ export function routeScreen(r, style = {}) {
|
|
|
177
194
|
lines.push(row('a model fails', policy.fallback_on_error ? 'try the next one' : 'stop there'));
|
|
178
195
|
const stick = policy.stick_minutes ?? 5;
|
|
179
196
|
lines.push(row('conversations', stick ? `stay on their model for ${plural(stick, 'minute')}${policy.stick_by_prompt === false ? ' (by x-phantom-session only)' : ''}` : 'can change model every request'));
|
|
197
|
+
lines.push(providers);
|
|
180
198
|
const rules = policy.rules ?? [];
|
|
181
199
|
lines.push('', ...caption(p, w, 'RULES', p.dim(rules.length ? 'checked in order; the first that matches picks' : '')));
|
|
182
200
|
if (!rules.length)
|
package/dist/cli/start.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// day to day. It reads no network, so it prints at once, even offline.
|
|
4
4
|
import { brandHeader } from "../core/brand.mjs";
|
|
5
5
|
import { paint } from "../core/style.mjs";
|
|
6
|
+
import { FREE_LINE } from "../core/free.mjs";
|
|
6
7
|
/** One row: a command in cyan, padded, then what it does. */
|
|
7
8
|
const row = (p, command, about) => ` ${p.cyan(command.padEnd(18))}${about}`;
|
|
8
9
|
/** An uppercase caption over a thin rule, like the site's section labels. */
|
|
@@ -15,7 +16,7 @@ export function startScreen(env, color, signedIn) {
|
|
|
15
16
|
const p = paint(env, color);
|
|
16
17
|
const lines = ['', ...brandHeader(p, color, '')];
|
|
17
18
|
if (!signedIn) {
|
|
18
|
-
lines.push(...caption(p, 'START HERE'), row(p, 'phantom setup', 'signs you in and connects your coding agents'), ` ${' '.repeat(18)}${p.dim(
|
|
19
|
+
lines.push(...caption(p, 'START HERE'), row(p, 'phantom setup', 'signs you in and connects your coding agents'), ` ${' '.repeat(18)}${p.dim(FREE_LINE)}`, '', ` Then use your agent as usual, or start it with ${p.cyan('phantom claude')}, ${p.cyan('phantom codex')} or ${p.cyan('phantom pi')}.`);
|
|
19
20
|
}
|
|
20
21
|
else {
|
|
21
22
|
lines.push(` ${p.lime('✓')} Signed in.`, '', ...caption(p, 'EVERYDAY'), row(p, 'phantom claude', 'run Claude Code on Phantom AI (also codex, pi)'), row(p, 'phantom balance', 'your credit'), row(p, 'phantom spend', 'what you spent, and what phantom-1 saved'), row(p, 'phantom setup', 'connect more agents, or change how they run'));
|
|
@@ -67,7 +68,7 @@ export function startChoices(signedIn, setUp = signedIn) {
|
|
|
67
68
|
const quit = { label: 'Quit', value: [] };
|
|
68
69
|
if (!signedIn) {
|
|
69
70
|
return [
|
|
70
|
-
{ label: 'Set up my coding agents', hint: 'sign in and connect them
|
|
71
|
+
{ label: 'Set up my coding agents', hint: 'sign in and connect them, free to start', value: ['setup'] },
|
|
71
72
|
{ label: 'Sign in only', hint: 'or save a Phantom key you already have', value: ['login'] },
|
|
72
73
|
{ label: 'See every command', value: ['--help'] },
|
|
73
74
|
quit,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// What phantom-1 gives a key for free, said the same way on every screen that mentions it.
|
|
2
|
+
/**
|
|
3
|
+
* Free phantom-1 requests a workspace gets each UTC day, before it has had $10
|
|
4
|
+
* of credit. Phantom's own limit is `FREE_ROUTER_CAPS.perDay` in the Phantom
|
|
5
|
+
* repo's `lib/config.ts`; change both together.
|
|
6
|
+
*/
|
|
7
|
+
export const FREE_REQUESTS_A_DAY = 50;
|
|
8
|
+
/** The free offer in one line short enough for an 80-column terminal. */
|
|
9
|
+
export const FREE_LINE = `phantom-1 runs free models, ${FREE_REQUESTS_A_DAY} requests a day, with no card`;
|
package/dist/core/style.mjs
CHANGED
|
@@ -34,3 +34,65 @@ export function paint(env, on) {
|
|
|
34
34
|
}
|
|
35
35
|
/** `text` without color codes, to measure how wide it prints. */
|
|
36
36
|
export const unstyled = (text) => text.replace(/\x1b\[[0-9;]*m/g, '');
|
|
37
|
+
/**
|
|
38
|
+
* One line of a screen, fitted to a terminal `columns` wide: a longer line
|
|
39
|
+
* breaks at spaces, and each piece after the first starts under the text it
|
|
40
|
+
* continues. That is the second column for a table row or a help entry
|
|
41
|
+
* (text after a run of two spaces), the words after a mark such as `✓ ` or
|
|
42
|
+
* `→ `, or else the line's own indent. A word longer than the room, such as
|
|
43
|
+
* a URL, stays whole. Color codes don't count toward the width.
|
|
44
|
+
*/
|
|
45
|
+
export function fitLine(line, columns) {
|
|
46
|
+
const seen = unstyled(line);
|
|
47
|
+
if (seen.length <= columns)
|
|
48
|
+
return line;
|
|
49
|
+
const lead = seen.length - seen.trimStart().length;
|
|
50
|
+
const column = /^(\s*\S.*?\s{2,})\S/.exec(seen)?.[1].length;
|
|
51
|
+
const mark = /^\s*[✓✗→·!◆?❯] /.exec(seen)?.[0].length;
|
|
52
|
+
const hang = column !== undefined && column <= columns / 2 ? column : (mark ?? lead);
|
|
53
|
+
const rows = [];
|
|
54
|
+
let row = '';
|
|
55
|
+
let width = 0;
|
|
56
|
+
// A command stays on one line to copy: `phantom` keeps the word after it, and a flag the word before.
|
|
57
|
+
const words = [];
|
|
58
|
+
for (const word of line.split(' ')) {
|
|
59
|
+
const last = words.length ? unstyled(words[words.length - 1]) : '';
|
|
60
|
+
if (words.length && word && (/(^|[^\w-])phantom$/.test(last) || /^-{1,2}[a-z]/.test(unstyled(word))))
|
|
61
|
+
words[words.length - 1] += ' ' + word;
|
|
62
|
+
else
|
|
63
|
+
words.push(word);
|
|
64
|
+
}
|
|
65
|
+
words.forEach((word, i) => {
|
|
66
|
+
const w = unstyled(word).length;
|
|
67
|
+
const blank = unstyled(row).trim() === '';
|
|
68
|
+
if (i > 0 && !blank && width + 1 + w > columns) {
|
|
69
|
+
rows.push(row.trimEnd());
|
|
70
|
+
row = ' '.repeat(hang) + word;
|
|
71
|
+
width = hang + w;
|
|
72
|
+
}
|
|
73
|
+
else if (i > 0 && blank && rows.length) {
|
|
74
|
+
row += word;
|
|
75
|
+
width += w;
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
row += (i > 0 ? ' ' : '') + word;
|
|
79
|
+
width += (i > 0 ? 1 : 0) + w;
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
return [...rows, row.trimEnd()].join('\n');
|
|
83
|
+
}
|
|
84
|
+
/** Moves the cursor or redraws: text drawn in place (a menu, a spinner), which must not be wrapped. */
|
|
85
|
+
const DRAWN = /\r|\x1b\[[0-9;?]*[A-HJKSTfhlsu]/;
|
|
86
|
+
/**
|
|
87
|
+
* Text fitted to `columns` with {@link fitLine}, line by line. JSON (text
|
|
88
|
+
* that starts with `{` or `[`) and anything drawn in place come back as they
|
|
89
|
+
* are, since a break there would change what they mean.
|
|
90
|
+
*/
|
|
91
|
+
export function fitText(text, columns) {
|
|
92
|
+
if (!columns || DRAWN.test(text) || /^\s*[{[]/.test(text))
|
|
93
|
+
return text;
|
|
94
|
+
return text
|
|
95
|
+
.split('\n')
|
|
96
|
+
.map((l) => fitLine(l, columns))
|
|
97
|
+
.join('\n');
|
|
98
|
+
}
|
package/dist/core/version.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** This release of phantom. `npm version` keeps it equal to the version in package.json. */
|
|
2
|
-
export const VERSION = '1.2.
|
|
2
|
+
export const VERSION = '1.2.8';
|
package/dist/plan/command.mjs
CHANGED
|
@@ -65,7 +65,7 @@ export async function planCommand(input) {
|
|
|
65
65
|
const pick = pickIo(input);
|
|
66
66
|
if (pick) {
|
|
67
67
|
const offer = (await getPlan(apiKey, baseUrl)).prices?.[subcommand];
|
|
68
|
-
const what = offer ? `$${offer.usd_per_month} a month
|
|
68
|
+
const what = offer ? `$${offer.usd_per_month} a month for $${offer.week_limit_usd} of model cost a week, every key` : 'for every key in this workspace';
|
|
69
69
|
const go = await choose(pick, `Start ${subcommand === 'max' ? 'Max' : 'Pro'}?`, [
|
|
70
70
|
{ label: 'Open Stripe to pay', hint: what, value: true },
|
|
71
71
|
{ label: 'Cancel', hint: 'nothing is charged', value: false },
|
package/dist/setup/command.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { createInterface } from 'node:readline/promises';
|
|
|
4
4
|
import { die } from "../core/errors.mjs";
|
|
5
5
|
import { out, pickIo } from "../core/helpers.mjs";
|
|
6
6
|
import { BackToMenu, checklist, choose } from "../core/pick.mjs";
|
|
7
|
+
import { request } from "../core/request.mjs";
|
|
7
8
|
import { resolveApiKey } from "../keys/saved.mjs";
|
|
8
9
|
import { workspaceLogin } from "../workspace/command.mjs";
|
|
9
10
|
import { SETUP_AGENTS, SETUP_NAMES } from "./agents.mjs";
|
|
@@ -104,6 +105,8 @@ export async function setupCommand(input) {
|
|
|
104
105
|
// Plain `phantom setup` sets up everything it finds and asks first. Any of
|
|
105
106
|
// the older flags runs just what they name, without asking.
|
|
106
107
|
const guided = agent === undefined && provider === undefined;
|
|
108
|
+
// Checked before anything is written, so a typo in --limit changes nothing.
|
|
109
|
+
const limit = capFlag(flags['limit']);
|
|
107
110
|
// With no key yet, sign in first, as phantom claude does, so the agents
|
|
108
111
|
// get one in this run. Its words go to stderr; stdout stays setup's result.
|
|
109
112
|
if (guided && !dryRun && io.isTTY && !resolveApiKey(env)) {
|
|
@@ -119,19 +122,25 @@ export async function setupCommand(input) {
|
|
|
119
122
|
provider: guided || provider === true ? true : provider === 'off' ? 'off' : false,
|
|
120
123
|
model: typeof model === 'string' ? model : undefined,
|
|
121
124
|
guided,
|
|
125
|
+
shell: !flags['no-shell'],
|
|
122
126
|
};
|
|
123
127
|
const found = planSetup(env, options);
|
|
124
128
|
let plan = found;
|
|
125
129
|
// At a terminal, plain setup asks which of the agents it found to connect (all ticked, Space unticks),
|
|
126
130
|
// then whether to make the changes. Esc on the question goes back to the checklist.
|
|
127
131
|
const pick = guided && !flags['yes'] && !dryRun ? pickIo(input) : null;
|
|
128
|
-
const ticks = Boolean(pick) && found.agents.length > 1;
|
|
132
|
+
const ticks = Boolean(pick) && (found.agents.length > 1 || found.files.some((f) => f.agent === 'shell'));
|
|
129
133
|
/** Shows the checklist, or returns false when Esc there means leave. */
|
|
134
|
+
// The shell file is a row of its own, so it can be left out like an agent.
|
|
135
|
+
const shellRow = found.files.some((f) => f.agent === 'shell');
|
|
130
136
|
const tick = async () => {
|
|
131
|
-
const only = await checklist(pick, 'Which agents should use Phantom AI?',
|
|
137
|
+
const only = await checklist(pick, 'Which agents should use Phantom AI?', [
|
|
138
|
+
...found.agents.map((a) => ({ label: SETUP_AGENTS[a.agent].label, hint: a.provider ? 'skill and model settings' : 'skill', value: a.agent, checked: plan.agents.some((p) => p.agent === a.agent) })),
|
|
139
|
+
...(shellRow ? [{ label: 'Your shell', hint: 'new terminals get your key; untick to add it yourself', value: 'shell', checked: plan.files.some((f) => f.agent === 'shell') }] : []),
|
|
140
|
+
]);
|
|
132
141
|
if (!only)
|
|
133
142
|
return false;
|
|
134
|
-
plan = planSetup(env, { ...options, only });
|
|
143
|
+
plan = planSetup(env, { ...options, only: only.filter((a) => a !== 'shell'), shell: shellRow ? only.includes('shell') : options.shell });
|
|
135
144
|
return true;
|
|
136
145
|
};
|
|
137
146
|
/** Esc on setup's first step: back to the menu when it started setup, otherwise stop with nothing changed. */
|
|
@@ -190,7 +199,7 @@ export async function setupCommand(input) {
|
|
|
190
199
|
}
|
|
191
200
|
break;
|
|
192
201
|
}
|
|
193
|
-
return finish(applySetup(env, plan));
|
|
202
|
+
return capAfter(input, await finish(applySetup(env, plan)), limit, guided ? pick : null);
|
|
194
203
|
}
|
|
195
204
|
const ask = io.ask ??
|
|
196
205
|
(process.stdin.isTTY
|
|
@@ -213,5 +222,49 @@ export async function setupCommand(input) {
|
|
|
213
222
|
return 0;
|
|
214
223
|
}
|
|
215
224
|
}
|
|
216
|
-
return finish(applySetup(env, plan));
|
|
225
|
+
return capAfter(input, await finish(applySetup(env, plan)), limit, null);
|
|
226
|
+
}
|
|
227
|
+
/** `--limit`: dollars a month above zero, `none` to remove the cap, or undefined when it isn't given. */
|
|
228
|
+
function capFlag(value) {
|
|
229
|
+
if (value === undefined)
|
|
230
|
+
return undefined;
|
|
231
|
+
if (value === 'none')
|
|
232
|
+
return null;
|
|
233
|
+
const usd = Number(String(value).replace(/^\$/, ''));
|
|
234
|
+
if (typeof value !== 'string' || !Number.isFinite(usd) || usd <= 0)
|
|
235
|
+
die('--limit takes dollars a month above zero, such as 20, or none', 'setup_limit');
|
|
236
|
+
return usd;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* After setup has written its files: the key's monthly spend cap (PATCH
|
|
240
|
+
* /key/budget, a calendar month). `--limit` sets it; without one, setup at a
|
|
241
|
+
* terminal asks once, only when the key has no cap yet. Returns setup's exit code.
|
|
242
|
+
*/
|
|
243
|
+
async function capAfter(input, code, limit, pick) {
|
|
244
|
+
const { env, io, baseUrl } = input;
|
|
245
|
+
const apiKey = resolveApiKey(env);
|
|
246
|
+
if (!apiKey || (limit === undefined && !pick))
|
|
247
|
+
return code;
|
|
248
|
+
let cap = limit;
|
|
249
|
+
if (cap === undefined) {
|
|
250
|
+
const now = await request('GET', '/key/budget', apiKey, undefined, baseUrl).catch(() => null);
|
|
251
|
+
if (!now || now.budget_usd !== null)
|
|
252
|
+
return code;
|
|
253
|
+
io.stderr('\n');
|
|
254
|
+
const answer = await choose(pick, 'Cap what this key can spend each month?', [
|
|
255
|
+
{ label: 'No cap', hint: 'free use is never charged', value: 0 },
|
|
256
|
+
{ label: '$10 a month', value: 10 },
|
|
257
|
+
{ label: '$25 a month', value: 25 },
|
|
258
|
+
{ label: '$50 a month', value: 50 },
|
|
259
|
+
]);
|
|
260
|
+
if (!answer)
|
|
261
|
+
return code;
|
|
262
|
+
cap = answer;
|
|
263
|
+
}
|
|
264
|
+
await request('PATCH', '/key/budget', apiKey, cap === null ? { budget_usd: null } : { budget_usd: cap, period_days: null }, baseUrl);
|
|
265
|
+
const p = paint(env, colorOn(env, io.isTTY));
|
|
266
|
+
io.stderr(cap === null
|
|
267
|
+
? ` ${p.lime('✓')} This key has no monthly cap now.\n`
|
|
268
|
+
: ` ${p.lime('✓')} This key can spend at most $${cap} a month. Change it any time: ${p.cyan('phantom setup --limit <USD|none>')}\n`);
|
|
269
|
+
return code;
|
|
217
270
|
}
|
package/dist/setup/plan.mjs
CHANGED
|
@@ -109,7 +109,12 @@ export function planSetup(env, opts = {}) {
|
|
|
109
109
|
}
|
|
110
110
|
agents.push({ agent, skill, ...(result ? { provider: result } : {}) });
|
|
111
111
|
}
|
|
112
|
-
if (shellOn.size
|
|
112
|
+
if (shellOn.size && opts.shell === false) {
|
|
113
|
+
const block = withShellBlock(env, ctx.shellRc, '', shellOn, [], opts.onPath) ?? '';
|
|
114
|
+
const short = ctx.shellRc.startsWith(home + path.sep) ? '~' + ctx.shellRc.slice(home.length) : ctx.shellRc;
|
|
115
|
+
notes.push(`Your shell file was left alone. So ${orList([...shellOn.keys()].map((a) => SETUP_AGENTS[a].label))} can find your key, add these lines to ${short}:`, '', ...block.trimEnd().split('\n').map((l) => ` ${l}`));
|
|
116
|
+
}
|
|
117
|
+
else if (shellOn.size || shellOff.length) {
|
|
113
118
|
try {
|
|
114
119
|
add('shell', ctx.shellRc, withShellBlock(env, ctx.shellRc, readText(ctx.shellRc), shellOn, shellOff, opts.onPath));
|
|
115
120
|
}
|
package/dist/setup/screen.mjs
CHANGED
|
@@ -5,10 +5,11 @@
|
|
|
5
5
|
// The JSON result (setupCommand without a terminal) never goes through here.
|
|
6
6
|
import os from 'node:os';
|
|
7
7
|
import path from 'node:path';
|
|
8
|
-
import { paint, unstyled } from "../core/style.mjs";
|
|
8
|
+
import { fitText, paint, unstyled } from "../core/style.mjs";
|
|
9
9
|
import { brandHeader } from "../core/brand.mjs";
|
|
10
10
|
import { inApp } from "../cli/screens.mjs";
|
|
11
11
|
import { SETUP_AGENTS } from "./agents.mjs";
|
|
12
|
+
import { FREE_LINE } from "../core/free.mjs";
|
|
12
13
|
/** `text` broken at spaces into lines of at most `width` characters; a single longer word stays whole. */
|
|
13
14
|
export function wrapWords(text, width) {
|
|
14
15
|
const lines = [];
|
|
@@ -64,10 +65,12 @@ export function setupScreen(r, opts = {}) {
|
|
|
64
65
|
// unless that's wider than the window: then rows stay short and unaligned.
|
|
65
66
|
const longest = Math.max(0, ...r.files.map((f) => short(f.file).length));
|
|
66
67
|
const columns = opts.columns ?? process.stdout.columns ?? 100;
|
|
67
|
-
|
|
68
|
+
// The widest "what happens" phrase (`new file, 12 lines`), plus room for "copy kept".
|
|
69
|
+
const said = Math.max(8, ...r.files.map((f) => change(f).length + (f.backup ? 11 : 0)));
|
|
70
|
+
const fits = width + 4 + 10 + longest + 2 + said <= columns;
|
|
68
71
|
const pathWidth = fits ? longest : 0;
|
|
69
72
|
// As wide as a file row (name, kind, path, action and a line count), and never wider than the window.
|
|
70
|
-
const rule = Math.max(20, Math.min(width + 2 + 10 + longest + 2 +
|
|
73
|
+
const rule = Math.max(20, Math.min(width + 2 + 10 + longest + 2 + said, columns - 4));
|
|
71
74
|
// After the prompt, a blank line sets the result apart from the answer typed above it.
|
|
72
75
|
const lines = [''];
|
|
73
76
|
const found = r.agents.length;
|
|
@@ -134,7 +137,16 @@ export function setupScreen(r, opts = {}) {
|
|
|
134
137
|
lines.push('');
|
|
135
138
|
if (routes) {
|
|
136
139
|
lines.push(` ${p.dim('What this does:')} these agents send their model requests to Phantom AI, and phantom-1 picks the model.`);
|
|
137
|
-
lines.push(` ${p.dim('
|
|
140
|
+
lines.push(` ${p.dim('Free to start:')} ${FREE_LINE}.`);
|
|
141
|
+
// Claude Code's settings file applies in every folder, so its own login stops being used.
|
|
142
|
+
if (r.agents.some((a) => a.agent === 'claude' && a.provider?.on)) {
|
|
143
|
+
lines.push(` ${p.dim('Claude Code:')} uses Phantom AI in place of your Anthropic login, in every folder. To keep your login, leave Claude Code out and start it with ${p.cyan('phantom claude')} when you want Phantom.`);
|
|
144
|
+
}
|
|
145
|
+
const readers = r.agents.filter((a) => a.provider?.on && SETUP_AGENTS[a.agent]?.shellEnv);
|
|
146
|
+
if (readers.length && r.files.some((f) => f.agent === 'shell')) {
|
|
147
|
+
lines.push(` ${p.dim('Your shell:')} new terminals get your key as PHANTOM_API_KEY, for ${readers.map((a) => label(a.agent)).join(', ')}. The file runs ${p.cyan('phantom key show')}; the key itself isn't written in it.`);
|
|
148
|
+
}
|
|
149
|
+
lines.push(` ${p.dim('Undo:')} phantom keeps a copy of each file it changes, and ${p.cyan('phantom setup --restore')} puts them back.`);
|
|
138
150
|
}
|
|
139
151
|
if (skills)
|
|
140
152
|
lines.push(` ${p.dim('skill:')} a short note that teaches the agent the phantom commands.`);
|
|
@@ -153,7 +165,8 @@ export function setupScreen(r, opts = {}) {
|
|
|
153
165
|
lines.push('', ...nextStep(r, p, opts.columns));
|
|
154
166
|
lines.push('', inApp() ? ` ${p.dim('Undo any time: More, then Undo setup.')}` : ` Undo any time: ${p.cyan('phantom setup --restore')}`);
|
|
155
167
|
}
|
|
156
|
-
|
|
168
|
+
// Long sentences break at a word under their own indent, at the terminal's width.
|
|
169
|
+
return fitText(lines.join('\n'), columns);
|
|
157
170
|
}
|
|
158
171
|
/** What a file is, in the words the screen uses: its settings (an agent's, or the shell's ~/.zshrc), or its skill. */
|
|
159
172
|
function kind(f) {
|
|
@@ -162,22 +175,32 @@ function kind(f) {
|
|
|
162
175
|
// ~/.zshrc is the shell's own settings file, and the row already says "Your shell".
|
|
163
176
|
return 'settings';
|
|
164
177
|
}
|
|
165
|
-
/**
|
|
166
|
-
const
|
|
178
|
+
/** `n lines`, or `1 line`. */
|
|
179
|
+
const lineCount = (n) => `${n} line${n === 1 ? '' : 's'}`;
|
|
167
180
|
/**
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
* `settings ~/.codex/config.toml new +12`.
|
|
181
|
+
* What happens to a file, in words: `new file, 12 lines`, `adds 3 lines`,
|
|
182
|
+
* `adds 3, removes 1`, `updated` (a skill) or `deleted`.
|
|
171
183
|
*/
|
|
172
|
-
function
|
|
173
|
-
const action = ACTION[f.action].padEnd(6);
|
|
184
|
+
function change(f) {
|
|
174
185
|
const { added, removed } = counts(f.diff);
|
|
175
|
-
|
|
186
|
+
if (f.action === 'remove')
|
|
187
|
+
return 'deleted';
|
|
188
|
+
if (f.action === 'create')
|
|
189
|
+
return added ? `new file, ${lineCount(added)}` : 'new file';
|
|
190
|
+
if (!f.diff)
|
|
191
|
+
return 'updated';
|
|
192
|
+
return removed ? `adds ${added}, removes ${removed}` : `adds ${lineCount(added)}`;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* One file under an agent, in columns: what the file is, where it is, and
|
|
196
|
+
* what happens to it. For example `settings ~/.codex/config.toml new file, 12 lines`.
|
|
197
|
+
*/
|
|
198
|
+
function fileRow(f, p, short, pathWidth) {
|
|
199
|
+
const what = change(f);
|
|
176
200
|
return [
|
|
177
201
|
p.dim(kind(f).padEnd(8)),
|
|
178
202
|
short(f.file).padEnd(pathWidth),
|
|
179
|
-
f.action === 'remove' ? p.red(
|
|
180
|
-
change + (f.backup ? p.dim(' copy kept') : ''),
|
|
203
|
+
(f.action === 'remove' ? p.red(what) : p.cyan(what)) + (f.backup ? p.dim(' copy kept') : ''),
|
|
181
204
|
]
|
|
182
205
|
.join(' ')
|
|
183
206
|
.trimEnd();
|
package/dist/spend/command.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { colorOn, paint } from "../core/style.mjs";
|
|
|
6
6
|
import { choose } from "../core/pick.mjs";
|
|
7
7
|
import { hideHints, lineupScreen, priceHint, routeScreen, routeTestScreen, TIER_ABOUT } from "../cli/screens.mjs";
|
|
8
8
|
import { getLineup, putLineup, resetLineup, LINEUP_TIERS } from "./lineup.mjs";
|
|
9
|
-
import { getRoute, putRoute, patchRoute, clearRoute, testRoute, parseCondition } from "./routing.mjs";
|
|
9
|
+
import { getRoute, putRoute, patchRoute, clearRoute, testRoute, parseCondition, SORT_WORDS } from "./routing.mjs";
|
|
10
10
|
/** `phantom route`: the key's route policy, its rules, and a dry run. Run by `run()`. */
|
|
11
11
|
export async function routeCommand(input) {
|
|
12
12
|
const { argv, io, apiKey, subcommand, flags, baseUrl, env } = input;
|
|
@@ -44,11 +44,28 @@ export async function routeCommand(input) {
|
|
|
44
44
|
fields.stick_minutes = stick;
|
|
45
45
|
if (flags['stick-by-prompt'] !== undefined)
|
|
46
46
|
fields.stick_by_prompt = flags['stick-by-prompt'] !== 'false';
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
const sort = flags['sort'];
|
|
48
|
+
if (sort !== undefined && (typeof sort !== 'string' || !(sort in SORT_WORDS || sort === 'default'))) {
|
|
49
|
+
die('--sort takes fast (quickest to start answering), cheap (the default), throughput (most tokens a second) or default', 'route_usage');
|
|
50
|
+
}
|
|
51
|
+
if (typeof sort === 'string' && sort !== 'default')
|
|
52
|
+
fields.sort = SORT_WORDS[sort];
|
|
53
|
+
if (Object.keys(fields).length === 0 && sort !== 'default') {
|
|
54
|
+
die('route set needs --models a,b,c, --sort, --applies-to, --on-empty, --fallback, --stick-minutes, --stick-by-prompt, or --file policy.json');
|
|
49
55
|
}
|
|
50
56
|
const current = (await getRoute(apiKey, baseUrl)).route_policy;
|
|
51
|
-
|
|
57
|
+
let result;
|
|
58
|
+
if (sort === 'default') {
|
|
59
|
+
// Back to cheapest first: drop sort, and the whole policy when sort was all it held.
|
|
60
|
+
const { sort: _was, ...rest } = { ...(current ?? { models: [] }), ...fields };
|
|
61
|
+
void _was;
|
|
62
|
+
const empty = !rest.models.length && !rest.zdr && !rest.no_training && !(rest.rules ?? []).length;
|
|
63
|
+
result = !current ? { route_policy: null } : empty ? await clearRoute(apiKey, baseUrl) : await putRoute(apiKey, rest, baseUrl);
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
// A key with no policy gets one of preferences only, which routes nothing, unless --models is given too.
|
|
67
|
+
result = current ? await patchRoute(apiKey, fields, baseUrl) : await putRoute(apiKey, { models: [], ...fields }, baseUrl);
|
|
68
|
+
}
|
|
52
69
|
out(result, table, tableRoute, io.stdout);
|
|
53
70
|
}
|
|
54
71
|
}
|
|
@@ -174,7 +191,7 @@ async function lineupPicker(input, pick, show) {
|
|
|
174
191
|
io.stdout(show(read) + '\n\n');
|
|
175
192
|
const tier = await choose({ ...pick, esc: 'back' }, 'Which tier do you want to change?', [
|
|
176
193
|
...LINEUP_TIERS.map((t) => ({ label: t[0].toUpperCase() + t.slice(1), hint: `${TIER_ABOUT[t]} · now ${read.tiers[t].model}`, value: t })),
|
|
177
|
-
{ label: '
|
|
194
|
+
{ label: 'Reset every tier', hint: 'back to its default', value: 'reset' },
|
|
178
195
|
], last);
|
|
179
196
|
if (tier === null)
|
|
180
197
|
return 0;
|
package/dist/spend/routing.mjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { die } from "../core/errors.mjs";
|
|
2
2
|
import { request } from "../core/request.mjs";
|
|
3
|
+
/** The words `phantom route set --sort` takes, and the server's name for each. The server's own names work too. */
|
|
4
|
+
export const SORT_WORDS = { cheap: 'cost', fast: 'ttft', throughput: 'tps', cost: 'cost', ttft: 'ttft', tps: 'tps' };
|
|
3
5
|
/** The key's route policy (GET /key/route). */
|
|
4
6
|
export function getRoute(apiKey, baseUrl) {
|
|
5
7
|
return request('GET', '/key/route', apiKey, undefined, baseUrl);
|
|
@@ -12,8 +12,8 @@ import { BackToMenu, Cancelled, askText, cancelKeys, choose, keyboard } from "..
|
|
|
12
12
|
import { spawnSync } from 'node:child_process';
|
|
13
13
|
import { onPath } from "../setup/shared.mjs";
|
|
14
14
|
import { colorOn, paint } from "../core/style.mjs";
|
|
15
|
-
import { fundScreen, keyChangeScreen, keysScreen, paymentScreen, signedInScreen, workspaceScreen } from "../cli/screens.mjs";
|
|
16
|
-
import { keyPath, listNamedKeys, namedKeyPath, readNamedKey, saveApiKey, saveNamedKey } from "../keys/saved.mjs";
|
|
15
|
+
import { alreadySignedInScreen, fundScreen, keyChangeScreen, keysScreen, paymentScreen, signedInScreen, workspaceScreen } from "../cli/screens.mjs";
|
|
16
|
+
import { keyId, keyPath, listNamedKeys, namedKeyPath, readNamedKey, resolveApiKey, saveApiKey, saveNamedKey } from "../keys/saved.mjs";
|
|
17
17
|
const credentialPath = (env) => path.join(stateDir(env), 'workspace');
|
|
18
18
|
const base = (input) => httpsOnly(input.baseUrl || input.env.PHANTOM_BASE_URL || DEFAULT_BASE_URL).replace(/\/$/, '');
|
|
19
19
|
async function wire(url, method, endpoint, body, token) {
|
|
@@ -52,8 +52,12 @@ export async function workspaceLogin(input, opts = {}) {
|
|
|
52
52
|
die('--save requires a key name.');
|
|
53
53
|
const name = typeof flags.save === 'string' ? flags.save : 'default';
|
|
54
54
|
const target = name === 'default' ? keyPath(env) : namedKeyPath(env, name);
|
|
55
|
-
if (existsSync(target))
|
|
56
|
-
|
|
55
|
+
if (existsSync(target)) {
|
|
56
|
+
// Signing in again is a question ("am I signed in?"), so it gets an answer, not an error.
|
|
57
|
+
if (name === 'default')
|
|
58
|
+
return alreadySignedIn(input);
|
|
59
|
+
die(`A key named ${name} is already saved here. Pick another name with --save <name>; nothing was changed.`, 'key_exists');
|
|
60
|
+
}
|
|
57
61
|
// At a terminal, how to sign in is a choice; --no-browser or a pipe skips it.
|
|
58
62
|
const pick = flags['no-browser'] ? null : pickIo(input);
|
|
59
63
|
let noBrowser = Boolean(flags['no-browser']);
|
|
@@ -101,6 +105,17 @@ export async function workspaceLogin(input, opts = {}) {
|
|
|
101
105
|
cancel?.close();
|
|
102
106
|
}
|
|
103
107
|
}
|
|
108
|
+
/** `phantom login` on a computer that already has a key: which key, and how to use another. Exit 0. */
|
|
109
|
+
function alreadySignedIn(input) {
|
|
110
|
+
const { env, io } = input;
|
|
111
|
+
const key = resolveApiKey(env);
|
|
112
|
+
const result = { signed_in: true, already: true, key_id: key ? keyId(key) : null };
|
|
113
|
+
if (showsScreen(input))
|
|
114
|
+
io.stdout(alreadySignedInScreen(result.key_id, { env, color: colorOn(env, io.stdoutIsTTY) }) + '\n');
|
|
115
|
+
else
|
|
116
|
+
io.stdout(JSON.stringify(result) + '\n');
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
104
119
|
/** A spinner on one line: `⠋ Waiting for you to approve...` until stopped, then `✓ Approved.` or nothing. */
|
|
105
120
|
function waiting(write, p, words = 'Waiting for you to approve...', doneWords = 'Approved.') {
|
|
106
121
|
const frames = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏';
|
|
@@ -324,7 +339,7 @@ export async function workspaceCommand(input) {
|
|
|
324
339
|
}
|
|
325
340
|
if (pick && !method) {
|
|
326
341
|
method = (await choose({ ...pick, esc: 'back' }, 'How do you want to pay?', [
|
|
327
|
-
{ label: 'Card', hint: "on Stripe's page
|
|
342
|
+
{ label: 'Card', hint: "on Stripe's page, plus Stripe's card fee", value: 'card' },
|
|
328
343
|
{ label: 'USDC', hint: 'from a Solana wallet', value: 'usdc' },
|
|
329
344
|
{ label: 'USDT', hint: 'from a Solana wallet', value: 'usdt' },
|
|
330
345
|
{ label: 'SOL', hint: 'from a Solana wallet', value: 'sol' },
|