@phantomcodes/cli 0.0.0-stage → 1.0.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 +191 -2
- package/bin/phantom.mjs +14 -0
- package/dist/agents/command.mjs +155 -0
- package/dist/agents/proxy.mjs +140 -0
- package/dist/cli/commands.mjs +129 -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 +78 -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 +57 -0
- package/dist/receipts/command.mjs +41 -0
- package/dist/receipts/receipts.mjs +82 -0
- package/dist/setup/agents.mjs +437 -0
- package/dist/setup/apply.mjs +218 -0
- package/dist/setup/command.mjs +114 -0
- package/dist/setup/editors.mjs +486 -0
- package/dist/setup/legacy.mjs +36 -0
- package/dist/setup/plan.mjs +142 -0
- package/dist/setup/shared.mjs +89 -0
- package/dist/spend/command.mjs +105 -0
- package/dist/spend/routing.mjs +32 -0
- package/dist/workspace/command.mjs +204 -0
- package/package.json +83 -4
- package/skills/phantom-ai/SKILL.md +117 -0
|
@@ -0,0 +1,437 @@
|
|
|
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, MCP_ARGS, mcpByCli, 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. No MCP; 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
|
+
mcp: mcpByCli('codex'),
|
|
338
|
+
provider: codexProvider,
|
|
339
|
+
shellEnv: () => ({}),
|
|
340
|
+
},
|
|
341
|
+
/** Claude Code. Needs no shell block: it runs `phantom key show` itself. */
|
|
342
|
+
claude: {
|
|
343
|
+
label: 'Claude Code',
|
|
344
|
+
/** ~/.claude, or $CLAUDE_CONFIG_DIR */
|
|
345
|
+
home: '.claude',
|
|
346
|
+
detect: (env, home) => existsSync(claudeDir(env, home)),
|
|
347
|
+
/** ~/.claude/skills */
|
|
348
|
+
skills: '.claude/skills',
|
|
349
|
+
mcp: mcpByCli('claude'),
|
|
350
|
+
provider: (ctx, off) => claudePlan(ctx.env, { off, model: ctx.model, onPath: ctx.onPath }),
|
|
351
|
+
},
|
|
352
|
+
/** Cursor. Its models are set on its settings screen; the shell block gives it PHANTOM_API_KEY to copy. */
|
|
353
|
+
cursor: {
|
|
354
|
+
label: 'Cursor',
|
|
355
|
+
/** ~/.cursor */
|
|
356
|
+
home: '.cursor',
|
|
357
|
+
/** ~/.cursor/skills */
|
|
358
|
+
skills: '.cursor/skills',
|
|
359
|
+
/** Merges the server into ~/.cursor/mcp.json, keeping the others. */
|
|
360
|
+
mcp: (ctx, add) => {
|
|
361
|
+
const server = { command: 'npx', args: MCP_ARGS };
|
|
362
|
+
const mcp = `add to ~/.cursor/mcp.json: ${JSON.stringify({ mcpServers: { phantom: server } })}`;
|
|
363
|
+
if (!add)
|
|
364
|
+
return { mcp };
|
|
365
|
+
const file = path.join(ctx.home, '.cursor', 'mcp.json');
|
|
366
|
+
return { mcp, edits: [{ file, after: jsonEdit(file, readText(file), [[['mcpServers', 'phantom'], server]]) }] };
|
|
367
|
+
},
|
|
368
|
+
provider: manualProvider('Cursor Settings, Models', (ctx) => [
|
|
369
|
+
'Open Cursor Settings (Cmd+Shift+J on macOS), then Models.',
|
|
370
|
+
`Under OpenAI API Key, paste your Phantom AI key, then turn on Override OpenAI Base URL and set it to ${agentBaseUrl(ctx.env, 'cursor')}`,
|
|
371
|
+
'Copy the key without showing it: phantom key show | pbcopy',
|
|
372
|
+
`Use Add model to add ${ctx.model ?? SETUP_MODEL} and any other Phantom AI model ids you want in the picker.`,
|
|
373
|
+
'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.',
|
|
374
|
+
]),
|
|
375
|
+
shellEnv: () => ({}),
|
|
376
|
+
},
|
|
377
|
+
/** opencode. */
|
|
378
|
+
opencode: {
|
|
379
|
+
label: 'opencode',
|
|
380
|
+
/** ~/.config/opencode/opencode.json, or under $XDG_CONFIG_HOME */
|
|
381
|
+
home: '.config/opencode/opencode.json',
|
|
382
|
+
detect: (env, home) => existsSync(opencodeFile(env, home)),
|
|
383
|
+
provider: opencodeProvider,
|
|
384
|
+
shellEnv: () => ({}),
|
|
385
|
+
},
|
|
386
|
+
/** Continue, in the `cn` CLI and the editor. */
|
|
387
|
+
continue: {
|
|
388
|
+
label: 'Continue',
|
|
389
|
+
/** ~/.continue */
|
|
390
|
+
home: '.continue',
|
|
391
|
+
provider: continueProvider,
|
|
392
|
+
shellEnv: () => ({}),
|
|
393
|
+
},
|
|
394
|
+
/** Goose. */
|
|
395
|
+
goose: {
|
|
396
|
+
label: 'Goose',
|
|
397
|
+
/** ~/.config/goose, or under $XDG_CONFIG_HOME */
|
|
398
|
+
home: '.config/goose',
|
|
399
|
+
detect: (env, home) => existsSync(path.join(configHome(env, home), 'goose')),
|
|
400
|
+
provider: gooseProvider,
|
|
401
|
+
shellEnv: () => ({}),
|
|
402
|
+
},
|
|
403
|
+
/** Aider. */
|
|
404
|
+
aider: {
|
|
405
|
+
label: 'Aider',
|
|
406
|
+
/** ~/.aider, or ~/.aider.conf.yml */
|
|
407
|
+
home: '.aider',
|
|
408
|
+
detect: (env, home) => existsSync(path.join(home, '.aider')) || existsSync(path.join(home, '.aider.conf.yml')),
|
|
409
|
+
provider: aiderProvider,
|
|
410
|
+
shellEnv: () => ({ AIDER_OPENAI_API_KEY: '$PHANTOM_API_KEY' }),
|
|
411
|
+
},
|
|
412
|
+
/** GitHub Copilot CLI. Set up by its COPILOT_PROVIDER_ variables, which make it use a model provider of your own. */
|
|
413
|
+
copilot: {
|
|
414
|
+
label: 'GitHub Copilot CLI',
|
|
415
|
+
/** ~/.copilot, or $COPILOT_HOME */
|
|
416
|
+
home: '.copilot',
|
|
417
|
+
detect: (env, home) => existsSync(copilotDir(env, home)),
|
|
418
|
+
provider: copilotProvider,
|
|
419
|
+
shellEnv: (ctx) => ({
|
|
420
|
+
COPILOT_PROVIDER_BASE_URL: agentBaseUrl(ctx.env, 'coding-agent'),
|
|
421
|
+
COPILOT_PROVIDER_API_KEY: '$PHANTOM_API_KEY',
|
|
422
|
+
COPILOT_MODEL: ctx.model ?? SETUP_MODEL,
|
|
423
|
+
}),
|
|
424
|
+
},
|
|
425
|
+
/** Cline, in its CLI and editor extension. */
|
|
426
|
+
cline: {
|
|
427
|
+
label: 'Cline',
|
|
428
|
+
/** ~/.cline */
|
|
429
|
+
home: '.cline',
|
|
430
|
+
provider: clineProvider,
|
|
431
|
+
},
|
|
432
|
+
};
|
|
433
|
+
export const SETUP_NAMES = Object.keys(SETUP_AGENTS);
|
|
434
|
+
/** `a, b or c`. */
|
|
435
|
+
export function orList(items) {
|
|
436
|
+
return items.length < 2 ? items.join('') : `${items.slice(0, -1).join(', ')} or ${items[items.length - 1]}`;
|
|
437
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
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 { execFileSync } from 'node:child_process';
|
|
5
|
+
import { stateDir } from "../core/state.mjs";
|
|
6
|
+
import { readText } from "./shared.mjs";
|
|
7
|
+
import { jsonText } from "./editors.mjs";
|
|
8
|
+
import { claudePlan, SETUP_AGENTS } from "./agents.mjs";
|
|
9
|
+
import { planSetup } from "./plan.mjs";
|
|
10
|
+
/** Hides Phantom AI keys in text that is shown: `sk-phantom-****`. */
|
|
11
|
+
export function maskKeys(text) {
|
|
12
|
+
return text.replace(/sk-phantom-[A-Za-z0-9_-]+/g, 'sk-phantom-****');
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* A line diff of two texts: unchanged lines start with two spaces, removed
|
|
16
|
+
* lines with `- ` and added lines with `+ `. Unchanged lines more than two
|
|
17
|
+
* away from a change are left out, and each gap shows as ` ...`.
|
|
18
|
+
*/
|
|
19
|
+
export function lineDiff(before, after) {
|
|
20
|
+
const split = (t) => (t === null || t === '' ? [] : t.replace(/\n$/, '').split('\n'));
|
|
21
|
+
const a = split(before);
|
|
22
|
+
const b = split(after);
|
|
23
|
+
const rows = [];
|
|
24
|
+
if (a.length * b.length > 4_000_000) {
|
|
25
|
+
rows.push(...a.map((l) => '- ' + l), ...b.map((l) => '+ ' + l));
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
// Longest common subsequence, from the end, then walk it from the start.
|
|
29
|
+
const dp = Array.from({ length: a.length + 1 }, () => new Uint32Array(b.length + 1));
|
|
30
|
+
for (let i = a.length - 1; i >= 0; i--)
|
|
31
|
+
for (let j = b.length - 1; j >= 0; j--)
|
|
32
|
+
dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
|
33
|
+
let i = 0;
|
|
34
|
+
let j = 0;
|
|
35
|
+
while (i < a.length || j < b.length) {
|
|
36
|
+
if (i < a.length && j < b.length && a[i] === b[j]) {
|
|
37
|
+
rows.push(' ' + a[i++]);
|
|
38
|
+
j++;
|
|
39
|
+
}
|
|
40
|
+
else if (i < a.length && (j === b.length || dp[i + 1][j] >= dp[i][j + 1]))
|
|
41
|
+
rows.push('- ' + a[i++]);
|
|
42
|
+
else
|
|
43
|
+
rows.push('+ ' + b[j++]);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const changed = rows.map((r) => !r.startsWith(' '));
|
|
47
|
+
const out = [];
|
|
48
|
+
for (let k = 0; k < rows.length; k++) {
|
|
49
|
+
if (changed.slice(Math.max(0, k - 2), k + 3).some(Boolean))
|
|
50
|
+
out.push(rows[k]);
|
|
51
|
+
else if (out[out.length - 1] !== ' ...')
|
|
52
|
+
out.push(' ...');
|
|
53
|
+
}
|
|
54
|
+
return out.join('\n');
|
|
55
|
+
}
|
|
56
|
+
/** 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. */
|
|
57
|
+
export function setupPreview(plan) {
|
|
58
|
+
const parts = [];
|
|
59
|
+
for (const f of plan.files) {
|
|
60
|
+
if (f.skill)
|
|
61
|
+
parts.push(`${f.action === 'create' ? 'install' : 'update'} skill ${f.file}`);
|
|
62
|
+
else if (f.action === 'remove')
|
|
63
|
+
parts.push(`remove ${f.file}`);
|
|
64
|
+
else
|
|
65
|
+
parts.push(`${f.action} ${f.file}\n${maskKeys(lineDiff(f.before, f.after))}`);
|
|
66
|
+
}
|
|
67
|
+
for (const r of plan.runs)
|
|
68
|
+
parts.push(`run ${r.cmd} ${r.args.join(' ')}`);
|
|
69
|
+
for (const s of plan.skipped)
|
|
70
|
+
parts.push(`skip ${s.agent}: ${s.reason}`);
|
|
71
|
+
for (const a of plan.agents) {
|
|
72
|
+
const steps = a.provider?.manual ?? [];
|
|
73
|
+
if (steps.length)
|
|
74
|
+
parts.push([`${SETUP_AGENTS[a.agent].label}, by hand:`, ...steps.map((s, k) => ` ${k + 1}. ${s}`)].join('\n'));
|
|
75
|
+
}
|
|
76
|
+
for (const n of plan.notes)
|
|
77
|
+
parts.push(n);
|
|
78
|
+
return parts.length ? parts.join('\n\n') : 'Nothing to change.';
|
|
79
|
+
}
|
|
80
|
+
function backupsFile(env) {
|
|
81
|
+
return path.join(stateDir(env), 'setup-backups.json');
|
|
82
|
+
}
|
|
83
|
+
function readBackups(env) {
|
|
84
|
+
try {
|
|
85
|
+
return JSON.parse(readFileSync(backupsFile(env), 'utf-8'));
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return {};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function writeBackups(env, entries) {
|
|
92
|
+
mkdirSync(stateDir(env), { recursive: true, mode: 0o700 });
|
|
93
|
+
writeFileSync(backupsFile(env), jsonText(entries), { mode: 0o600 });
|
|
94
|
+
}
|
|
95
|
+
const sha256 = (text) => createHash('sha256').update(text).digest('hex');
|
|
96
|
+
/**
|
|
97
|
+
* Writes a plan from {@link planSetup}. Before it edits a file it copies it
|
|
98
|
+
* to `<file>.phantom.bak`, unless the file is still as phantom last wrote it: then the
|
|
99
|
+
* copy from before phantom's first change stays. A file that changed after the
|
|
100
|
+
* plan read it is left alone and listed in `skipped`. Then it runs any MCP
|
|
101
|
+
* commands.
|
|
102
|
+
* @param exec Runs an agent's CLI, such as `claude mcp add`.
|
|
103
|
+
*/
|
|
104
|
+
export function applySetup(env, plan, exec = (cmd, args) => execFileSync(cmd, args, { stdio: 'ignore' })) {
|
|
105
|
+
const backups = readBackups(env);
|
|
106
|
+
const skipped = [...plan.skipped];
|
|
107
|
+
const files = [];
|
|
108
|
+
for (const f of plan.files) {
|
|
109
|
+
if (readText(f.file) !== f.before) {
|
|
110
|
+
skipped.push({ agent: f.agent, reason: `${f.file} changed after setup read it; run phantom setup again` });
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
let backup;
|
|
114
|
+
if (!f.skill) {
|
|
115
|
+
const prior = backups[f.file];
|
|
116
|
+
const keep = Boolean(prior && f.before !== null && prior.sha === sha256(f.before) && (prior.backup === null || existsSync(prior.backup)));
|
|
117
|
+
let from = keep ? prior.backup : null;
|
|
118
|
+
if (!keep && f.before !== null) {
|
|
119
|
+
from = `${f.file}.phantom.bak`;
|
|
120
|
+
writeFileSync(from, f.before, { mode: 0o600 });
|
|
121
|
+
}
|
|
122
|
+
if (from)
|
|
123
|
+
backup = from;
|
|
124
|
+
if (f.after === null && from === null)
|
|
125
|
+
delete backups[f.file];
|
|
126
|
+
else
|
|
127
|
+
backups[f.file] = { agent: f.agent, backup: from, sha: sha256(f.after ?? '') };
|
|
128
|
+
}
|
|
129
|
+
if (f.after === null)
|
|
130
|
+
rmSync(f.file, { force: true });
|
|
131
|
+
else {
|
|
132
|
+
mkdirSync(path.dirname(f.file), { recursive: true });
|
|
133
|
+
writeFileSync(f.file, f.after, f.mode ? { mode: f.mode } : {});
|
|
134
|
+
if (f.mode)
|
|
135
|
+
chmodSync(f.file, f.mode);
|
|
136
|
+
}
|
|
137
|
+
files.push({ agent: f.agent, file: f.file, action: f.action, ...(backup ? { backup } : {}), ...(f.skill ? {} : { diff: maskKeys(lineDiff(f.before, f.after)) }) });
|
|
138
|
+
}
|
|
139
|
+
if (plan.files.some((f) => !f.skill))
|
|
140
|
+
writeBackups(env, backups);
|
|
141
|
+
for (const r of plan.runs)
|
|
142
|
+
exec(r.cmd, r.args);
|
|
143
|
+
const agents = plan.agents.map((a) => ({ ...a, mcp_added: plan.mcp.includes(a.agent) }));
|
|
144
|
+
return { agents, files, ...(skipped.length ? { skipped } : {}), ...(plan.notes.length ? { notes: plan.notes } : {}) };
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Points Claude Code at Phantom AI, or removes phantom's settings with `off`.
|
|
148
|
+
* Uses the same backups and restore safeguards as {@link applySetup}.
|
|
149
|
+
* The return value remains the provider result for existing callers.
|
|
150
|
+
* @deprecated Use {@link setupAgents} or {@link planSetup} and {@link applySetup}. This helper remains for settings-only callers.
|
|
151
|
+
*/
|
|
152
|
+
export function claudeProvider(env, opts = {}) {
|
|
153
|
+
const provider = claudePlan(env, opts);
|
|
154
|
+
const files = provider.edits.map((edit) => {
|
|
155
|
+
const before = readText(edit.file);
|
|
156
|
+
return { ...edit, before, agent: 'claude', action: before === null ? 'create' : edit.after === null ? 'remove' : 'update' };
|
|
157
|
+
});
|
|
158
|
+
applySetup(env, { agents: [], files, runs: [], mcp: [], skipped: [], notes: [] });
|
|
159
|
+
return provider.result;
|
|
160
|
+
}
|
|
161
|
+
/** A plan as `--dry-run` reports it: each file that would change, with its diff, and nothing written. */
|
|
162
|
+
export function previewSetup(plan) {
|
|
163
|
+
return {
|
|
164
|
+
agents: plan.agents,
|
|
165
|
+
files: plan.files.map((f) => ({ agent: f.agent, file: f.file, action: f.action, ...(f.skill ? {} : { diff: maskKeys(lineDiff(f.before, f.after)) }) })),
|
|
166
|
+
...(plan.skipped.length ? { skipped: plan.skipped } : {}),
|
|
167
|
+
...(plan.notes.length ? { notes: plan.notes } : {}),
|
|
168
|
+
dry_run: true,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Installs the phantom-ai skill for one agent, or for every agent found
|
|
173
|
+
* (see {@link SETUP_AGENTS}), and with `mcp` and `provider` sets those up
|
|
174
|
+
* too, without asking: {@link planSetup}, then {@link applySetup}.
|
|
175
|
+
* @throws {@link CliError} when no agent is found, the agent is unknown, or `provider` is set without an agent that supports it.
|
|
176
|
+
*/
|
|
177
|
+
export function setupAgents(env, opts = {}) {
|
|
178
|
+
return applySetup(env, planSetup(env, opts), opts.exec);
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Puts back the files `phantom setup` changed, from the copies it kept
|
|
182
|
+
* (`<file>.phantom.bak`), and removes the files it created. Only a file still as
|
|
183
|
+
* phantom wrote it: one changed since (Codex adds `[projects]` tables to its
|
|
184
|
+
* config, for example) is left alone and listed, since the copy would undo
|
|
185
|
+
* that change too. `--provider off` removes phantom's lines from such a file.
|
|
186
|
+
* @param opts `agent` limits it to one agent's files (`shell` for the startup file).
|
|
187
|
+
*/
|
|
188
|
+
export function restoreSetup(env, opts = {}) {
|
|
189
|
+
const backups = readBackups(env);
|
|
190
|
+
const restored = [];
|
|
191
|
+
const skipped = [];
|
|
192
|
+
for (const [file, entry] of Object.entries(backups)) {
|
|
193
|
+
if (opts.agent && opts.agent !== 'all' && entry.agent !== opts.agent)
|
|
194
|
+
continue;
|
|
195
|
+
const now = readText(file);
|
|
196
|
+
if (sha256(now ?? '') !== entry.sha) {
|
|
197
|
+
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` });
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (entry.backup !== null && !existsSync(entry.backup)) {
|
|
201
|
+
skipped.push({ file, reason: `its copy ${entry.backup} is gone` });
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (!opts.dryRun) {
|
|
205
|
+
if (entry.backup === null)
|
|
206
|
+
rmSync(file, { force: true });
|
|
207
|
+
else {
|
|
208
|
+
writeFileSync(file, readFileSync(entry.backup));
|
|
209
|
+
rmSync(entry.backup);
|
|
210
|
+
}
|
|
211
|
+
delete backups[file];
|
|
212
|
+
}
|
|
213
|
+
restored.push({ file, from: entry.backup });
|
|
214
|
+
}
|
|
215
|
+
if (!opts.dryRun && restored.length)
|
|
216
|
+
writeBackups(env, backups);
|
|
217
|
+
return { restored, skipped, ...(opts.dryRun ? { dry_run: true } : {}) };
|
|
218
|
+
}
|