@phantomcodes/cli 1.2.3 → 1.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -10
- package/dist/agents/command.mjs +26 -8
- 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 +60 -27
- package/dist/cli/screens.mjs +335 -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 +295 -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 +62 -9
- 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/setup/shared.mjs +4 -3
- package/dist/spend/command.mjs +75 -38
- package/dist/spend/report.mjs +12 -3
- package/dist/workspace/command.mjs +355 -42
- package/package.json +3 -3
- package/skills/phantom-ai/SKILL.md +3 -2
package/dist/setup/plan.mjs
CHANGED
|
@@ -33,6 +33,8 @@ export function planSetup(env, opts = {}) {
|
|
|
33
33
|
picked = names.filter((a) => SETUP_AGENTS[a].detect?.(env, home) ?? existsSync(path.join(home, SETUP_AGENTS[a].home)));
|
|
34
34
|
if (picked.length === 0)
|
|
35
35
|
die(`Found no agent to set up. Pass --agent ${names.join('|')}`);
|
|
36
|
+
if (opts.only)
|
|
37
|
+
picked = picked.filter((a) => opts.only.includes(a));
|
|
36
38
|
}
|
|
37
39
|
else if (names.includes(opts.agent)) {
|
|
38
40
|
picked = [opts.agent];
|
|
@@ -55,8 +57,8 @@ export function planSetup(env, opts = {}) {
|
|
|
55
57
|
}
|
|
56
58
|
if (provider === true && !resolveApiKey(env)) {
|
|
57
59
|
if (!opts.guided)
|
|
58
|
-
die('No
|
|
59
|
-
notes.push('No Phantom
|
|
60
|
+
die('No Phantom key yet to give the agents. Run phantom login to sign in, then run this again.', 'no_key');
|
|
61
|
+
notes.push('No Phantom key yet, so only the skills are installed. Run phantom setup in a terminal to sign in and set up the rest.');
|
|
60
62
|
provider = false;
|
|
61
63
|
}
|
|
62
64
|
const ctx = { env, home, onPath: opts.onPath, model: opts.model, shellRc: shellRcFile(env, home) };
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// How `phantom setup` looks in a terminal, drawn in the site's style: the
|
|
2
|
+
// ghost and a title, an uppercase caption over a thin rule, one row per agent
|
|
3
|
+
// with its files lined up in columns, and after the changes a ✓ per agent and
|
|
4
|
+
// the next step in corner brackets, like the site's hero.
|
|
5
|
+
// The JSON result (setupCommand without a terminal) never goes through here.
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { paint, unstyled } from "../core/style.mjs";
|
|
9
|
+
import { brandHeader } from "../core/brand.mjs";
|
|
10
|
+
import { inApp } from "../cli/screens.mjs";
|
|
11
|
+
import { SETUP_AGENTS } from "./agents.mjs";
|
|
12
|
+
/** `text` broken at spaces into lines of at most `width` characters; a single longer word stays whole. */
|
|
13
|
+
export function wrapWords(text, width) {
|
|
14
|
+
const lines = [];
|
|
15
|
+
let line = '';
|
|
16
|
+
for (const word of text.split(' ')) {
|
|
17
|
+
if (line && (line + ' ' + word).length > width) {
|
|
18
|
+
lines.push(line);
|
|
19
|
+
line = word;
|
|
20
|
+
}
|
|
21
|
+
else
|
|
22
|
+
line = line ? line + ' ' + word : word;
|
|
23
|
+
}
|
|
24
|
+
return [...lines, line];
|
|
25
|
+
}
|
|
26
|
+
/** The name a person knows an agent by: "Claude Code", or "Your shell" for the startup file. */
|
|
27
|
+
const label = (agent) => (agent === 'shell' ? 'Your shell' : (SETUP_AGENTS[agent]?.label ?? agent));
|
|
28
|
+
/** How many lines a diff from `lineDiff` adds and removes. */
|
|
29
|
+
function counts(diff) {
|
|
30
|
+
const rows = (diff ?? '').split('\n');
|
|
31
|
+
return { added: rows.filter((r) => r.startsWith('+')).length, removed: rows.filter((r) => r.startsWith('-')).length };
|
|
32
|
+
}
|
|
33
|
+
/** An uppercase caption over a thin rule, like the site's section labels (SPEND, CALLS). */
|
|
34
|
+
function caption(p, text, extra, width) {
|
|
35
|
+
return [` ${p.bold(text)} ${extra}`.trimEnd(), ` ${p.dim('─'.repeat(width))}`];
|
|
36
|
+
}
|
|
37
|
+
/** Text framed by corner brackets, the site's hero frame, for the one thing to do next. */
|
|
38
|
+
function framed(p, text, columns) {
|
|
39
|
+
const inner = unstyled(text).length + 4;
|
|
40
|
+
// Too wide for the window: the corners would wrap away from the text, so it's a plain line instead.
|
|
41
|
+
if (columns && inner + 4 > columns)
|
|
42
|
+
return [` ${text}`];
|
|
43
|
+
return [` ${p.lime('┌')}${' '.repeat(inner)}${p.lime('┐')}`, ` ${text}`, ` ${p.lime('└')}${' '.repeat(inner)}${p.lime('┘')}`];
|
|
44
|
+
}
|
|
45
|
+
/** `phantom setup`'s screen for one result. See {@link ScreenMode} for the moments it shows. */
|
|
46
|
+
export function setupScreen(r, opts = {}) {
|
|
47
|
+
const env = opts.env ?? {};
|
|
48
|
+
const color = Boolean(opts.color);
|
|
49
|
+
const p = paint(env, color);
|
|
50
|
+
const mode = opts.mode ?? (r.dry_run ? 'dry' : r.applied === false ? 'declined' : 'done');
|
|
51
|
+
// The list is already on screen above the question, so a no only needs saying.
|
|
52
|
+
if (mode === 'declined')
|
|
53
|
+
return '\n Nothing was changed.';
|
|
54
|
+
const home = env.HOME || os.homedir();
|
|
55
|
+
const short = (file) => (file === home || file.startsWith(home + path.sep) ? '~' + file.slice(home.length) : file);
|
|
56
|
+
// Rows run in the order setup found the agents, then the shell file last.
|
|
57
|
+
const agents = [...r.agents.map((a) => a.agent), ...(r.files.some((f) => f.agent === 'shell') ? ['shell'] : [])];
|
|
58
|
+
const skipped = (r.skipped ?? []).filter((s) => !agents.includes(s.agent));
|
|
59
|
+
const names = [...agents, ...skipped.map((s) => s.agent)];
|
|
60
|
+
const width = Math.max(12, ...names.map((a) => label(a).length + 2));
|
|
61
|
+
const pad = ' '.repeat(width + 4);
|
|
62
|
+
const name = (agent) => p.bold(label(agent).padEnd(width));
|
|
63
|
+
// Every path is padded to the longest, so the line counts form one column,
|
|
64
|
+
// unless that's wider than the window: then rows stay short and unaligned.
|
|
65
|
+
const longest = Math.max(0, ...r.files.map((f) => short(f.file).length));
|
|
66
|
+
const columns = opts.columns ?? process.stdout.columns ?? 100;
|
|
67
|
+
const fits = width + 4 + 10 + longest + 2 + 8 + 4 <= columns;
|
|
68
|
+
const pathWidth = fits ? longest : 0;
|
|
69
|
+
// 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 + 8 + 4, columns - 4));
|
|
71
|
+
// After the prompt, a blank line sets the result apart from the answer typed above it.
|
|
72
|
+
const lines = [''];
|
|
73
|
+
const found = r.agents.length;
|
|
74
|
+
if (mode !== 'done') {
|
|
75
|
+
lines.push(...brandHeader(p, color, 'setup'), ...caption(p, 'AGENTS FOUND', p.lime(p.bold(String(found))), rule));
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
lines.push(...caption(p, 'DONE', '', rule));
|
|
79
|
+
}
|
|
80
|
+
for (const agent of agents) {
|
|
81
|
+
const a = r.agents.find((x) => x.agent === agent);
|
|
82
|
+
// The agent's settings before its skill: the settings are what change how it runs.
|
|
83
|
+
const files = r.files.filter((f) => f.agent === agent).sort((x, y) => Number(!x.diff) - Number(!y.diff));
|
|
84
|
+
const skip = (r.skipped ?? []).filter((s) => s.agent === agent);
|
|
85
|
+
const manual = a?.provider?.manual ?? [];
|
|
86
|
+
const warnings = a?.provider?.warnings ?? [];
|
|
87
|
+
const detail = [];
|
|
88
|
+
if (mode === 'done') {
|
|
89
|
+
// Turning Phantom off for an agent that was never on changed nothing, so it gets no check mark.
|
|
90
|
+
const unchanged = !files.length && a?.provider && !a.provider.on;
|
|
91
|
+
const mark = skip.length ? p.red('✗') : manual.length ? p.yellow('!') : unchanged ? p.dim('·') : p.lime('✓');
|
|
92
|
+
lines.push(` ${mark} ${name(agent)}${doneSummary(agent, a, files.length, skip.length > 0, p)}`);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
// The files first, so the eye reads down one column of paths.
|
|
96
|
+
const [first, ...rest] = files.map((f) => fileRow(f, p, short, pathWidth));
|
|
97
|
+
lines.push(` ${name(agent)}${first ?? p.dim(manual.length ? 'nothing to write' : 'already set up')}`);
|
|
98
|
+
for (const row of rest)
|
|
99
|
+
detail.push(pad + row);
|
|
100
|
+
if (opts.diff) {
|
|
101
|
+
for (const f of files.filter((x) => x.diff)) {
|
|
102
|
+
detail.push('', ...f.diff.split('\n').map((row) => pad + ' ' + diffRow(row, p)), '');
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
for (const s of skip)
|
|
107
|
+
detail.push(pad + p.red(`skipped: ${s.reason}`));
|
|
108
|
+
for (const w of warnings)
|
|
109
|
+
detail.push(pad + p.yellow(w));
|
|
110
|
+
// Steps to do by hand show once, after the changes, when they can be done.
|
|
111
|
+
if (manual.length && mode !== 'done')
|
|
112
|
+
detail.push(pad + p.yellow(`then a few steps by hand in ${a?.provider?.settings}, shown after`));
|
|
113
|
+
else if (manual.length) {
|
|
114
|
+
detail.push(pad + p.yellow(`In ${a?.provider?.settings}:`));
|
|
115
|
+
// Each step wraps under its own first word, not under the number.
|
|
116
|
+
const room = Math.max(30, (opts.columns ?? 100) - pad.length - 5);
|
|
117
|
+
manual.forEach((step, k) => {
|
|
118
|
+
const [first, ...rest] = wrapWords(step, room);
|
|
119
|
+
detail.push(`${pad} ${p.dim(`${k + 1}.`)} ${first}`, ...rest.map((l) => `${pad} ${l}`));
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
lines.push(...detail);
|
|
123
|
+
}
|
|
124
|
+
for (const s of skipped)
|
|
125
|
+
lines.push(` ${mode === 'done' ? p.red('✗') + ' ' : ' '}${name(s.agent)}${p.red(`skipped: ${s.reason}`)}`);
|
|
126
|
+
const notes = r.notes ?? [];
|
|
127
|
+
if (notes.length)
|
|
128
|
+
lines.push('', ...notes.map((n) => ` ${n}`));
|
|
129
|
+
if (mode === 'ask' || mode === 'dry') {
|
|
130
|
+
// The file list says where; these lines say what it means, in plain words, before anyone reads a diff.
|
|
131
|
+
const routes = r.files.some((f) => f.diff && f.agent !== 'shell');
|
|
132
|
+
const skills = r.files.some((f) => !f.diff);
|
|
133
|
+
if (routes || skills)
|
|
134
|
+
lines.push('');
|
|
135
|
+
if (routes) {
|
|
136
|
+
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('Nothing is bought:')} phantom-1 runs free until you add credit.`);
|
|
138
|
+
}
|
|
139
|
+
if (skills)
|
|
140
|
+
lines.push(` ${p.dim('skill:')} a short note that teaches the agent the phantom commands.`);
|
|
141
|
+
// Most people only see the 'ask' screen, so its hint is the plain command they just ran, plus --diff.
|
|
142
|
+
if (!opts.diff && opts.hint !== false && r.files.some((f) => f.diff)) {
|
|
143
|
+
lines.push('', mode === 'ask' ? ` To see every line first, answer n and run: ${p.cyan('phantom setup --diff')}` : ` To see every line, add ${p.cyan('--diff')}.`);
|
|
144
|
+
}
|
|
145
|
+
if (mode === 'dry')
|
|
146
|
+
lines.push(p.dim(' This was a preview. Nothing was changed.'));
|
|
147
|
+
}
|
|
148
|
+
else if (!r.files.length && !(r.skipped ?? []).length && !r.agents.some((a) => a.provider?.manual?.length)) {
|
|
149
|
+
const off = r.agents.length > 0 && r.agents.every((a) => a.provider && !a.provider.on);
|
|
150
|
+
lines.push(off ? ` ${p.dim('·')} Phantom AI was already off for these agents. Nothing to change.` : ` ${p.lime('✓')} Everything is already set up. Nothing to change.`);
|
|
151
|
+
}
|
|
152
|
+
else if (r.files.length) {
|
|
153
|
+
lines.push('', ...nextStep(r, p, opts.columns));
|
|
154
|
+
lines.push('', inApp() ? ` ${p.dim('Undo any time: More, then Undo setup.')}` : ` Undo any time: ${p.cyan('phantom setup --restore')}`);
|
|
155
|
+
}
|
|
156
|
+
return lines.join('\n');
|
|
157
|
+
}
|
|
158
|
+
/** What a file is, in the words the screen uses: its settings (an agent's, or the shell's ~/.zshrc), or its skill. */
|
|
159
|
+
function kind(f) {
|
|
160
|
+
if (!f.diff)
|
|
161
|
+
return 'skill';
|
|
162
|
+
// ~/.zshrc is the shell's own settings file, and the row already says "Your shell".
|
|
163
|
+
return 'settings';
|
|
164
|
+
}
|
|
165
|
+
/** What happens to a file, in one short word. */
|
|
166
|
+
const ACTION = { create: 'new', update: 'update', remove: 'remove' };
|
|
167
|
+
/**
|
|
168
|
+
* One file under an agent, in columns: what the file is, where it is, what
|
|
169
|
+
* happens to it, and how many lines change. For example
|
|
170
|
+
* `settings ~/.codex/config.toml new +12`.
|
|
171
|
+
*/
|
|
172
|
+
function fileRow(f, p, short, pathWidth) {
|
|
173
|
+
const action = ACTION[f.action].padEnd(6);
|
|
174
|
+
const { added, removed } = counts(f.diff);
|
|
175
|
+
const change = [added ? p.green(`+${added}`) : '', removed ? p.red(`-${removed}`) : ''].filter(Boolean).join(' ');
|
|
176
|
+
return [
|
|
177
|
+
p.dim(kind(f).padEnd(8)),
|
|
178
|
+
short(f.file).padEnd(pathWidth),
|
|
179
|
+
f.action === 'remove' ? p.red(action) : p.cyan(action),
|
|
180
|
+
change + (f.backup ? p.dim(' copy kept') : ''),
|
|
181
|
+
]
|
|
182
|
+
.join(' ')
|
|
183
|
+
.trimEnd();
|
|
184
|
+
}
|
|
185
|
+
/** One line of a diff, colored by whether it adds, removes or only shows context. */
|
|
186
|
+
function diffRow(row, p) {
|
|
187
|
+
if (row.startsWith('+'))
|
|
188
|
+
return p.green(row);
|
|
189
|
+
if (row.startsWith('-'))
|
|
190
|
+
return p.red(row);
|
|
191
|
+
return p.dim(row);
|
|
192
|
+
}
|
|
193
|
+
/** After the changes: one plain sentence per agent on what it does now. */
|
|
194
|
+
function doneSummary(agent, a, files, skipped, p) {
|
|
195
|
+
if (skipped)
|
|
196
|
+
return p.red('left as it was.');
|
|
197
|
+
if (agent === 'shell')
|
|
198
|
+
return 'loads your Phantom AI key in new terminals.';
|
|
199
|
+
const prov = a?.provider;
|
|
200
|
+
if (prov?.manual?.length)
|
|
201
|
+
return p.yellow('not finished: 2 minutes by hand:');
|
|
202
|
+
if (prov && !prov.on)
|
|
203
|
+
return !files ? p.dim('already off: nothing to remove.') : agent === 'claude' ? 'back on Anthropic.' : 'Phantom AI settings removed.';
|
|
204
|
+
if (prov?.on) {
|
|
205
|
+
const model = prov.model ? ` ${p.pill(` ${prov.model} `)}` : '';
|
|
206
|
+
return `runs on Phantom AI${model} ${p.dim(`restart ${label(agent)} to pick it up`)}`;
|
|
207
|
+
}
|
|
208
|
+
return files ? 'skill installed.' : p.dim('already set up.');
|
|
209
|
+
}
|
|
210
|
+
/** The command that starts each agent that runs in a terminal. Editors (Cursor, Continue, Cline) are opened, not run. */
|
|
211
|
+
const START = { claude: 'claude', codex: 'codex', pi: 'pi', opencode: 'opencode', goose: 'goose', aider: 'aider', copilot: 'copilot', gemini: 'gemini' };
|
|
212
|
+
/**
|
|
213
|
+
* The one thing to do after setup, in corner brackets: run the agent. When an
|
|
214
|
+
* agent reads its key from the shell (Codex, Goose and others), the step says
|
|
215
|
+
* to open a new terminal first, and names that agent; otherwise it doesn't.
|
|
216
|
+
*/
|
|
217
|
+
function nextStep(r, p, columns) {
|
|
218
|
+
const on = r.agents.filter((a) => a.provider?.on || (!a.provider && r.files.some((f) => f.agent === a.agent)));
|
|
219
|
+
const shell = r.files.some((f) => f.agent === 'shell');
|
|
220
|
+
const readsShell = on.filter((a) => SETUP_AGENTS[a.agent]?.shellEnv);
|
|
221
|
+
const first = (readsShell.length && shell ? readsShell : on).find((a) => START[a.agent]) ?? on[0];
|
|
222
|
+
if (!first)
|
|
223
|
+
return [];
|
|
224
|
+
const cmd = START[first.agent];
|
|
225
|
+
const name = label(first.agent);
|
|
226
|
+
const step = cmd
|
|
227
|
+
? shell && readsShell.some((a) => a.agent === first.agent)
|
|
228
|
+
? `${p.lime('→')} ${p.bold('Open a new terminal')}, then run ${p.cyan(cmd)}`
|
|
229
|
+
: `${p.lime('→')} ${p.bold('Next:')} run ${p.cyan(cmd)}`
|
|
230
|
+
: `${p.lime('→')} ${p.bold('Next:')} open ${name}`;
|
|
231
|
+
const lines = framed(p, step, columns);
|
|
232
|
+
if (cmd && ['claude', 'codex', 'pi'].includes(first.agent))
|
|
233
|
+
lines.push(p.dim(` or start it through phantom: phantom ${first.agent}`));
|
|
234
|
+
if (shell && readsShell.length)
|
|
235
|
+
lines.push(p.dim(` ${readsShell.map((a) => label(a.agent)).join(', ')} ${readsShell.length === 1 ? 'reads' : 'read'} your Phantom AI key from the shell, so a new terminal is needed.`));
|
|
236
|
+
return lines;
|
|
237
|
+
}
|
package/dist/setup/shared.mjs
CHANGED
|
@@ -11,13 +11,14 @@ export function onPath(bin) {
|
|
|
11
11
|
export const SETUP_MODEL = 'phantom-1';
|
|
12
12
|
/**
|
|
13
13
|
* The address Phantom AI serves a coding agent at, from PHANTOM_BASE_URL
|
|
14
|
-
* without its `/v1`: `/claude-code` (Claude Code adds `/v1` itself),
|
|
15
|
-
* `/
|
|
14
|
+
* without its `/v1`: `/claude-code` (Claude Code adds `/v1` itself),
|
|
15
|
+
* `/gemini-cli` (Gemini CLI adds Google's `/v1beta`), or `/codex/v1`,
|
|
16
|
+
* `/cursor/v1` and `/coding-agent/v1` for the others.
|
|
16
17
|
* @throws {@link CliError} when PHANTOM_BASE_URL is not https ({@link httpsOnly}).
|
|
17
18
|
*/
|
|
18
19
|
export function agentBaseUrl(env, agent) {
|
|
19
20
|
const origin = httpsOnly(env.PHANTOM_BASE_URL || DEFAULT_BASE_URL).replace(/\/+$/, '').replace(/\/v1$/, '');
|
|
20
|
-
return agent === 'claude-code' ? `${origin}
|
|
21
|
+
return agent === 'claude-code' || agent === 'gemini-cli' ? `${origin}/${agent}` : `${origin}/${agent}/v1`;
|
|
21
22
|
}
|
|
22
23
|
/** The end of every line phantom adds to a TOML, YAML or shell file. */
|
|
23
24
|
export const SETUP_MARK = '# phantom setup';
|
package/dist/spend/command.mjs
CHANGED
|
@@ -1,30 +1,19 @@
|
|
|
1
|
+
import { unknownSub } from "../cli/start.mjs";
|
|
1
2
|
import { readFileSync } from 'node:fs';
|
|
2
3
|
import { die } from "../core/errors.mjs";
|
|
3
|
-
import { flagNum, out } from "../core/helpers.mjs";
|
|
4
|
+
import { flagNum, out, pickIo, showsScreen } from "../core/helpers.mjs";
|
|
5
|
+
import { colorOn, paint } from "../core/style.mjs";
|
|
6
|
+
import { choose } from "../core/pick.mjs";
|
|
7
|
+
import { hideHints, lineupScreen, priceHint, routeScreen, routeTestScreen, TIER_ABOUT } from "../cli/screens.mjs";
|
|
4
8
|
import { getLineup, putLineup, resetLineup, LINEUP_TIERS } from "./lineup.mjs";
|
|
5
9
|
import { getRoute, putRoute, patchRoute, clearRoute, testRoute, parseCondition } from "./routing.mjs";
|
|
6
|
-
/** `--table` output for `route`. */
|
|
7
|
-
export function tableRoute(d) {
|
|
8
|
-
const p = d.route_policy;
|
|
9
|
-
if (!p)
|
|
10
|
-
return 'route none (model "auto" is refused)';
|
|
11
|
-
const rules = p.rules ?? [];
|
|
12
|
-
return [
|
|
13
|
-
`models ${p.models.join(', ')}`,
|
|
14
|
-
`applies_to ${p.applies_to ?? 'auto'}`,
|
|
15
|
-
`on_empty ${p.on_empty ?? 'stop'}`,
|
|
16
|
-
`fallback ${p.fallback_on_error ?? false}`,
|
|
17
|
-
`sticky ${p.stick_minutes ?? 5} min${(p.stick_by_prompt ?? true) ? '' : ' (x-phantom-session only)'}`,
|
|
18
|
-
...(rules.length ? rules.map((r, i) => `rule ${String(i + 1).padEnd(11)}if ${Object.entries(r.if).map(([k, v]) => `${k}=${v}`).join(' ')} use ${r.use}`) : ['rules none (auto runs the first model)']),
|
|
19
|
-
].join('\n');
|
|
20
|
-
}
|
|
21
|
-
/** `--table` output for `route test`. */
|
|
22
|
-
export function tableRouteTest(d) {
|
|
23
|
-
const t = d;
|
|
24
|
-
return [`model ${t.model}`, `reason ${t.reason}`].join('\n');
|
|
25
|
-
}
|
|
26
10
|
/** `phantom route`: the key's route policy, its rules, and a dry run. Run by `run()`. */
|
|
27
|
-
export async function routeCommand(
|
|
11
|
+
export async function routeCommand(input) {
|
|
12
|
+
const { argv, io, apiKey, subcommand, flags, baseUrl, env } = input;
|
|
13
|
+
const table = showsScreen(input);
|
|
14
|
+
const style = { env, color: colorOn(env, io.stdoutIsTTY) };
|
|
15
|
+
const tableRoute = (d) => routeScreen(d, style);
|
|
16
|
+
const tableRouteTest = (d) => routeTestScreen(d, style);
|
|
28
17
|
if (!subcommand || subcommand === 'get') {
|
|
29
18
|
out(await getRoute(apiKey, baseUrl), table, tableRoute, io.stdout);
|
|
30
19
|
}
|
|
@@ -100,23 +89,10 @@ export async function routeCommand({ argv, io, apiKey, subcommand, flags, table,
|
|
|
100
89
|
out(await clearRoute(apiKey, baseUrl), table, tableRoute, io.stdout);
|
|
101
90
|
}
|
|
102
91
|
else {
|
|
103
|
-
die(
|
|
92
|
+
die(unknownSub('route', String(subcommand), ['get', 'set', 'rule', 'test', 'clear']), 'route_usage');
|
|
104
93
|
}
|
|
105
94
|
return 0;
|
|
106
95
|
}
|
|
107
|
-
/** `--table` output for `lineup`. */
|
|
108
|
-
export function tableLineup(d) {
|
|
109
|
-
const { lineup, tiers, choices } = d;
|
|
110
|
-
return LINEUP_TIERS.flatMap((tier) => {
|
|
111
|
-
const pick = lineup?.[tier];
|
|
112
|
-
const fallback = lineup?.fallbacks?.[tier];
|
|
113
|
-
return [
|
|
114
|
-
`${tier.padEnd(15)} ${tiers[tier].model} ${pick ? '(your pick)' : '(default)'}`,
|
|
115
|
-
...(fallback ? [`${''.padEnd(15)} falls back to ${fallback}`] : []),
|
|
116
|
-
...(choices ? choices[tier].map((c) => `${''.padEnd(15)} - ${c.model}${c.default ? ' (default)' : ''}`) : []),
|
|
117
|
-
];
|
|
118
|
-
}).join('\n');
|
|
119
|
-
}
|
|
120
96
|
/**
|
|
121
97
|
* Changes one tier's entry in `into` from a flag value: a model id sets it,
|
|
122
98
|
* and `default` clears it so the tier runs its default again.
|
|
@@ -132,7 +108,16 @@ function applyPick(into, tier, flag, value) {
|
|
|
132
108
|
into[tier] = value;
|
|
133
109
|
}
|
|
134
110
|
/** `phantom lineup`: the model phantom-1 runs in each tier, and a fallback for each. Run by `run()`. */
|
|
135
|
-
export async function lineupCommand(
|
|
111
|
+
export async function lineupCommand(input) {
|
|
112
|
+
const { io, apiKey, subcommand, flags, baseUrl, env } = input;
|
|
113
|
+
const table = showsScreen(input);
|
|
114
|
+
const style = { env, color: colorOn(env, io.stdoutIsTTY) };
|
|
115
|
+
const tableLineup = (d) => lineupScreen(d, style);
|
|
116
|
+
// lineup set with no flags, at a terminal: pick a tier, then its model, from lists with prices.
|
|
117
|
+
// lineup alone, or set with no flags, at a terminal: the table, then a picker to change it, instead of a command to type.
|
|
118
|
+
const pick = (!subcommand || subcommand === 'set') && !LINEUP_FLAGS.some((name) => flags[name] !== undefined) ? pickIo(input) : null;
|
|
119
|
+
if (pick)
|
|
120
|
+
return lineupPicker(input, pick, tableLineup);
|
|
136
121
|
if (!subcommand || subcommand === 'get') {
|
|
137
122
|
out(await getLineup(apiKey, baseUrl), table, tableLineup, io.stdout);
|
|
138
123
|
}
|
|
@@ -166,7 +151,59 @@ export async function lineupCommand({ io, apiKey, subcommand, flags, table, base
|
|
|
166
151
|
out(await resetLineup(apiKey, baseUrl), table, tableLineup, io.stdout);
|
|
167
152
|
}
|
|
168
153
|
else {
|
|
169
|
-
die(
|
|
154
|
+
die(unknownSub('lineup', String(subcommand), ['get', 'set', 'reset']), 'lineup_usage');
|
|
170
155
|
}
|
|
171
156
|
return 0;
|
|
172
157
|
}
|
|
158
|
+
/** The flags `lineup set` reads; with none of them, at a terminal, it opens the picker instead. */
|
|
159
|
+
const LINEUP_FLAGS = ['economy', 'standard', 'frontier', 'economy-fallback', 'standard-fallback', 'frontier-fallback'];
|
|
160
|
+
/**
|
|
161
|
+
* `phantom lineup set` at a terminal: choose a tier, then a model for it from
|
|
162
|
+
* a list with prices (the default marked), and it saves at once. It loops
|
|
163
|
+
* back to the tiers so you can change another; Esc there is done.
|
|
164
|
+
*/
|
|
165
|
+
async function lineupPicker(input, pick, show) {
|
|
166
|
+
const { io, apiKey, baseUrl } = input;
|
|
167
|
+
let read = await getLineup(apiKey, baseUrl);
|
|
168
|
+
// The table's "→ phantom lineup set" line would point at the picker that's already open.
|
|
169
|
+
const hintsWere = hideHints(true);
|
|
170
|
+
// The marker stays on the tier you last changed, so changing it again (or the next one) is one move.
|
|
171
|
+
let last = 0;
|
|
172
|
+
try {
|
|
173
|
+
for (;;) {
|
|
174
|
+
io.stdout(show(read) + '\n\n');
|
|
175
|
+
const tier = await choose({ ...pick, esc: 'back' }, 'Which tier do you want to change?', [
|
|
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: 'Put every tier back to its default', value: 'reset' },
|
|
178
|
+
], last);
|
|
179
|
+
if (tier === null)
|
|
180
|
+
return 0;
|
|
181
|
+
if (tier === 'reset') {
|
|
182
|
+
read = { ...(await resetLineup(apiKey, baseUrl)), choices: read.choices };
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
const options = read.choices?.[tier] ?? [];
|
|
186
|
+
const now = read.tiers[tier].model;
|
|
187
|
+
const model = await choose({ ...pick, esc: 'back' }, `Which model should ${tier} run?`, options.map((c) => ({ label: c.model, hint: priceHint(c), value: c.model })), Math.max(0, options.findIndex((c) => c.model === now)));
|
|
188
|
+
if (model === null)
|
|
189
|
+
continue;
|
|
190
|
+
const { fallbacks, ...picks } = read.lineup ?? {};
|
|
191
|
+
const chosen = options.find((c) => c.model === model);
|
|
192
|
+
if (chosen?.default)
|
|
193
|
+
delete picks[tier];
|
|
194
|
+
else
|
|
195
|
+
picks[tier] = model;
|
|
196
|
+
// A fallback can't be the tier's own model, which the server refuses.
|
|
197
|
+
const keep = { ...fallbacks };
|
|
198
|
+
if (keep[tier] === model)
|
|
199
|
+
delete keep[tier];
|
|
200
|
+
read = { ...(await putLineup(apiKey, Object.keys(keep).length ? { ...picks, fallbacks: keep } : picks, baseUrl)), choices: read.choices };
|
|
201
|
+
last = LINEUP_TIERS.indexOf(tier);
|
|
202
|
+
// Said on its own line, so the change is plain before the table redraws.
|
|
203
|
+
io.stdout(`\n ${paint(input.env, colorOn(input.env, io.stdoutIsTTY)).lime('✓')} ${tier[0].toUpperCase() + tier.slice(1)} now runs ${model}.\n`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
finally {
|
|
207
|
+
hideHints(hintsWere);
|
|
208
|
+
}
|
|
209
|
+
}
|
package/dist/spend/report.mjs
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { die } from "../core/errors.mjs";
|
|
2
|
-
import { out } from "../core/helpers.mjs";
|
|
2
|
+
import { out, showsScreen } from "../core/helpers.mjs";
|
|
3
|
+
import { colorOn } from "../core/style.mjs";
|
|
4
|
+
import { barsGrowing, motionOn, play } from "../core/motion.mjs";
|
|
5
|
+
import { spendScreen } from "../cli/screens.mjs";
|
|
3
6
|
import { request } from "../core/request.mjs";
|
|
4
7
|
import { workspaceGet } from "../workspace/command.mjs";
|
|
5
8
|
/** The dates `--from` and `--to` ask for, as a query string. The server checks them. */
|
|
@@ -51,11 +54,17 @@ export function tableSpend(d) {
|
|
|
51
54
|
* workspace connection `phantom login` saved. Run by `run()`.
|
|
52
55
|
*/
|
|
53
56
|
export async function spendCommand(input) {
|
|
54
|
-
const { io, apiKey,
|
|
57
|
+
const { io, apiKey, baseUrl, flags, env } = input;
|
|
55
58
|
const dates = { from: flags['from'], to: flags['to'] };
|
|
56
59
|
const result = flags['workspace']
|
|
57
60
|
? (await workspaceGet(input, `/spend${query(dates)}`))
|
|
58
61
|
: await getSpend(apiKey, dates, baseUrl);
|
|
59
|
-
|
|
62
|
+
const screen = showsScreen(input);
|
|
63
|
+
// The bars grow in, the way the site's cost bars do; JSON, pipes and NO_COLOR get the result at once.
|
|
64
|
+
if (screen && motionOn(env, io.stdoutIsTTY)) {
|
|
65
|
+
await play(io.stdout, barsGrowing(spendScreen(result, { env, color: true }).split('\n')), { rows: process.stdout.rows, skipOnKey: true });
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
out(result, screen, (d) => spendScreen(d, { env, color: colorOn(env, io.stdoutIsTTY) }), io.stdout);
|
|
60
69
|
return 0;
|
|
61
70
|
}
|