@phantomcodes/cli 1.2.2 → 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 +38 -14
- package/dist/cli/help.mjs +26 -5
- package/dist/cli/run.mjs +57 -28
- 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 +154 -57
- 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 +364 -41
- package/package.json +1 -1
- package/skills/phantom-ai/SKILL.md +3 -2
package/dist/setup/agents.mjs
CHANGED
|
@@ -8,10 +8,11 @@ import { SETUP_MODEL, agentBaseUrl, readText, configHome, onPath, phantomCommand
|
|
|
8
8
|
import { jsonObject, jsonEdit, tomlSet, tomlRemoveOurs, yamlSetTop, yamlAddModel, yamlRemoveOurs } from "./editors.mjs";
|
|
9
9
|
// ── Manual Provider Helper ───────────────────────────────────────────────────
|
|
10
10
|
/** An agent whose model settings live on its own settings screen: setup prints the steps. */
|
|
11
|
-
export function manualProvider(where, steps) {
|
|
11
|
+
export function manualProvider(where, steps, note) {
|
|
12
12
|
return (ctx, off) => ({
|
|
13
13
|
edits: [],
|
|
14
|
-
|
|
14
|
+
// The note is something to know, not a step to do, so it shows apart from the numbered steps.
|
|
15
|
+
result: { settings: where, on: false, warnings: off || !note ? [] : [note], manual: off ? [`Remove the Phantom AI settings in ${where}.`] : steps(ctx) },
|
|
15
16
|
});
|
|
16
17
|
}
|
|
17
18
|
// ── Codex ────────────────────────────────────────────────────────────────────
|
|
@@ -76,7 +77,7 @@ export function claudePlan(env, opts) {
|
|
|
76
77
|
};
|
|
77
78
|
}
|
|
78
79
|
if (!resolveApiKey(env))
|
|
79
|
-
die('No
|
|
80
|
+
die('No Phantom key yet to give Claude Code. Run phantom login to sign in, then run this again.', 'no_key');
|
|
80
81
|
const baseUrl = agentBaseUrl(env, 'claude-code');
|
|
81
82
|
// `phantom` if it's installed, else this very phantom by absolute path: Claude Code
|
|
82
83
|
// runs the helper outside this shell, so a relative or npx path won't do.
|
|
@@ -354,13 +355,12 @@ export const SETUP_AGENTS = {
|
|
|
354
355
|
home: '.cursor',
|
|
355
356
|
/** ~/.cursor/skills */
|
|
356
357
|
skills: '.cursor/skills',
|
|
358
|
+
// Three short steps; the key gets copied without being shown (phantom's menu has "Copy my key").
|
|
357
359
|
provider: manualProvider('Cursor Settings, Models', (ctx) => [
|
|
358
360
|
'Open Cursor Settings (Cmd+Shift+J on macOS), then Models.',
|
|
359
|
-
`
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
'While the override is on, Cursor\'s own non-OpenAI models stop working; turn it off to go back. Tab completions never use your own key.',
|
|
363
|
-
]),
|
|
361
|
+
`Paste your Phantom AI key under OpenAI API Key (copy it with "Copy my key" in phantom's menu, or phantom keys show --copy), then turn on Override OpenAI Base URL and set it to ${agentBaseUrl(ctx.env, 'cursor')}`,
|
|
362
|
+
`Under Add model, add ${ctx.model ?? SETUP_MODEL}.`,
|
|
363
|
+
], "While the override is on, Cursor's own non-OpenAI models stop working; turn it off to go back. Tab completions never use your key."),
|
|
364
364
|
shellEnv: () => ({}),
|
|
365
365
|
},
|
|
366
366
|
/** opencode. */
|
package/dist/setup/apply.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
1
2
|
import path from 'node:path';
|
|
2
3
|
import { createHash } from 'node:crypto';
|
|
3
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
5
|
import { stateDir } from "../core/state.mjs";
|
|
5
6
|
import { readText } from "./shared.mjs";
|
|
6
7
|
import { jsonText } from "./editors.mjs";
|
|
@@ -52,28 +53,6 @@ export function lineDiff(before, after) {
|
|
|
52
53
|
}
|
|
53
54
|
return out.join('\n');
|
|
54
55
|
}
|
|
55
|
-
/** A plan as people read it: each file with its diff (keys masked), the skill in one line, then commands, skips and steps to do by hand. */
|
|
56
|
-
export function setupPreview(plan) {
|
|
57
|
-
const parts = [];
|
|
58
|
-
for (const f of plan.files) {
|
|
59
|
-
if (f.skill)
|
|
60
|
-
parts.push(`${f.action === 'create' ? 'install' : 'update'} skill ${f.file}`);
|
|
61
|
-
else if (f.action === 'remove')
|
|
62
|
-
parts.push(`remove ${f.file}`);
|
|
63
|
-
else
|
|
64
|
-
parts.push(`${f.action} ${f.file}\n${maskKeys(lineDiff(f.before, f.after))}`);
|
|
65
|
-
}
|
|
66
|
-
for (const s of plan.skipped)
|
|
67
|
-
parts.push(`skip ${s.agent}: ${s.reason}`);
|
|
68
|
-
for (const a of plan.agents) {
|
|
69
|
-
const steps = a.provider?.manual ?? [];
|
|
70
|
-
if (steps.length)
|
|
71
|
-
parts.push([`${SETUP_AGENTS[a.agent].label}, by hand:`, ...steps.map((s, k) => ` ${k + 1}. ${s}`)].join('\n'));
|
|
72
|
-
}
|
|
73
|
-
for (const n of plan.notes)
|
|
74
|
-
parts.push(n);
|
|
75
|
-
return parts.length ? parts.join('\n\n') : 'Nothing to change.';
|
|
76
|
-
}
|
|
77
56
|
function backupsFile(env) {
|
|
78
57
|
return path.join(stateDir(env), 'setup-backups.json');
|
|
79
58
|
}
|
|
@@ -89,6 +68,10 @@ function writeBackups(env, entries) {
|
|
|
89
68
|
mkdirSync(stateDir(env), { recursive: true, mode: 0o700 });
|
|
90
69
|
writeFileSync(backupsFile(env), jsonText(entries), { mode: 0o600 });
|
|
91
70
|
}
|
|
71
|
+
/** Whether setup has written anything on this computer that undo could put back: it keeps a record of each file. */
|
|
72
|
+
export function isSetUp(env) {
|
|
73
|
+
return Object.keys(readBackups(env)).length > 0;
|
|
74
|
+
}
|
|
92
75
|
const sha256 = (text) => createHash('sha256').update(text).digest('hex');
|
|
93
76
|
/**
|
|
94
77
|
* Writes a plan from {@link planSetup}. Before it edits a file it copies it
|
|
@@ -213,5 +196,25 @@ export function restoreSetup(env, opts = {}) {
|
|
|
213
196
|
}
|
|
214
197
|
if (!opts.dryRun && restored.length)
|
|
215
198
|
writeBackups(env, backups);
|
|
199
|
+
// The phantom-ai skill is phantom's own file, kept out of the backups, so undo removes it here.
|
|
200
|
+
const home = env.HOME || os.homedir();
|
|
201
|
+
for (const [agent, spec] of Object.entries(SETUP_AGENTS)) {
|
|
202
|
+
if (!spec.skills || (opts.agent && opts.agent !== 'all' && agent !== opts.agent))
|
|
203
|
+
continue;
|
|
204
|
+
const skill = path.join(home, spec.skills, 'phantom-ai', 'SKILL.md');
|
|
205
|
+
// Two agents can share a skills folder (pi and Codex use ~/.agents/skills), so each file is listed once.
|
|
206
|
+
if (!existsSync(skill) || restored.some((r) => r.file === skill) || !/^name: phantom-ai$/m.test(readText(skill) ?? ''))
|
|
207
|
+
continue;
|
|
208
|
+
if (!opts.dryRun) {
|
|
209
|
+
rmSync(skill, { force: true });
|
|
210
|
+
try {
|
|
211
|
+
rmdirSync(path.dirname(skill));
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
// Not empty: something else lives there, so the folder stays.
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
restored.push({ file: skill, from: null });
|
|
218
|
+
}
|
|
216
219
|
return { restored, skipped, ...(opts.dryRun ? { dry_run: true } : {}) };
|
|
217
220
|
}
|
package/dist/setup/command.mjs
CHANGED
|
@@ -1,62 +1,68 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
1
3
|
import { createInterface } from 'node:readline/promises';
|
|
2
4
|
import { die } from "../core/errors.mjs";
|
|
3
|
-
import { out } from "../core/helpers.mjs";
|
|
5
|
+
import { out, pickIo } from "../core/helpers.mjs";
|
|
6
|
+
import { BackToMenu, checklist, choose } from "../core/pick.mjs";
|
|
4
7
|
import { resolveApiKey } from "../keys/saved.mjs";
|
|
5
8
|
import { workspaceLogin } from "../workspace/command.mjs";
|
|
6
9
|
import { SETUP_AGENTS, SETUP_NAMES } from "./agents.mjs";
|
|
7
10
|
import { planSetup } from "./plan.mjs";
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
return [
|
|
17
|
-
`${a.agent.padEnd(16)}${a.skill ? `skill ${r.dry_run || r.applied === false ? 'goes' : 'installed'} at ${a.skill}` : 'no skill (this agent does not read skills)'}`,
|
|
18
|
-
...(!p
|
|
19
|
-
? []
|
|
20
|
-
: p.manual
|
|
21
|
-
? [`${pad}set up by hand in ${p.settings}:`, ...p.manual.map((step, k) => `${pad} ${k + 1}. ${step}`)]
|
|
22
|
-
: p.on
|
|
23
|
-
? [
|
|
24
|
-
`${pad}models via Phantom AI at ${p.base_url}${p.model ? `, model ${p.model}` : ''} (${p.settings})`,
|
|
25
|
-
`${pad}restart ${label} to pick it up; undo with: phantom setup --agent ${a.agent} --provider off`,
|
|
26
|
-
]
|
|
27
|
-
: [a.agent === 'claude' ? `${pad}models back to Anthropic (${p.settings})` : `${pad}Phantom AI settings removed (${p.settings})`]),
|
|
28
|
-
...(p?.warnings ?? []).map((w) => `${pad}warning: ${w}`),
|
|
29
|
-
].join('\n');
|
|
30
|
-
});
|
|
31
|
-
const files = (r.files ?? []).filter((f) => f.diff !== undefined);
|
|
32
|
-
if (files.length) {
|
|
33
|
-
lines.push('');
|
|
34
|
-
for (const f of files) {
|
|
35
|
-
lines.push(`${r.dry_run || r.applied === false ? `would ${f.action}` : `${f.action}d`} ${f.file}${f.backup ? ` (copy before: ${f.backup})` : ''}`);
|
|
36
|
-
if (f.diff && (r.dry_run || r.applied === false))
|
|
37
|
-
lines.push(f.diff.replace(/^/gm, ' '));
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
for (const s of r.skipped ?? [])
|
|
41
|
-
lines.push(`skipped ${s.agent}: ${s.reason}`);
|
|
42
|
-
for (const n of r.notes ?? [])
|
|
43
|
-
lines.push(n);
|
|
44
|
-
if (r.applied === false)
|
|
45
|
-
lines.push('Nothing was changed.');
|
|
46
|
-
return lines.join('\n');
|
|
11
|
+
import { applySetup, previewSetup, restoreSetup } from "./apply.mjs";
|
|
12
|
+
import { setupScreen } from "./screen.mjs";
|
|
13
|
+
import { colorOn, paint, unstyled } from "../core/style.mjs";
|
|
14
|
+
import { inApp } from "../cli/screens.mjs";
|
|
15
|
+
import { brandIntro, motionOn, play, reveal } from "../core/motion.mjs";
|
|
16
|
+
/** `--table` output for `setup`, and what a terminal shows: see {@link setupScreen}. */
|
|
17
|
+
export function tableSetup(d, opts = {}) {
|
|
18
|
+
return setupScreen(d, opts);
|
|
47
19
|
}
|
|
48
|
-
/** `--table` output for `setup --restore
|
|
49
|
-
export function tableRestore(d) {
|
|
20
|
+
/** `--table` output for `setup --restore`, and what a terminal shows: a ✓ per file put back, a ✗ per file left alone. */
|
|
21
|
+
export function tableRestore(d, opts = {}) {
|
|
50
22
|
const r = d;
|
|
23
|
+
const p = paint(opts.env ?? {}, Boolean(opts.color));
|
|
24
|
+
const home = opts.env?.HOME;
|
|
25
|
+
const short = (file) => (home && file.startsWith(home + '/') ? '~' + file.slice(home.length) : file);
|
|
26
|
+
if (!r.restored.length && !r.skipped.length)
|
|
27
|
+
return 'Nothing to restore.';
|
|
51
28
|
const lines = [
|
|
52
|
-
...r.restored.map((x) =>
|
|
53
|
-
|
|
29
|
+
...r.restored.map((x) => {
|
|
30
|
+
const verb = x.from ? (r.dry_run ? 'puts back' : 'put back') : r.dry_run ? 'deletes' : 'removed';
|
|
31
|
+
const what = x.from ? `${verb} ${short(x.file)} as it was before setup` : `${verb} ${short(x.file)} (phantom made this file)`;
|
|
32
|
+
return ` ${r.dry_run ? p.dim('·') : p.lime('✓')} ${what}`;
|
|
33
|
+
}),
|
|
34
|
+
...r.skipped.map((x) => ` ${p.red('✗')} left ${short(x.file)}: ${x.reason}`),
|
|
54
35
|
];
|
|
55
|
-
|
|
36
|
+
if (!r.dry_run && r.restored.length) {
|
|
37
|
+
// What undo leaves alone, so nobody thinks they're back to nothing when they're still signed in.
|
|
38
|
+
lines.push('', inApp() ? ` ${p.dim('Still here: your sign-in and saved keys. To remove those too, pick Sign out below.')}` : ` ${p.dim('Still here: your sign-in and saved keys. To remove those too:')} ${p.cyan('phantom logout --all')}`);
|
|
39
|
+
// Cursor was set up by hand, so undo can't reach it; say what to do there.
|
|
40
|
+
if (opts.env?.HOME && existsSync(path.join(opts.env.HOME, '.cursor')))
|
|
41
|
+
lines.push(` ${p.dim("Cursor: if you set it up by hand, turn off Override OpenAI Base URL in Cursor Settings, Models.")}`);
|
|
42
|
+
lines.push(` ${p.lime('→')} Restart your agents (and open a new terminal) so they go back to how they were.`);
|
|
43
|
+
}
|
|
44
|
+
return lines.join('\n');
|
|
56
45
|
}
|
|
57
46
|
/** `phantom setup`: plans, previews, asks, applies or restores agent setup. Plain setup with no key yet signs in first when it runs in a terminal. Run by `run()`. */
|
|
58
47
|
export async function setupCommand(input) {
|
|
59
|
-
const { env, io, flags
|
|
48
|
+
const { env, io, flags } = input;
|
|
49
|
+
// A person at a terminal gets the short summary; a pipe, or --json, gets JSON.
|
|
50
|
+
const table = input.table || (Boolean(io.stdoutIsTTY) && !flags['json']);
|
|
51
|
+
const diff = Boolean(flags['diff']);
|
|
52
|
+
// The summary goes to stdout and the question to stderr, so each gets color only when it is a terminal.
|
|
53
|
+
const shown = (opts = {}) => (d) => tableSetup(d, { env, diff, color: colorOn(env, io.stdoutIsTTY), columns: process.stdout.columns, ...opts });
|
|
54
|
+
/**
|
|
55
|
+
* Prints a result to stdout: JSON for a pipe, the screen otherwise, played
|
|
56
|
+
* as a flipbook when motion is on. The header draws in on a preview; after
|
|
57
|
+
* the changes, each ✓ ticks in after the one before.
|
|
58
|
+
*/
|
|
59
|
+
const emit = async (result) => {
|
|
60
|
+
if (!table || !motionOn(env, io.stdoutIsTTY))
|
|
61
|
+
return out(result, table, shown(), io.stdout);
|
|
62
|
+
const lines = shown()(result).split('\n');
|
|
63
|
+
const book = result.dry_run ? brandIntro(lines) : reveal(lines, (line) => (/^\s+\S*[✓!✗]/.test(unstyled(line)) ? 60 : 0));
|
|
64
|
+
await play(io.stdout, book, { rows: process.stdout.rows, skipOnKey: true });
|
|
65
|
+
};
|
|
60
66
|
const agent = flags['agent'];
|
|
61
67
|
if (agent === true)
|
|
62
68
|
die(`--agent requires ${SETUP_NAMES.join(', ')} or all`);
|
|
@@ -70,8 +76,29 @@ export async function setupCommand(input) {
|
|
|
70
76
|
die('--mcp has been removed. Phantom inference runs directly through phantom claude, phantom codex, or phantom pi.');
|
|
71
77
|
const dryRun = Boolean(flags['dry-run']);
|
|
72
78
|
if (flags['restore']) {
|
|
79
|
+
// At a terminal, undo shows what it will put back, and asks, the way setup asks before it changes anything.
|
|
80
|
+
const pick = !dryRun && !flags['yes'] ? pickIo(input) : null;
|
|
81
|
+
if (pick) {
|
|
82
|
+
const preview = restoreSetup(env, { agent, dryRun: true });
|
|
83
|
+
if (!preview.restored.length && !preview.skipped.length) {
|
|
84
|
+
io.stdout(' Nothing to undo: setup has no changes on this computer.\n');
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
io.stderr(`\n${tableRestore(preview, { env, color: colorOn(env, io.isTTY) })}\n\n`);
|
|
88
|
+
// The marker starts on Cancel: undo can't itself be undone, so it takes a deliberate move.
|
|
89
|
+
const go = await choose(pick, 'Undo these changes?', [
|
|
90
|
+
{ label: 'Undo them', hint: 'your agents go back to how they were', value: true },
|
|
91
|
+
{ label: 'Cancel', hint: 'change nothing', value: false },
|
|
92
|
+
], 1);
|
|
93
|
+
if (!go) {
|
|
94
|
+
if (go === null && io.fromMenu)
|
|
95
|
+
throw new BackToMenu();
|
|
96
|
+
io.stderr(' Nothing was changed.\n');
|
|
97
|
+
return 0;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
73
100
|
const result = restoreSetup(env, { agent, dryRun });
|
|
74
|
-
out(result, table, tableRestore, io.stdout);
|
|
101
|
+
out(result, table, (d) => tableRestore(d, { env, color: colorOn(env, io.stdoutIsTTY) }), io.stdout);
|
|
75
102
|
return result.skipped.length ? 1 : 0;
|
|
76
103
|
}
|
|
77
104
|
// Plain `phantom setup` sets up everything it finds and asks first. Any of
|
|
@@ -80,20 +107,91 @@ export async function setupCommand(input) {
|
|
|
80
107
|
// With no key yet, sign in first, as phantom claude does, so the agents
|
|
81
108
|
// get one in this run. Its words go to stderr; stdout stays setup's result.
|
|
82
109
|
if (guided && !dryRun && io.isTTY && !resolveApiKey(env)) {
|
|
83
|
-
await workspaceLogin({ ...input, io: { ...io, stdout: io.stderr }, flags: flags['no-browser'] ? { 'no-browser': true } : {} });
|
|
110
|
+
await workspaceLogin({ ...input, io: { ...io, stdout: io.stderr }, flags: flags['no-browser'] ? { 'no-browser': true } : {} }, { inSetup: true });
|
|
111
|
+
// Sign-in was cancelled: stop here rather than set up the agents without a key.
|
|
112
|
+
if (!resolveApiKey(env)) {
|
|
113
|
+
io.stderr(' Nothing was changed.\n');
|
|
114
|
+
return 0;
|
|
115
|
+
}
|
|
84
116
|
}
|
|
85
|
-
const
|
|
117
|
+
const options = {
|
|
86
118
|
agent,
|
|
87
119
|
provider: guided || provider === true ? true : provider === 'off' ? 'off' : false,
|
|
88
120
|
model: typeof model === 'string' ? model : undefined,
|
|
89
121
|
guided,
|
|
90
|
-
}
|
|
122
|
+
};
|
|
123
|
+
const found = planSetup(env, options);
|
|
124
|
+
let plan = found;
|
|
125
|
+
// At a terminal, plain setup asks which of the agents it found to connect (all ticked, Space unticks),
|
|
126
|
+
// then whether to make the changes. Esc on the question goes back to the checklist.
|
|
127
|
+
const pick = guided && !flags['yes'] && !dryRun ? pickIo(input) : null;
|
|
128
|
+
const ticks = Boolean(pick) && found.agents.length > 1;
|
|
129
|
+
/** Shows the checklist, or returns false when Esc there means leave. */
|
|
130
|
+
const tick = async () => {
|
|
131
|
+
const only = await checklist(pick, 'Which agents should use Phantom AI?', 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) })));
|
|
132
|
+
if (!only)
|
|
133
|
+
return false;
|
|
134
|
+
plan = planSetup(env, { ...options, only });
|
|
135
|
+
return true;
|
|
136
|
+
};
|
|
137
|
+
/** Esc on setup's first step: back to the menu when it started setup, otherwise stop with nothing changed. */
|
|
138
|
+
const leave = () => {
|
|
139
|
+
if (io.fromMenu)
|
|
140
|
+
throw new BackToMenu();
|
|
141
|
+
io.stderr(' Nothing was changed.\n');
|
|
142
|
+
return 0;
|
|
143
|
+
};
|
|
144
|
+
if (ticks && !(await tick()))
|
|
145
|
+
return leave();
|
|
146
|
+
/**
|
|
147
|
+
* Prints the result and gives the exit code: 1 when a file was skipped, or
|
|
148
|
+
* when there was no key so the agents weren't pointed at Phantom AI. A script
|
|
149
|
+
* checking only the exit code then can't mistake half a setup for all of it.
|
|
150
|
+
*/
|
|
151
|
+
const finish = async (result) => {
|
|
152
|
+
const partial = guided && provider !== 'off' && !resolveApiKey(env);
|
|
153
|
+
await emit(partial ? { ...result, complete: false } : result);
|
|
154
|
+
return result.skipped || partial ? 1 : 0;
|
|
155
|
+
};
|
|
91
156
|
if (dryRun) {
|
|
92
|
-
|
|
157
|
+
await emit(previewSetup(plan));
|
|
93
158
|
return 0;
|
|
94
159
|
}
|
|
95
160
|
if (guided && !flags['yes'] && plan.files.length) {
|
|
96
|
-
|
|
161
|
+
const preview = setupScreen(previewSetup(plan), { env, diff, color: colorOn(env, io.isTTY), columns: process.stderr.columns, mode: 'ask', hint: !(pick && !io.ask) });
|
|
162
|
+
// The question waits for the header and rows to finish drawing in.
|
|
163
|
+
if (motionOn(env, io.isTTY))
|
|
164
|
+
await play(io.stderr, brandIntro(preview.split('\n')), { rows: process.stderr.rows, skipOnKey: true });
|
|
165
|
+
else
|
|
166
|
+
io.stderr(preview + '\n');
|
|
167
|
+
io.stderr('\n');
|
|
168
|
+
// At a terminal: a choice instead of typing y or n, with a look at every line first if wanted.
|
|
169
|
+
if (pick && !io.ask) {
|
|
170
|
+
for (;;) {
|
|
171
|
+
const answer = await choose({ ...pick, esc: ticks || io.fromMenu ? 'back' : 'quit' }, 'Make these changes?', [
|
|
172
|
+
{ label: 'Make these changes', value: 'yes' },
|
|
173
|
+
{ label: 'Show every line first', value: 'diff' },
|
|
174
|
+
{ label: 'Cancel', hint: 'change nothing', value: 'no' },
|
|
175
|
+
]);
|
|
176
|
+
if (answer === 'diff') {
|
|
177
|
+
io.stderr(setupScreen(previewSetup(plan), { env, diff: true, color: colorOn(env, io.isTTY), columns: process.stderr.columns, mode: 'ask' }) + '\n\n');
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
// Esc: back to the checklist, then the preview again; or out, when there was no checklist.
|
|
181
|
+
if (answer === null) {
|
|
182
|
+
if (!ticks || !(await tick()))
|
|
183
|
+
return leave();
|
|
184
|
+
io.stderr('\n' + setupScreen(previewSetup(plan), { env, diff, color: colorOn(env, io.isTTY), columns: process.stderr.columns, mode: 'ask', hint: false }) + '\n\n');
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (answer !== 'yes') {
|
|
188
|
+
await emit({ ...previewSetup(plan), dry_run: undefined, applied: false });
|
|
189
|
+
return 0;
|
|
190
|
+
}
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
return finish(applySetup(env, plan));
|
|
194
|
+
}
|
|
97
195
|
const ask = io.ask ??
|
|
98
196
|
(process.stdin.isTTY
|
|
99
197
|
? async (q) => {
|
|
@@ -108,13 +206,12 @@ export async function setupCommand(input) {
|
|
|
108
206
|
: null);
|
|
109
207
|
if (!ask)
|
|
110
208
|
die('phantom setup changes the files above. Run it in a terminal to confirm, or add --yes (or --dry-run to only look)', 'confirm_needed');
|
|
111
|
-
const
|
|
209
|
+
const q = paint(env, colorOn(env, io.isTTY));
|
|
210
|
+
const answer = await ask(` ${q.lime('?')} ${q.bold('Make these changes?')} ${q.dim('[y/N]')} `);
|
|
112
211
|
if (!/^y(es)?$/i.test(answer.trim())) {
|
|
113
|
-
|
|
212
|
+
await emit({ ...previewSetup(plan), dry_run: undefined, applied: false });
|
|
114
213
|
return 0;
|
|
115
214
|
}
|
|
116
215
|
}
|
|
117
|
-
|
|
118
|
-
out(result, table, tableSetup, io.stdout);
|
|
119
|
-
return result.skipped ? 1 : 0;
|
|
216
|
+
return finish(applySetup(env, plan));
|
|
120
217
|
}
|
package/dist/setup/editors.mjs
CHANGED
|
@@ -373,7 +373,7 @@ export function shellBlock(env, fish, state, onPathFn = onPath) {
|
|
|
373
373
|
'# Written by phantom setup. Sets PHANTOM_API_KEY to the key phantom uses when the',
|
|
374
374
|
'# shell starts, for coding agents that read it. The key is not stored here.',
|
|
375
375
|
`# agents: ${state.agents.join(' ')}`,
|
|
376
|
-
'# Undo: phantom setup --
|
|
376
|
+
'# Undo: phantom setup --restore',
|
|
377
377
|
];
|
|
378
378
|
if (fish) {
|
|
379
379
|
return [
|
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' };
|
|
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
|
+
}
|