@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.
@@ -0,0 +1,426 @@
1
+ import path from 'node:path';
2
+ import os from 'node:os';
3
+ import { existsSync } from 'node:fs';
4
+ import { isDeepStrictEqual } from 'node:util';
5
+ import { die } from "../core/errors.mjs";
6
+ import { resolveApiKey } from "../keys/saved.mjs";
7
+ import { SETUP_MODEL, agentBaseUrl, readText, configHome, onPath, phantomCommand, offResult, unreadable, SETUP_MARK, SETUP_CONTEXT_WINDOW, } from "./shared.mjs";
8
+ import { jsonObject, jsonEdit, tomlSet, tomlRemoveOurs, yamlSetTop, yamlAddModel, yamlRemoveOurs } from "./editors.mjs";
9
+ // ── Manual Provider Helper ───────────────────────────────────────────────────
10
+ /** An agent whose model settings live on its own settings screen: setup prints the steps. */
11
+ export function manualProvider(where, steps) {
12
+ return (ctx, off) => ({
13
+ edits: [],
14
+ result: { settings: where, on: false, warnings: [], manual: off ? [`Remove the Phantom AI settings in ${where}.`] : steps(ctx) },
15
+ });
16
+ }
17
+ // ── Codex ────────────────────────────────────────────────────────────────────
18
+ /** Codex's folder: `$CODEX_HOME`, else `~/.codex`. */
19
+ export function codexDir(env, home) {
20
+ return env.CODEX_HOME?.trim() ? env.CODEX_HOME : path.join(home, '.codex');
21
+ }
22
+ /**
23
+ * Codex: `config.toml` in Codex's folder gets a `phantom` model provider at
24
+ * `/codex/v1` that reads the key from PHANTOM_API_KEY (as Vercel's writes a
25
+ * `vercel` provider with `env_key`). phantom also writes the model, and Phantom
26
+ * AI's model list at `/codex/v1/models`, which Codex 0.156 reads only with
27
+ * `[features] api_key_model_discovery`.
28
+ */
29
+ export function codexProvider(ctx, off) {
30
+ const file = path.join(codexDir(ctx.env, ctx.home), 'config.toml');
31
+ const text = readText(file);
32
+ if (off)
33
+ return offResult('Codex', file, text, text === null ? null : tomlRemoveOurs(file, text));
34
+ const base = agentBaseUrl(ctx.env, 'codex');
35
+ const model = ctx.model ?? SETUP_MODEL;
36
+ let t = tomlSet(file, text ?? '', [], 'model', JSON.stringify(model));
37
+ t = tomlSet(file, t, [], 'model_provider', '"phantom"');
38
+ const provider = [
39
+ ['name', 'Phantom AI'],
40
+ ['base_url', base],
41
+ ['wire_api', 'responses'],
42
+ ['env_key', 'PHANTOM_API_KEY'],
43
+ ['model_catalog_url', `${base}/models`],
44
+ ];
45
+ for (const [k, v] of provider)
46
+ t = tomlSet(file, t, ['model_providers', 'phantom'], k, JSON.stringify(v));
47
+ t = tomlSet(file, t, ['features'], 'api_key_model_discovery', 'true');
48
+ return { edits: [{ file, after: t }], result: { settings: file, on: true, base_url: base, model, warnings: [] } };
49
+ }
50
+ // ── Claude Code ──────────────────────────────────────────────────────────────
51
+ /** Claude Code's folder: `$CLAUDE_CONFIG_DIR`, else `~/.claude`. */
52
+ export function claudeDir(env, home) {
53
+ return env.CLAUDE_CONFIG_DIR?.trim() ? env.CLAUDE_CONFIG_DIR : path.join(home, '.claude');
54
+ }
55
+ /** The plan behind {@link claudeProvider}. */
56
+ export function claudePlan(env, opts) {
57
+ const file = path.join(claudeDir(env, env.HOME || os.homedir()), 'settings.json');
58
+ const text = readText(file);
59
+ const settings = jsonObject(file, text);
60
+ const ours = typeof settings.apiKeyHelper === 'string' && /(?:pai|phantom).* key show$/.test(settings.apiKeyHelper);
61
+ const vars = { ...(settings.env ?? {}) };
62
+ if (opts.off) {
63
+ const changes = [];
64
+ if (ours) {
65
+ changes.push([['apiKeyHelper'], undefined]);
66
+ const mine = ['ANTHROPIC_BASE_URL', 'ENABLE_TOOL_SEARCH', 'ANTHROPIC_MODEL', 'CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY', ...(vars.ANTHROPIC_API_KEY === '' ? ['ANTHROPIC_API_KEY'] : [])];
67
+ if (Object.keys(vars).every((k) => mine.includes(k)))
68
+ changes.push([['env'], undefined]);
69
+ else
70
+ for (const k of mine)
71
+ changes.push([['env', k], undefined]);
72
+ }
73
+ return {
74
+ edits: text === null || !ours ? [] : [{ file, after: jsonEdit(file, text, changes) }],
75
+ result: { settings: file, on: false, warnings: ours ? [] : ['Claude Code was not set to Phantom AI by phantom; nothing changed'] },
76
+ };
77
+ }
78
+ if (!resolveApiKey(env))
79
+ die('No API key to give Claude Code. Run: phantom login', 'no_key');
80
+ const baseUrl = agentBaseUrl(env, 'claude-code');
81
+ // `phantom` if it's installed, else this very phantom by absolute path: Claude Code
82
+ // runs the helper outside this shell, so a relative or npx path won't do.
83
+ const set = {
84
+ ANTHROPIC_BASE_URL: baseUrl,
85
+ // Empty, as Vercel's setup writes it, so an ANTHROPIC_API_KEY in the shell
86
+ // can't win over the Phantom key: the settings file outranks the shell.
87
+ ANTHROPIC_API_KEY: '',
88
+ ENABLE_TOOL_SEARCH: 'true',
89
+ CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: '1',
90
+ ...(opts.model ? { ANTHROPIC_MODEL: opts.model } : {}),
91
+ };
92
+ const changes = [[['apiKeyHelper'], `${phantomCommand(opts.onPath ?? onPath)} key show`], ...Object.entries(set).map(([k, v]) => [['env', k], v])];
93
+ // A token outranks apiKeyHelper, and would send a non-Phantom key.
94
+ const warnings = env.ANTHROPIC_AUTH_TOKEN ? ['ANTHROPIC_AUTH_TOKEN is set in this shell and overrides the Phantom key; unset it before running claude'] : [];
95
+ if (opts.model?.replace(/^claude-code\//, '') === 'auto')
96
+ warnings.push('auto runs what the key\'s route policy picks; set one with: phantom route set --models a,b');
97
+ return { edits: [{ file, after: jsonEdit(file, text, changes) }], result: { settings: file, on: true, base_url: baseUrl, model: set.ANTHROPIC_MODEL ?? vars.ANTHROPIC_MODEL, warnings } };
98
+ }
99
+ // ── Copilot ──────────────────────────────────────────────────────────────────
100
+ /** GitHub Copilot CLI's folder: `$COPILOT_HOME`, else `~/.copilot`. */
101
+ export function copilotDir(env, home) {
102
+ return env.COPILOT_HOME?.trim() ? env.COPILOT_HOME : path.join(home, '.copilot');
103
+ }
104
+ /** GitHub Copilot CLI: set up by the shell block alone, as Vercel's setup does. Its variables are in {@link SetupAgentSpec.shellEnv}. */
105
+ export function copilotProvider(ctx, off) {
106
+ return off
107
+ ? { edits: [], result: { settings: ctx.shellRc, on: false, warnings: [] } }
108
+ : {
109
+ edits: [],
110
+ result: {
111
+ settings: ctx.shellRc,
112
+ on: true,
113
+ base_url: agentBaseUrl(ctx.env, 'coding-agent'),
114
+ model: ctx.model ?? SETUP_MODEL,
115
+ warnings: ['GitHub Copilot CLI reads its settings from the shell; open a new terminal before running it'],
116
+ },
117
+ };
118
+ }
119
+ // ── opencode ─────────────────────────────────────────────────────────────────
120
+ /** opencode's config file: `opencode.json` (or `.jsonc`) in `$XDG_CONFIG_HOME/opencode`, else `~/.config/opencode`. */
121
+ export function opencodeFile(env, home) {
122
+ const dir = path.join(configHome(env, home), 'opencode');
123
+ return existsSync(path.join(dir, 'opencode.jsonc')) ? path.join(dir, 'opencode.jsonc') : path.join(dir, 'opencode.json');
124
+ }
125
+ /**
126
+ * opencode: a `phantom` provider at `/coding-agent/v1` whose key is
127
+ * `{env:PHANTOM_API_KEY}`. Like Vercel's setup, it pins no model unless
128
+ * `--model` asks; pick `phantom/<model>` in opencode.
129
+ */
130
+ export function opencodeProvider(ctx, off) {
131
+ const file = opencodeFile(ctx.env, ctx.home);
132
+ const text = readText(file);
133
+ const config = jsonObject(file, text);
134
+ if (off) {
135
+ const ours = Boolean(config.provider?.phantom);
136
+ const changes = [Object.keys(config.provider ?? {}).length === 1 ? [['provider'], undefined] : [['provider', 'phantom'], undefined]];
137
+ if (typeof config.model === 'string' && config.model.startsWith('phantom/'))
138
+ changes.push([['model'], undefined]);
139
+ const left = jsonObject(file, ours ? jsonEdit(file, text, changes) : text);
140
+ const empty = Object.keys(left).every((k) => k === '$schema');
141
+ return offResult('opencode', file, text, ours ? (empty ? '' : jsonEdit(file, text, changes)) : text);
142
+ }
143
+ const base = agentBaseUrl(ctx.env, 'coding-agent');
144
+ const model = ctx.model ?? SETUP_MODEL;
145
+ const changes = [
146
+ ...(!text?.trim() ? [[['$schema'], 'https://opencode.ai/config.json']] : []),
147
+ [
148
+ ['provider', 'phantom'],
149
+ { npm: '@ai-sdk/openai-compatible', name: 'Phantom AI', options: { baseURL: base, apiKey: '{env:PHANTOM_API_KEY}' }, models: { [model]: { name: model } } },
150
+ ],
151
+ ...(ctx.model ? [[['model'], `phantom/${ctx.model}`]] : []),
152
+ ];
153
+ return {
154
+ edits: [{ file, after: jsonEdit(file, text, changes) }],
155
+ result: { settings: file, on: true, base_url: base, ...(ctx.model ? { model: `phantom/${ctx.model}` } : {}), warnings: ctx.model ? [] : [`Pick phantom/${model} with /models in opencode`] },
156
+ };
157
+ }
158
+ // ── Continue ─────────────────────────────────────────────────────────────────
159
+ /**
160
+ * Continue: `~/.continue/config.yaml` gets a model at `/coding-agent/v1`,
161
+ * first in `models:` so the `cn` CLI starts with it, with the key from
162
+ * `${{ secrets.PHANTOM_API_KEY }}`. Like Vercel's setup it also writes
163
+ * `.onboarding_complete` beside it, so `cn`'s first-run questions don't
164
+ * replace the list.
165
+ */
166
+ export function continueProvider(ctx, off) {
167
+ const file = path.join(ctx.home, '.continue', 'config.yaml');
168
+ const text = readText(file);
169
+ if (off)
170
+ return offResult('Continue', file, text, text === null ? null : yamlRemoveOurs(file, text));
171
+ const base = agentBaseUrl(ctx.env, 'coding-agent');
172
+ const model = ctx.model ?? SETUP_MODEL;
173
+ const after = yamlAddModel(file, text, [`- name: Phantom AI ${model}`, 'provider: openai', `model: ${model}`, `apiBase: ${base}`, 'apiKey: ${{ secrets.PHANTOM_API_KEY }}'], ['name: Local Config', 'version: 1.0.0', 'schema: v1']);
174
+ const marker = path.join(ctx.home, '.continue', '.onboarding_complete');
175
+ return {
176
+ edits: [{ file, after }, { file: marker, after: readText(marker) ?? `${new Date().toISOString()}\n` }],
177
+ result: { settings: file, on: true, base_url: base, model, warnings: [] },
178
+ };
179
+ }
180
+ // ── Goose ────────────────────────────────────────────────────────────────────
181
+ /**
182
+ * Goose, as Vercel's setup does it: a custom provider file,
183
+ * `custom_providers/custom_phantom.json` in Goose's folder, at
184
+ * `/coding-agent/v1/chat/completions` with the key from PHANTOM_API_KEY, and
185
+ * GOOSE_PROVIDER and GOOSE_MODEL in Goose's `config.yaml`, which the desktop
186
+ * app reads too.
187
+ */
188
+ export function gooseProvider(ctx, off) {
189
+ const dir = path.join(configHome(ctx.env, ctx.home), 'goose');
190
+ const config = path.join(dir, 'config.yaml');
191
+ const file = path.join(dir, 'custom_providers', 'custom_phantom.json');
192
+ const text = readText(file);
193
+ const yaml = readText(config);
194
+ if (off) {
195
+ const after = yaml === null ? null : yamlRemoveOurs(config, yaml);
196
+ const edits = [...(text === null ? [] : [{ file, after: null }]), ...(yaml !== null && after !== yaml ? [{ file: config, after: after || null }] : [])];
197
+ return { edits, result: { settings: config, on: false, warnings: edits.length ? [] : ['Goose was not set to Phantom AI by phantom; nothing changed'] } };
198
+ }
199
+ const base = agentBaseUrl(ctx.env, 'coding-agent');
200
+ const model = ctx.model ?? SETUP_MODEL;
201
+ const provider = {
202
+ name: 'custom_phantom',
203
+ engine: 'openai',
204
+ display_name: 'Phantom AI',
205
+ description: 'Phantom AI, set up by phantom',
206
+ api_key_env: 'PHANTOM_API_KEY',
207
+ base_url: `${base}/chat/completions`,
208
+ models: [{ name: model, context_limit: SETUP_CONTEXT_WINDOW }],
209
+ supports_streaming: true,
210
+ requires_auth: true,
211
+ };
212
+ const yamlAfter = yamlSetTop(config, yamlSetTop(config, yaml, 'GOOSE_PROVIDER', 'custom_phantom'), 'GOOSE_MODEL', model);
213
+ return {
214
+ edits: [
215
+ { file, after: jsonEdit(file, text, Object.entries(provider).map(([k, v]) => [[k], v])) },
216
+ { file: config, after: yamlAfter },
217
+ ],
218
+ result: { settings: config, on: true, base_url: base, model, warnings: [] },
219
+ };
220
+ }
221
+ // ── Aider ────────────────────────────────────────────────────────────────────
222
+ /**
223
+ * Aider, as Vercel's setup does it: `~/.aider.conf.yml` gets
224
+ * `openai-api-base` at `/coding-agent/v1` and the model as `openai/<model>`,
225
+ * `~/.aider.model.metadata.json` and `~/.aider.model.settings.yml` tell Aider
226
+ * the model's context window and edit format, and the key comes from
227
+ * AIDER_OPENAI_API_KEY in the shell block.
228
+ */
229
+ export function aiderProvider(ctx, off) {
230
+ const conf = path.join(ctx.home, '.aider.conf.yml');
231
+ const meta = path.join(ctx.home, '.aider.model.metadata.json');
232
+ const settingsFile = path.join(ctx.home, '.aider.model.settings.yml');
233
+ const [confText, metaText, setText] = [readText(conf), readText(meta), readText(settingsFile)];
234
+ const id = `openai/${ctx.model ?? SETUP_MODEL}`;
235
+ const metadata = jsonObject(meta, metaText);
236
+ if (off) {
237
+ const edits = [];
238
+ const confAfter = confText === null ? null : yamlRemoveOurs(conf, confText);
239
+ if (confText !== null && confAfter !== confText)
240
+ edits.push({ file: conf, after: confAfter || null });
241
+ const setAfter = setText === null ? null : yamlRemoveOurs(settingsFile, setText);
242
+ if (setText !== null && setAfter !== setText)
243
+ edits.push({ file: settingsFile, after: setAfter || null });
244
+ const ours = Object.keys(metadata).filter((k) => [metadata[k]].some((m) => m?.phantom === true || m?.pai === true));
245
+ if (ours.length)
246
+ edits.push({ file: meta, after: ours.length === Object.keys(metadata).length ? null : jsonEdit(meta, metaText, ours.map((k) => [[k], undefined])) });
247
+ return { edits, result: { settings: conf, on: false, warnings: edits.length ? [] : ['Aider was not set to Phantom AI by phantom; nothing changed'] } };
248
+ }
249
+ const base = agentBaseUrl(ctx.env, 'coding-agent');
250
+ const confAfter = yamlSetTop(conf, yamlSetTop(conf, confText, 'openai-api-base', base), 'model', id);
251
+ // `phantom: true` marks the entries phantom wrote, so off removes only those.
252
+ const metaAfter = jsonEdit(meta, metaText, [[[id], { max_input_tokens: SETUP_CONTEXT_WINDOW, litellm_provider: 'openai', mode: 'chat', phantom: true }]]);
253
+ const setBase = setText === null ? '' : yamlRemoveOurs(settingsFile, setText);
254
+ const entry = [`- name: ${id}`, ' edit_format: diff', ' use_repo_map: true'].map((l) => `${l} ${SETUP_MARK}`);
255
+ const setAfter = (setBase === '' ? [] : [setBase.replace(/\n$/, '')]).concat(entry).join('\n') + '\n';
256
+ return {
257
+ edits: [
258
+ { file: conf, after: confAfter },
259
+ { file: meta, after: metaAfter },
260
+ { file: settingsFile, after: setAfter },
261
+ ],
262
+ result: { settings: conf, on: true, base_url: base, model: id, warnings: ['Aider reads its key from the shell; open a new terminal before running it'] },
263
+ };
264
+ }
265
+ /**
266
+ * Cline, as Vercel's setup does it: `~/.cline/data/settings/providers.json`
267
+ * (mode 600), which Cline's CLI and editor extension share, gets an OpenAI
268
+ * Compatible provider at `/coding-agent/v1` and is made the one Cline uses.
269
+ * Cline reads its key only from this file, with no variable or command to
270
+ * point it at, so this is the one config phantom writes the key into. After
271
+ * `phantom rotate`, run setup again to update it.
272
+ */
273
+ export function clineProvider(ctx, off) {
274
+ const file = path.join(ctx.home, '.cline', 'data', 'settings', 'providers.json');
275
+ const text = readText(file);
276
+ const data = jsonObject(file, text);
277
+ const base = agentBaseUrl(ctx.env, 'coding-agent');
278
+ const current = data.providers?.['openai-compatible'];
279
+ const ours = Boolean(current?.settings?.baseUrl && /\/coding-agent\/v1$/.test(current.settings.baseUrl) && current.settings.baseUrl.startsWith(base.replace(/\/coding-agent\/v1$/, '')));
280
+ if (off) {
281
+ const changes = [[['providers', 'openai-compatible'], undefined]];
282
+ if (data.lastUsedProvider === 'openai-compatible')
283
+ changes.push([['lastUsedProvider'], undefined]);
284
+ return offResult('Cline', file, text, ours ? jsonEdit(file, text, changes) : text);
285
+ }
286
+ if (current && !ours)
287
+ unreadable(file, `already has an OpenAI Compatible provider at ${current.settings?.baseUrl ?? 'another address'}, which phantom leaves alone`);
288
+ const model = ctx.model ?? SETUP_MODEL;
289
+ const entry = {
290
+ settings: { provider: 'openai-compatible', apiKey: resolveApiKey(ctx.env), model, baseUrl: base, contextWindow: SETUP_CONTEXT_WINDOW },
291
+ updatedAt: new Date().toISOString(),
292
+ tokenSource: 'manual',
293
+ };
294
+ // A rerun with the same settings keeps the old timestamp, so nothing changes.
295
+ if (current && isDeepStrictEqual({ ...current, updatedAt: '' }, { ...entry, updatedAt: '' }))
296
+ entry.updatedAt = current.updatedAt;
297
+ const changes = [
298
+ ...(text === null ? [[['version'], 1]] : []),
299
+ [['lastUsedProvider'], 'openai-compatible'],
300
+ [['providers', 'openai-compatible'], entry],
301
+ ];
302
+ return {
303
+ edits: [{ file, after: jsonEdit(file, text, changes), mode: 0o600 }],
304
+ result: {
305
+ settings: file,
306
+ on: true,
307
+ base_url: base,
308
+ model,
309
+ warnings: ['Cline keeps the key in this file (mode 600); run phantom setup --agent cline --provider again after phantom rotate', 'In the editor extension, pick OpenAI Compatible under Settings, API Provider'],
310
+ },
311
+ };
312
+ }
313
+ // ── Registry ─────────────────────────────────────────────────────────────────
314
+ /**
315
+ * The agents `phantom setup` knows, in the order setup runs them. Adding an agent
316
+ * is one entry here and its name in {@link SetupAgent}: help, errors and
317
+ * `--agent` read their names from this list.
318
+ * @deprecated Compatibility export. Use {@link planSetup}, {@link applySetup}, and {@link restoreSetup} for agent setup.
319
+ */
320
+ export const SETUP_AGENTS = {
321
+ /** pi. It uses the CLI through the skill. */
322
+ pi: {
323
+ label: 'pi',
324
+ /** ~/.pi */
325
+ home: '.pi',
326
+ /** ~/.agents/skills */
327
+ skills: '.agents/skills',
328
+ },
329
+ /** Codex. */
330
+ codex: {
331
+ label: 'Codex',
332
+ /** ~/.codex, or $CODEX_HOME */
333
+ home: '.codex',
334
+ detect: (env, home) => existsSync(codexDir(env, home)),
335
+ /** ~/.agents/skills */
336
+ skills: '.agents/skills',
337
+ provider: codexProvider,
338
+ shellEnv: () => ({}),
339
+ },
340
+ /** Claude Code. Needs no shell block: it runs `phantom key show` itself. */
341
+ claude: {
342
+ label: 'Claude Code',
343
+ /** ~/.claude, or $CLAUDE_CONFIG_DIR */
344
+ home: '.claude',
345
+ detect: (env, home) => existsSync(claudeDir(env, home)),
346
+ /** ~/.claude/skills */
347
+ skills: '.claude/skills',
348
+ provider: (ctx, off) => claudePlan(ctx.env, { off, model: ctx.model, onPath: ctx.onPath }),
349
+ },
350
+ /** Cursor. Its models are set on its settings screen; the shell block gives it PHANTOM_API_KEY to copy. */
351
+ cursor: {
352
+ label: 'Cursor',
353
+ /** ~/.cursor */
354
+ home: '.cursor',
355
+ /** ~/.cursor/skills */
356
+ skills: '.cursor/skills',
357
+ provider: manualProvider('Cursor Settings, Models', (ctx) => [
358
+ 'Open Cursor Settings (Cmd+Shift+J on macOS), then Models.',
359
+ `Under OpenAI API Key, paste your Phantom AI key, then turn on Override OpenAI Base URL and set it to ${agentBaseUrl(ctx.env, 'cursor')}`,
360
+ 'Copy the key without showing it: phantom key show | pbcopy',
361
+ `Use Add model to add ${ctx.model ?? SETUP_MODEL} and any other Phantom AI model ids you want in the picker.`,
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
+ ]),
364
+ shellEnv: () => ({}),
365
+ },
366
+ /** opencode. */
367
+ opencode: {
368
+ label: 'opencode',
369
+ /** ~/.config/opencode/opencode.json, or under $XDG_CONFIG_HOME */
370
+ home: '.config/opencode/opencode.json',
371
+ detect: (env, home) => existsSync(opencodeFile(env, home)),
372
+ provider: opencodeProvider,
373
+ shellEnv: () => ({}),
374
+ },
375
+ /** Continue, in the `cn` CLI and the editor. */
376
+ continue: {
377
+ label: 'Continue',
378
+ /** ~/.continue */
379
+ home: '.continue',
380
+ provider: continueProvider,
381
+ shellEnv: () => ({}),
382
+ },
383
+ /** Goose. */
384
+ goose: {
385
+ label: 'Goose',
386
+ /** ~/.config/goose, or under $XDG_CONFIG_HOME */
387
+ home: '.config/goose',
388
+ detect: (env, home) => existsSync(path.join(configHome(env, home), 'goose')),
389
+ provider: gooseProvider,
390
+ shellEnv: () => ({}),
391
+ },
392
+ /** Aider. */
393
+ aider: {
394
+ label: 'Aider',
395
+ /** ~/.aider, or ~/.aider.conf.yml */
396
+ home: '.aider',
397
+ detect: (env, home) => existsSync(path.join(home, '.aider')) || existsSync(path.join(home, '.aider.conf.yml')),
398
+ provider: aiderProvider,
399
+ shellEnv: () => ({ AIDER_OPENAI_API_KEY: '$PHANTOM_API_KEY' }),
400
+ },
401
+ /** GitHub Copilot CLI. Set up by its COPILOT_PROVIDER_ variables, which make it use a model provider of your own. */
402
+ copilot: {
403
+ label: 'GitHub Copilot CLI',
404
+ /** ~/.copilot, or $COPILOT_HOME */
405
+ home: '.copilot',
406
+ detect: (env, home) => existsSync(copilotDir(env, home)),
407
+ provider: copilotProvider,
408
+ shellEnv: (ctx) => ({
409
+ COPILOT_PROVIDER_BASE_URL: agentBaseUrl(ctx.env, 'coding-agent'),
410
+ COPILOT_PROVIDER_API_KEY: '$PHANTOM_API_KEY',
411
+ COPILOT_MODEL: ctx.model ?? SETUP_MODEL,
412
+ }),
413
+ },
414
+ /** Cline, in its CLI and editor extension. */
415
+ cline: {
416
+ label: 'Cline',
417
+ /** ~/.cline */
418
+ home: '.cline',
419
+ provider: clineProvider,
420
+ },
421
+ };
422
+ export const SETUP_NAMES = Object.keys(SETUP_AGENTS);
423
+ /** `a, b or c`. */
424
+ export function orList(items) {
425
+ return items.length < 2 ? items.join('') : `${items.slice(0, -1).join(', ')} or ${items[items.length - 1]}`;
426
+ }
@@ -0,0 +1,210 @@
1
+ import path from 'node:path';
2
+ import { createHash } from 'node:crypto';
3
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { stateDir } from "../core/state.mjs";
5
+ import { readText } from "./shared.mjs";
6
+ import { jsonText } from "./editors.mjs";
7
+ import { claudePlan, SETUP_AGENTS } from "./agents.mjs";
8
+ import { planSetup } from "./plan.mjs";
9
+ /** Hides Phantom AI keys in text that is shown: `sk-phantom-****`. */
10
+ export function maskKeys(text) {
11
+ return text.replace(/sk-phantom-[A-Za-z0-9_-]+/g, 'sk-phantom-****');
12
+ }
13
+ /**
14
+ * A line diff of two texts: unchanged lines start with two spaces, removed
15
+ * lines with `- ` and added lines with `+ `. Unchanged lines more than two
16
+ * away from a change are left out, and each gap shows as ` ...`.
17
+ */
18
+ export function lineDiff(before, after) {
19
+ const split = (t) => (t === null || t === '' ? [] : t.replace(/\n$/, '').split('\n'));
20
+ const a = split(before);
21
+ const b = split(after);
22
+ const rows = [];
23
+ if (a.length * b.length > 4_000_000) {
24
+ rows.push(...a.map((l) => '- ' + l), ...b.map((l) => '+ ' + l));
25
+ }
26
+ else {
27
+ // Longest common subsequence, from the end, then walk it from the start.
28
+ const dp = Array.from({ length: a.length + 1 }, () => new Uint32Array(b.length + 1));
29
+ for (let i = a.length - 1; i >= 0; i--)
30
+ for (let j = b.length - 1; j >= 0; j--)
31
+ dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
32
+ let i = 0;
33
+ let j = 0;
34
+ while (i < a.length || j < b.length) {
35
+ if (i < a.length && j < b.length && a[i] === b[j]) {
36
+ rows.push(' ' + a[i++]);
37
+ j++;
38
+ }
39
+ else if (i < a.length && (j === b.length || dp[i + 1][j] >= dp[i][j + 1]))
40
+ rows.push('- ' + a[i++]);
41
+ else
42
+ rows.push('+ ' + b[j++]);
43
+ }
44
+ }
45
+ const changed = rows.map((r) => !r.startsWith(' '));
46
+ const out = [];
47
+ for (let k = 0; k < rows.length; k++) {
48
+ if (changed.slice(Math.max(0, k - 2), k + 3).some(Boolean))
49
+ out.push(rows[k]);
50
+ else if (out[out.length - 1] !== ' ...')
51
+ out.push(' ...');
52
+ }
53
+ return out.join('\n');
54
+ }
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
+ function backupsFile(env) {
78
+ return path.join(stateDir(env), 'setup-backups.json');
79
+ }
80
+ function readBackups(env) {
81
+ try {
82
+ return JSON.parse(readFileSync(backupsFile(env), 'utf-8'));
83
+ }
84
+ catch {
85
+ return {};
86
+ }
87
+ }
88
+ function writeBackups(env, entries) {
89
+ mkdirSync(stateDir(env), { recursive: true, mode: 0o700 });
90
+ writeFileSync(backupsFile(env), jsonText(entries), { mode: 0o600 });
91
+ }
92
+ const sha256 = (text) => createHash('sha256').update(text).digest('hex');
93
+ /**
94
+ * Writes a plan from {@link planSetup}. Before it edits a file it copies it
95
+ * to `<file>.phantom.bak`, unless the file is still as phantom last wrote it: then the
96
+ * copy from before phantom's first change stays. A file that changed after the
97
+ * plan read it is left alone and listed in `skipped`.
98
+ */
99
+ export function applySetup(env, plan) {
100
+ const backups = readBackups(env);
101
+ const skipped = [...plan.skipped];
102
+ const files = [];
103
+ for (const f of plan.files) {
104
+ if (readText(f.file) !== f.before) {
105
+ skipped.push({ agent: f.agent, reason: `${f.file} changed after setup read it; run phantom setup again` });
106
+ continue;
107
+ }
108
+ let backup;
109
+ if (!f.skill) {
110
+ const prior = backups[f.file];
111
+ const keep = Boolean(prior && f.before !== null && prior.sha === sha256(f.before) && (prior.backup === null || existsSync(prior.backup)));
112
+ let from = keep ? prior.backup : null;
113
+ if (!keep && f.before !== null) {
114
+ from = `${f.file}.phantom.bak`;
115
+ writeFileSync(from, f.before, { mode: 0o600 });
116
+ }
117
+ if (from)
118
+ backup = from;
119
+ if (f.after === null && from === null)
120
+ delete backups[f.file];
121
+ else
122
+ backups[f.file] = { agent: f.agent, backup: from, sha: sha256(f.after ?? '') };
123
+ }
124
+ if (f.after === null)
125
+ rmSync(f.file, { force: true });
126
+ else {
127
+ mkdirSync(path.dirname(f.file), { recursive: true });
128
+ writeFileSync(f.file, f.after, f.mode ? { mode: f.mode } : {});
129
+ if (f.mode)
130
+ chmodSync(f.file, f.mode);
131
+ }
132
+ files.push({ agent: f.agent, file: f.file, action: f.action, ...(backup ? { backup } : {}), ...(f.skill ? {} : { diff: maskKeys(lineDiff(f.before, f.after)) }) });
133
+ }
134
+ if (plan.files.some((f) => !f.skill))
135
+ writeBackups(env, backups);
136
+ return { agents: plan.agents, files, ...(skipped.length ? { skipped } : {}), ...(plan.notes.length ? { notes: plan.notes } : {}) };
137
+ }
138
+ /**
139
+ * Points Claude Code at Phantom AI, or removes phantom's settings with `off`.
140
+ * Uses the same backups and restore safeguards as {@link applySetup}.
141
+ * The return value remains the provider result for existing callers.
142
+ * @deprecated Use {@link setupAgents} or {@link planSetup} and {@link applySetup}. This helper remains for settings-only callers.
143
+ */
144
+ export function claudeProvider(env, opts = {}) {
145
+ const provider = claudePlan(env, opts);
146
+ const files = provider.edits.map((edit) => {
147
+ const before = readText(edit.file);
148
+ return { ...edit, before, agent: 'claude', action: before === null ? 'create' : edit.after === null ? 'remove' : 'update' };
149
+ });
150
+ applySetup(env, { agents: [], files, skipped: [], notes: [] });
151
+ return provider.result;
152
+ }
153
+ /** A plan as `--dry-run` reports it: each file that would change, with its diff, and nothing written. */
154
+ export function previewSetup(plan) {
155
+ return {
156
+ agents: plan.agents,
157
+ files: plan.files.map((f) => ({ agent: f.agent, file: f.file, action: f.action, ...(f.skill ? {} : { diff: maskKeys(lineDiff(f.before, f.after)) }) })),
158
+ ...(plan.skipped.length ? { skipped: plan.skipped } : {}),
159
+ ...(plan.notes.length ? { notes: plan.notes } : {}),
160
+ dry_run: true,
161
+ };
162
+ }
163
+ /**
164
+ * Installs the phantom-ai skill for one agent, or for every agent found
165
+ * (see {@link SETUP_AGENTS}), and with `provider` sets that up
166
+ * too, without asking: {@link planSetup}, then {@link applySetup}.
167
+ * @throws {@link CliError} when no agent is found, the agent is unknown, or `provider` is set without an agent that supports it.
168
+ */
169
+ export function setupAgents(env, opts = {}) {
170
+ return applySetup(env, planSetup(env, opts));
171
+ }
172
+ /**
173
+ * Puts back the files `phantom setup` changed, from the copies it kept
174
+ * (`<file>.phantom.bak`), and removes the files it created. Only a file still as
175
+ * phantom wrote it: one changed since (Codex adds `[projects]` tables to its
176
+ * config, for example) is left alone and listed, since the copy would undo
177
+ * that change too. `--provider off` removes phantom's lines from such a file.
178
+ * @param opts `agent` limits it to one agent's files (`shell` for the startup file).
179
+ */
180
+ export function restoreSetup(env, opts = {}) {
181
+ const backups = readBackups(env);
182
+ const restored = [];
183
+ const skipped = [];
184
+ for (const [file, entry] of Object.entries(backups)) {
185
+ if (opts.agent && opts.agent !== 'all' && entry.agent !== opts.agent)
186
+ continue;
187
+ const now = readText(file);
188
+ if (sha256(now ?? '') !== entry.sha) {
189
+ skipped.push({ file, reason: `changed since phantom wrote it${entry.backup ? `; the copy from before is ${entry.backup}` : ''}. phantom setup --provider off removes only phantom's lines` });
190
+ continue;
191
+ }
192
+ if (entry.backup !== null && !existsSync(entry.backup)) {
193
+ skipped.push({ file, reason: `its copy ${entry.backup} is gone` });
194
+ continue;
195
+ }
196
+ if (!opts.dryRun) {
197
+ if (entry.backup === null)
198
+ rmSync(file, { force: true });
199
+ else {
200
+ writeFileSync(file, readFileSync(entry.backup));
201
+ rmSync(entry.backup);
202
+ }
203
+ delete backups[file];
204
+ }
205
+ restored.push({ file, from: entry.backup });
206
+ }
207
+ if (!opts.dryRun && restored.length)
208
+ writeBackups(env, backups);
209
+ return { restored, skipped, ...(opts.dryRun ? { dry_run: true } : {}) };
210
+ }