@phantomcodes/cli 1.2.6 → 1.2.7
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 +3 -1
- package/dist/cli/app.mjs +6 -3
- package/dist/cli/commands.mjs +2 -0
- package/dist/cli/run.mjs +11 -1
- package/dist/cli/screens.mjs +18 -4
- 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 +9 -3
- package/dist/setup/plan.mjs +6 -1
- package/dist/setup/screen.mjs +38 -15
- package/dist/spend/command.mjs +1 -1
- package/dist/workspace/command.mjs +20 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -68,7 +68,9 @@ 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. The Phantom CLI keeps backups;
|
|
72
74
|
undo the changes with `phantom setup --restore`.
|
|
73
75
|
|
|
74
76
|
## Connect for one run
|
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
|
@@ -160,6 +160,8 @@ export const COMMANDS = [
|
|
|
160
160
|
[--no-browser] print the sign-in link instead of opening it
|
|
161
161
|
[--dry-run] preview the changes without making them
|
|
162
162
|
[--diff] show every line each file gains or loses
|
|
163
|
+
[--no-shell] leave your shell startup file alone and list
|
|
164
|
+
the lines to add yourself
|
|
163
165
|
[--json] print JSON in a terminal too (a pipe always gets JSON)
|
|
164
166
|
[--model <id>] the model each agent starts on (default ${SETUP_MODEL})
|
|
165
167
|
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)}` : ''}`;
|
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.7';
|
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
|
@@ -119,19 +119,25 @@ export async function setupCommand(input) {
|
|
|
119
119
|
provider: guided || provider === true ? true : provider === 'off' ? 'off' : false,
|
|
120
120
|
model: typeof model === 'string' ? model : undefined,
|
|
121
121
|
guided,
|
|
122
|
+
shell: !flags['no-shell'],
|
|
122
123
|
};
|
|
123
124
|
const found = planSetup(env, options);
|
|
124
125
|
let plan = found;
|
|
125
126
|
// At a terminal, plain setup asks which of the agents it found to connect (all ticked, Space unticks),
|
|
126
127
|
// then whether to make the changes. Esc on the question goes back to the checklist.
|
|
127
128
|
const pick = guided && !flags['yes'] && !dryRun ? pickIo(input) : null;
|
|
128
|
-
const ticks = Boolean(pick) && found.agents.length > 1;
|
|
129
|
+
const ticks = Boolean(pick) && (found.agents.length > 1 || found.files.some((f) => f.agent === 'shell'));
|
|
129
130
|
/** Shows the checklist, or returns false when Esc there means leave. */
|
|
131
|
+
// The shell file is a row of its own, so it can be left out like an agent.
|
|
132
|
+
const shellRow = found.files.some((f) => f.agent === 'shell');
|
|
130
133
|
const tick = async () => {
|
|
131
|
-
const only = await checklist(pick, 'Which agents should use Phantom AI?',
|
|
134
|
+
const only = await checklist(pick, 'Which agents should use Phantom AI?', [
|
|
135
|
+
...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) })),
|
|
136
|
+
...(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') }] : []),
|
|
137
|
+
]);
|
|
132
138
|
if (!only)
|
|
133
139
|
return false;
|
|
134
|
-
plan = planSetup(env, { ...options, only });
|
|
140
|
+
plan = planSetup(env, { ...options, only: only.filter((a) => a !== 'shell'), shell: shellRow ? only.includes('shell') : options.shell });
|
|
135
141
|
return true;
|
|
136
142
|
};
|
|
137
143
|
/** Esc on setup's first step: back to the menu when it started setup, otherwise stop with nothing changed. */
|
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
|
@@ -174,7 +174,7 @@ async function lineupPicker(input, pick, show) {
|
|
|
174
174
|
io.stdout(show(read) + '\n\n');
|
|
175
175
|
const tier = await choose({ ...pick, esc: 'back' }, 'Which tier do you want to change?', [
|
|
176
176
|
...LINEUP_TIERS.map((t) => ({ label: t[0].toUpperCase() + t.slice(1), hint: `${TIER_ABOUT[t]} · now ${read.tiers[t].model}`, value: t })),
|
|
177
|
-
{ label: '
|
|
177
|
+
{ label: 'Reset every tier', hint: 'back to its default', value: 'reset' },
|
|
178
178
|
], last);
|
|
179
179
|
if (tier === null)
|
|
180
180
|
return 0;
|
|
@@ -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' },
|