@phantomcodes/cli 1.2.3 → 1.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -5
- package/dist/agents/command.mjs +15 -5
- package/dist/cli/app.mjs +162 -0
- package/dist/cli/commands.mjs +37 -14
- package/dist/cli/help.mjs +26 -5
- package/dist/cli/run.mjs +54 -27
- package/dist/cli/screens.mjs +330 -0
- package/dist/cli/start.mjs +142 -0
- package/dist/core/brand.mjs +31 -0
- package/dist/core/errors.mjs +2 -2
- package/dist/core/helpers.mjs +47 -14
- package/dist/core/motion.mjs +156 -0
- package/dist/core/pick.mjs +293 -0
- package/dist/core/style.mjs +36 -0
- package/dist/core/version.mjs +1 -1
- package/dist/keys/command.mjs +37 -13
- package/dist/phantom.mjs +1 -1
- package/dist/plan/command.mjs +26 -8
- package/dist/setup/agents.mjs +8 -8
- package/dist/setup/apply.mjs +26 -23
- package/dist/setup/command.mjs +150 -59
- package/dist/setup/editors.mjs +1 -1
- package/dist/setup/plan.mjs +4 -2
- package/dist/setup/screen.mjs +237 -0
- package/dist/spend/command.mjs +75 -38
- package/dist/spend/report.mjs +12 -3
- package/dist/workspace/command.mjs +355 -42
- package/package.json +1 -1
- package/skills/phantom-ai/SKILL.md +3 -2
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { colorOn } from "./style.mjs";
|
|
2
|
+
import { GHOST, WORDMARK } from "./brand.mjs";
|
|
3
|
+
/** Whether to animate: color is on for that stream (a terminal, no NO_COLOR), it isn't CI, and PHANTOM_NO_MOTION isn't set. */
|
|
4
|
+
export function motionOn(env, isTTY) {
|
|
5
|
+
if (env.PHANTOM_NO_MOTION && env.PHANTOM_NO_MOTION !== '0')
|
|
6
|
+
return false;
|
|
7
|
+
if (env.CI && env.CI !== 'false' && env.CI !== '0')
|
|
8
|
+
return false;
|
|
9
|
+
// FORCE_COLOR can color a pipe, but cursor moves only make sense on a real terminal.
|
|
10
|
+
return colorOn(env, isTTY) && Boolean(isTTY);
|
|
11
|
+
}
|
|
12
|
+
/** Fast at first, then easing to a stop: the curve for things that arrive. */
|
|
13
|
+
export const easeOut = (t) => 1 - (1 - Math.min(1, Math.max(0, t))) ** 3;
|
|
14
|
+
/**
|
|
15
|
+
* Lines appearing top to bottom: frame k shows the first k lines and keeps the
|
|
16
|
+
* rest blank, so the block's height never changes. `delay(line)` is how long
|
|
17
|
+
* the line waits before it appears; 0 lets it come with the one before.
|
|
18
|
+
*/
|
|
19
|
+
export function reveal(lines, delay) {
|
|
20
|
+
const book = { frames: [], delays: [] };
|
|
21
|
+
lines.forEach((line, k) => {
|
|
22
|
+
const wait = delay(line, k);
|
|
23
|
+
const frame = lines.map((l, j) => (j <= k ? l : ''));
|
|
24
|
+
// A line with no wait joins the frame before it instead of making its own.
|
|
25
|
+
if (book.frames.length && wait === 0)
|
|
26
|
+
book.frames[book.frames.length - 1] = frame;
|
|
27
|
+
else {
|
|
28
|
+
book.frames.push(frame);
|
|
29
|
+
book.delays.push(book.frames.length === 1 ? 0 : wait);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
return book;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Every bar in `lines` (a run of ━ followed by its ─ track) drawn at
|
|
36
|
+
* `progress` of its length, 0 to 1. The track grows to fill what the bar gives
|
|
37
|
+
* up, so each line keeps its width.
|
|
38
|
+
*/
|
|
39
|
+
export function growBars(lines, progress) {
|
|
40
|
+
return lines.map((line) => line.replace(/(━+)((?:\x1b\[[0-9;]*m)*)(─*)/g, (_all, fill, codes, track) => {
|
|
41
|
+
const shown = Math.round(fill.length * progress);
|
|
42
|
+
return '━'.repeat(shown) + codes + '─'.repeat(fill.length - shown + track.length);
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
45
|
+
/** Bars growing from nothing to their length over `ms`, easing out. */
|
|
46
|
+
export function barsGrowing(lines, ms = 250, steps = 10) {
|
|
47
|
+
const frames = Array.from({ length: steps + 1 }, (_, k) => growBars(lines, easeOut(k / steps)));
|
|
48
|
+
return { frames, delays: frames.map((_, k) => (k === 0 ? 0 : ms / steps)) };
|
|
49
|
+
}
|
|
50
|
+
/** The ghost's eye row with the eyes closed: the two gaps filled with a low half block, like lids. */
|
|
51
|
+
export const CLOSED_EYES = GHOST[1][0].replace(/ (?=█)/, '▄').replace(/ $/, '▄');
|
|
52
|
+
/**
|
|
53
|
+
* The brand header drawing in, for a screen whose lines start with it
|
|
54
|
+
* (setup): PHANTOM appears letter by letter, the rest of the screen follows
|
|
55
|
+
* row by row, then the ghost blinks once.
|
|
56
|
+
*/
|
|
57
|
+
export function brandIntro(lines, opts = {}) {
|
|
58
|
+
const { letterMs = 20, rowMs = 18, blinkMs = 90 } = opts;
|
|
59
|
+
const wordRows = lines.map((l, k) => [k, WORDMARK.findIndex((w) => l.includes(w))]).filter(([, r]) => r >= 0);
|
|
60
|
+
const eyeRow = lines.findIndex((l) => l.includes(GHOST[1][0]));
|
|
61
|
+
const headerEnd = Math.max(eyeRow, ...wordRows.map(([k]) => k)) + 2;
|
|
62
|
+
const book = { frames: [], delays: [] };
|
|
63
|
+
const push = (frame, wait) => (book.frames.push(frame), book.delays.push(book.frames.length === 1 ? 0 : wait));
|
|
64
|
+
// 1. The ghost, then PHANTOM letter by letter beside it.
|
|
65
|
+
const letterCount = 7;
|
|
66
|
+
for (let n = 0; n <= letterCount; n++) {
|
|
67
|
+
push(lines.map((l, k) => {
|
|
68
|
+
if (k >= headerEnd)
|
|
69
|
+
return '';
|
|
70
|
+
const hit = wordRows.find(([row]) => row === k);
|
|
71
|
+
if (!hit)
|
|
72
|
+
return l;
|
|
73
|
+
return l.replace(WORDMARK[hit[1]], lettersOf(hit[1], n));
|
|
74
|
+
}), letterMs);
|
|
75
|
+
}
|
|
76
|
+
// 2. The rest of the screen, a row at a time.
|
|
77
|
+
for (let k = headerEnd; k < lines.length; k++)
|
|
78
|
+
push(lines.map((l, j) => (j <= k ? l : '')), lines[k] ? rowMs : 0);
|
|
79
|
+
// 3. One blink.
|
|
80
|
+
if (eyeRow >= 0) {
|
|
81
|
+
push(lines.map((l, k) => (k === eyeRow ? l.replace(GHOST[1][0], CLOSED_EYES) : l)), blinkMs);
|
|
82
|
+
push(lines, blinkMs);
|
|
83
|
+
}
|
|
84
|
+
return book;
|
|
85
|
+
}
|
|
86
|
+
/** Row `row` of PHANTOM with its first `n` letters drawn and the rest left as spaces. */
|
|
87
|
+
function lettersOf(row, n) {
|
|
88
|
+
const full = WORDMARK[row];
|
|
89
|
+
const cut = LETTER_ENDS[n];
|
|
90
|
+
return full.slice(0, cut).padEnd(full.length);
|
|
91
|
+
}
|
|
92
|
+
/** Where each letter of PHANTOM ends in a wordmark row (the rows share widths): 0 letters, 1 letter and so on. */
|
|
93
|
+
const LETTER_ENDS = (() => {
|
|
94
|
+
const widths = [4, 4, 4, 4, 3, 4, 5];
|
|
95
|
+
const ends = [0];
|
|
96
|
+
widths.forEach((w, k) => ends.push(ends[k] + w + (k ? 1 : 0)));
|
|
97
|
+
return ends;
|
|
98
|
+
})();
|
|
99
|
+
/** Wait `ms` milliseconds. Tests pass their own so they don't wait. */
|
|
100
|
+
const realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
101
|
+
/**
|
|
102
|
+
* Plays a flipbook on a stream: writes the first frame, then moves the cursor
|
|
103
|
+
* back up and redraws each next one in place. Ends on the last frame with a
|
|
104
|
+
* newline. A key press (on a terminal) or `rows` too few to fit the block
|
|
105
|
+
* skips straight to the last frame.
|
|
106
|
+
*/
|
|
107
|
+
export async function play(write, book, opts = {}) {
|
|
108
|
+
const last = book.frames[book.frames.length - 1] ?? [];
|
|
109
|
+
// Taller than the window: moving the cursor up would run off the top, so print the result.
|
|
110
|
+
if (!book.frames.length || (opts.rows !== undefined && last.length >= opts.rows)) {
|
|
111
|
+
write(last.join('\n') + '\n');
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const sleep = opts.sleep ?? realSleep;
|
|
115
|
+
const stopSkip = opts.skipOnKey ? listenForKey() : null;
|
|
116
|
+
try {
|
|
117
|
+
write(book.frames[0].join('\n'));
|
|
118
|
+
for (let k = 1; k < book.frames.length; k++) {
|
|
119
|
+
if (stopSkip?.pressed())
|
|
120
|
+
break;
|
|
121
|
+
await sleep(book.delays[k]);
|
|
122
|
+
write(redraw(book.frames[k]));
|
|
123
|
+
}
|
|
124
|
+
if (stopSkip?.pressed())
|
|
125
|
+
write(redraw(last));
|
|
126
|
+
write('\n');
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
stopSkip?.stop();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/** The codes that move back to the block's first line and write `frame` over it, clearing each line first. */
|
|
133
|
+
function redraw(frame) {
|
|
134
|
+
const up = frame.length > 1 ? `\x1b[${frame.length - 1}A` : '';
|
|
135
|
+
return `\r${up}` + frame.map((line) => `\x1b[2K${line}`).join('\n');
|
|
136
|
+
}
|
|
137
|
+
/** Watches the keyboard while an animation plays, so any key skips it. Leaves the terminal as it found it. */
|
|
138
|
+
function listenForKey() {
|
|
139
|
+
const input = process.stdin;
|
|
140
|
+
if (!input.isTTY || typeof input.setRawMode !== 'function')
|
|
141
|
+
return null;
|
|
142
|
+
let hit = false;
|
|
143
|
+
const onData = () => void (hit = true);
|
|
144
|
+
const wasRaw = input.isRaw;
|
|
145
|
+
input.setRawMode(true);
|
|
146
|
+
input.on('data', onData);
|
|
147
|
+
input.resume();
|
|
148
|
+
return {
|
|
149
|
+
pressed: () => hit,
|
|
150
|
+
stop: () => {
|
|
151
|
+
input.off('data', onData);
|
|
152
|
+
input.setRawMode(wasRaw);
|
|
153
|
+
input.pause();
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
// Choosing with the keyboard: a list you move through with ↑ and ↓ (or j and
|
|
2
|
+
// k) and pick with Enter, and a checklist you tick with Space. Drawn on
|
|
3
|
+
// stderr in phantom's style, redrawn in place as you move, and folded into
|
|
4
|
+
// one line (the question and your answer) once you choose. Only for a person
|
|
5
|
+
// at a terminal: scripts, pipes and CI get the plain prompts or flags instead.
|
|
6
|
+
import { emitKeypressEvents } from 'node:readline';
|
|
7
|
+
import { colorOn, paint } from "./style.mjs";
|
|
8
|
+
/** A hint cut to the room left on the line, so a row never wraps in a narrow window. */
|
|
9
|
+
const fitHint = (hint, used, columns) => {
|
|
10
|
+
const room = (columns ?? process.stderr.columns ?? 100) - used - 1;
|
|
11
|
+
return hint.length <= room ? hint : room > 3 ? hint.slice(0, room - 1) + '…' : '';
|
|
12
|
+
};
|
|
13
|
+
/** Thrown by a picker when Ctrl+C is pressed: stop the whole command, wherever it is. */
|
|
14
|
+
export class Cancelled extends Error {
|
|
15
|
+
constructor() {
|
|
16
|
+
super('Cancelled.');
|
|
17
|
+
this.name = 'Cancelled';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Thrown by a command started from the start menu when Esc is pressed on its
|
|
22
|
+
* first step, so the menu shows again instead of the command ending.
|
|
23
|
+
*/
|
|
24
|
+
export class BackToMenu extends Error {
|
|
25
|
+
constructor() {
|
|
26
|
+
super('Back to the menu.');
|
|
27
|
+
this.name = 'BackToMenu';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** Which way a key moves the marker: -1 up, 1 down, 0 not at all. */
|
|
31
|
+
function step(key) {
|
|
32
|
+
if (key === 'up' || key === 'k' || key === 'shift-tab')
|
|
33
|
+
return -1;
|
|
34
|
+
if (key === 'down' || key === 'j' || key === 'tab')
|
|
35
|
+
return 1;
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
38
|
+
/** Whether this run can show a picker: a person at a terminal on both ends, and not CI. */
|
|
39
|
+
export function canPick(env, stderrIsTTY, stdinIsTTY = Boolean(process.stdin.isTTY)) {
|
|
40
|
+
if (env.CI && env.CI !== 'false' && env.CI !== '0')
|
|
41
|
+
return false;
|
|
42
|
+
return Boolean(stderrIsTTY) && stdinIsTTY;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The keyboard, one key at a time, named the way pickers read them: `up`,
|
|
46
|
+
* `down`, `return`, `space`, `escape`, `ctrl-c`, or the letter itself.
|
|
47
|
+
*/
|
|
48
|
+
export function keyboard() {
|
|
49
|
+
const input = process.stdin;
|
|
50
|
+
emitKeypressEvents(input);
|
|
51
|
+
const wasRaw = input.isRaw;
|
|
52
|
+
input.setRawMode?.(true);
|
|
53
|
+
input.resume();
|
|
54
|
+
const waiting = [];
|
|
55
|
+
const queued = [];
|
|
56
|
+
const onKey = (text, key = {}) => {
|
|
57
|
+
// A printable character comes through as itself, capitals and all, so the text box gets "Dana", not "dana".
|
|
58
|
+
const printable = Boolean(text && text.length === 1 && text >= ' ' && !key.ctrl && (key.name === undefined || key.name.length === 1));
|
|
59
|
+
const name = key.ctrl && key.name === 'c' ? 'ctrl-c' : key.name === 'enter' ? 'return' : key.shift && key.name === 'tab' ? 'shift-tab' : printable ? text : (key.name ?? text ?? '');
|
|
60
|
+
const resolve = waiting.shift();
|
|
61
|
+
if (resolve)
|
|
62
|
+
resolve(name);
|
|
63
|
+
else
|
|
64
|
+
queued.push(name);
|
|
65
|
+
};
|
|
66
|
+
input.on('keypress', onKey);
|
|
67
|
+
return {
|
|
68
|
+
next: () => new Promise((resolve) => (queued.length ? resolve(queued.shift()) : waiting.push(resolve))),
|
|
69
|
+
close: () => {
|
|
70
|
+
input.off('keypress', onKey);
|
|
71
|
+
input.setRawMode?.(wasRaw);
|
|
72
|
+
input.pause();
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Keys from a list, for tests: each `next()` returns the next name, and `end`
|
|
78
|
+
* once the list runs out (which pickers treat as Esc). Every picker in a run reads from the same list, as
|
|
79
|
+
* they would from one keyboard.
|
|
80
|
+
*/
|
|
81
|
+
export function scriptedKeys(names) {
|
|
82
|
+
const left = [...names];
|
|
83
|
+
// `end` when the list runs out: pickers read it as Esc; a wait that only listens for Esc ignores it.
|
|
84
|
+
const source = { next: async () => left.shift() ?? 'end', close: () => { } };
|
|
85
|
+
return () => source;
|
|
86
|
+
}
|
|
87
|
+
/** A block of lines that redraws itself in place: the first draw prints it, each later one moves up and writes over it. */
|
|
88
|
+
function block(write) {
|
|
89
|
+
let height = 0;
|
|
90
|
+
return (lines) => {
|
|
91
|
+
const up = height > 1 ? `\x1b[${height - 1}A` : '';
|
|
92
|
+
// \x1b[J clears whatever the last draw left below, when the new one is shorter.
|
|
93
|
+
write((height ? `\r${up}\x1b[J` : '') + lines.join('\n'));
|
|
94
|
+
height = lines.length;
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/** The question line: a lime ? and the question in bold, then the answer once there is one. */
|
|
98
|
+
const asked = (p, question, answer = '') => ` ${p.lime('?')} ${p.bold(question)}${answer ? ` ${p.cyan(answer)}` : ''}`;
|
|
99
|
+
/** The hint under a picker: which keys do what. */
|
|
100
|
+
const keysHint = (p, words) => ` ${p.dim(words)}`;
|
|
101
|
+
/**
|
|
102
|
+
* Asks a question with a list of answers. ↑ and ↓ (or k and j, or Tab and
|
|
103
|
+
* Shift+Tab) move and Enter chooses. Esc or q returns null, which the caller
|
|
104
|
+
* reads as "go back a step"; Ctrl+C throws {@link Cancelled} to stop the
|
|
105
|
+
* whole command. `start` is where the marker begins.
|
|
106
|
+
*/
|
|
107
|
+
export async function choose(io, question, choices, start = 0) {
|
|
108
|
+
const p = paint(io.env, colorOn(io.env, true));
|
|
109
|
+
const draw = block(io.write);
|
|
110
|
+
const width = Math.max(...choices.map((c) => c.label.length)) + 3;
|
|
111
|
+
let at = Math.min(Math.max(0, start), choices.length - 1);
|
|
112
|
+
const frame = () => [
|
|
113
|
+
asked(p, question),
|
|
114
|
+
...choices.map((c, k) => k === at
|
|
115
|
+
? ` ${p.lime('❯')} ${p.bold(c.label.padEnd(width))}${p.dim(fitHint(c.hint ?? '', width + 4, io.columns))}`
|
|
116
|
+
: ` ${c.label.padEnd(width)}${p.dim(fitHint(c.hint ?? '', width + 4, io.columns))}`),
|
|
117
|
+
keysHint(p, `↑↓ or tab move · enter choose · esc ${io.esc ?? 'back'} · ctrl+c stop`),
|
|
118
|
+
];
|
|
119
|
+
const keys = (io.keys ?? keyboard)();
|
|
120
|
+
io.write('\x1b[?25l');
|
|
121
|
+
try {
|
|
122
|
+
draw(frame());
|
|
123
|
+
for (;;) {
|
|
124
|
+
const key = await keys.next();
|
|
125
|
+
if (step(key))
|
|
126
|
+
at = (at + choices.length + step(key)) % choices.length;
|
|
127
|
+
else if (key === 'return') {
|
|
128
|
+
draw([asked(p, question, choices[at].label)]);
|
|
129
|
+
return choices[at].value;
|
|
130
|
+
}
|
|
131
|
+
else if (key === 'escape' || key === 'q' || key === 'end') {
|
|
132
|
+
draw([asked(p, question, io.esc === 'quit' ? 'quit' : 'back')]);
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
else if (key === 'ctrl-c') {
|
|
136
|
+
draw([asked(p, question, 'cancelled')]);
|
|
137
|
+
throw new Cancelled();
|
|
138
|
+
}
|
|
139
|
+
else
|
|
140
|
+
continue;
|
|
141
|
+
draw(frame());
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
finally {
|
|
145
|
+
io.write('\n\x1b[?25h');
|
|
146
|
+
keys.close();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* A checklist: ↑ and ↓ (or Tab) move, Space ticks or unticks, `a` ticks all,
|
|
151
|
+
* Enter goes on with the ticked items (it waits while none is ticked). Esc
|
|
152
|
+
* or q returns null (go back); Ctrl+C throws {@link Cancelled}.
|
|
153
|
+
*/
|
|
154
|
+
export async function checklist(io, question, items) {
|
|
155
|
+
const p = paint(io.env, colorOn(io.env, true));
|
|
156
|
+
const draw = block(io.write);
|
|
157
|
+
const ticked = items.map((i) => i.checked !== false);
|
|
158
|
+
const width = Math.max(...items.map((i) => i.label.length)) + 3;
|
|
159
|
+
let at = 0;
|
|
160
|
+
let nudge = '';
|
|
161
|
+
const frame = () => [
|
|
162
|
+
asked(p, question),
|
|
163
|
+
...items.map((item, k) => {
|
|
164
|
+
const box = ticked[k] ? p.lime('◉') : p.dim('◯');
|
|
165
|
+
const label = k === at ? p.bold(item.label.padEnd(width)) : item.label.padEnd(width);
|
|
166
|
+
return ` ${k === at ? p.lime('❯') : ' '} ${box} ${label}${p.dim(fitHint(item.hint ?? '', width + 6, io.columns))}`;
|
|
167
|
+
}),
|
|
168
|
+
nudge ? ` ${p.yellow(nudge)}` : keysHint(p, `↑↓ or tab move · space tick · a all · enter go on · esc ${io.esc ?? 'back'}`),
|
|
169
|
+
];
|
|
170
|
+
const keys = (io.keys ?? keyboard)();
|
|
171
|
+
io.write('\x1b[?25l');
|
|
172
|
+
try {
|
|
173
|
+
draw(frame());
|
|
174
|
+
for (;;) {
|
|
175
|
+
const key = await keys.next();
|
|
176
|
+
nudge = '';
|
|
177
|
+
if (step(key))
|
|
178
|
+
at = (at + items.length + step(key)) % items.length;
|
|
179
|
+
else if (key === 'space')
|
|
180
|
+
ticked[at] = !ticked[at];
|
|
181
|
+
else if (key === 'a')
|
|
182
|
+
ticked.fill(!ticked.every(Boolean));
|
|
183
|
+
else if (key === 'return') {
|
|
184
|
+
if (!ticked.some(Boolean))
|
|
185
|
+
nudge = 'Tick at least one, or press esc to go back.';
|
|
186
|
+
else {
|
|
187
|
+
const chosen = items.filter((_, k) => ticked[k]);
|
|
188
|
+
draw([asked(p, question, chosen.map((i) => i.label).join(', '))]);
|
|
189
|
+
return chosen.map((i) => i.value);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
else if (key === 'escape' || key === 'q' || key === 'end') {
|
|
193
|
+
draw([asked(p, question, io.esc === 'quit' ? 'quit' : 'back')]);
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
else if (key === 'ctrl-c') {
|
|
197
|
+
draw([asked(p, question, 'cancelled')]);
|
|
198
|
+
throw new Cancelled();
|
|
199
|
+
}
|
|
200
|
+
else
|
|
201
|
+
continue;
|
|
202
|
+
draw(frame());
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
finally {
|
|
206
|
+
io.write('\n\x1b[?25h');
|
|
207
|
+
keys.close();
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Listens for Esc or Ctrl+C while something waits (sign-in waiting for
|
|
212
|
+
* approval), without taking over the screen. `pressed` settles with the key;
|
|
213
|
+
* other keys are ignored, and the end of a test's keys never settles it.
|
|
214
|
+
*/
|
|
215
|
+
export function cancelKeys(source) {
|
|
216
|
+
let closed = false;
|
|
217
|
+
const pressed = new Promise((resolve) => {
|
|
218
|
+
const listen = async () => {
|
|
219
|
+
while (!closed) {
|
|
220
|
+
const key = await source.next();
|
|
221
|
+
if (key === 'end')
|
|
222
|
+
return;
|
|
223
|
+
if (key === 'escape' || key === 'q')
|
|
224
|
+
return resolve('escape');
|
|
225
|
+
if (key === 'ctrl-c')
|
|
226
|
+
return resolve('ctrl-c');
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
void listen();
|
|
230
|
+
});
|
|
231
|
+
return {
|
|
232
|
+
pressed,
|
|
233
|
+
close: () => {
|
|
234
|
+
closed = true;
|
|
235
|
+
source.close();
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Asks for a short piece of text, such as a key's name or an amount: type it,
|
|
241
|
+
* Backspace to fix it, Enter to go on (it waits while `check` refuses the
|
|
242
|
+
* text, and shows why), Esc to go back (null), Ctrl+C to stop. Keys longer
|
|
243
|
+
* than one character that aren't named keys are typed as text, so a test can
|
|
244
|
+
* send "sam" as one key.
|
|
245
|
+
*/
|
|
246
|
+
export async function askText(io, question, opts = {}) {
|
|
247
|
+
const p = paint(io.env, colorOn(io.env, true));
|
|
248
|
+
const draw = block(io.write);
|
|
249
|
+
let text = '';
|
|
250
|
+
let problem = '';
|
|
251
|
+
const NAMED = new Set(['up', 'down', 'left', 'right', 'tab', 'shift-tab', 'space', 'return', 'escape', 'ctrl-c', 'backspace', 'delete', 'end']);
|
|
252
|
+
const frame = () => [
|
|
253
|
+
asked(p, question),
|
|
254
|
+
` ${p.lime('❯')} ${text ? p.bold(text) : p.dim(opts.placeholder ?? '')}${p.lime('▏')}`,
|
|
255
|
+
problem ? ` ${p.yellow(problem)}` : keysHint(p, `type · enter go on · esc ${io.esc ?? 'back'}`),
|
|
256
|
+
];
|
|
257
|
+
const keys = (io.keys ?? keyboard)();
|
|
258
|
+
io.write('\x1b[?25l');
|
|
259
|
+
try {
|
|
260
|
+
draw(frame());
|
|
261
|
+
for (;;) {
|
|
262
|
+
const key = await keys.next();
|
|
263
|
+
problem = '';
|
|
264
|
+
if (key === 'return') {
|
|
265
|
+
const why = opts.check?.(text.trim()) ?? (text.trim() ? null : 'Type something, or press esc to go back.');
|
|
266
|
+
if (!why) {
|
|
267
|
+
draw([asked(p, question, text.trim())]);
|
|
268
|
+
return text.trim();
|
|
269
|
+
}
|
|
270
|
+
problem = why;
|
|
271
|
+
}
|
|
272
|
+
else if (key === 'escape' || key === 'end') {
|
|
273
|
+
draw([asked(p, question, io.esc === 'quit' ? 'quit' : 'back')]);
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
else if (key === 'ctrl-c') {
|
|
277
|
+
draw([asked(p, question, 'cancelled')]);
|
|
278
|
+
throw new Cancelled();
|
|
279
|
+
}
|
|
280
|
+
else if (key === 'backspace' || key === 'delete')
|
|
281
|
+
text = text.slice(0, -1);
|
|
282
|
+
else if (key === 'space')
|
|
283
|
+
text += ' ';
|
|
284
|
+
else if (!NAMED.has(key))
|
|
285
|
+
text += key;
|
|
286
|
+
draw(frame());
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
finally {
|
|
290
|
+
io.write('\n\x1b[?25h');
|
|
291
|
+
keys.close();
|
|
292
|
+
}
|
|
293
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether to print color: only to a terminal, never with `NO_COLOR` set
|
|
3
|
+
* (https://no-color.org) or on a `dumb` terminal. `FORCE_COLOR` turns it on
|
|
4
|
+
* anywhere, and `FORCE_COLOR=0` turns it off.
|
|
5
|
+
*/
|
|
6
|
+
export function colorOn(env, isTTY) {
|
|
7
|
+
if (env.NO_COLOR)
|
|
8
|
+
return false;
|
|
9
|
+
if (env.FORCE_COLOR !== undefined)
|
|
10
|
+
return env.FORCE_COLOR !== '0';
|
|
11
|
+
return Boolean(isTTY) && env.TERM !== 'dumb';
|
|
12
|
+
}
|
|
13
|
+
const wrap = (open, close) => (s) => `\x1b[${open}m${s}\x1b[${close}m`;
|
|
14
|
+
const plain = (s) => s;
|
|
15
|
+
/**
|
|
16
|
+
* The styles for one screen. Lime and cyan use 24-bit color when the terminal says it
|
|
17
|
+
* has it (`COLORTERM=truecolor`), and the nearest of the 256 standard colors otherwise.
|
|
18
|
+
*/
|
|
19
|
+
export function paint(env, on) {
|
|
20
|
+
if (!on)
|
|
21
|
+
return { lime: plain, bold: plain, white: plain, pill: plain, dim: plain, cyan: plain, green: plain, red: plain, yellow: plain };
|
|
22
|
+
const truecolor = env.COLORTERM === 'truecolor' || env.COLORTERM === '24bit';
|
|
23
|
+
return {
|
|
24
|
+
lime: wrap(truecolor ? '38;2;62;230;168' : '38;5;79', '39'),
|
|
25
|
+
cyan: wrap(truecolor ? '38;2;0;209;255' : '38;5;45', '39'),
|
|
26
|
+
bold: wrap('1', '22'),
|
|
27
|
+
white: wrap('97', '39'),
|
|
28
|
+
pill: (s) => (truecolor ? `\x1b[48;2;62;230;168m\x1b[38;2;8;9;12m${s}\x1b[39m\x1b[49m` : `\x1b[48;5;79m\x1b[38;5;232m${s}\x1b[39m\x1b[49m`),
|
|
29
|
+
dim: wrap('2', '22'),
|
|
30
|
+
green: wrap('32', '39'),
|
|
31
|
+
red: wrap('31', '39'),
|
|
32
|
+
yellow: wrap('33', '39'),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/** `text` without color codes, to measure how wide it prints. */
|
|
36
|
+
export const unstyled = (text) => text.replace(/\x1b\[[0-9;]*m/g, '');
|
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.4';
|
package/dist/keys/command.mjs
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
+
import { unknownSub } from "../cli/start.mjs";
|
|
1
2
|
import { createInterface } from 'node:readline/promises';
|
|
2
|
-
import {
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { die, NO_KEY } from "../core/errors.mjs";
|
|
3
5
|
import { getBalance } from "../core/request.mjs";
|
|
4
|
-
import { out } from "../core/helpers.mjs";
|
|
6
|
+
import { out, showsScreen } from "../core/helpers.mjs";
|
|
7
|
+
import { colorOn, paint } from "../core/style.mjs";
|
|
8
|
+
import { balanceScreen, keySavedScreen, logoutScreen, savedKeysScreen } from "../cli/screens.mjs";
|
|
5
9
|
import { resolveApiKey, readNamedKey, saveNamedKey, removeNamedKey, listNamedKeys, saveApiKey, removeApiKey } from "./saved.mjs";
|
|
6
10
|
/** `--table` output for `balance`. */
|
|
7
11
|
export function tableBalance(d) {
|
|
@@ -27,9 +31,10 @@ export function tableKeys(d) {
|
|
|
27
31
|
.join('\n');
|
|
28
32
|
}
|
|
29
33
|
/** `phantom balance`: the key's credit and expiry. Run by `run()`. */
|
|
30
|
-
export async function balanceCommand(
|
|
34
|
+
export async function balanceCommand(input) {
|
|
35
|
+
const { io, apiKey, baseUrl, env } = input;
|
|
31
36
|
const result = await getBalance(apiKey, baseUrl);
|
|
32
|
-
out(result,
|
|
37
|
+
out(result, showsScreen(input), (d) => balanceScreen(d, { env, color: colorOn(env, io.stdoutIsTTY) }), io.stdout);
|
|
33
38
|
return 0;
|
|
34
39
|
}
|
|
35
40
|
/** `phantom key list|save|show|rm`: keys saved by name. Run by `run()`. */
|
|
@@ -47,10 +52,10 @@ export async function keyCommand({ argv, env, io, subcommand, flags, table, base
|
|
|
47
52
|
return { ...k, active: false };
|
|
48
53
|
}
|
|
49
54
|
}));
|
|
50
|
-
out({ keys: withBalance }, table,
|
|
55
|
+
out({ keys: withBalance }, table, (d) => savedKeysScreen(d.keys, { env, color: colorOn(env, io.stdoutIsTTY) }), io.stdout);
|
|
51
56
|
}
|
|
52
57
|
else {
|
|
53
|
-
out(list, table,
|
|
58
|
+
out(list, table, (d) => savedKeysScreen(d.keys, { env, color: colorOn(env, io.stdoutIsTTY) }), io.stdout);
|
|
54
59
|
}
|
|
55
60
|
}
|
|
56
61
|
else if (subcommand === 'save') {
|
|
@@ -68,14 +73,22 @@ export async function keyCommand({ argv, env, io, subcommand, flags, table, base
|
|
|
68
73
|
if (!key.startsWith('sk-phantom-'))
|
|
69
74
|
die('That is not a Phantom AI key. Keys start with sk-phantom-');
|
|
70
75
|
await getBalance(key, baseUrl);
|
|
71
|
-
out(saveNamedKey(env, name, key), table, (d) =>
|
|
76
|
+
out(saveNamedKey(env, name, key), table, (d) => keySavedScreen(d.name, { env, color: colorOn(env, io.stdoutIsTTY) }), io.stdout);
|
|
72
77
|
}
|
|
73
78
|
else if (subcommand === 'show') {
|
|
74
79
|
// Raw, for PHANTOM_API_KEY=$(phantom key show <name>). With no name, the
|
|
75
80
|
// key phantom itself would use, which is what Claude Code's apiKeyHelper runs.
|
|
76
81
|
const key = name ? readNamedKey(env, name) : resolveApiKey(env);
|
|
77
82
|
if (!key)
|
|
78
|
-
die(
|
|
83
|
+
die(NO_KEY);
|
|
84
|
+
// --copy: to the clipboard instead of the screen, for pasting into an app's settings (Cursor's API key box).
|
|
85
|
+
if (flags['copy']) {
|
|
86
|
+
const copied = process.platform === 'darwin' ? spawnSync('pbcopy', { input: key }).status === 0 : false;
|
|
87
|
+
if (!copied)
|
|
88
|
+
die('Copying needs pbcopy, which is on every Mac. Elsewhere: phantom keys show | your clipboard tool.', 'copy_unavailable');
|
|
89
|
+
io.stderr(` ${paint(env, colorOn(env, io.isTTY)).lime('✓')} Copied your key. Paste it where it's asked for; it works like a password.\n`);
|
|
90
|
+
return 0;
|
|
91
|
+
}
|
|
79
92
|
io.stdout(key + '\n');
|
|
80
93
|
}
|
|
81
94
|
else if (subcommand === 'rm') {
|
|
@@ -84,7 +97,7 @@ export async function keyCommand({ argv, env, io, subcommand, flags, table, base
|
|
|
84
97
|
out(removeNamedKey(env, name), table, (d) => `removed ${d.removed}`, io.stdout);
|
|
85
98
|
}
|
|
86
99
|
else {
|
|
87
|
-
die(
|
|
100
|
+
die(unknownSub('key', String(subcommand), ['list', 'save', 'show', 'rm']), 'key_usage');
|
|
88
101
|
}
|
|
89
102
|
return 0;
|
|
90
103
|
}
|
|
@@ -106,11 +119,22 @@ export async function loginCommand({ env, io, subcommand, table, baseUrl }) {
|
|
|
106
119
|
// Check the key works before saving it.
|
|
107
120
|
const balance = await getBalance(key, baseUrl);
|
|
108
121
|
const saved = saveApiKey(env, key);
|
|
109
|
-
out({ ...saved, ...balance }, table, (d) =>
|
|
122
|
+
out({ ...saved, ...balance }, table, (d) => `\n ${paint(env, colorOn(env, io.stdoutIsTTY)).lime('✓')} Key saved.\n${balanceScreen(d, { env, color: colorOn(env, io.stdoutIsTTY) })}`, io.stdout);
|
|
110
123
|
return 0;
|
|
111
124
|
}
|
|
112
|
-
/**
|
|
113
|
-
|
|
114
|
-
|
|
125
|
+
/**
|
|
126
|
+
* `phantom logout`: forgets the login key. `forgotWorkspace` says whether the
|
|
127
|
+
* workspace sign-in was forgotten too, so the screen doesn't say nothing changed. Run by `run()`.
|
|
128
|
+
*/
|
|
129
|
+
export async function logoutCommand({ env, io, table, flags }, forgotWorkspace = false) {
|
|
130
|
+
const result = removeApiKey(env);
|
|
131
|
+
// --all also forgets the keys saved by name; without it they stay, and the screen says so.
|
|
132
|
+
const named = listNamedKeys(env).keys.map((k) => k.name);
|
|
133
|
+
if (flags['all'])
|
|
134
|
+
for (const name of named)
|
|
135
|
+
removeNamedKey(env, name);
|
|
136
|
+
const kept = flags['all'] ? [] : named;
|
|
137
|
+
const removedNamed = flags['all'] ? named : [];
|
|
138
|
+
out({ ...result, removed_named: removedNamed, kept_named: kept }, table, (d) => logoutScreen(d.removed || forgotWorkspace || removedNamed.length > 0, { env, color: colorOn(env, io.stdoutIsTTY), kept, removedNamed }), io.stdout);
|
|
115
139
|
return 0;
|
|
116
140
|
}
|
package/dist/phantom.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* phantom:
|
|
3
|
+
* phantom: points your coding agents at Phantom AI.
|
|
4
4
|
*
|
|
5
5
|
* Keys, money and setup for any AI agent: a Phantom AI key, model routing,
|
|
6
6
|
* and receipts that show which model answered. It runs as a CLI and through
|
package/dist/plan/command.mjs
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
import { unknownSub } from "../cli/start.mjs";
|
|
2
|
+
import { colorOn } from "../core/style.mjs";
|
|
3
|
+
import { linkScreen, planScreen } from "../cli/screens.mjs";
|
|
1
4
|
import { die } from "../core/errors.mjs";
|
|
2
|
-
import { openUrl, out } from "../core/helpers.mjs";
|
|
5
|
+
import { pickIo, showsScreen, openUrl, out } from "../core/helpers.mjs";
|
|
6
|
+
import { choose } from "../core/pick.mjs";
|
|
3
7
|
import { request } from "../core/request.mjs";
|
|
4
8
|
import { savedManagement } from "../workspace/command.mjs";
|
|
5
9
|
/** The plan of the key's workspace and its week (GET /plan). */
|
|
@@ -37,8 +41,6 @@ export function tablePlan(d) {
|
|
|
37
41
|
lines.push(`period ends ${p.renews_at}`);
|
|
38
42
|
return lines.join('\n');
|
|
39
43
|
}
|
|
40
|
-
/** What `phantom plan` accepts after `plan`. */
|
|
41
|
-
const USAGE = 'Use phantom plan, phantom plan pro, phantom plan max or phantom plan manage.';
|
|
42
44
|
/**
|
|
43
45
|
* `phantom plan`: the plan of the key's workspace and how much of the week is
|
|
44
46
|
* used. `phantom plan pro|max` prints a Stripe Checkout link that puts the
|
|
@@ -48,26 +50,42 @@ const USAGE = 'Use phantom plan, phantom plan pro, phantom plan max or phantom p
|
|
|
48
50
|
* login` saved, since a key can't manage billing. Run by `run()`.
|
|
49
51
|
*/
|
|
50
52
|
export async function planCommand(input) {
|
|
51
|
-
const { io, apiKey,
|
|
53
|
+
const { io, apiKey, baseUrl, flags, subcommand, env } = input;
|
|
54
|
+
const table = showsScreen(input);
|
|
55
|
+
const style = { env, color: colorOn(env, io.stdoutIsTTY), opened: !flags['no-browser'] };
|
|
52
56
|
const open = (url) => {
|
|
53
57
|
if (!flags['no-browser'])
|
|
54
58
|
openUrl(url);
|
|
55
59
|
};
|
|
56
60
|
if (subcommand === undefined || subcommand === 'status') {
|
|
57
|
-
out(await getPlan(apiKey, baseUrl), table,
|
|
61
|
+
out(await getPlan(apiKey, baseUrl), table, (d) => planScreen(d, style), io.stdout);
|
|
58
62
|
}
|
|
59
63
|
else if (subcommand === 'pro' || subcommand === 'max') {
|
|
64
|
+
// At a terminal, say what the plan costs and covers, and ask, before sending anyone to pay.
|
|
65
|
+
const pick = pickIo(input);
|
|
66
|
+
if (pick) {
|
|
67
|
+
const offer = (await getPlan(apiKey, baseUrl)).prices?.[subcommand];
|
|
68
|
+
const what = offer ? `$${offer.usd_per_month} a month, paying for $${offer.week_limit_usd} of model cost a week, for every key in this workspace` : 'for every key in this workspace';
|
|
69
|
+
const go = await choose(pick, `Start ${subcommand === 'max' ? 'Max' : 'Pro'}?`, [
|
|
70
|
+
{ label: 'Open Stripe to pay', hint: what, value: true },
|
|
71
|
+
{ label: 'Cancel', hint: 'nothing is charged', value: false },
|
|
72
|
+
]);
|
|
73
|
+
if (!go) {
|
|
74
|
+
io.stderr(' Nothing was charged.\n');
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
60
78
|
const result = await planCheckout(apiKey, subcommand, baseUrl);
|
|
61
|
-
out(result, table, (d) => `
|
|
79
|
+
out(result, table, (d) => linkScreen(`Start ${subcommand === 'max' ? 'Max' : 'Pro'} on Stripe`, d.checkout_url, ['Once it goes through:', 'phantom plan'], style), io.stdout);
|
|
62
80
|
open(result.checkout_url);
|
|
63
81
|
}
|
|
64
82
|
else if (subcommand === 'manage') {
|
|
65
83
|
const result = await planPortal(savedManagement(input) ?? apiKey, baseUrl);
|
|
66
|
-
out(result, table, (d) =>
|
|
84
|
+
out(result, table, (d) => linkScreen('Manage billing on Stripe: card, switch plan, cancel', d.portal_url, null, style), io.stdout);
|
|
67
85
|
open(result.portal_url);
|
|
68
86
|
}
|
|
69
87
|
else {
|
|
70
|
-
die(
|
|
88
|
+
die(unknownSub('plan', String(subcommand), ['status', 'pro', 'max', 'manage']), 'plan_usage');
|
|
71
89
|
}
|
|
72
90
|
return 0;
|
|
73
91
|
}
|