@phantomcodes/cli 1.2.5 → 1.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -5
- package/dist/cli/app.mjs +6 -3
- package/dist/cli/commands.mjs +2 -0
- package/dist/cli/run.mjs +11 -1
- package/dist/cli/screens.mjs +18 -4
- package/dist/cli/start.mjs +3 -2
- package/dist/core/free.mjs +9 -0
- package/dist/core/style.mjs +62 -0
- package/dist/core/version.mjs +1 -1
- package/dist/plan/command.mjs +1 -1
- package/dist/setup/agents.mjs +391 -0
- package/dist/setup/command.mjs +9 -3
- package/dist/setup/plan.mjs +6 -1
- package/dist/setup/screen.mjs +39 -16
- package/dist/spend/command.mjs +1 -1
- package/dist/workspace/command.mjs +20 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -62,11 +62,15 @@ The Phantom CLI detects supported agents, previews the configuration changes, th
|
|
|
62
62
|
applying them. It connects their model calls to Phantom AI and wires up the saved
|
|
63
63
|
key. Restart your agent, or open a new terminal, to use the connection.
|
|
64
64
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
65
|
+
Saved setup covers Claude Code, Codex, opencode, Continue, Goose, Aider, GitHub
|
|
66
|
+
Copilot CLI, Cline, Gemini CLI, Crush, Qwen Code, Kilo Code, Kimi Code, Droid,
|
|
67
|
+
Mistral Vibe and Zed. Cursor, VS Code, JetBrains AI Assistant, Warp and Trae
|
|
68
|
+
keep these settings on their own settings screens, so setup prints the steps;
|
|
69
|
+
pi gets the agent skill.
|
|
70
|
+
|
|
71
|
+
Preview without changing files with `phantom setup --dry-run`. To leave your
|
|
72
|
+
shell startup file alone, untick "Your shell" in the checklist or pass
|
|
73
|
+
`--no-shell`; setup then prints the lines to add yourself. The Phantom CLI keeps backups;
|
|
70
74
|
undo the changes with `phantom setup --restore`.
|
|
71
75
|
|
|
72
76
|
## Connect for one run
|
package/dist/cli/app.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { resolveApiKey } from "../keys/saved.mjs";
|
|
|
6
6
|
import { hideHints } from "./screens.mjs";
|
|
7
7
|
import { startHeader } from "./start.mjs";
|
|
8
8
|
import { COMMANDS } from "./commands.mjs";
|
|
9
|
+
import { FREE_LINE } from "../core/free.mjs";
|
|
9
10
|
/** The agents the menu can start, and the program each needs on this computer. */
|
|
10
11
|
const AGENTS = [
|
|
11
12
|
{ label: 'Start Claude Code', bin: 'claude', run: ['claude'] },
|
|
@@ -17,7 +18,7 @@ export function mainMenu(s) {
|
|
|
17
18
|
const quit = { label: 'Quit', value: { run: [] } };
|
|
18
19
|
if (!s.signedIn) {
|
|
19
20
|
return [
|
|
20
|
-
{ label: 'Set up my coding agents', hint: 'sign in and connect them
|
|
21
|
+
{ label: 'Set up my coding agents', hint: 'sign in and connect them, free to start', value: { run: ['setup'] } },
|
|
21
22
|
{ label: 'Sign in only', hint: 'or save a Phantom key you already have', value: { run: ['login'] } },
|
|
22
23
|
{ label: 'Every command', hint: 'pick one to run', value: { open: 'commands' } },
|
|
23
24
|
quit,
|
|
@@ -26,7 +27,7 @@ export function mainMenu(s) {
|
|
|
26
27
|
// Only agents that are installed here, so the menu never offers one that can't start.
|
|
27
28
|
const agents = s.setUp ? AGENTS.filter((a) => s.installed(a.bin)).map((a) => ({ label: a.label, hint: 'on Phantom AI', value: { run: a.run } })) : [];
|
|
28
29
|
return [
|
|
29
|
-
...(s.setUp ? [] : [{ label: 'Finish setting up my agents', hint:
|
|
30
|
+
...(s.setUp ? [] : [{ label: 'Finish setting up my agents', hint: 'no agent uses Phantom yet', value: { run: ['setup'] } }]),
|
|
30
31
|
...agents,
|
|
31
32
|
{ label: 'Balance', hint: 'what this key can spend', value: { run: ['balance'] } },
|
|
32
33
|
{ label: 'Spend', hint: 'what you spent, and what phantom-1 saved', value: { run: ['spend'] } },
|
|
@@ -105,7 +106,9 @@ export async function runApp(env, pick, write, runCommand, installed = onPath) {
|
|
|
105
106
|
// A clean screen for each visit to the menu: a title, then where you are.
|
|
106
107
|
write('\x1b[2J\x1b[H' + startHeader(env, color) + '\n');
|
|
107
108
|
const s = state();
|
|
108
|
-
write(` ${p.dim(s.signedIn ? (s.setUp ? 'Signed in · your agents use Phantom AI' : 'Signed in · no agent set up yet') : 'Not signed in yet')}\n
|
|
109
|
+
write(` ${p.dim(s.signedIn ? (s.setUp ? 'Signed in · your agents use Phantom AI' : 'Signed in · no agent set up yet') : 'Not signed in yet')}\n`);
|
|
110
|
+
// Before signing in, the one line that says what free means, whole, since it is why most people came.
|
|
111
|
+
write(s.signedIn ? '\n' : ` ${p.lime('Free to start:')} ${FREE_LINE}.\n\n`);
|
|
109
112
|
let action = await choose({ ...pick, esc: 'quit' }, 'What would you like to do?', mainMenu(s));
|
|
110
113
|
if (action === null)
|
|
111
114
|
return 0;
|
package/dist/cli/commands.mjs
CHANGED
|
@@ -160,6 +160,8 @@ export const COMMANDS = [
|
|
|
160
160
|
[--no-browser] print the sign-in link instead of opening it
|
|
161
161
|
[--dry-run] preview the changes without making them
|
|
162
162
|
[--diff] show every line each file gains or loses
|
|
163
|
+
[--no-shell] leave your shell startup file alone and list
|
|
164
|
+
the lines to add yourself
|
|
163
165
|
[--json] print JSON in a terminal too (a pipe always gets JSON)
|
|
164
166
|
[--model <id>] the model each agent starts on (default ${SETUP_MODEL})
|
|
165
167
|
setup --agent <name> install the skill for one agent, without asking:
|
package/dist/cli/run.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { VERSION } from "../core/version.mjs";
|
|
2
2
|
import { nodeTrustWarning } from "../core/node-trust.mjs";
|
|
3
3
|
import { die, NO_KEY } from "../core/errors.mjs";
|
|
4
|
-
import { colorOn } from "../core/style.mjs";
|
|
4
|
+
import { colorOn, fitText } from "../core/style.mjs";
|
|
5
5
|
import { ALIASES, closestCommand, commandFromWords, startScreen } from "./start.mjs";
|
|
6
6
|
import { BackToMenu } from "../core/pick.mjs";
|
|
7
7
|
import { runApp } from "./app.mjs";
|
|
@@ -41,7 +41,17 @@ export async function run(argv = process.argv.slice(2), env = process.env, io =
|
|
|
41
41
|
stderr: (s) => process.stderr.write(s),
|
|
42
42
|
isTTY: Boolean(process.stderr.isTTY),
|
|
43
43
|
stdoutIsTTY: Boolean(process.stdout.isTTY),
|
|
44
|
+
columns: process.stdout.columns || process.stderr.columns,
|
|
44
45
|
}, node = process.versions.node) {
|
|
46
|
+
// A line wider than the terminal breaks at a word, under its own column, rather than wherever the edge falls.
|
|
47
|
+
if (io.columns) {
|
|
48
|
+
const { columns, stdout, stderr } = io;
|
|
49
|
+
io = {
|
|
50
|
+
...io,
|
|
51
|
+
...(io.stdoutIsTTY ? { stdout: (s) => stdout(fitText(s, columns)) } : {}),
|
|
52
|
+
...(io.isTTY ? { stderr: (s) => stderr(fitText(s, columns)) } : {}),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
45
55
|
const unsafe = nodeTrustWarning(node);
|
|
46
56
|
if (unsafe)
|
|
47
57
|
io.stderr(unsafe);
|
package/dist/cli/screens.mjs
CHANGED
|
@@ -100,8 +100,12 @@ export function spendScreen(s, style = {}) {
|
|
|
100
100
|
if (s.keys.length > 1) {
|
|
101
101
|
lines.push('', ...caption(p, w, 'BY KEY'));
|
|
102
102
|
for (const k of s.keys) {
|
|
103
|
-
const
|
|
104
|
-
|
|
103
|
+
const savedText = k.saved_usd ? ` saved ${usd(k.saved_usd)}` : '';
|
|
104
|
+
const offText = k.active ? '' : ' revoked or rotated';
|
|
105
|
+
// The dollars line up with the model rows above, unless that pushes the row past the window's edge.
|
|
106
|
+
const tail = 8 + 2 + plural(k.calls, 'call').length + savedText.length + offText.length;
|
|
107
|
+
const pad = Math.max(LABEL, Math.min(LABEL + BAR + 2, widthOf(style) - 2 - tail));
|
|
108
|
+
lines.push(` ${k.name.slice(0, LABEL - 1).padEnd(pad)}${usd(k.spent_usd).padStart(8)} ${p.dim(plural(k.calls, 'call'))}${savedText ? p.lime(savedText) : ''}${offText ? p.dim(offText) : ''}`);
|
|
105
109
|
}
|
|
106
110
|
}
|
|
107
111
|
if (!s.total.calls)
|
|
@@ -116,12 +120,12 @@ function keyRows(p, keys) {
|
|
|
116
120
|
if (!keys.length)
|
|
117
121
|
return [` ${p.dim('No keys yet.')}`];
|
|
118
122
|
return keys.map((k) => {
|
|
119
|
-
const limit = k.limit_usd != null ? `limit ${usd(k.limit_usd)}` : k.budget_usd != null ? `${usd(k.budget_usd)}
|
|
123
|
+
const limit = k.limit_usd != null ? `limit ${usd(k.limit_usd)}` : k.budget_usd != null ? `${usd(k.budget_usd)} per ${plural(k.period_days ?? 30, 'day')}` : 'no limit';
|
|
120
124
|
// A revoked or rotated key gets a hollow mark and the word off at the end, so the columns stay in line.
|
|
121
125
|
const off = k.is_active === false;
|
|
122
126
|
const mark = off ? p.dim('○') : p.lime('●');
|
|
123
127
|
// The id's first 8 characters: enough for --id, which also takes the name.
|
|
124
|
-
return ` ${mark} ${clip(k.name ?? 'Key', 20).padEnd(21)}${p.dim((k.id ?? '').slice(0, 8).padEnd(
|
|
128
|
+
return ` ${mark} ${clip(k.name ?? 'Key', 20).padEnd(21)}${p.dim((k.id ?? '').slice(0, 8).padEnd(9))}${p.dim((k.prefix ?? '').slice(0, 18).padEnd(20))}${usd(k.spent_usd ?? 0).padStart(8)} ${p.dim(limit)}${off ? ` ${p.dim('off')}` : ''}`;
|
|
125
129
|
});
|
|
126
130
|
}
|
|
127
131
|
/** `phantom workspace`: the shared balance, each key, and the last few charges. */
|
|
@@ -154,6 +158,16 @@ export function signedInScreen(saved, style = {}) {
|
|
|
154
158
|
const where = saved.saved_as === 'default' ? 'Your key is' : `Key "${saved.saved_as}" is`;
|
|
155
159
|
return ['', ` ${p.lime('✓')} ${p.bold('Signed in.')} ${p.dim(`${where} saved in your ${saved.storage}.`)}`, ...next(p, 'Connect your coding agents:', 'phantom setup')].join('\n');
|
|
156
160
|
}
|
|
161
|
+
/** `phantom login` when this computer already has a key: say so, then how to switch or add one. */
|
|
162
|
+
export function alreadySignedInScreen(keyIdShown, style = {}) {
|
|
163
|
+
const p = paint(style.env ?? {}, Boolean(style.color));
|
|
164
|
+
return [
|
|
165
|
+
'',
|
|
166
|
+
` ${p.lime('✓')} ${p.bold('You are already signed in on this computer.')}${keyIdShown ? ` ${p.dim(`key ${keyIdShown}`)}` : ''}`,
|
|
167
|
+
...next(p, 'Use another workspace: run phantom logout, then', 'phantom login'),
|
|
168
|
+
...next(p, 'Add a second key here:', 'phantom login --save <name>'),
|
|
169
|
+
].join('\n');
|
|
170
|
+
}
|
|
157
171
|
/** A ✓ line with an optional dim detail, for a change that worked. */
|
|
158
172
|
function done(p, words, detail = '') {
|
|
159
173
|
return ` ${p.lime('✓')} ${words}${detail ? ` ${p.dim(detail)}` : ''}`;
|
package/dist/cli/start.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// day to day. It reads no network, so it prints at once, even offline.
|
|
4
4
|
import { brandHeader } from "../core/brand.mjs";
|
|
5
5
|
import { paint } from "../core/style.mjs";
|
|
6
|
+
import { FREE_LINE } from "../core/free.mjs";
|
|
6
7
|
/** One row: a command in cyan, padded, then what it does. */
|
|
7
8
|
const row = (p, command, about) => ` ${p.cyan(command.padEnd(18))}${about}`;
|
|
8
9
|
/** An uppercase caption over a thin rule, like the site's section labels. */
|
|
@@ -15,7 +16,7 @@ export function startScreen(env, color, signedIn) {
|
|
|
15
16
|
const p = paint(env, color);
|
|
16
17
|
const lines = ['', ...brandHeader(p, color, '')];
|
|
17
18
|
if (!signedIn) {
|
|
18
|
-
lines.push(...caption(p, 'START HERE'), row(p, 'phantom setup', 'signs you in and connects your coding agents'), ` ${' '.repeat(18)}${p.dim(
|
|
19
|
+
lines.push(...caption(p, 'START HERE'), row(p, 'phantom setup', 'signs you in and connects your coding agents'), ` ${' '.repeat(18)}${p.dim(FREE_LINE)}`, '', ` Then use your agent as usual, or start it with ${p.cyan('phantom claude')}, ${p.cyan('phantom codex')} or ${p.cyan('phantom pi')}.`);
|
|
19
20
|
}
|
|
20
21
|
else {
|
|
21
22
|
lines.push(` ${p.lime('✓')} Signed in.`, '', ...caption(p, 'EVERYDAY'), row(p, 'phantom claude', 'run Claude Code on Phantom AI (also codex, pi)'), row(p, 'phantom balance', 'your credit'), row(p, 'phantom spend', 'what you spent, and what phantom-1 saved'), row(p, 'phantom setup', 'connect more agents, or change how they run'));
|
|
@@ -67,7 +68,7 @@ export function startChoices(signedIn, setUp = signedIn) {
|
|
|
67
68
|
const quit = { label: 'Quit', value: [] };
|
|
68
69
|
if (!signedIn) {
|
|
69
70
|
return [
|
|
70
|
-
{ label: 'Set up my coding agents', hint: 'sign in and connect them
|
|
71
|
+
{ label: 'Set up my coding agents', hint: 'sign in and connect them, free to start', value: ['setup'] },
|
|
71
72
|
{ label: 'Sign in only', hint: 'or save a Phantom key you already have', value: ['login'] },
|
|
72
73
|
{ label: 'See every command', value: ['--help'] },
|
|
73
74
|
quit,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// What phantom-1 gives a key for free, said the same way on every screen that mentions it.
|
|
2
|
+
/**
|
|
3
|
+
* Free phantom-1 requests a workspace gets each UTC day, before it has had $10
|
|
4
|
+
* of credit. Phantom's own limit is `FREE_ROUTER_CAPS.perDay` in the Phantom
|
|
5
|
+
* repo's `lib/config.ts`; change both together.
|
|
6
|
+
*/
|
|
7
|
+
export const FREE_REQUESTS_A_DAY = 50;
|
|
8
|
+
/** The free offer in one line short enough for an 80-column terminal. */
|
|
9
|
+
export const FREE_LINE = `phantom-1 runs free models, ${FREE_REQUESTS_A_DAY} requests a day, with no card`;
|
package/dist/core/style.mjs
CHANGED
|
@@ -34,3 +34,65 @@ export function paint(env, on) {
|
|
|
34
34
|
}
|
|
35
35
|
/** `text` without color codes, to measure how wide it prints. */
|
|
36
36
|
export const unstyled = (text) => text.replace(/\x1b\[[0-9;]*m/g, '');
|
|
37
|
+
/**
|
|
38
|
+
* One line of a screen, fitted to a terminal `columns` wide: a longer line
|
|
39
|
+
* breaks at spaces, and each piece after the first starts under the text it
|
|
40
|
+
* continues. That is the second column for a table row or a help entry
|
|
41
|
+
* (text after a run of two spaces), the words after a mark such as `✓ ` or
|
|
42
|
+
* `→ `, or else the line's own indent. A word longer than the room, such as
|
|
43
|
+
* a URL, stays whole. Color codes don't count toward the width.
|
|
44
|
+
*/
|
|
45
|
+
export function fitLine(line, columns) {
|
|
46
|
+
const seen = unstyled(line);
|
|
47
|
+
if (seen.length <= columns)
|
|
48
|
+
return line;
|
|
49
|
+
const lead = seen.length - seen.trimStart().length;
|
|
50
|
+
const column = /^(\s*\S.*?\s{2,})\S/.exec(seen)?.[1].length;
|
|
51
|
+
const mark = /^\s*[✓✗→·!◆?❯] /.exec(seen)?.[0].length;
|
|
52
|
+
const hang = column !== undefined && column <= columns / 2 ? column : (mark ?? lead);
|
|
53
|
+
const rows = [];
|
|
54
|
+
let row = '';
|
|
55
|
+
let width = 0;
|
|
56
|
+
// A command stays on one line to copy: `phantom` keeps the word after it, and a flag the word before.
|
|
57
|
+
const words = [];
|
|
58
|
+
for (const word of line.split(' ')) {
|
|
59
|
+
const last = words.length ? unstyled(words[words.length - 1]) : '';
|
|
60
|
+
if (words.length && word && (/(^|[^\w-])phantom$/.test(last) || /^-{1,2}[a-z]/.test(unstyled(word))))
|
|
61
|
+
words[words.length - 1] += ' ' + word;
|
|
62
|
+
else
|
|
63
|
+
words.push(word);
|
|
64
|
+
}
|
|
65
|
+
words.forEach((word, i) => {
|
|
66
|
+
const w = unstyled(word).length;
|
|
67
|
+
const blank = unstyled(row).trim() === '';
|
|
68
|
+
if (i > 0 && !blank && width + 1 + w > columns) {
|
|
69
|
+
rows.push(row.trimEnd());
|
|
70
|
+
row = ' '.repeat(hang) + word;
|
|
71
|
+
width = hang + w;
|
|
72
|
+
}
|
|
73
|
+
else if (i > 0 && blank && rows.length) {
|
|
74
|
+
row += word;
|
|
75
|
+
width += w;
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
row += (i > 0 ? ' ' : '') + word;
|
|
79
|
+
width += (i > 0 ? 1 : 0) + w;
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
return [...rows, row.trimEnd()].join('\n');
|
|
83
|
+
}
|
|
84
|
+
/** Moves the cursor or redraws: text drawn in place (a menu, a spinner), which must not be wrapped. */
|
|
85
|
+
const DRAWN = /\r|\x1b\[[0-9;?]*[A-HJKSTfhlsu]/;
|
|
86
|
+
/**
|
|
87
|
+
* Text fitted to `columns` with {@link fitLine}, line by line. JSON (text
|
|
88
|
+
* that starts with `{` or `[`) and anything drawn in place come back as they
|
|
89
|
+
* are, since a break there would change what they mean.
|
|
90
|
+
*/
|
|
91
|
+
export function fitText(text, columns) {
|
|
92
|
+
if (!columns || DRAWN.test(text) || /^\s*[{[]/.test(text))
|
|
93
|
+
return text;
|
|
94
|
+
return text
|
|
95
|
+
.split('\n')
|
|
96
|
+
.map((l) => fitLine(l, columns))
|
|
97
|
+
.join('\n');
|
|
98
|
+
}
|
package/dist/core/version.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** This release of phantom. `npm version` keeps it equal to the version in package.json. */
|
|
2
|
-
export const VERSION = '1.2.
|
|
2
|
+
export const VERSION = '1.2.7';
|
package/dist/plan/command.mjs
CHANGED
|
@@ -65,7 +65,7 @@ export async function planCommand(input) {
|
|
|
65
65
|
const pick = pickIo(input);
|
|
66
66
|
if (pick) {
|
|
67
67
|
const offer = (await getPlan(apiKey, baseUrl)).prices?.[subcommand];
|
|
68
|
-
const what = offer ? `$${offer.usd_per_month} a month
|
|
68
|
+
const what = offer ? `$${offer.usd_per_month} a month for $${offer.week_limit_usd} of model cost a week, every key` : 'for every key in this workspace';
|
|
69
69
|
const go = await choose(pick, `Start ${subcommand === 'max' ? 'Max' : 'Pro'}?`, [
|
|
70
70
|
{ label: 'Open Stripe to pay', hint: what, value: true },
|
|
71
71
|
{ label: 'Cancel', hint: 'nothing is charged', value: false },
|
package/dist/setup/agents.mjs
CHANGED
|
@@ -6,6 +6,13 @@ import { die } from "../core/errors.mjs";
|
|
|
6
6
|
import { resolveApiKey } from "../keys/saved.mjs";
|
|
7
7
|
import { SETUP_MODEL, agentBaseUrl, readText, configHome, onPath, phantomCommand, offResult, unreadable, SETUP_MARK, SETUP_CONTEXT_WINDOW, } from "./shared.mjs";
|
|
8
8
|
import { jsonObject, jsonEdit, tomlSet, tomlRemoveOurs, yamlSetTop, yamlAddModel, yamlRemoveOurs } from "./editors.mjs";
|
|
9
|
+
/** The most output a model call may ask for, for agents that need a number. */
|
|
10
|
+
const SETUP_OUTPUT_TOKENS = 32_000;
|
|
11
|
+
/** A JSON edit's result, or '' when only `$schema` (or nothing) is left, so `off` deletes a file phantom made. */
|
|
12
|
+
function unlessEmpty(file, after) {
|
|
13
|
+
const left = jsonObject(file, after);
|
|
14
|
+
return Object.keys(left).every((k) => k === '$schema') ? '' : after;
|
|
15
|
+
}
|
|
9
16
|
// ── Manual Provider Helper ───────────────────────────────────────────────────
|
|
10
17
|
/** An agent whose model settings live on its own settings screen: setup prints the steps. */
|
|
11
18
|
export function manualProvider(where, steps, note) {
|
|
@@ -350,6 +357,274 @@ export function geminiProvider(ctx, off) {
|
|
|
350
357
|
},
|
|
351
358
|
};
|
|
352
359
|
}
|
|
360
|
+
// ── Crush ────────────────────────────────────────────────────────────────────
|
|
361
|
+
/** Crush's config folder: `$CRUSH_GLOBAL_CONFIG`, else `crush` in the config folder. */
|
|
362
|
+
export function crushDir(env, home) {
|
|
363
|
+
return env.CRUSH_GLOBAL_CONFIG?.trim() ? env.CRUSH_GLOBAL_CONFIG : path.join(configHome(env, home), 'crush');
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Crush: `crush.json` in Crush's config folder gets a `phantom` provider at
|
|
367
|
+
* `/coding-agent/v1` whose key is `$PHANTOM_API_KEY`, which Crush expands
|
|
368
|
+
* when it loads, and phantom-1 as both its large and small model. Crush 0.99
|
|
369
|
+
* reads this file beside a `crushrc`.
|
|
370
|
+
*/
|
|
371
|
+
export function crushProvider(ctx, off) {
|
|
372
|
+
const file = path.join(crushDir(ctx.env, ctx.home), 'crush.json');
|
|
373
|
+
const text = readText(file);
|
|
374
|
+
const config = jsonObject(file, text);
|
|
375
|
+
const sizes = ['large', 'small'];
|
|
376
|
+
if (off) {
|
|
377
|
+
if (!config.providers?.phantom)
|
|
378
|
+
return offResult('Crush', file, text, text);
|
|
379
|
+
const changes = [Object.keys(config.providers).length === 1 ? [['providers'], undefined] : [['providers', 'phantom'], undefined]];
|
|
380
|
+
const mine = Object.keys(config.models ?? {}).filter((k) => sizes.includes(k) && config.models?.[k]?.provider === 'phantom');
|
|
381
|
+
if (mine.length && mine.length === Object.keys(config.models ?? {}).length)
|
|
382
|
+
changes.push([['models'], undefined]);
|
|
383
|
+
else
|
|
384
|
+
for (const k of mine)
|
|
385
|
+
changes.push([['models', k], undefined]);
|
|
386
|
+
return offResult('Crush', file, text, unlessEmpty(file, jsonEdit(file, text, changes)));
|
|
387
|
+
}
|
|
388
|
+
const base = agentBaseUrl(ctx.env, 'coding-agent');
|
|
389
|
+
const model = ctx.model ?? SETUP_MODEL;
|
|
390
|
+
const changes = [
|
|
391
|
+
...(!text?.trim() ? [[['$schema'], 'https://charm.land/crush.json']] : []),
|
|
392
|
+
[
|
|
393
|
+
['providers', 'phantom'],
|
|
394
|
+
{
|
|
395
|
+
name: 'Phantom AI',
|
|
396
|
+
type: 'openai-compat',
|
|
397
|
+
base_url: base,
|
|
398
|
+
api_key: '$PHANTOM_API_KEY',
|
|
399
|
+
models: [{ id: model, name: model, context_window: SETUP_CONTEXT_WINDOW, default_max_tokens: SETUP_OUTPUT_TOKENS }],
|
|
400
|
+
},
|
|
401
|
+
],
|
|
402
|
+
...sizes.map((size) => [['models', size], { provider: 'phantom', model }]),
|
|
403
|
+
];
|
|
404
|
+
return { edits: [{ file, after: jsonEdit(file, text, changes) }], result: { settings: file, on: true, base_url: base, model, warnings: [] } };
|
|
405
|
+
}
|
|
406
|
+
// ── Qwen Code ────────────────────────────────────────────────────────────────
|
|
407
|
+
/** Qwen Code's folder: `$QWEN_HOME`, else `~/.qwen`. */
|
|
408
|
+
export function qwenDir(env, home) {
|
|
409
|
+
return env.QWEN_HOME?.trim() ? env.QWEN_HOME : path.join(home, '.qwen');
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Qwen Code: `settings.json` in its folder gets a model under
|
|
413
|
+
* `modelProviders.openai` at `/coding-agent/v1` that reads its key from
|
|
414
|
+
* PHANTOM_API_KEY (`envKey`), is picked as the model (`model.name`), and the
|
|
415
|
+
* OpenAI-compatible sign-in (`security.auth.selectedType`), so Qwen Code
|
|
416
|
+
* starts without asking. Its other models stay.
|
|
417
|
+
*/
|
|
418
|
+
export function qwenProvider(ctx, off) {
|
|
419
|
+
const file = path.join(qwenDir(ctx.env, ctx.home), 'settings.json');
|
|
420
|
+
const text = readText(file);
|
|
421
|
+
const config = jsonObject(file, text);
|
|
422
|
+
const list = config.modelProviders?.openai ?? [];
|
|
423
|
+
if (!Array.isArray(list))
|
|
424
|
+
unreadable(file, 'has modelProviders.openai as something other than a list');
|
|
425
|
+
const base = agentBaseUrl(ctx.env, 'coding-agent');
|
|
426
|
+
const ours = (m) => m?.baseUrl === base;
|
|
427
|
+
const others = list.filter((m) => !ours(m));
|
|
428
|
+
const mineIds = list.filter(ours).map((m) => m.id);
|
|
429
|
+
if (off) {
|
|
430
|
+
if (others.length === list.length)
|
|
431
|
+
return offResult('Qwen Code', file, text, text);
|
|
432
|
+
const changes = [];
|
|
433
|
+
if (others.length)
|
|
434
|
+
changes.push([['modelProviders', 'openai'], others]);
|
|
435
|
+
else
|
|
436
|
+
changes.push(Object.keys(config.modelProviders ?? {}).length === 1 ? [['modelProviders'], undefined] : [['modelProviders', 'openai'], undefined]);
|
|
437
|
+
if (mineIds.includes(config.model?.name))
|
|
438
|
+
changes.push(Object.keys(config.model ?? {}).length === 1 ? [['model'], undefined] : [['model', 'name'], undefined]);
|
|
439
|
+
return offResult('Qwen Code', file, text, unlessEmpty(file, jsonEdit(file, text, changes)));
|
|
440
|
+
}
|
|
441
|
+
const model = ctx.model ?? SETUP_MODEL;
|
|
442
|
+
const entry = { id: model, name: `Phantom AI ${model}`, baseUrl: base, envKey: 'PHANTOM_API_KEY', generationConfig: { contextWindowSize: SETUP_CONTEXT_WINDOW } };
|
|
443
|
+
const changes = [
|
|
444
|
+
[['modelProviders', 'openai'], [entry, ...others]],
|
|
445
|
+
[['security', 'auth', 'selectedType'], 'openai'],
|
|
446
|
+
[['model', 'name'], model],
|
|
447
|
+
];
|
|
448
|
+
return { edits: [{ file, after: jsonEdit(file, text, changes) }], result: { settings: file, on: true, base_url: base, model, warnings: [] } };
|
|
449
|
+
}
|
|
450
|
+
// ── Kilo Code ────────────────────────────────────────────────────────────────
|
|
451
|
+
/** Kilo Code's config file: `kilo.jsonc` (or `kilo.json`) in `$XDG_CONFIG_HOME/kilo`, else `~/.config/kilo`. */
|
|
452
|
+
export function kiloFile(env, home) {
|
|
453
|
+
const dir = path.join(configHome(env, home), 'kilo');
|
|
454
|
+
return existsSync(path.join(dir, 'kilo.jsonc')) ? path.join(dir, 'kilo.jsonc') : path.join(dir, 'kilo.json');
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Kilo Code's CLI, a fork of opencode: its global config gets the same
|
|
458
|
+
* `phantom` provider opencode does, with the key as `{env:PHANTOM_API_KEY}`,
|
|
459
|
+
* and `phantom/<model>` as the model. Kilo resolves `{env:...}` only in the
|
|
460
|
+
* global file, which is the one written here.
|
|
461
|
+
*/
|
|
462
|
+
export function kiloProvider(ctx, off) {
|
|
463
|
+
const file = kiloFile(ctx.env, ctx.home);
|
|
464
|
+
const text = readText(file);
|
|
465
|
+
const config = jsonObject(file, text);
|
|
466
|
+
if (off) {
|
|
467
|
+
if (!config.provider?.phantom)
|
|
468
|
+
return offResult('Kilo Code', file, text, text);
|
|
469
|
+
const changes = [Object.keys(config.provider).length === 1 ? [['provider'], undefined] : [['provider', 'phantom'], undefined]];
|
|
470
|
+
if (typeof config.model === 'string' && config.model.startsWith('phantom/'))
|
|
471
|
+
changes.push([['model'], undefined]);
|
|
472
|
+
return offResult('Kilo Code', file, text, unlessEmpty(file, jsonEdit(file, text, changes)));
|
|
473
|
+
}
|
|
474
|
+
const base = agentBaseUrl(ctx.env, 'coding-agent');
|
|
475
|
+
const model = ctx.model ?? SETUP_MODEL;
|
|
476
|
+
const changes = [
|
|
477
|
+
...(!text?.trim() ? [[['$schema'], 'https://app.kilo.ai/config.json']] : []),
|
|
478
|
+
[
|
|
479
|
+
['provider', 'phantom'],
|
|
480
|
+
{
|
|
481
|
+
npm: '@ai-sdk/openai-compatible',
|
|
482
|
+
name: 'Phantom AI',
|
|
483
|
+
options: { baseURL: base, apiKey: '{env:PHANTOM_API_KEY}' },
|
|
484
|
+
// Kilo turns off compaction for a model without limits.
|
|
485
|
+
models: { [model]: { name: model, tool_call: true, reasoning: true, limit: { context: SETUP_CONTEXT_WINDOW, output: SETUP_OUTPUT_TOKENS } } },
|
|
486
|
+
},
|
|
487
|
+
],
|
|
488
|
+
[['model'], `phantom/${model}`],
|
|
489
|
+
];
|
|
490
|
+
return { edits: [{ file, after: jsonEdit(file, text, changes) }], result: { settings: file, on: true, base_url: base, model: `phantom/${model}`, warnings: [] } };
|
|
491
|
+
}
|
|
492
|
+
// ── Kimi Code ────────────────────────────────────────────────────────────────
|
|
493
|
+
/** Kimi Code's folder: `$KIMI_CODE_HOME`, else `~/.kimi-code`. */
|
|
494
|
+
export function kimiDir(env, home) {
|
|
495
|
+
return env.KIMI_CODE_HOME?.trim() ? env.KIMI_CODE_HOME : path.join(home, '.kimi-code');
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Kimi Code CLI: `config.toml` in its folder gets a `phantom` provider at
|
|
499
|
+
* `/coding-agent/v1` that reads its key from PHANTOM_API_KEY
|
|
500
|
+
* (`api_key_env`), a `phantom/<model>` model, and that model as
|
|
501
|
+
* `default_model`. Each run starts from the file without phantom's lines, so
|
|
502
|
+
* a new `--model` replaces the old one.
|
|
503
|
+
*/
|
|
504
|
+
export function kimiProvider(ctx, off) {
|
|
505
|
+
const file = path.join(kimiDir(ctx.env, ctx.home), 'config.toml');
|
|
506
|
+
const text = readText(file);
|
|
507
|
+
const before = text === null ? null : tomlRemoveOurs(file, text);
|
|
508
|
+
if (off)
|
|
509
|
+
return offResult('Kimi Code', file, text, before);
|
|
510
|
+
const base = agentBaseUrl(ctx.env, 'coding-agent');
|
|
511
|
+
const model = ctx.model ?? SETUP_MODEL;
|
|
512
|
+
const alias = `phantom/${model}`;
|
|
513
|
+
let t = tomlSet(file, before ?? '', [], 'default_model', JSON.stringify(alias));
|
|
514
|
+
for (const [k, v] of [['type', '"openai"'], ['base_url', JSON.stringify(base)], ['api_key_env', '"PHANTOM_API_KEY"']])
|
|
515
|
+
t = tomlSet(file, t, ['providers', 'phantom'], k, v);
|
|
516
|
+
for (const [k, v] of [['provider', '"phantom"'], ['model', JSON.stringify(model)], ['max_context_size', String(SETUP_CONTEXT_WINDOW)], ['capabilities', '["thinking", "tool_use"]']]) {
|
|
517
|
+
t = tomlSet(file, t, ['models', alias], k, v);
|
|
518
|
+
}
|
|
519
|
+
return { edits: [{ file, after: t }], result: { settings: file, on: true, base_url: base, model: alias, warnings: [] } };
|
|
520
|
+
}
|
|
521
|
+
// ── Droid ────────────────────────────────────────────────────────────────────
|
|
522
|
+
/** The name phantom gives its model in Droid. Droid calls it `custom:Phantom-AI-<n>`. */
|
|
523
|
+
const DROID_NAME = 'Phantom AI';
|
|
524
|
+
/**
|
|
525
|
+
* Factory's Droid: `~/.factory/settings.json` gets a custom model at
|
|
526
|
+
* `/coding-agent/v1` (chat completions) with the key as `${PHANTOM_API_KEY}`,
|
|
527
|
+
* which Droid expands, and that model as `model`. Droid names a custom
|
|
528
|
+
* model by its place in `customModels`, so phantom's goes last, or stays
|
|
529
|
+
* where it was, and the user's own keep their names.
|
|
530
|
+
*/
|
|
531
|
+
export function droidProvider(ctx, off) {
|
|
532
|
+
const file = path.join(ctx.home, '.factory', 'settings.json');
|
|
533
|
+
const text = readText(file);
|
|
534
|
+
const config = jsonObject(file, text);
|
|
535
|
+
const list = config.customModels ?? [];
|
|
536
|
+
if (!Array.isArray(list))
|
|
537
|
+
unreadable(file, 'has customModels as something other than a list');
|
|
538
|
+
const base = agentBaseUrl(ctx.env, 'coding-agent');
|
|
539
|
+
const at = list.findIndex((m) => m?.baseUrl === base);
|
|
540
|
+
const mine = (m) => typeof m === 'string' && m.startsWith(`custom:${DROID_NAME.replace(/ /g, '-')}-`);
|
|
541
|
+
if (off) {
|
|
542
|
+
if (at === -1)
|
|
543
|
+
return offResult('Droid', file, text, text);
|
|
544
|
+
const rest = list.filter((_, i) => i !== at);
|
|
545
|
+
const changes = [[['customModels'], rest.length ? rest : undefined]];
|
|
546
|
+
if (mine(config.model))
|
|
547
|
+
changes.push([['model'], undefined]);
|
|
548
|
+
return offResult('Droid', file, text, unlessEmpty(file, jsonEdit(file, text, changes)));
|
|
549
|
+
}
|
|
550
|
+
const model = ctx.model ?? SETUP_MODEL;
|
|
551
|
+
const entry = { model, displayName: DROID_NAME, baseUrl: base, apiKey: '${PHANTOM_API_KEY}', provider: 'generic-chat-completion-api', maxOutputTokens: SETUP_OUTPUT_TOKENS };
|
|
552
|
+
const index = at === -1 ? list.length : at;
|
|
553
|
+
const next = [...list];
|
|
554
|
+
next[index] = entry;
|
|
555
|
+
const id = `custom:${DROID_NAME.replace(/ /g, '-')}-${index}`;
|
|
556
|
+
const changes = [
|
|
557
|
+
[['customModels'], next],
|
|
558
|
+
[['model'], id],
|
|
559
|
+
];
|
|
560
|
+
return { edits: [{ file, after: jsonEdit(file, text, changes) }], result: { settings: file, on: true, base_url: base, model: id, warnings: [] } };
|
|
561
|
+
}
|
|
562
|
+
// ── Mistral Vibe ─────────────────────────────────────────────────────────────
|
|
563
|
+
/**
|
|
564
|
+
* Mistral Vibe: `~/.vibe/config.toml` gets a `phantom` provider at
|
|
565
|
+
* `/coding-agent/v1` that reads its key from PHANTOM_API_KEY
|
|
566
|
+
* (`api_key_env_var`), a model on it, and that model as `active_model`.
|
|
567
|
+
* Providers and models are lists of tables (`[[providers]]`), so phantom's
|
|
568
|
+
* go at the end as marked lines, which `--provider off` takes out again.
|
|
569
|
+
*/
|
|
570
|
+
export function vibeProvider(ctx, off) {
|
|
571
|
+
const file = path.join(ctx.home, '.vibe', 'config.toml');
|
|
572
|
+
const text = readText(file);
|
|
573
|
+
const before = text === null ? null : tomlRemoveOurs(file, text);
|
|
574
|
+
if (off)
|
|
575
|
+
return offResult('Mistral Vibe', file, text, before);
|
|
576
|
+
const base = agentBaseUrl(ctx.env, 'coding-agent');
|
|
577
|
+
const model = ctx.model ?? SETUP_MODEL;
|
|
578
|
+
const block = [
|
|
579
|
+
'[[providers]]',
|
|
580
|
+
'name = "phantom"',
|
|
581
|
+
`api_base = ${JSON.stringify(base)}`,
|
|
582
|
+
'api_key_env_var = "PHANTOM_API_KEY"',
|
|
583
|
+
'backend = "generic"',
|
|
584
|
+
'api_style = "openai"',
|
|
585
|
+
'',
|
|
586
|
+
'[[models]]',
|
|
587
|
+
`name = ${JSON.stringify(model)}`,
|
|
588
|
+
'provider = "phantom"',
|
|
589
|
+
`alias = ${JSON.stringify(model)}`,
|
|
590
|
+
].map((l) => (l ? `${l} ${SETUP_MARK}` : l));
|
|
591
|
+
const body = (before ?? '').replace(/\s*$/, '');
|
|
592
|
+
const t = tomlSet(file, `${body ? `${body}\n\n` : ''}${block.join('\n')}\n`, [], 'active_model', JSON.stringify(model));
|
|
593
|
+
return { edits: [{ file, after: t }], result: { settings: file, on: true, base_url: base, model, warnings: [] } };
|
|
594
|
+
}
|
|
595
|
+
// ── Zed ──────────────────────────────────────────────────────────────────────
|
|
596
|
+
/**
|
|
597
|
+
* Zed's agent panel: `settings.json` in Zed's config folder gets an OpenAI
|
|
598
|
+
* compatible provider named `phantom` at `/coding-agent/v1`, and its model
|
|
599
|
+
* as the agent's default. Zed reads the key for a provider named `phantom`
|
|
600
|
+
* from PHANTOM_API_KEY by itself, so no key goes in the file.
|
|
601
|
+
*/
|
|
602
|
+
export function zedProvider(ctx, off) {
|
|
603
|
+
const file = path.join(configHome(ctx.env, ctx.home), 'zed', 'settings.json');
|
|
604
|
+
const text = readText(file);
|
|
605
|
+
const config = jsonObject(file, text);
|
|
606
|
+
if (off) {
|
|
607
|
+
if (!config.language_models?.openai_compatible?.phantom)
|
|
608
|
+
return offResult('Zed', file, text, text);
|
|
609
|
+
const changes = [[['language_models', 'openai_compatible', 'phantom'], undefined]];
|
|
610
|
+
if (config.agent?.default_model?.provider === 'phantom')
|
|
611
|
+
changes.push([['agent', 'default_model'], undefined]);
|
|
612
|
+
return offResult('Zed', file, text, jsonEdit(file, text, changes));
|
|
613
|
+
}
|
|
614
|
+
const base = agentBaseUrl(ctx.env, 'coding-agent');
|
|
615
|
+
const model = ctx.model ?? SETUP_MODEL;
|
|
616
|
+
const changes = [
|
|
617
|
+
[
|
|
618
|
+
['language_models', 'openai_compatible', 'phantom'],
|
|
619
|
+
{ api_url: base, available_models: [{ name: model, display_name: `Phantom AI ${model}`, max_tokens: SETUP_CONTEXT_WINDOW, capabilities: { tools: true } }] },
|
|
620
|
+
],
|
|
621
|
+
[['agent', 'default_model'], { provider: 'phantom', model }],
|
|
622
|
+
];
|
|
623
|
+
return {
|
|
624
|
+
edits: [{ file, after: jsonEdit(file, text, changes) }],
|
|
625
|
+
result: { settings: file, on: true, base_url: base, model, warnings: ['Zed reads PHANTOM_API_KEY from your login shell; quit and reopen Zed'] },
|
|
626
|
+
};
|
|
627
|
+
}
|
|
353
628
|
// ── Registry ─────────────────────────────────────────────────────────────────
|
|
354
629
|
/**
|
|
355
630
|
* The agents `phantom setup` knows, in the order setup runs them. Adding an agent
|
|
@@ -471,6 +746,122 @@ export const SETUP_AGENTS = {
|
|
|
471
746
|
GEMINI_API_KEY: '$PHANTOM_API_KEY',
|
|
472
747
|
}),
|
|
473
748
|
},
|
|
749
|
+
/** Crush. */
|
|
750
|
+
crush: {
|
|
751
|
+
label: 'Crush',
|
|
752
|
+
/** ~/.config/crush, or $CRUSH_GLOBAL_CONFIG */
|
|
753
|
+
home: '.config/crush',
|
|
754
|
+
detect: (env, home) => existsSync(crushDir(env, home)),
|
|
755
|
+
/** ~/.agents/skills */
|
|
756
|
+
skills: '.agents/skills',
|
|
757
|
+
provider: crushProvider,
|
|
758
|
+
shellEnv: () => ({}),
|
|
759
|
+
},
|
|
760
|
+
/** Qwen Code. */
|
|
761
|
+
qwen: {
|
|
762
|
+
label: 'Qwen Code',
|
|
763
|
+
/** ~/.qwen, or $QWEN_HOME */
|
|
764
|
+
home: '.qwen',
|
|
765
|
+
detect: (env, home) => existsSync(qwenDir(env, home)),
|
|
766
|
+
/** ~/.agents/skills */
|
|
767
|
+
skills: '.agents/skills',
|
|
768
|
+
provider: qwenProvider,
|
|
769
|
+
shellEnv: () => ({}),
|
|
770
|
+
},
|
|
771
|
+
/** Kilo Code's CLI. */
|
|
772
|
+
kilo: {
|
|
773
|
+
label: 'Kilo Code',
|
|
774
|
+
/** ~/.config/kilo, or under $XDG_CONFIG_HOME */
|
|
775
|
+
home: '.config/kilo',
|
|
776
|
+
detect: (env, home) => existsSync(path.join(configHome(env, home), 'kilo')),
|
|
777
|
+
/** ~/.agents/skills */
|
|
778
|
+
skills: '.agents/skills',
|
|
779
|
+
provider: kiloProvider,
|
|
780
|
+
shellEnv: () => ({}),
|
|
781
|
+
},
|
|
782
|
+
/** Kimi Code CLI. */
|
|
783
|
+
kimi: {
|
|
784
|
+
label: 'Kimi Code',
|
|
785
|
+
/** ~/.kimi-code, or $KIMI_CODE_HOME */
|
|
786
|
+
home: '.kimi-code',
|
|
787
|
+
detect: (env, home) => existsSync(kimiDir(env, home)),
|
|
788
|
+
/** ~/.agents/skills */
|
|
789
|
+
skills: '.agents/skills',
|
|
790
|
+
provider: kimiProvider,
|
|
791
|
+
shellEnv: () => ({}),
|
|
792
|
+
},
|
|
793
|
+
/** Factory's Droid. */
|
|
794
|
+
droid: {
|
|
795
|
+
label: 'Droid',
|
|
796
|
+
/** ~/.factory */
|
|
797
|
+
home: '.factory',
|
|
798
|
+
/** ~/.factory/skills */
|
|
799
|
+
skills: '.factory/skills',
|
|
800
|
+
provider: droidProvider,
|
|
801
|
+
shellEnv: () => ({}),
|
|
802
|
+
},
|
|
803
|
+
/** Mistral Vibe. */
|
|
804
|
+
vibe: {
|
|
805
|
+
label: 'Mistral Vibe',
|
|
806
|
+
/** ~/.vibe */
|
|
807
|
+
home: '.vibe',
|
|
808
|
+
provider: vibeProvider,
|
|
809
|
+
shellEnv: () => ({}),
|
|
810
|
+
},
|
|
811
|
+
/** Zed's agent panel. It reads PHANTOM_API_KEY from the login shell. */
|
|
812
|
+
zed: {
|
|
813
|
+
label: 'Zed',
|
|
814
|
+
/** ~/.config/zed, or under $XDG_CONFIG_HOME */
|
|
815
|
+
home: '.config/zed',
|
|
816
|
+
detect: (env, home) => existsSync(path.join(configHome(env, home), 'zed')),
|
|
817
|
+
/** ~/.agents/skills */
|
|
818
|
+
skills: '.agents/skills',
|
|
819
|
+
provider: zedProvider,
|
|
820
|
+
shellEnv: () => ({}),
|
|
821
|
+
},
|
|
822
|
+
/** VS Code's chat. Its Custom Endpoint models are added on its Language Models screen. */
|
|
823
|
+
vscode: {
|
|
824
|
+
label: 'VS Code',
|
|
825
|
+
/** ~/.vscode */
|
|
826
|
+
home: '.vscode',
|
|
827
|
+
provider: manualProvider('VS Code, Chat: Manage Language Models', (ctx) => [
|
|
828
|
+
'In VS Code, run Chat: Manage Language Models from the Command Palette, then Add Models, Custom Endpoint.',
|
|
829
|
+
'Name the group Phantom AI, paste your Phantom AI key (copy it with "Copy my key" in phantom\'s menu, or phantom keys show --copy), and pick Chat Completions.',
|
|
830
|
+
`In the chatLanguageModels.json file VS Code opens, set the model's id to ${ctx.model ?? SETUP_MODEL}, its url to ${agentBaseUrl(ctx.env, 'coding-agent')}/chat/completions, and toolCalling to true, then save.`,
|
|
831
|
+
]),
|
|
832
|
+
},
|
|
833
|
+
/** JetBrains AI Assistant, in IntelliJ IDEA, PyCharm and the other JetBrains editors. */
|
|
834
|
+
jetbrains: {
|
|
835
|
+
label: 'JetBrains AI Assistant',
|
|
836
|
+
/** ~/Library/Application Support/JetBrains on macOS, ~/.config/JetBrains elsewhere */
|
|
837
|
+
home: 'Library/Application Support/JetBrains',
|
|
838
|
+
detect: (env, home) => existsSync(path.join(home, 'Library', 'Application Support', 'JetBrains')) || existsSync(path.join(configHome(env, home), 'JetBrains')),
|
|
839
|
+
provider: manualProvider('Settings, Tools, AI Assistant, Providers & API keys', (ctx) => [
|
|
840
|
+
'In your JetBrains editor, open Settings, then Tools, AI Assistant, Providers & API keys.',
|
|
841
|
+
`Under OpenAI-compatible, set the URL to ${agentBaseUrl(ctx.env, 'coding-agent')}, paste your Phantom AI key (copy it with "Copy my key" in phantom's menu, or phantom keys show --copy), and turn on Tool calling.`,
|
|
842
|
+
`Pick ${ctx.model ?? SETUP_MODEL} in the AI Assistant chat.`,
|
|
843
|
+
]),
|
|
844
|
+
},
|
|
845
|
+
/** Warp. Its custom inference endpoint is set on its settings screen. */
|
|
846
|
+
warp: {
|
|
847
|
+
label: 'Warp',
|
|
848
|
+
/** ~/.warp */
|
|
849
|
+
home: '.warp',
|
|
850
|
+
provider: manualProvider('Warp Settings', (ctx) => [
|
|
851
|
+
'In Warp, open Settings and search for "inference endpoint".',
|
|
852
|
+
`Set the endpoint to ${agentBaseUrl(ctx.env, 'coding-agent')}, paste your Phantom AI key (copy it with "Copy my key" in phantom's menu, or phantom keys show --copy), and add the model ${ctx.model ?? SETUP_MODEL}.`,
|
|
853
|
+
], 'Warp uses a custom endpoint for agents that run on this computer only.'),
|
|
854
|
+
},
|
|
855
|
+
/** Trae. Its custom models are added on its settings screen. */
|
|
856
|
+
trae: {
|
|
857
|
+
label: 'Trae',
|
|
858
|
+
/** ~/.trae */
|
|
859
|
+
home: '.trae',
|
|
860
|
+
provider: manualProvider('Trae Settings, Models', (ctx) => [
|
|
861
|
+
'In Trae, open Settings, then Models, Add Model, Custom Model.',
|
|
862
|
+
`Pick the OpenAI API format, set the request URL to ${agentBaseUrl(ctx.env, 'coding-agent')}, the model id to ${ctx.model ?? SETUP_MODEL}, and paste your Phantom AI key (copy it with "Copy my key" in phantom's menu, or phantom keys show --copy).`,
|
|
863
|
+
]),
|
|
864
|
+
},
|
|
474
865
|
};
|
|
475
866
|
export const SETUP_NAMES = Object.keys(SETUP_AGENTS);
|
|
476
867
|
/** `a, b or c`. */
|
package/dist/setup/command.mjs
CHANGED
|
@@ -119,19 +119,25 @@ export async function setupCommand(input) {
|
|
|
119
119
|
provider: guided || provider === true ? true : provider === 'off' ? 'off' : false,
|
|
120
120
|
model: typeof model === 'string' ? model : undefined,
|
|
121
121
|
guided,
|
|
122
|
+
shell: !flags['no-shell'],
|
|
122
123
|
};
|
|
123
124
|
const found = planSetup(env, options);
|
|
124
125
|
let plan = found;
|
|
125
126
|
// At a terminal, plain setup asks which of the agents it found to connect (all ticked, Space unticks),
|
|
126
127
|
// then whether to make the changes. Esc on the question goes back to the checklist.
|
|
127
128
|
const pick = guided && !flags['yes'] && !dryRun ? pickIo(input) : null;
|
|
128
|
-
const ticks = Boolean(pick) && found.agents.length > 1;
|
|
129
|
+
const ticks = Boolean(pick) && (found.agents.length > 1 || found.files.some((f) => f.agent === 'shell'));
|
|
129
130
|
/** Shows the checklist, or returns false when Esc there means leave. */
|
|
131
|
+
// The shell file is a row of its own, so it can be left out like an agent.
|
|
132
|
+
const shellRow = found.files.some((f) => f.agent === 'shell');
|
|
130
133
|
const tick = async () => {
|
|
131
|
-
const only = await checklist(pick, 'Which agents should use Phantom AI?',
|
|
134
|
+
const only = await checklist(pick, 'Which agents should use Phantom AI?', [
|
|
135
|
+
...found.agents.map((a) => ({ label: SETUP_AGENTS[a.agent].label, hint: a.provider ? 'skill and model settings' : 'skill', value: a.agent, checked: plan.agents.some((p) => p.agent === a.agent) })),
|
|
136
|
+
...(shellRow ? [{ label: 'Your shell', hint: 'new terminals get your key; untick to add it yourself', value: 'shell', checked: plan.files.some((f) => f.agent === 'shell') }] : []),
|
|
137
|
+
]);
|
|
132
138
|
if (!only)
|
|
133
139
|
return false;
|
|
134
|
-
plan = planSetup(env, { ...options, only });
|
|
140
|
+
plan = planSetup(env, { ...options, only: only.filter((a) => a !== 'shell'), shell: shellRow ? only.includes('shell') : options.shell });
|
|
135
141
|
return true;
|
|
136
142
|
};
|
|
137
143
|
/** Esc on setup's first step: back to the menu when it started setup, otherwise stop with nothing changed. */
|
package/dist/setup/plan.mjs
CHANGED
|
@@ -109,7 +109,12 @@ export function planSetup(env, opts = {}) {
|
|
|
109
109
|
}
|
|
110
110
|
agents.push({ agent, skill, ...(result ? { provider: result } : {}) });
|
|
111
111
|
}
|
|
112
|
-
if (shellOn.size
|
|
112
|
+
if (shellOn.size && opts.shell === false) {
|
|
113
|
+
const block = withShellBlock(env, ctx.shellRc, '', shellOn, [], opts.onPath) ?? '';
|
|
114
|
+
const short = ctx.shellRc.startsWith(home + path.sep) ? '~' + ctx.shellRc.slice(home.length) : ctx.shellRc;
|
|
115
|
+
notes.push(`Your shell file was left alone. So ${orList([...shellOn.keys()].map((a) => SETUP_AGENTS[a].label))} can find your key, add these lines to ${short}:`, '', ...block.trimEnd().split('\n').map((l) => ` ${l}`));
|
|
116
|
+
}
|
|
117
|
+
else if (shellOn.size || shellOff.length) {
|
|
113
118
|
try {
|
|
114
119
|
add('shell', ctx.shellRc, withShellBlock(env, ctx.shellRc, readText(ctx.shellRc), shellOn, shellOff, opts.onPath));
|
|
115
120
|
}
|
package/dist/setup/screen.mjs
CHANGED
|
@@ -5,10 +5,11 @@
|
|
|
5
5
|
// The JSON result (setupCommand without a terminal) never goes through here.
|
|
6
6
|
import os from 'node:os';
|
|
7
7
|
import path from 'node:path';
|
|
8
|
-
import { paint, unstyled } from "../core/style.mjs";
|
|
8
|
+
import { fitText, paint, unstyled } from "../core/style.mjs";
|
|
9
9
|
import { brandHeader } from "../core/brand.mjs";
|
|
10
10
|
import { inApp } from "../cli/screens.mjs";
|
|
11
11
|
import { SETUP_AGENTS } from "./agents.mjs";
|
|
12
|
+
import { FREE_LINE } from "../core/free.mjs";
|
|
12
13
|
/** `text` broken at spaces into lines of at most `width` characters; a single longer word stays whole. */
|
|
13
14
|
export function wrapWords(text, width) {
|
|
14
15
|
const lines = [];
|
|
@@ -64,10 +65,12 @@ export function setupScreen(r, opts = {}) {
|
|
|
64
65
|
// unless that's wider than the window: then rows stay short and unaligned.
|
|
65
66
|
const longest = Math.max(0, ...r.files.map((f) => short(f.file).length));
|
|
66
67
|
const columns = opts.columns ?? process.stdout.columns ?? 100;
|
|
67
|
-
|
|
68
|
+
// The widest "what happens" phrase (`new file, 12 lines`), plus room for "copy kept".
|
|
69
|
+
const said = Math.max(8, ...r.files.map((f) => change(f).length + (f.backup ? 11 : 0)));
|
|
70
|
+
const fits = width + 4 + 10 + longest + 2 + said <= columns;
|
|
68
71
|
const pathWidth = fits ? longest : 0;
|
|
69
72
|
// As wide as a file row (name, kind, path, action and a line count), and never wider than the window.
|
|
70
|
-
const rule = Math.max(20, Math.min(width + 2 + 10 + longest + 2 +
|
|
73
|
+
const rule = Math.max(20, Math.min(width + 2 + 10 + longest + 2 + said, columns - 4));
|
|
71
74
|
// After the prompt, a blank line sets the result apart from the answer typed above it.
|
|
72
75
|
const lines = [''];
|
|
73
76
|
const found = r.agents.length;
|
|
@@ -134,7 +137,16 @@ export function setupScreen(r, opts = {}) {
|
|
|
134
137
|
lines.push('');
|
|
135
138
|
if (routes) {
|
|
136
139
|
lines.push(` ${p.dim('What this does:')} these agents send their model requests to Phantom AI, and phantom-1 picks the model.`);
|
|
137
|
-
lines.push(` ${p.dim('
|
|
140
|
+
lines.push(` ${p.dim('Free to start:')} ${FREE_LINE}.`);
|
|
141
|
+
// Claude Code's settings file applies in every folder, so its own login stops being used.
|
|
142
|
+
if (r.agents.some((a) => a.agent === 'claude' && a.provider?.on)) {
|
|
143
|
+
lines.push(` ${p.dim('Claude Code:')} uses Phantom AI in place of your Anthropic login, in every folder. To keep your login, leave Claude Code out and start it with ${p.cyan('phantom claude')} when you want Phantom.`);
|
|
144
|
+
}
|
|
145
|
+
const readers = r.agents.filter((a) => a.provider?.on && SETUP_AGENTS[a.agent]?.shellEnv);
|
|
146
|
+
if (readers.length && r.files.some((f) => f.agent === 'shell')) {
|
|
147
|
+
lines.push(` ${p.dim('Your shell:')} new terminals get your key as PHANTOM_API_KEY, for ${readers.map((a) => label(a.agent)).join(', ')}. The file runs ${p.cyan('phantom key show')}; the key itself isn't written in it.`);
|
|
148
|
+
}
|
|
149
|
+
lines.push(` ${p.dim('Undo:')} phantom keeps a copy of each file it changes, and ${p.cyan('phantom setup --restore')} puts them back.`);
|
|
138
150
|
}
|
|
139
151
|
if (skills)
|
|
140
152
|
lines.push(` ${p.dim('skill:')} a short note that teaches the agent the phantom commands.`);
|
|
@@ -153,7 +165,8 @@ export function setupScreen(r, opts = {}) {
|
|
|
153
165
|
lines.push('', ...nextStep(r, p, opts.columns));
|
|
154
166
|
lines.push('', inApp() ? ` ${p.dim('Undo any time: More, then Undo setup.')}` : ` Undo any time: ${p.cyan('phantom setup --restore')}`);
|
|
155
167
|
}
|
|
156
|
-
|
|
168
|
+
// Long sentences break at a word under their own indent, at the terminal's width.
|
|
169
|
+
return fitText(lines.join('\n'), columns);
|
|
157
170
|
}
|
|
158
171
|
/** What a file is, in the words the screen uses: its settings (an agent's, or the shell's ~/.zshrc), or its skill. */
|
|
159
172
|
function kind(f) {
|
|
@@ -162,22 +175,32 @@ function kind(f) {
|
|
|
162
175
|
// ~/.zshrc is the shell's own settings file, and the row already says "Your shell".
|
|
163
176
|
return 'settings';
|
|
164
177
|
}
|
|
165
|
-
/**
|
|
166
|
-
const
|
|
178
|
+
/** `n lines`, or `1 line`. */
|
|
179
|
+
const lineCount = (n) => `${n} line${n === 1 ? '' : 's'}`;
|
|
167
180
|
/**
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
* `settings ~/.codex/config.toml new +12`.
|
|
181
|
+
* What happens to a file, in words: `new file, 12 lines`, `adds 3 lines`,
|
|
182
|
+
* `adds 3, removes 1`, `updated` (a skill) or `deleted`.
|
|
171
183
|
*/
|
|
172
|
-
function
|
|
173
|
-
const action = ACTION[f.action].padEnd(6);
|
|
184
|
+
function change(f) {
|
|
174
185
|
const { added, removed } = counts(f.diff);
|
|
175
|
-
|
|
186
|
+
if (f.action === 'remove')
|
|
187
|
+
return 'deleted';
|
|
188
|
+
if (f.action === 'create')
|
|
189
|
+
return added ? `new file, ${lineCount(added)}` : 'new file';
|
|
190
|
+
if (!f.diff)
|
|
191
|
+
return 'updated';
|
|
192
|
+
return removed ? `adds ${added}, removes ${removed}` : `adds ${lineCount(added)}`;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* One file under an agent, in columns: what the file is, where it is, and
|
|
196
|
+
* what happens to it. For example `settings ~/.codex/config.toml new file, 12 lines`.
|
|
197
|
+
*/
|
|
198
|
+
function fileRow(f, p, short, pathWidth) {
|
|
199
|
+
const what = change(f);
|
|
176
200
|
return [
|
|
177
201
|
p.dim(kind(f).padEnd(8)),
|
|
178
202
|
short(f.file).padEnd(pathWidth),
|
|
179
|
-
f.action === 'remove' ? p.red(
|
|
180
|
-
change + (f.backup ? p.dim(' copy kept') : ''),
|
|
203
|
+
(f.action === 'remove' ? p.red(what) : p.cyan(what)) + (f.backup ? p.dim(' copy kept') : ''),
|
|
181
204
|
]
|
|
182
205
|
.join(' ')
|
|
183
206
|
.trimEnd();
|
|
@@ -208,7 +231,7 @@ function doneSummary(agent, a, files, skipped, p) {
|
|
|
208
231
|
return files ? 'skill installed.' : p.dim('already set up.');
|
|
209
232
|
}
|
|
210
233
|
/** 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' };
|
|
234
|
+
const START = { claude: 'claude', codex: 'codex', pi: 'pi', opencode: 'opencode', goose: 'goose', aider: 'aider', copilot: 'copilot', gemini: 'gemini', crush: 'crush', qwen: 'qwen', kilo: 'kilo', kimi: 'kimi', droid: 'droid', vibe: 'vibe' };
|
|
212
235
|
/**
|
|
213
236
|
* The one thing to do after setup, in corner brackets: run the agent. When an
|
|
214
237
|
* agent reads its key from the shell (Codex, Goose and others), the step says
|
package/dist/spend/command.mjs
CHANGED
|
@@ -174,7 +174,7 @@ async function lineupPicker(input, pick, show) {
|
|
|
174
174
|
io.stdout(show(read) + '\n\n');
|
|
175
175
|
const tier = await choose({ ...pick, esc: 'back' }, 'Which tier do you want to change?', [
|
|
176
176
|
...LINEUP_TIERS.map((t) => ({ label: t[0].toUpperCase() + t.slice(1), hint: `${TIER_ABOUT[t]} · now ${read.tiers[t].model}`, value: t })),
|
|
177
|
-
{ label: '
|
|
177
|
+
{ label: 'Reset every tier', hint: 'back to its default', value: 'reset' },
|
|
178
178
|
], last);
|
|
179
179
|
if (tier === null)
|
|
180
180
|
return 0;
|
|
@@ -12,8 +12,8 @@ import { BackToMenu, Cancelled, askText, cancelKeys, choose, keyboard } from "..
|
|
|
12
12
|
import { spawnSync } from 'node:child_process';
|
|
13
13
|
import { onPath } from "../setup/shared.mjs";
|
|
14
14
|
import { colorOn, paint } from "../core/style.mjs";
|
|
15
|
-
import { fundScreen, keyChangeScreen, keysScreen, paymentScreen, signedInScreen, workspaceScreen } from "../cli/screens.mjs";
|
|
16
|
-
import { keyPath, listNamedKeys, namedKeyPath, readNamedKey, saveApiKey, saveNamedKey } from "../keys/saved.mjs";
|
|
15
|
+
import { alreadySignedInScreen, fundScreen, keyChangeScreen, keysScreen, paymentScreen, signedInScreen, workspaceScreen } from "../cli/screens.mjs";
|
|
16
|
+
import { keyId, keyPath, listNamedKeys, namedKeyPath, readNamedKey, resolveApiKey, saveApiKey, saveNamedKey } from "../keys/saved.mjs";
|
|
17
17
|
const credentialPath = (env) => path.join(stateDir(env), 'workspace');
|
|
18
18
|
const base = (input) => httpsOnly(input.baseUrl || input.env.PHANTOM_BASE_URL || DEFAULT_BASE_URL).replace(/\/$/, '');
|
|
19
19
|
async function wire(url, method, endpoint, body, token) {
|
|
@@ -52,8 +52,12 @@ export async function workspaceLogin(input, opts = {}) {
|
|
|
52
52
|
die('--save requires a key name.');
|
|
53
53
|
const name = typeof flags.save === 'string' ? flags.save : 'default';
|
|
54
54
|
const target = name === 'default' ? keyPath(env) : namedKeyPath(env, name);
|
|
55
|
-
if (existsSync(target))
|
|
56
|
-
|
|
55
|
+
if (existsSync(target)) {
|
|
56
|
+
// Signing in again is a question ("am I signed in?"), so it gets an answer, not an error.
|
|
57
|
+
if (name === 'default')
|
|
58
|
+
return alreadySignedIn(input);
|
|
59
|
+
die(`A key named ${name} is already saved here. Pick another name with --save <name>; nothing was changed.`, 'key_exists');
|
|
60
|
+
}
|
|
57
61
|
// At a terminal, how to sign in is a choice; --no-browser or a pipe skips it.
|
|
58
62
|
const pick = flags['no-browser'] ? null : pickIo(input);
|
|
59
63
|
let noBrowser = Boolean(flags['no-browser']);
|
|
@@ -101,6 +105,17 @@ export async function workspaceLogin(input, opts = {}) {
|
|
|
101
105
|
cancel?.close();
|
|
102
106
|
}
|
|
103
107
|
}
|
|
108
|
+
/** `phantom login` on a computer that already has a key: which key, and how to use another. Exit 0. */
|
|
109
|
+
function alreadySignedIn(input) {
|
|
110
|
+
const { env, io } = input;
|
|
111
|
+
const key = resolveApiKey(env);
|
|
112
|
+
const result = { signed_in: true, already: true, key_id: key ? keyId(key) : null };
|
|
113
|
+
if (showsScreen(input))
|
|
114
|
+
io.stdout(alreadySignedInScreen(result.key_id, { env, color: colorOn(env, io.stdoutIsTTY) }) + '\n');
|
|
115
|
+
else
|
|
116
|
+
io.stdout(JSON.stringify(result) + '\n');
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
104
119
|
/** A spinner on one line: `⠋ Waiting for you to approve...` until stopped, then `✓ Approved.` or nothing. */
|
|
105
120
|
function waiting(write, p, words = 'Waiting for you to approve...', doneWords = 'Approved.') {
|
|
106
121
|
const frames = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏';
|
|
@@ -324,7 +339,7 @@ export async function workspaceCommand(input) {
|
|
|
324
339
|
}
|
|
325
340
|
if (pick && !method) {
|
|
326
341
|
method = (await choose({ ...pick, esc: 'back' }, 'How do you want to pay?', [
|
|
327
|
-
{ label: 'Card', hint: "on Stripe's page
|
|
342
|
+
{ label: 'Card', hint: "on Stripe's page, plus Stripe's card fee", value: 'card' },
|
|
328
343
|
{ label: 'USDC', hint: 'from a Solana wallet', value: 'usdc' },
|
|
329
344
|
{ label: 'USDT', hint: 'from a Solana wallet', value: 'usdt' },
|
|
330
345
|
{ label: 'SOL', hint: 'from a Solana wallet', value: 'sol' },
|