@phantomcodes/cli 0.0.0-stage → 1.1.0
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/LICENSE +81 -0
- package/README.md +231 -2
- package/bin/phantom.mjs +14 -0
- package/dist/agents/command.mjs +157 -0
- package/dist/agents/proxy.mjs +140 -0
- package/dist/cli/commands.mjs +154 -0
- package/dist/cli/help.mjs +38 -0
- package/dist/cli/run.mjs +79 -0
- package/dist/core/errors.mjs +39 -0
- package/dist/core/helpers.mjs +85 -0
- package/dist/core/request.mjs +108 -0
- package/dist/core/secrets.mjs +94 -0
- package/dist/core/shapes.mjs +1 -0
- package/dist/core/state.mjs +7 -0
- package/dist/core/version.mjs +2 -0
- package/dist/keys/command.mjs +116 -0
- package/dist/keys/saved.mjs +172 -0
- package/dist/phantom.mjs +60 -0
- package/dist/plan/command.mjs +68 -0
- package/dist/receipts/command.mjs +41 -0
- package/dist/receipts/receipts.mjs +82 -0
- package/dist/setup/agents.mjs +426 -0
- package/dist/setup/apply.mjs +210 -0
- package/dist/setup/command.mjs +112 -0
- package/dist/setup/editors.mjs +486 -0
- package/dist/setup/legacy.mjs +36 -0
- package/dist/setup/plan.mjs +121 -0
- package/dist/setup/shared.mjs +81 -0
- package/dist/spend/command.mjs +172 -0
- package/dist/spend/lineup.mjs +20 -0
- package/dist/spend/report.mjs +61 -0
- package/dist/spend/routing.mjs +32 -0
- package/dist/workspace/command.mjs +209 -0
- package/package.json +83 -4
- package/skills/phantom-ai/SKILL.md +155 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline/promises';
|
|
2
|
+
import { die } from "../core/errors.mjs";
|
|
3
|
+
import { out } from "../core/helpers.mjs";
|
|
4
|
+
import { SETUP_AGENTS, SETUP_NAMES } from "./agents.mjs";
|
|
5
|
+
import { planSetup } from "./plan.mjs";
|
|
6
|
+
import { setupPreview, applySetup, previewSetup, restoreSetup } from "./apply.mjs";
|
|
7
|
+
/** `--table` output for `setup`. */
|
|
8
|
+
export function tableSetup(d) {
|
|
9
|
+
const r = d;
|
|
10
|
+
const pad = ''.padEnd(16);
|
|
11
|
+
const lines = r.agents.map((a) => {
|
|
12
|
+
const label = SETUP_AGENTS[a.agent]?.label ?? a.agent;
|
|
13
|
+
const p = a.provider;
|
|
14
|
+
return [
|
|
15
|
+
`${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)'}`,
|
|
16
|
+
...(!p
|
|
17
|
+
? []
|
|
18
|
+
: p.manual
|
|
19
|
+
? [`${pad}set up by hand in ${p.settings}:`, ...p.manual.map((step, k) => `${pad} ${k + 1}. ${step}`)]
|
|
20
|
+
: p.on
|
|
21
|
+
? [
|
|
22
|
+
`${pad}models via Phantom AI at ${p.base_url}${p.model ? `, model ${p.model}` : ''} (${p.settings})`,
|
|
23
|
+
`${pad}restart ${label} to pick it up; undo with: phantom setup --agent ${a.agent} --provider off`,
|
|
24
|
+
]
|
|
25
|
+
: [a.agent === 'claude' ? `${pad}models back to Anthropic (${p.settings})` : `${pad}Phantom AI settings removed (${p.settings})`]),
|
|
26
|
+
...(p?.warnings ?? []).map((w) => `${pad}warning: ${w}`),
|
|
27
|
+
].join('\n');
|
|
28
|
+
});
|
|
29
|
+
const files = (r.files ?? []).filter((f) => f.diff !== undefined);
|
|
30
|
+
if (files.length) {
|
|
31
|
+
lines.push('');
|
|
32
|
+
for (const f of files) {
|
|
33
|
+
lines.push(`${r.dry_run || r.applied === false ? `would ${f.action}` : `${f.action}d`} ${f.file}${f.backup ? ` (copy before: ${f.backup})` : ''}`);
|
|
34
|
+
if (f.diff && (r.dry_run || r.applied === false))
|
|
35
|
+
lines.push(f.diff.replace(/^/gm, ' '));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
for (const s of r.skipped ?? [])
|
|
39
|
+
lines.push(`skipped ${s.agent}: ${s.reason}`);
|
|
40
|
+
for (const n of r.notes ?? [])
|
|
41
|
+
lines.push(n);
|
|
42
|
+
if (r.applied === false)
|
|
43
|
+
lines.push('Nothing was changed.');
|
|
44
|
+
return lines.join('\n');
|
|
45
|
+
}
|
|
46
|
+
/** `--table` output for `setup --restore`. */
|
|
47
|
+
export function tableRestore(d) {
|
|
48
|
+
const r = d;
|
|
49
|
+
const lines = [
|
|
50
|
+
...r.restored.map((x) => `${r.dry_run ? 'would restore' : 'restored'} ${x.file}${x.from ? ` from ${x.from}` : ' (phantom created it, so it is removed)'}`),
|
|
51
|
+
...r.skipped.map((x) => `skipped ${x.file}: ${x.reason}`),
|
|
52
|
+
];
|
|
53
|
+
return lines.length ? lines.join('\n') : 'Nothing to restore.';
|
|
54
|
+
}
|
|
55
|
+
/** `phantom setup`: plans, previews, asks, applies or restores agent setup. Run by `run()`. */
|
|
56
|
+
export async function setupCommand({ env, io, flags, table }) {
|
|
57
|
+
const agent = flags['agent'];
|
|
58
|
+
if (agent === true)
|
|
59
|
+
die(`--agent requires ${SETUP_NAMES.join(', ')} or all`);
|
|
60
|
+
const provider = flags['provider'];
|
|
61
|
+
if (provider !== undefined && provider !== true && provider !== 'off')
|
|
62
|
+
die('--provider takes no value, or off');
|
|
63
|
+
const model = flags['model'];
|
|
64
|
+
if (model === true)
|
|
65
|
+
die('--model requires a model id, such as auto or claude-sonnet-5');
|
|
66
|
+
if (flags['mcp'])
|
|
67
|
+
die('--mcp has been removed. Phantom inference runs directly through phantom claude, phantom codex, or phantom pi.');
|
|
68
|
+
const dryRun = Boolean(flags['dry-run']);
|
|
69
|
+
if (flags['restore']) {
|
|
70
|
+
const result = restoreSetup(env, { agent, dryRun });
|
|
71
|
+
out(result, table, tableRestore, io.stdout);
|
|
72
|
+
return result.skipped.length ? 1 : 0;
|
|
73
|
+
}
|
|
74
|
+
// Plain `phantom setup` sets up everything it finds and asks first. Any of
|
|
75
|
+
// the older flags runs just what they name, without asking.
|
|
76
|
+
const guided = agent === undefined && provider === undefined;
|
|
77
|
+
const plan = planSetup(env, {
|
|
78
|
+
agent,
|
|
79
|
+
provider: guided || provider === true ? true : provider === 'off' ? 'off' : false,
|
|
80
|
+
model: typeof model === 'string' ? model : undefined,
|
|
81
|
+
guided,
|
|
82
|
+
});
|
|
83
|
+
if (dryRun) {
|
|
84
|
+
out(previewSetup(plan), table, tableSetup, io.stdout);
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
if (guided && !flags['yes'] && plan.files.length) {
|
|
88
|
+
io.stderr(setupPreview(plan) + '\n\n');
|
|
89
|
+
const ask = io.ask ??
|
|
90
|
+
(process.stdin.isTTY
|
|
91
|
+
? async (q) => {
|
|
92
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
93
|
+
try {
|
|
94
|
+
return await rl.question(q);
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
rl.close();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
: null);
|
|
101
|
+
if (!ask)
|
|
102
|
+
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');
|
|
103
|
+
const answer = await ask('Make these changes? [y/N] ');
|
|
104
|
+
if (!/^y(es)?$/i.test(answer.trim())) {
|
|
105
|
+
out({ ...previewSetup(plan), dry_run: undefined, applied: false }, table, tableSetup, io.stdout);
|
|
106
|
+
return 0;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const result = applySetup(env, plan);
|
|
110
|
+
out(result, table, tableSetup, io.stdout);
|
|
111
|
+
return result.skipped ? 1 : 0;
|
|
112
|
+
}
|
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
4
|
+
import { applyEdits, format as formatJsonc, modify as modifyJsonc, parse as parseJsonc, printParseErrorCode } from 'jsonc-parser';
|
|
5
|
+
import { parse as parseTomlText } from 'smol-toml';
|
|
6
|
+
import { KEYCHAIN_SERVICE, KEYCHAIN_POINTER, keychainOn } from "../core/secrets.mjs";
|
|
7
|
+
import { keyPath } from "../keys/saved.mjs";
|
|
8
|
+
import { SETUP_MARK, SETUP_WAS, setupMarked, stripMark, unwas, unreadable, onPath, phantomCommand, configHome } from "./shared.mjs";
|
|
9
|
+
// ── JSON & JSONC ─────────────────────────────────────────────────────────────
|
|
10
|
+
/**
|
|
11
|
+
* A JSON or JSONC file's object, `{}` when it does not exist. Comments and
|
|
12
|
+
* trailing commas are fine: jsonc-parser reads them, as in Vercel's setup.
|
|
13
|
+
*/
|
|
14
|
+
export function jsonObject(file, text) {
|
|
15
|
+
if (text === null || text.trim() === '')
|
|
16
|
+
return {};
|
|
17
|
+
const errors = [];
|
|
18
|
+
const v = parseJsonc(text, errors, { allowTrailingComma: true });
|
|
19
|
+
if (errors.length)
|
|
20
|
+
unreadable(file, `is not valid JSON (${printParseErrorCode(errors[0].error)} at offset ${errors[0].offset})`);
|
|
21
|
+
if (!v || typeof v !== 'object' || Array.isArray(v))
|
|
22
|
+
unreadable(file, 'does not hold a JSON object');
|
|
23
|
+
return v;
|
|
24
|
+
}
|
|
25
|
+
export function jsonText(v) {
|
|
26
|
+
return JSON.stringify(v, null, 2) + '\n';
|
|
27
|
+
}
|
|
28
|
+
const isPlainObject = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v);
|
|
29
|
+
function jsonGet(obj, p) {
|
|
30
|
+
return p.reduce((n, k) => (isPlainObject(n) ? n[k] : undefined), obj);
|
|
31
|
+
}
|
|
32
|
+
function jsonSet(obj, p, value) {
|
|
33
|
+
let n = obj;
|
|
34
|
+
for (const k of p.slice(0, -1)) {
|
|
35
|
+
if (!isPlainObject(n[k]))
|
|
36
|
+
n[k] = {};
|
|
37
|
+
n = n[k];
|
|
38
|
+
}
|
|
39
|
+
if (value === undefined)
|
|
40
|
+
delete n[p[p.length - 1]];
|
|
41
|
+
else
|
|
42
|
+
n[p[p.length - 1]] = value;
|
|
43
|
+
}
|
|
44
|
+
/** The indent and line ending a JSON file already uses, for the lines an edit adds. */
|
|
45
|
+
function jsonFormatting(text) {
|
|
46
|
+
const indent = /\n([ \t]+)\S/.exec(text)?.[1] ?? ' ';
|
|
47
|
+
return { insertSpaces: !indent.startsWith('\t'), tabSize: indent.startsWith('\t') ? 1 : indent.length, eol: text.includes('\r\n') ? '\r\n' : '\n' };
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Applies `changes` to a JSON or JSONC file's text with jsonc-parser's
|
|
51
|
+
* `modify` and `applyEdits`, as Vercel's setup does, so comments, key order
|
|
52
|
+
* and formatting stay as they were. Only the text an edit inserts is
|
|
53
|
+
* formatted, in the file's own indent (none in a one-line file), so removing
|
|
54
|
+
* what was added gives back the same bytes. A new file is written in full. If
|
|
55
|
+
* the edited text doesn't read back as expected, the file is written in full
|
|
56
|
+
* instead, as Vercel's does.
|
|
57
|
+
* @throws {@link CliError} `setup_unreadable` when the file is not valid JSON or JSONC.
|
|
58
|
+
* @deprecated Compatibility export. Use {@link planSetup}, {@link applySetup}, and {@link restoreSetup} for agent setup.
|
|
59
|
+
*/
|
|
60
|
+
export function jsonEdit(file, text, changes) {
|
|
61
|
+
const expected = jsonObject(file, text);
|
|
62
|
+
const todo = changes.filter(([p, v]) => !isDeepStrictEqual(jsonGet(expected, p), v));
|
|
63
|
+
for (const [p, v] of todo)
|
|
64
|
+
jsonSet(expected, p, v);
|
|
65
|
+
if (text === null || text.trim() === '')
|
|
66
|
+
return jsonText(expected);
|
|
67
|
+
if (!todo.length)
|
|
68
|
+
return text;
|
|
69
|
+
const formatting = text.trim().includes('\n') ? jsonFormatting(text) : null;
|
|
70
|
+
let out = text;
|
|
71
|
+
try {
|
|
72
|
+
for (const [p, v] of todo) {
|
|
73
|
+
const edits = modifyJsonc(out, p, v, {});
|
|
74
|
+
out = applyEdits(out, edits);
|
|
75
|
+
if (formatting && v !== undefined && edits.length === 1)
|
|
76
|
+
out = applyEdits(out, formatJsonc(out, { offset: edits[0].offset, length: edits[0].content.length }, formatting));
|
|
77
|
+
}
|
|
78
|
+
if (isDeepStrictEqual(jsonObject(file, out), expected))
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// Written in full below.
|
|
83
|
+
}
|
|
84
|
+
return jsonText(expected);
|
|
85
|
+
}
|
|
86
|
+
// ── YAML ─────────────────────────────────────────────────────────────────────
|
|
87
|
+
// YAML, line by line: one entry at the top of Continue's `models:` list, and
|
|
88
|
+
// top-level keys such as Goose's GOOSE_PROVIDER and Aider's `model`.
|
|
89
|
+
/**
|
|
90
|
+
* Sets a top-level `key: value` in a YAML file, marked `# phantom setup`. A line
|
|
91
|
+
* it replaces stays as a comment, so `--provider off` can put it back.
|
|
92
|
+
* @throws {@link CliError} `setup_unreadable` when the file indents with tabs, or the key holds more than one line.
|
|
93
|
+
* @deprecated Compatibility export. Use {@link planSetup}, {@link applySetup}, and {@link restoreSetup} for agent setup.
|
|
94
|
+
*/
|
|
95
|
+
export function yamlSetTop(file, text, key, value) {
|
|
96
|
+
const base = text ?? '';
|
|
97
|
+
if (/^ *\t/m.test(base))
|
|
98
|
+
unreadable(file, 'indents with tabs, which YAML does not allow');
|
|
99
|
+
const lines = base === '' ? [] : base.replace(/\n$/, '').split('\n');
|
|
100
|
+
const mine = `${key}: ${value} ${SETUP_MARK}`;
|
|
101
|
+
const at = lines.findIndex((l) => l.startsWith(`${key}:`));
|
|
102
|
+
if (at !== -1) {
|
|
103
|
+
if (lines[at].replace(/\s+#.*$/, '') === `${key}: ${value}`)
|
|
104
|
+
return base;
|
|
105
|
+
if (/^\S+:\s*(#.*)?$/.test(lines[at]) || /^\s/.test(lines[at + 1] ?? ''))
|
|
106
|
+
unreadable(file, `holds ${key} over several lines; set it by hand`);
|
|
107
|
+
lines.splice(at, 1, ...(setupMarked(lines[at]) ? [] : [SETUP_WAS + lines[at]]), mine);
|
|
108
|
+
}
|
|
109
|
+
else
|
|
110
|
+
lines.push(mine);
|
|
111
|
+
return lines.join('\n') + '\n';
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Adds `item` (lines, unindented, the first starting with `- `) first in the
|
|
115
|
+
* top-level `models:` list of a YAML file, each line marked `# phantom setup`.
|
|
116
|
+
* Starts the list when there is none, and the file with `head` when it is
|
|
117
|
+
* empty. An entry phantom added before is replaced.
|
|
118
|
+
* @throws {@link CliError} `setup_unreadable` when the file indents with tabs or writes the list on one line.
|
|
119
|
+
* @deprecated Compatibility export. Use {@link planSetup}, {@link applySetup}, and {@link restoreSetup} for agent setup.
|
|
120
|
+
*/
|
|
121
|
+
export function yamlAddModel(file, text, item, head = []) {
|
|
122
|
+
const base = yamlRemoveOurs(file, text ?? '');
|
|
123
|
+
const lines = base === '' ? [] : base.replace(/\n$/, '').split('\n');
|
|
124
|
+
const at = lines.findIndex((l) => /^models\s*:/.test(l));
|
|
125
|
+
if (at !== -1 && !/^models\s*:\s*(#.*)?$/.test(lines[at]))
|
|
126
|
+
unreadable(file, 'writes models: on one line, which phantom does not edit; add the model by hand');
|
|
127
|
+
let indent = ' ';
|
|
128
|
+
for (let n = at + 1; at !== -1 && n < lines.length; n++) {
|
|
129
|
+
if (lines[n].trim() === '' || lines[n].trim().startsWith('#'))
|
|
130
|
+
continue;
|
|
131
|
+
const m = /^(\s*)-/.exec(lines[n]);
|
|
132
|
+
if (m)
|
|
133
|
+
indent = m[1];
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
const mark = (l) => `${l} ${SETUP_MARK}`;
|
|
137
|
+
const entry = item.map((l, k) => mark(indent + (k === 0 ? l : ' ' + l)));
|
|
138
|
+
if (at !== -1)
|
|
139
|
+
lines.splice(at + 1, 0, ...entry);
|
|
140
|
+
else {
|
|
141
|
+
if (lines.length === 0)
|
|
142
|
+
lines.push(...head.map(mark));
|
|
143
|
+
else if (lines[lines.length - 1].trim() !== '')
|
|
144
|
+
lines.push('');
|
|
145
|
+
lines.push('models:', ...entry);
|
|
146
|
+
}
|
|
147
|
+
return lines.join('\n') + '\n';
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Removes each YAML line marked `# phantom setup`, and puts back the lines they replaced. Returns '' when that leaves nothing but an empty `models:`, as in a file phantom started.
|
|
151
|
+
* @deprecated Compatibility export. Use {@link planSetup}, {@link applySetup}, and {@link restoreSetup} for agent setup.
|
|
152
|
+
*/
|
|
153
|
+
export function yamlRemoveOurs(file, text) {
|
|
154
|
+
if (/^ *\t/m.test(text))
|
|
155
|
+
unreadable(file, 'indents with tabs, which YAML does not allow');
|
|
156
|
+
const kept = text
|
|
157
|
+
.split('\n')
|
|
158
|
+
.filter((l) => !setupMarked(l))
|
|
159
|
+
.map(unwas);
|
|
160
|
+
const left = kept.filter((l) => l.trim() !== '');
|
|
161
|
+
const empty = left.length === 0 || (left.length === 1 && /^models\s*:\s*$/.test(left[0]));
|
|
162
|
+
return empty && kept.length < text.split('\n').length ? '' : kept.join('\n');
|
|
163
|
+
}
|
|
164
|
+
// ── TOML ─────────────────────────────────────────────────────────────────────
|
|
165
|
+
// TOML. smol-toml, the parser Vercel's `ai-gateway setup` uses, checks each
|
|
166
|
+
// file before an edit and after it. The edit itself goes line by line, so
|
|
167
|
+
// comments and every table phantom does not touch stay as they were.
|
|
168
|
+
const TOML_HEADER = /^\s*\[(\[)?\s*([^\]]*?)\s*\]\]?\s*(?:#.*)?$/;
|
|
169
|
+
/** Parses TOML text, or refuses the file as one setup can't edit. */
|
|
170
|
+
function readToml(file, text) {
|
|
171
|
+
try {
|
|
172
|
+
return parseTomlText(text);
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
unreadable(file, `is not valid TOML (${err.message.split('\n')[0]})`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/** TOML data as text with sorted keys, to compare two parses. */
|
|
179
|
+
function tomlCanonical(v) {
|
|
180
|
+
return JSON.stringify(v, (_, x) => x && typeof x === 'object' && !Array.isArray(x) && !(x instanceof Date) ? Object.fromEntries(Object.keys(x).sort().map((k) => [k, x[k]])) : x);
|
|
181
|
+
}
|
|
182
|
+
/** A table name as written in a header, with the spaces around its dots taken out. */
|
|
183
|
+
function tomlHeaderName(name) {
|
|
184
|
+
return name
|
|
185
|
+
.split('.')
|
|
186
|
+
.map((s) => s.trim())
|
|
187
|
+
.join('.');
|
|
188
|
+
}
|
|
189
|
+
function tomlName(p) {
|
|
190
|
+
return p.map((k) => (/^[A-Za-z0-9_-]+$/.test(k) ? k : JSON.stringify(k))).join('.');
|
|
191
|
+
}
|
|
192
|
+
/** True when any line has text. */
|
|
193
|
+
function hasText(lines) {
|
|
194
|
+
return lines.some((l) => l.trim() !== '');
|
|
195
|
+
}
|
|
196
|
+
/** The lines as file text, ending in a newline. */
|
|
197
|
+
function linesText(lines, crlf) {
|
|
198
|
+
const out = [...lines];
|
|
199
|
+
if (out.length && out[out.length - 1] !== '')
|
|
200
|
+
out.push('');
|
|
201
|
+
return out.join(crlf ? '\r\n' : '\n');
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Sets `key` in `table` ([] is the top of the file) to `value`, written as
|
|
205
|
+
* TOML, and marks the line with `# phantom setup`. A line it replaces stays as a
|
|
206
|
+
* comment, so `--provider off` can put it back. A missing table is added at
|
|
207
|
+
* the end of the file. The result must parse to the old file with this one
|
|
208
|
+
* key changed.
|
|
209
|
+
* @throws {@link CliError} `setup_unreadable` when the file is not valid TOML, or sets the key in a way a line edit can't change, such as an inline table or a dotted key.
|
|
210
|
+
* @deprecated Compatibility export. Use {@link planSetup}, {@link applySetup}, and {@link restoreSetup} for agent setup.
|
|
211
|
+
*/
|
|
212
|
+
export function tomlSet(file, text, table, key, value) {
|
|
213
|
+
const crlf = text.includes('\r\n');
|
|
214
|
+
const expected = hasText(text.split('\n')) ? readToml(file, text) : {};
|
|
215
|
+
let node = expected;
|
|
216
|
+
for (const part of table) {
|
|
217
|
+
const next = node[part] ?? (node[part] = {});
|
|
218
|
+
if (!next || typeof next !== 'object' || Array.isArray(next))
|
|
219
|
+
unreadable(file, `has ${tomlName(table)} as something other than a table; set it by hand`);
|
|
220
|
+
node = next;
|
|
221
|
+
}
|
|
222
|
+
const parsedValue = parseTomlText(`v = ${value}`).v;
|
|
223
|
+
if (tomlCanonical(node[key]) === tomlCanonical(parsedValue))
|
|
224
|
+
return text;
|
|
225
|
+
node[key] = parsedValue;
|
|
226
|
+
const lines = hasText(text.split('\n')) ? text.replace(/\r\n/g, '\n').split('\n') : [];
|
|
227
|
+
const mine = `${tomlName([key])} = ${value} ${SETUP_MARK}`;
|
|
228
|
+
const name = tomlName(table);
|
|
229
|
+
const headers = lines.map((l, i) => ({ i, m: TOML_HEADER.exec(l) })).filter((h) => h.m && !lines[h.i].trim().startsWith('#'));
|
|
230
|
+
const own = headers.find((h) => !h.m[1] && tomlHeaderName(h.m[2]) === name);
|
|
231
|
+
let start = -1;
|
|
232
|
+
let end = lines.length;
|
|
233
|
+
if (table.length === 0) {
|
|
234
|
+
start = 0;
|
|
235
|
+
end = headers[0]?.i ?? lines.length;
|
|
236
|
+
}
|
|
237
|
+
else if (own) {
|
|
238
|
+
start = own.i + 1;
|
|
239
|
+
end = headers.find((h) => h.i > own.i)?.i ?? lines.length;
|
|
240
|
+
}
|
|
241
|
+
if (start === -1) {
|
|
242
|
+
while (lines.length && lines[lines.length - 1].trim() === '')
|
|
243
|
+
lines.pop();
|
|
244
|
+
if (lines.length)
|
|
245
|
+
lines.push('');
|
|
246
|
+
lines.push(`[${name}] ${SETUP_MARK}`, mine);
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
const assignment = new RegExp(`^\\s*${RegExp.escape(key)}\\s*=`);
|
|
250
|
+
const at = lines.slice(start, end).findIndex((l) => assignment.test(l));
|
|
251
|
+
if (at !== -1) {
|
|
252
|
+
const old = lines[start + at];
|
|
253
|
+
lines.splice(start + at, 1, ...(setupMarked(old) ? [] : [SETUP_WAS + old]), mine);
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
let last = -1;
|
|
257
|
+
for (let i = start; i < end; i++)
|
|
258
|
+
if (lines[i].trim() !== '' && !lines[i].trim().startsWith('#'))
|
|
259
|
+
last = i;
|
|
260
|
+
if (last !== -1)
|
|
261
|
+
lines.splice(last + 1, 0, mine);
|
|
262
|
+
else if (table.length)
|
|
263
|
+
lines.splice(start, 0, mine);
|
|
264
|
+
else if (headers.length) {
|
|
265
|
+
// Nothing at the top yet: go above the first table and the comments on it.
|
|
266
|
+
let i = headers[0].i;
|
|
267
|
+
while (i > 0 && lines[i - 1].trim().startsWith('#'))
|
|
268
|
+
i--;
|
|
269
|
+
lines.splice(i, 0, mine, '');
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
while (lines.length && lines[lines.length - 1].trim() === '')
|
|
273
|
+
lines.pop();
|
|
274
|
+
lines.push(mine);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
const result = linesText(lines, crlf);
|
|
279
|
+
let after = '';
|
|
280
|
+
try {
|
|
281
|
+
after = tomlCanonical(parseTomlText(result));
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
// Checked below.
|
|
285
|
+
}
|
|
286
|
+
if (after !== tomlCanonical(expected))
|
|
287
|
+
unreadable(file, `sets ${tomlName([...table, key])} in a way phantom can't edit line by line (an inline table, a dotted key or a value over several lines); set it by hand`);
|
|
288
|
+
return result;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Removes what {@link tomlSet} wrote: each line marked `# phantom setup`, and a
|
|
292
|
+
* table phantom added once nothing else is left in it. Lines it replaced come back.
|
|
293
|
+
* @throws {@link CliError} `setup_unreadable` when the file is not valid TOML, before or after.
|
|
294
|
+
* @deprecated Compatibility export. Use {@link planSetup}, {@link applySetup}, and {@link restoreSetup} for agent setup.
|
|
295
|
+
*/
|
|
296
|
+
export function tomlRemoveOurs(file, text) {
|
|
297
|
+
const crlf = text.includes('\r\n');
|
|
298
|
+
if (hasText(text.split('\n')))
|
|
299
|
+
readToml(file, text);
|
|
300
|
+
const lines = text.replace(/\r\n/g, '\n').split('\n');
|
|
301
|
+
const marked = (i) => !lines[i].trim().startsWith('#') && setupMarked(lines[i]);
|
|
302
|
+
const headers = lines.map((l, i) => (TOML_HEADER.test(l) && !l.trim().startsWith('#') ? i : -1)).filter((i) => i !== -1);
|
|
303
|
+
const drop = new Set();
|
|
304
|
+
for (let i = 0; i < lines.length; i++)
|
|
305
|
+
if (marked(i) && !headers.includes(i))
|
|
306
|
+
drop.add(i);
|
|
307
|
+
for (const [k, h] of headers.entries()) {
|
|
308
|
+
if (!marked(h))
|
|
309
|
+
continue;
|
|
310
|
+
const next = headers[k + 1] ?? lines.length;
|
|
311
|
+
let keysLeft = false;
|
|
312
|
+
for (let i = h + 1; i < next; i++)
|
|
313
|
+
if (!drop.has(i) && lines[i].trim() !== '' && !lines[i].trim().startsWith('#'))
|
|
314
|
+
keysLeft = true;
|
|
315
|
+
if (keysLeft) {
|
|
316
|
+
lines[h] = stripMark(lines[h]);
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
drop.add(h);
|
|
320
|
+
if (h > 0 && lines[h - 1].trim() === '')
|
|
321
|
+
drop.add(h - 1);
|
|
322
|
+
}
|
|
323
|
+
const kept = lines.filter((_, i) => !drop.has(i)).map(unwas);
|
|
324
|
+
const result = hasText(kept) ? linesText(kept, crlf) : '';
|
|
325
|
+
if (result)
|
|
326
|
+
readToml(file, result);
|
|
327
|
+
return result;
|
|
328
|
+
}
|
|
329
|
+
// ── Shell Block ──────────────────────────────────────────────────────────────
|
|
330
|
+
const BLOCK_BEGIN = '# >>> phantom setup >>>';
|
|
331
|
+
const BLOCK_END = '# <<< phantom setup <<<';
|
|
332
|
+
/** The block markers pai, this CLI's old name, wrote. Setup still finds that block. */
|
|
333
|
+
const LEGACY_BLOCK = ['# >>> pai setup >>>', '# <<< pai setup <<<'];
|
|
334
|
+
/**
|
|
335
|
+
* The shell startup file the key block goes in, by $SHELL, as Vercel's
|
|
336
|
+
* `ai-gateway setup` picks it: `~/.zshrc` (or `$ZDOTDIR/.zshrc`),
|
|
337
|
+
* `~/.bash_profile` on macOS and `~/.bashrc` elsewhere, fish's `config.fish`
|
|
338
|
+
* (under `$XDG_CONFIG_HOME`), or `~/.profile` for any other shell. With no
|
|
339
|
+
* $SHELL, zsh on macOS and bash elsewhere.
|
|
340
|
+
* @deprecated Compatibility export. Use {@link planSetup}, {@link applySetup}, and {@link restoreSetup} for agent setup.
|
|
341
|
+
*/
|
|
342
|
+
export function shellRcFile(env, home = env.HOME || os.homedir()) {
|
|
343
|
+
const shell = path.basename(env.SHELL || (process.platform === 'darwin' ? 'zsh' : 'bash'));
|
|
344
|
+
if (shell === 'zsh')
|
|
345
|
+
return path.join(env.ZDOTDIR || home, '.zshrc');
|
|
346
|
+
// Terminal windows on macOS start bash as a login shell, which reads ~/.bash_profile.
|
|
347
|
+
if (shell === 'bash')
|
|
348
|
+
return path.join(home, process.platform === 'darwin' ? '.bash_profile' : '.bashrc');
|
|
349
|
+
if (shell === 'fish')
|
|
350
|
+
return path.join(configHome(env, home), 'fish', 'config.fish');
|
|
351
|
+
return path.join(home, '.profile');
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* The block phantom adds to a shell startup file. When the shell starts it sets
|
|
355
|
+
* PHANTOM_API_KEY, unless it is set already: on macOS straight from the
|
|
356
|
+
* Keychain item the login key points to, and otherwise (or with
|
|
357
|
+
* PHANTOM_KEY_NAME set) by running `phantom key show`. The key itself is never
|
|
358
|
+
* written here. Each agent's own variables follow, set only when there is a key.
|
|
359
|
+
* @param fish Fish syntax instead of sh, bash and zsh.
|
|
360
|
+
* @deprecated Compatibility export. Use {@link planSetup}, {@link applySetup}, and {@link restoreSetup} for agent setup.
|
|
361
|
+
*/
|
|
362
|
+
export function shellBlock(env, fish, state, onPathFn = onPath) {
|
|
363
|
+
const home = env.HOME || os.homedir();
|
|
364
|
+
const login = keyPath(env);
|
|
365
|
+
const pointer = login.startsWith(home + path.sep) ? `$HOME/${path.relative(home, login)}` : login;
|
|
366
|
+
const show = `${phantomCommand(onPathFn)} key show 2>/dev/null`;
|
|
367
|
+
const fromKeychain = `/usr/bin/security find-generic-password -s ${KEYCHAIN_SERVICE} -a "$__phantom_id" -w 2>/dev/null | /usr/bin/base64 -D 2>/dev/null`;
|
|
368
|
+
const pointerId = `sed -n 's/^${KEYCHAIN_POINTER}//p' "${pointer}" 2>/dev/null`;
|
|
369
|
+
const keychain = keychainOn(env);
|
|
370
|
+
const own = state.agents.flatMap((a) => state.lines.get(a) ?? []);
|
|
371
|
+
const head = [
|
|
372
|
+
BLOCK_BEGIN,
|
|
373
|
+
'# Written by phantom setup. Sets PHANTOM_API_KEY to the key phantom uses when the',
|
|
374
|
+
'# shell starts, for coding agents that read it. The key is not stored here.',
|
|
375
|
+
`# agents: ${state.agents.join(' ')}`,
|
|
376
|
+
'# Undo: phantom setup --provider off',
|
|
377
|
+
];
|
|
378
|
+
if (fish) {
|
|
379
|
+
return [
|
|
380
|
+
...head,
|
|
381
|
+
'if test -z "$PHANTOM_API_KEY"',
|
|
382
|
+
...(keychain
|
|
383
|
+
? [
|
|
384
|
+
' set -l __phantom_id',
|
|
385
|
+
` test -z "$PHANTOM_KEY_NAME"; and set __phantom_id (${pointerId})`,
|
|
386
|
+
' if test -n "$__phantom_id"',
|
|
387
|
+
` set -gx PHANTOM_API_KEY (${fromKeychain})`,
|
|
388
|
+
' else',
|
|
389
|
+
` set -gx PHANTOM_API_KEY (${show})`,
|
|
390
|
+
' end',
|
|
391
|
+
]
|
|
392
|
+
: [` set -gx PHANTOM_API_KEY (${show})`]),
|
|
393
|
+
'end',
|
|
394
|
+
'if test -n "$PHANTOM_API_KEY"',
|
|
395
|
+
...own,
|
|
396
|
+
'else',
|
|
397
|
+
' set -e PHANTOM_API_KEY',
|
|
398
|
+
'end',
|
|
399
|
+
BLOCK_END,
|
|
400
|
+
].join('\n');
|
|
401
|
+
}
|
|
402
|
+
return [
|
|
403
|
+
...head,
|
|
404
|
+
'if [ -z "${PHANTOM_API_KEY:-}" ]; then',
|
|
405
|
+
...(keychain
|
|
406
|
+
? [
|
|
407
|
+
' __phantom_id=""',
|
|
408
|
+
` if [ -z "\${PHANTOM_KEY_NAME:-}" ]; then __phantom_id=$(${pointerId}); fi`,
|
|
409
|
+
' if [ -n "$__phantom_id" ]; then',
|
|
410
|
+
` PHANTOM_API_KEY=$(${fromKeychain})`,
|
|
411
|
+
' else',
|
|
412
|
+
` PHANTOM_API_KEY=$(${show})`,
|
|
413
|
+
' fi',
|
|
414
|
+
' unset __phantom_id',
|
|
415
|
+
]
|
|
416
|
+
: [` PHANTOM_API_KEY=$(${show})`]),
|
|
417
|
+
'fi',
|
|
418
|
+
'if [ -n "${PHANTOM_API_KEY:-}" ]; then',
|
|
419
|
+
' export PHANTOM_API_KEY',
|
|
420
|
+
...own,
|
|
421
|
+
'else',
|
|
422
|
+
' unset PHANTOM_API_KEY',
|
|
423
|
+
'fi',
|
|
424
|
+
BLOCK_END,
|
|
425
|
+
].join('\n');
|
|
426
|
+
}
|
|
427
|
+
/** One agent's variables as block lines, each ending with `# for <agent>`. */
|
|
428
|
+
function shellLines(fish, agent, vars) {
|
|
429
|
+
return Object.entries(vars).map(([k, v]) => (fish ? ` set -gx ${k} ${JSON.stringify(v)} # for ${agent}` : ` export ${k}=${JSON.stringify(v)} # for ${agent}`));
|
|
430
|
+
}
|
|
431
|
+
/** Where the phantom block sits in a startup file, and what it holds. null when there is none. */
|
|
432
|
+
function findBlock(file, lines) {
|
|
433
|
+
const marks = [[BLOCK_BEGIN, BLOCK_END], LEGACY_BLOCK].find(([b, e]) => lines.includes(b) || lines.includes(e));
|
|
434
|
+
if (!marks)
|
|
435
|
+
return null;
|
|
436
|
+
const [begin, finish] = marks;
|
|
437
|
+
const start = lines.indexOf(begin);
|
|
438
|
+
const end = lines.indexOf(finish);
|
|
439
|
+
if (start === -1 || end < start || lines.indexOf(begin, start + 1) !== -1)
|
|
440
|
+
unreadable(file, 'has a phantom setup block without its matching end line');
|
|
441
|
+
const inside = lines.slice(start, end);
|
|
442
|
+
const agents = (inside.find((l) => l.startsWith('# agents:')) ?? '').slice('# agents:'.length).trim().split(/\s+/).filter(Boolean);
|
|
443
|
+
const own = new Map();
|
|
444
|
+
for (const l of inside) {
|
|
445
|
+
const m = /# for ([a-z]+)$/.exec(l);
|
|
446
|
+
if (m)
|
|
447
|
+
own.set(m[1], [...(own.get(m[1]) ?? []), l]);
|
|
448
|
+
}
|
|
449
|
+
return { start, end, state: { agents, lines: own } };
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* The startup file's text with the phantom block updated: agents in `on` added
|
|
453
|
+
* with fresh lines, agents in `off` dropped. The block goes away when no agent
|
|
454
|
+
* is left. Text outside the block stays as it was.
|
|
455
|
+
*/
|
|
456
|
+
export function withShellBlock(env, file, text, on, off, onPathFn) {
|
|
457
|
+
const fish = file.endsWith('.fish');
|
|
458
|
+
const lines = text === null ? [] : text.split('\n');
|
|
459
|
+
const found = findBlock(file, lines);
|
|
460
|
+
const state = found ? found.state : { agents: [], lines: new Map() };
|
|
461
|
+
for (const a of off) {
|
|
462
|
+
state.agents = state.agents.filter((x) => x !== a);
|
|
463
|
+
state.lines.delete(a);
|
|
464
|
+
}
|
|
465
|
+
for (const [a, vars] of on) {
|
|
466
|
+
if (!state.agents.includes(a))
|
|
467
|
+
state.agents.push(a);
|
|
468
|
+
state.lines.set(a, shellLines(fish, a, vars));
|
|
469
|
+
}
|
|
470
|
+
const block = state.agents.length ? shellBlock(env, fish, state, onPathFn).split('\n') : [];
|
|
471
|
+
if (found) {
|
|
472
|
+
let from = found.start;
|
|
473
|
+
if (!block.length && from > 0 && lines[from - 1] === '')
|
|
474
|
+
from--;
|
|
475
|
+
lines.splice(from, found.end + 1 - from, ...block);
|
|
476
|
+
}
|
|
477
|
+
else if (block.length) {
|
|
478
|
+
while (lines.length && lines[lines.length - 1] === '')
|
|
479
|
+
lines.pop();
|
|
480
|
+
if (lines.length)
|
|
481
|
+
lines.push('');
|
|
482
|
+
lines.push(...block, '');
|
|
483
|
+
}
|
|
484
|
+
const result = lines.join('\n');
|
|
485
|
+
return text === null && result === '' ? null : result;
|
|
486
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { readText } from "./shared.mjs";
|
|
4
|
+
import { jsonObject, shellRcFile } from "./editors.mjs";
|
|
5
|
+
import { claudeDir } from "./agents.mjs";
|
|
6
|
+
/**
|
|
7
|
+
* What phantom prints, once per run in a terminal, while an agent setup that
|
|
8
|
+
* pai (this CLI's old name) wrote still runs `pai`. Once pai is gone, that
|
|
9
|
+
* setup can't get a key until `phantom setup` rewrites it.
|
|
10
|
+
*/
|
|
11
|
+
export const LEGACY_SETUP_HINT = 'Your agent setup still runs pai, the old name of this CLI. Run phantom setup to switch it to phantom.';
|
|
12
|
+
/**
|
|
13
|
+
* True when an agent setup still runs `pai`: Claude Code's `apiKeyHelper`
|
|
14
|
+
* calls it, or the shell startup file holds the block pai setup wrote.
|
|
15
|
+
* `phantom setup` finds and rewrites both.
|
|
16
|
+
*/
|
|
17
|
+
export function legacySetupFound(env) {
|
|
18
|
+
const home = env.HOME || os.homedir();
|
|
19
|
+
const file = path.join(claudeDir(env, home), 'settings.json');
|
|
20
|
+
try {
|
|
21
|
+
// JSONC, as setup reads it: Claude Code allows comments and trailing commas.
|
|
22
|
+
const helper = jsonObject(file, readText(file)).apiKeyHelper;
|
|
23
|
+
if (typeof helper === 'string' && /\bpai\b.* key show$/.test(helper))
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// Not valid JSON: phantom setup reports that file itself.
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
return readText(shellRcFile(env, home))?.split('\n').includes('# >>> pai setup >>>') ?? false;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// Unreadable (a folder, or no permission): the hint is optional, so skip it.
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|