@guidobuilds/forge-ai 0.6.0 → 0.8.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/CHANGELOG.md +57 -0
- package/README.md +45 -12
- package/artifacts/forge/forge.md +7 -4
- package/artifacts/forge-adversary/forge-adversary.md +3 -0
- package/artifacts/forge-worker/forge-worker.md +4 -5
- package/artifacts/forge-worker-leaf/forge-worker-leaf.md +3 -0
- package/artifacts/using-forge/using-forge.md +2 -2
- package/bin/forge-ai.mjs +1 -1
- package/dist/src/adapters/claude-known.js +6 -2
- package/dist/src/adapters/codex-known.js +9 -0
- package/dist/src/adapters/codex.js +6 -1
- package/dist/src/adapters/grok.js +3 -0
- package/dist/src/adapters/opencode-known.js +15 -0
- package/dist/src/adapters/opencode.js +20 -4
- package/dist/src/adapters/shared.js +15 -1
- package/dist/src/cli.js +505 -24
- package/dist/src/compose.js +12 -0
- package/dist/src/dispatch-snippets.js +56 -0
- package/dist/src/executable-resolution.js +49 -0
- package/dist/src/manifest.js +172 -14
- package/dist/src/model-preferences.js +41 -0
- package/dist/src/opencode-discovery.js +32 -0
- package/dist/src/paths.js +55 -3
- package/dist/src/platform-capabilities.js +17 -0
- package/dist/src/processor.js +76 -22
- package/dist/src/self-update.js +50 -4
- package/dist/src/writer.js +10 -5
- package/package.json +3 -2
package/dist/src/cli.js
CHANGED
|
@@ -5,14 +5,21 @@ import { readFileSync } from 'node:fs';
|
|
|
5
5
|
import os from 'node:os';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { rm } from 'node:fs/promises';
|
|
9
|
+
import { knownClaudeModels } from './adapters/claude-known.js';
|
|
10
|
+
import { knownGrokModels } from './adapters/grok-known.js';
|
|
8
11
|
import { formatDiagnostic, hasErrors } from './diagnostics.js';
|
|
9
|
-
import { buildManifest, classifyPruneEntries, loadManifest, pruneEntries, resolveBackupPath, resolveBackupRoot, resolveManifestLocation, saveManifest, staleEntries } from './manifest.js';
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
+
import { buildManifest, classifyPruneEntries, detectLegacyStateDrift, listInstalls, loadManifest, pruneEntries, resolveBackupPath, resolveBackupRoot, resolveManifestLocation, saveManifest, stateRoot, staleEntries } from './manifest.js';
|
|
13
|
+
import { getModelPreference, loadModelPreferences, modelPreferencesPath, saveModelPreferences, setModelPreference } from './model-preferences.js';
|
|
14
|
+
import { discoverOpenCodeModels } from './opencode-discovery.js';
|
|
15
|
+
import { allowedInstallRoots } from './paths.js';
|
|
16
|
+
import { supportsModel } from './platform-capabilities.js';
|
|
17
|
+
import { buildWritePlan, discoverArtifacts, parsePlatform, parseScope, resolvePlatforms } from './processor.js';
|
|
18
|
+
import { isValidVersionSpec, normalizeVersionSpec, runSelfUpdate } from './self-update.js';
|
|
12
19
|
import { checkLatestVersion, formatVersionNotice } from './version-check.js';
|
|
13
20
|
import { writeOutputs } from './writer.js';
|
|
14
21
|
import { hasPendingDecisions } from './model.js';
|
|
15
|
-
const emptyPrunePlan = { deletable: [], modifiedWithConsent: [], skippedMissing: [] };
|
|
22
|
+
const emptyPrunePlan = { deletable: [], modifiedWithConsent: [], skippedMissing: [], skippedUnsafe: [] };
|
|
16
23
|
export async function main(argv = process.argv.slice(2), promptIO = {}) {
|
|
17
24
|
if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
|
|
18
25
|
showUsage();
|
|
@@ -42,8 +49,17 @@ export async function main(argv = process.argv.slice(2), promptIO = {}) {
|
|
|
42
49
|
skipSpecUpdate: options.skipSpecUpdate,
|
|
43
50
|
});
|
|
44
51
|
}
|
|
52
|
+
if (command === 'uninstall') {
|
|
53
|
+
return runUninstall(options, promptIO);
|
|
54
|
+
}
|
|
55
|
+
if (command === 'list') {
|
|
56
|
+
return runList(promptIO);
|
|
57
|
+
}
|
|
58
|
+
if (command === 'configure') {
|
|
59
|
+
return runConfigure(options, promptIO);
|
|
60
|
+
}
|
|
45
61
|
const versionCheckPromise = shouldCheckForUpdates(options, promptIO)
|
|
46
|
-
? checkLatestVersion({ current: readPackageVersion(), cachePath: path.join(resolveHome(promptIO), '
|
|
62
|
+
? checkLatestVersion({ current: readPackageVersion(), cachePath: path.join(stateRoot(resolveHome(promptIO)), 'version-check.json') }).catch(() => undefined)
|
|
47
63
|
: Promise.resolve(undefined);
|
|
48
64
|
try {
|
|
49
65
|
const install = command === 'install' || command === 'update';
|
|
@@ -54,24 +70,44 @@ export async function main(argv = process.argv.slice(2), promptIO = {}) {
|
|
|
54
70
|
const interactive = install && isInteractivePrompt(promptIO);
|
|
55
71
|
if (interactive)
|
|
56
72
|
p.intro(`${pc.bold('Forge AI')} ${pc.dim(command === 'update' ? 'updater' : 'installer')}`, clackIO(promptIO));
|
|
73
|
+
const cwd = process.cwd();
|
|
74
|
+
const home = resolveHome(promptIO);
|
|
75
|
+
const now = new Date();
|
|
57
76
|
if (install) {
|
|
58
|
-
const prompted = await promptForMissingInstallOptions(options, promptIO);
|
|
77
|
+
const prompted = await promptForMissingInstallOptions(options, promptIO, cwd, home);
|
|
59
78
|
if (!prompted) {
|
|
60
79
|
if (interactive)
|
|
61
80
|
p.cancel('Cancelled', clackIO(promptIO));
|
|
62
81
|
return 1;
|
|
63
82
|
}
|
|
64
83
|
}
|
|
65
|
-
const cwd = process.cwd();
|
|
66
|
-
const home = resolveHome(promptIO);
|
|
67
|
-
const now = new Date();
|
|
68
84
|
let manifestLocation;
|
|
69
85
|
let oldManifest;
|
|
70
86
|
let backupRoot;
|
|
87
|
+
let modelPreferences;
|
|
71
88
|
if (install) {
|
|
72
89
|
manifestLocation = await resolveManifestLocation(options.scope, cwd, home);
|
|
73
90
|
oldManifest = await loadManifest(manifestLocation.manifestPath);
|
|
74
91
|
backupRoot = resolveBackupRoot(manifestLocation, now);
|
|
92
|
+
const drift = await detectLegacyStateDrift(manifestLocation, home);
|
|
93
|
+
if (drift)
|
|
94
|
+
console.error(`Warning: ${drift}`);
|
|
95
|
+
const prefsPath = modelPreferencesPath(manifestLocation);
|
|
96
|
+
modelPreferences = await loadModelPreferences(prefsPath);
|
|
97
|
+
if (options.modelExplicit || options.modelMapExplicit) {
|
|
98
|
+
const { artifacts } = await discoverArtifacts(options.source);
|
|
99
|
+
const applied = applyModelFlags(modelPreferences, options, artifacts);
|
|
100
|
+
if ('error' in applied) {
|
|
101
|
+
console.error(applied.error);
|
|
102
|
+
return 1;
|
|
103
|
+
}
|
|
104
|
+
modelPreferences = applied.preferences;
|
|
105
|
+
await saveModelPreferences(prefsPath, modelPreferences);
|
|
106
|
+
}
|
|
107
|
+
else if (options.pendingModelPreferences) {
|
|
108
|
+
modelPreferences = options.pendingModelPreferences;
|
|
109
|
+
await saveModelPreferences(prefsPath, modelPreferences);
|
|
110
|
+
}
|
|
75
111
|
}
|
|
76
112
|
const plan = await buildWritePlan({
|
|
77
113
|
source: options.source,
|
|
@@ -82,10 +118,13 @@ export async function main(argv = process.argv.slice(2), promptIO = {}) {
|
|
|
82
118
|
manifest: oldManifest,
|
|
83
119
|
backupRoot,
|
|
84
120
|
checkCollisions: install,
|
|
121
|
+
modelPreferences,
|
|
85
122
|
});
|
|
86
123
|
let prunePlan = emptyPrunePlan;
|
|
124
|
+
let pruneRoots = [];
|
|
87
125
|
if (install && command === 'update' && options.prune) {
|
|
88
|
-
|
|
126
|
+
pruneRoots = allowedInstallRoots(options.scope, home, cwd, oldManifest?.projectPath);
|
|
127
|
+
prunePlan = await classifyPrune(oldManifest, plan.files, backupRoot, options.scope, cwd, home, pruneRoots);
|
|
89
128
|
}
|
|
90
129
|
const needsConfirm = install && !options.force && (hasPendingDecisions(plan.pending) || prunePlan.modifiedWithConsent.length > 0);
|
|
91
130
|
if (install && !options.dryRun && needsConfirm) {
|
|
@@ -117,8 +156,8 @@ export async function main(argv = process.argv.slice(2), promptIO = {}) {
|
|
|
117
156
|
try {
|
|
118
157
|
await writeOutputs(plan.files);
|
|
119
158
|
if (command === 'update' && options.prune)
|
|
120
|
-
await pruneEntries([...prunePlan.deletable, ...prunePlan.modifiedWithConsent]);
|
|
121
|
-
await saveManifest(manifestLocation.manifestPath, await buildManifest(manifestLocation, plan.files));
|
|
159
|
+
await pruneEntries([...prunePlan.deletable, ...prunePlan.modifiedWithConsent], pruneRoots);
|
|
160
|
+
await saveManifest(manifestLocation.manifestPath, await buildManifest(manifestLocation, plan.files, readPackageVersion(), now, oldManifest));
|
|
122
161
|
spinner.stop(`Wrote ${plan.files.length} file(s).`);
|
|
123
162
|
}
|
|
124
163
|
catch (error) {
|
|
@@ -129,8 +168,8 @@ export async function main(argv = process.argv.slice(2), promptIO = {}) {
|
|
|
129
168
|
else {
|
|
130
169
|
await writeOutputs(plan.files);
|
|
131
170
|
if (command === 'update' && options.prune)
|
|
132
|
-
await pruneEntries([...prunePlan.deletable, ...prunePlan.modifiedWithConsent]);
|
|
133
|
-
await saveManifest(manifestLocation.manifestPath, await buildManifest(manifestLocation, plan.files));
|
|
171
|
+
await pruneEntries([...prunePlan.deletable, ...prunePlan.modifiedWithConsent], pruneRoots);
|
|
172
|
+
await saveManifest(manifestLocation.manifestPath, await buildManifest(manifestLocation, plan.files, readPackageVersion(), now, oldManifest));
|
|
134
173
|
console.log(`Wrote ${plan.files.length} file(s).`);
|
|
135
174
|
const totalDeleted = prunePlan.deletable.length + prunePlan.modifiedWithConsent.length;
|
|
136
175
|
if (command === 'update' && options.prune && totalDeleted > 0)
|
|
@@ -156,7 +195,7 @@ export async function main(argv = process.argv.slice(2), promptIO = {}) {
|
|
|
156
195
|
}
|
|
157
196
|
}
|
|
158
197
|
function parseArgs(argv) {
|
|
159
|
-
const options = { command: argv[0], platform: 'all', scope: 'user', source: '.', dryRun: false, force: false, prune: true, yes: false, noUpdateCheck: false, skipSpecUpdate: false, platformExplicit: false, scopeExplicit: false, sourceExplicit: false };
|
|
198
|
+
const options = { command: argv[0], platform: 'all', scope: 'user', source: '.', dryRun: false, force: false, prune: true, yes: false, noUpdateCheck: false, skipSpecUpdate: false, platformExplicit: false, scopeExplicit: false, sourceExplicit: false, modelExplicit: false, modelMapExplicit: false };
|
|
160
199
|
for (let index = 1; index < argv.length; index += 1) {
|
|
161
200
|
const arg = argv[index];
|
|
162
201
|
if (arg === '--dry-run')
|
|
@@ -175,7 +214,9 @@ function parseArgs(argv) {
|
|
|
175
214
|
const value = argv[++index];
|
|
176
215
|
if (!value)
|
|
177
216
|
return { error: 'Missing --to value' };
|
|
178
|
-
|
|
217
|
+
if (!isValidVersionSpec(value))
|
|
218
|
+
return { error: `Invalid --to ${value}; expected a semver or "latest"` };
|
|
219
|
+
options.targetVersion = normalizeVersionSpec(value);
|
|
179
220
|
}
|
|
180
221
|
else if (arg === '--platform') {
|
|
181
222
|
const value = argv[++index];
|
|
@@ -200,6 +241,23 @@ function parseArgs(argv) {
|
|
|
200
241
|
options.source = value;
|
|
201
242
|
options.sourceExplicit = true;
|
|
202
243
|
}
|
|
244
|
+
else if (arg === '--model') {
|
|
245
|
+
const value = argv[++index];
|
|
246
|
+
if (!value)
|
|
247
|
+
return { error: 'Missing --model value' };
|
|
248
|
+
options.model = value;
|
|
249
|
+
options.modelExplicit = true;
|
|
250
|
+
}
|
|
251
|
+
else if (arg === '--model-map') {
|
|
252
|
+
const value = argv[++index];
|
|
253
|
+
if (!value)
|
|
254
|
+
return { error: 'Missing --model-map value' };
|
|
255
|
+
const modelMap = parseModelMap(value);
|
|
256
|
+
if (!modelMap)
|
|
257
|
+
return { error: `Invalid --model-map ${value}; expected name=model,name2=model2` };
|
|
258
|
+
options.modelMap = modelMap;
|
|
259
|
+
options.modelMapExplicit = true;
|
|
260
|
+
}
|
|
203
261
|
else {
|
|
204
262
|
return { error: `Unknown argument ${arg}` };
|
|
205
263
|
}
|
|
@@ -211,31 +269,45 @@ function parseArgs(argv) {
|
|
|
211
269
|
return { error: 'validate only accepts --platform and --source' };
|
|
212
270
|
if (command !== 'self-update' && (options.targetVersion !== undefined || options.skipSpecUpdate))
|
|
213
271
|
return { error: '--to and --skip-spec-update are only accepted for self-update' };
|
|
272
|
+
if ((options.modelExplicit || options.modelMapExplicit) && (command !== 'install' && command !== 'update' && command !== 'configure'))
|
|
273
|
+
return { error: '--model and --model-map are only accepted for install, update, and configure' };
|
|
274
|
+
if ((options.modelExplicit || options.modelMapExplicit) && (!options.platformExplicit || options.platform === 'all'))
|
|
275
|
+
return { error: '--model and --model-map require an explicit single --platform (model ids are not portable across platforms)' };
|
|
214
276
|
if (command === 'self-update' && (options.platformExplicit || options.scopeExplicit || options.sourceExplicit || options.force || options.yes))
|
|
215
277
|
return { error: 'self-update only accepts --to, --dry-run, --skip-spec-update' };
|
|
278
|
+
if (command === 'uninstall' && options.sourceExplicit)
|
|
279
|
+
return { error: 'uninstall does not accept --source' };
|
|
280
|
+
if (command === 'list' && (options.platformExplicit || options.scopeExplicit || options.sourceExplicit || options.dryRun || options.force || options.yes))
|
|
281
|
+
return { error: 'list does not accept any flags' };
|
|
282
|
+
if (command === 'configure' && (options.force || options.yes))
|
|
283
|
+
return { error: 'configure does not accept --force or --yes; use --model/--model-map for non-interactive use' };
|
|
216
284
|
return { options };
|
|
217
285
|
}
|
|
218
|
-
async function classifyPrune(oldManifest, files, backupRoot, scope, cwd, home) {
|
|
286
|
+
async function classifyPrune(oldManifest, files, backupRoot, scope, cwd, home, roots) {
|
|
219
287
|
const stale = staleEntries(oldManifest, files);
|
|
220
|
-
const classified = await classifyPruneEntries(stale);
|
|
288
|
+
const classified = await classifyPruneEntries(stale, roots);
|
|
221
289
|
const anchor = scope === 'user' ? home : cwd;
|
|
222
290
|
const modifiedWithConsent = [];
|
|
223
291
|
const skippedMissing = [];
|
|
292
|
+
const skippedUnsafe = [];
|
|
224
293
|
for (const item of classified.skipped) {
|
|
225
294
|
if (item.reason === 'checksum-mismatch')
|
|
226
295
|
modifiedWithConsent.push({ ...item, backupPath: resolveBackupPath(backupRoot, item.path, anchor) });
|
|
296
|
+
else if (item.reason === 'unsafe-path')
|
|
297
|
+
skippedUnsafe.push(item);
|
|
227
298
|
else
|
|
228
299
|
skippedMissing.push(item);
|
|
229
300
|
}
|
|
230
|
-
return { deletable: classified.deletable, modifiedWithConsent, skippedMissing };
|
|
301
|
+
return { deletable: classified.deletable, modifiedWithConsent, skippedMissing, skippedUnsafe };
|
|
231
302
|
}
|
|
232
|
-
async function promptForMissingInstallOptions(options, promptIO) {
|
|
303
|
+
async function promptForMissingInstallOptions(options, promptIO, cwd, home) {
|
|
233
304
|
if (options.yes)
|
|
234
305
|
return true;
|
|
235
|
-
if (options.platformExplicit && options.scopeExplicit)
|
|
236
|
-
return true;
|
|
237
306
|
if (!isInteractivePrompt(promptIO))
|
|
238
307
|
return true;
|
|
308
|
+
const modelFlagsGiven = options.modelExplicit || options.modelMapExplicit;
|
|
309
|
+
if (options.platformExplicit && options.scopeExplicit && modelFlagsGiven)
|
|
310
|
+
return true;
|
|
239
311
|
const io = clackIO(promptIO);
|
|
240
312
|
if (!options.platformExplicit) {
|
|
241
313
|
const platform = await p.select({
|
|
@@ -268,8 +340,132 @@ async function promptForMissingInstallOptions(options, promptIO) {
|
|
|
268
340
|
return false;
|
|
269
341
|
options.scope = scope;
|
|
270
342
|
}
|
|
343
|
+
if (!modelFlagsGiven) {
|
|
344
|
+
return promptForModelSelection(options, promptIO, cwd, home);
|
|
345
|
+
}
|
|
271
346
|
return true;
|
|
272
347
|
}
|
|
348
|
+
function modelablePairs(artifacts, targetPlatforms) {
|
|
349
|
+
const pairs = [];
|
|
350
|
+
for (const platform of targetPlatforms) {
|
|
351
|
+
for (const artifact of artifacts) {
|
|
352
|
+
const effectiveKind = artifact[platform]?.kind ?? artifact.kind;
|
|
353
|
+
if (supportsModel(platform, effectiveKind))
|
|
354
|
+
pairs.push({ platform, artifact });
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return pairs;
|
|
358
|
+
}
|
|
359
|
+
const CONFIRM_PROMPT_COUNT_ABOVE = 6;
|
|
360
|
+
// Returns false only on user cancellation (Ctrl+C mid-prompt) — declining the "choose per agent"
|
|
361
|
+
// path, or there being nothing to choose, both return true (proceed with defaults).
|
|
362
|
+
async function promptForModelSelection(options, promptIO, cwd, home) {
|
|
363
|
+
const io = clackIO(promptIO);
|
|
364
|
+
const { artifacts } = await discoverArtifacts(options.source);
|
|
365
|
+
const targetPlatforms = resolvePlatforms(options.platform);
|
|
366
|
+
const pairs = modelablePairs(artifacts, targetPlatforms);
|
|
367
|
+
if (pairs.length === 0)
|
|
368
|
+
return true;
|
|
369
|
+
const mode = await p.select({
|
|
370
|
+
message: 'Model selection',
|
|
371
|
+
initialValue: 'default',
|
|
372
|
+
options: [
|
|
373
|
+
{ value: 'default', label: "Use each agent's recommended default" },
|
|
374
|
+
{ value: 'per-agent', label: 'Choose per agent' }
|
|
375
|
+
],
|
|
376
|
+
...io
|
|
377
|
+
});
|
|
378
|
+
if (p.isCancel(mode))
|
|
379
|
+
return false;
|
|
380
|
+
if (mode === 'default')
|
|
381
|
+
return true;
|
|
382
|
+
if (pairs.length > CONFIRM_PROMPT_COUNT_ABOVE) {
|
|
383
|
+
const proceed = await p.confirm({
|
|
384
|
+
message: `This will ask ${pairs.length} questions (one per agent per platform). Continue?`,
|
|
385
|
+
active: 'Continue',
|
|
386
|
+
inactive: 'Use defaults instead',
|
|
387
|
+
initialValue: true,
|
|
388
|
+
...io
|
|
389
|
+
});
|
|
390
|
+
if (p.isCancel(proceed))
|
|
391
|
+
return false;
|
|
392
|
+
if (!proceed)
|
|
393
|
+
return true;
|
|
394
|
+
}
|
|
395
|
+
const manifestLocation = await resolveManifestLocation(options.scope, cwd, home);
|
|
396
|
+
let preferences = await loadModelPreferences(modelPreferencesPath(manifestLocation));
|
|
397
|
+
const discoveredOpenCodeModels = targetPlatforms.includes('opencode') ? discoverOpenCodeModels(cwd) : undefined;
|
|
398
|
+
for (const { platform, artifact } of pairs) {
|
|
399
|
+
const current = getModelPreference(preferences, platform, artifact.name) ?? artifact[platform]?.model;
|
|
400
|
+
const chosen = await promptForModelValue(platform, artifact.name, current, discoveredOpenCodeModels, promptIO);
|
|
401
|
+
if (chosen === undefined)
|
|
402
|
+
return false;
|
|
403
|
+
preferences = setModelPreference(preferences, platform, artifact.name, chosen);
|
|
404
|
+
}
|
|
405
|
+
options.pendingModelPreferences = preferences;
|
|
406
|
+
return true;
|
|
407
|
+
}
|
|
408
|
+
const CUSTOM_MODEL_VALUE = '__custom__';
|
|
409
|
+
async function promptForModelValue(platform, artifactName, current, discoveredOpenCodeModels, promptIO) {
|
|
410
|
+
const io = clackIO(promptIO);
|
|
411
|
+
const knownChoices = platform === 'opencode' && discoveredOpenCodeModels ? discoveredOpenCodeModels
|
|
412
|
+
: platform === 'claude' ? [...knownClaudeModels]
|
|
413
|
+
: platform === 'grok' ? [...knownGrokModels]
|
|
414
|
+
: undefined;
|
|
415
|
+
if (knownChoices && knownChoices.length > 0) {
|
|
416
|
+
const initialValue = current && knownChoices.includes(current) ? current : knownChoices[0];
|
|
417
|
+
const choice = await p.select({
|
|
418
|
+
message: `Model for \`${artifactName}\` (${platform})`,
|
|
419
|
+
initialValue,
|
|
420
|
+
options: [...knownChoices.map((value) => ({ value, label: value })), { value: CUSTOM_MODEL_VALUE, label: 'Custom…' }],
|
|
421
|
+
...io
|
|
422
|
+
});
|
|
423
|
+
if (p.isCancel(choice))
|
|
424
|
+
return undefined;
|
|
425
|
+
if (choice !== CUSTOM_MODEL_VALUE)
|
|
426
|
+
return choice;
|
|
427
|
+
}
|
|
428
|
+
const text = await p.text({
|
|
429
|
+
message: `Model for \`${artifactName}\` (${platform})`,
|
|
430
|
+
initialValue: current,
|
|
431
|
+
validate: (value) => (value && value.trim().length > 0 ? undefined : 'A model id is required'),
|
|
432
|
+
...io
|
|
433
|
+
});
|
|
434
|
+
if (p.isCancel(text))
|
|
435
|
+
return undefined;
|
|
436
|
+
return text;
|
|
437
|
+
}
|
|
438
|
+
function parseModelMap(value) {
|
|
439
|
+
const result = {};
|
|
440
|
+
for (const pair of value.split(',')) {
|
|
441
|
+
const [name, model] = pair.split('=').map((part) => part?.trim());
|
|
442
|
+
if (!name || !model)
|
|
443
|
+
return undefined;
|
|
444
|
+
result[name] = model;
|
|
445
|
+
}
|
|
446
|
+
return Object.keys(result).length > 0 ? result : undefined;
|
|
447
|
+
}
|
|
448
|
+
function applyModelFlags(preferences, options, artifacts) {
|
|
449
|
+
const targetPlatforms = resolvePlatforms(options.platform);
|
|
450
|
+
const platform = targetPlatforms[0];
|
|
451
|
+
let next = preferences;
|
|
452
|
+
if (options.model) {
|
|
453
|
+
for (const artifact of artifacts) {
|
|
454
|
+
const effectiveKind = artifact[platform]?.kind ?? artifact.kind;
|
|
455
|
+
if (supportsModel(platform, effectiveKind))
|
|
456
|
+
next = setModelPreference(next, platform, artifact.name, options.model);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
if (options.modelMap) {
|
|
460
|
+
const knownNames = new Set(artifacts.map((artifact) => artifact.name));
|
|
461
|
+
for (const [name, model] of Object.entries(options.modelMap)) {
|
|
462
|
+
if (!knownNames.has(name))
|
|
463
|
+
return { error: `--model-map references unknown artifact "${name}"` };
|
|
464
|
+
next = setModelPreference(next, platform, name, model);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return { preferences: next };
|
|
468
|
+
}
|
|
273
469
|
async function promptForUpdate(plan, prunePlan, backupRoot, promptIO) {
|
|
274
470
|
if (!isInteractivePrompt(promptIO))
|
|
275
471
|
return false;
|
|
@@ -296,6 +492,279 @@ async function promptForUpdate(plan, prunePlan, backupRoot, promptIO) {
|
|
|
296
492
|
return undefined;
|
|
297
493
|
return accepted;
|
|
298
494
|
}
|
|
495
|
+
async function runUninstall(options, promptIO) {
|
|
496
|
+
if (options.yes)
|
|
497
|
+
options.force = true;
|
|
498
|
+
const interactive = isInteractivePrompt(promptIO);
|
|
499
|
+
if (interactive)
|
|
500
|
+
p.intro(`${pc.bold('Forge AI')} ${pc.dim('uninstaller')}`, clackIO(promptIO));
|
|
501
|
+
const prompted = await promptForMissingUninstallOptions(options, promptIO);
|
|
502
|
+
if (!prompted) {
|
|
503
|
+
if (interactive)
|
|
504
|
+
p.cancel('Cancelled', clackIO(promptIO));
|
|
505
|
+
return 1;
|
|
506
|
+
}
|
|
507
|
+
const cwd = process.cwd();
|
|
508
|
+
const home = resolveHome(promptIO);
|
|
509
|
+
const now = new Date();
|
|
510
|
+
const manifestLocation = await resolveManifestLocation(options.scope, cwd, home);
|
|
511
|
+
const manifest = await loadManifest(manifestLocation.manifestPath);
|
|
512
|
+
const drift = await detectLegacyStateDrift(manifestLocation, home);
|
|
513
|
+
if (drift)
|
|
514
|
+
console.error(`Warning: ${drift}`);
|
|
515
|
+
if (!manifest || manifest.entries.length === 0) {
|
|
516
|
+
console.log(`Nothing to uninstall for ${options.scope} scope.`);
|
|
517
|
+
return 0;
|
|
518
|
+
}
|
|
519
|
+
const targeted = options.platform === 'all' ? manifest.entries : manifest.entries.filter((entry) => entry.platform === options.platform);
|
|
520
|
+
if (targeted.length === 0) {
|
|
521
|
+
console.log(`No ${options.platform} files recorded for this ${options.scope} install.`);
|
|
522
|
+
return 0;
|
|
523
|
+
}
|
|
524
|
+
const backupRoot = resolveBackupRoot(manifestLocation, now);
|
|
525
|
+
const anchor = options.scope === 'user' ? home : cwd;
|
|
526
|
+
const roots = allowedInstallRoots(options.scope, home, cwd, manifest.projectPath);
|
|
527
|
+
const classified = await classifyPruneEntries(targeted, roots);
|
|
528
|
+
const modifiedWithConsent = classified.skipped
|
|
529
|
+
.filter((item) => item.reason === 'checksum-mismatch')
|
|
530
|
+
.map((item) => ({ ...item, backupPath: resolveBackupPath(backupRoot, item.path, anchor) }));
|
|
531
|
+
const skippedMissing = classified.skipped.filter((item) => item.reason === 'missing');
|
|
532
|
+
const skippedUnsafe = classified.skipped.filter((item) => item.reason === 'unsafe-path');
|
|
533
|
+
const needsConfirm = !options.force && modifiedWithConsent.length > 0;
|
|
534
|
+
if (!options.dryRun && needsConfirm) {
|
|
535
|
+
if (!interactive) {
|
|
536
|
+
printUninstallPlan(classified.deletable, modifiedWithConsent, skippedMissing, skippedUnsafe);
|
|
537
|
+
console.error('Forge needs your decision on edited files; re-run with --yes or --force to accept deletion + backup.');
|
|
538
|
+
return 1;
|
|
539
|
+
}
|
|
540
|
+
const accepted = await promptForUninstall(classified.deletable, modifiedWithConsent, backupRoot, promptIO);
|
|
541
|
+
if (accepted === undefined) {
|
|
542
|
+
p.cancel('Cancelled', clackIO(promptIO));
|
|
543
|
+
return 1;
|
|
544
|
+
}
|
|
545
|
+
if (!accepted) {
|
|
546
|
+
p.outro(pc.yellow('Forge was not uninstalled.'), clackIO(promptIO));
|
|
547
|
+
return 1;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
printUninstallPlan(classified.deletable, modifiedWithConsent, skippedMissing, skippedUnsafe);
|
|
551
|
+
if (options.dryRun) {
|
|
552
|
+
if (interactive)
|
|
553
|
+
p.outro(pc.cyan('Dry run complete.'), clackIO(promptIO));
|
|
554
|
+
return 0;
|
|
555
|
+
}
|
|
556
|
+
const toRemove = [...classified.deletable, ...modifiedWithConsent];
|
|
557
|
+
await pruneEntries(toRemove, roots);
|
|
558
|
+
const targetedPaths = new Set(targeted.map((entry) => entry.path));
|
|
559
|
+
const remaining = manifest.entries.filter((entry) => !targetedPaths.has(entry.path));
|
|
560
|
+
if (remaining.length === 0) {
|
|
561
|
+
await rm(manifestLocation.manifestPath, { force: true });
|
|
562
|
+
}
|
|
563
|
+
else {
|
|
564
|
+
await saveManifest(manifestLocation.manifestPath, { ...manifest, entries: remaining, updatedAt: now.toISOString() });
|
|
565
|
+
}
|
|
566
|
+
console.log(`Removed ${toRemove.length} file(s).`);
|
|
567
|
+
if (skippedMissing.length > 0)
|
|
568
|
+
console.log(`${skippedMissing.length} file(s) were already missing.`);
|
|
569
|
+
if (skippedUnsafe.length > 0)
|
|
570
|
+
console.error(`Warning: skipped ${skippedUnsafe.length} file(s) outside Forge's install roots (unsafe path); they were NOT removed.`);
|
|
571
|
+
if (interactive)
|
|
572
|
+
p.outro(pc.green('Forge was uninstalled.'), clackIO(promptIO));
|
|
573
|
+
return 0;
|
|
574
|
+
}
|
|
575
|
+
async function promptForMissingUninstallOptions(options, promptIO) {
|
|
576
|
+
if (options.yes)
|
|
577
|
+
return true;
|
|
578
|
+
if (options.platformExplicit && options.scopeExplicit)
|
|
579
|
+
return true;
|
|
580
|
+
if (!isInteractivePrompt(promptIO))
|
|
581
|
+
return true;
|
|
582
|
+
const io = clackIO(promptIO);
|
|
583
|
+
if (!options.platformExplicit) {
|
|
584
|
+
const platform = await p.select({
|
|
585
|
+
message: 'Uninstall Forge for which coding agent?',
|
|
586
|
+
initialValue: 'all',
|
|
587
|
+
options: [
|
|
588
|
+
{ value: 'all', label: 'All supported agents', hint: 'OpenCode, Codex, Claude Code, and Grok Build' },
|
|
589
|
+
{ value: 'opencode', label: 'OpenCode' },
|
|
590
|
+
{ value: 'codex', label: 'Codex' },
|
|
591
|
+
{ value: 'claude', label: 'Claude Code' },
|
|
592
|
+
{ value: 'grok', label: 'Grok Build' }
|
|
593
|
+
],
|
|
594
|
+
...io
|
|
595
|
+
});
|
|
596
|
+
if (p.isCancel(platform))
|
|
597
|
+
return false;
|
|
598
|
+
options.platform = platform;
|
|
599
|
+
}
|
|
600
|
+
if (!options.scopeExplicit) {
|
|
601
|
+
const scope = await p.select({
|
|
602
|
+
message: 'Uninstall Forge from which scope?',
|
|
603
|
+
initialValue: 'user',
|
|
604
|
+
options: [
|
|
605
|
+
{ value: 'user', label: 'User', hint: 'Installed for every project' },
|
|
606
|
+
{ value: 'project', label: 'Project', hint: 'Only this repository' }
|
|
607
|
+
],
|
|
608
|
+
...io
|
|
609
|
+
});
|
|
610
|
+
if (p.isCancel(scope))
|
|
611
|
+
return false;
|
|
612
|
+
options.scope = scope;
|
|
613
|
+
}
|
|
614
|
+
return true;
|
|
615
|
+
}
|
|
616
|
+
async function promptForUninstall(deletable, modifiedWithConsent, backupRoot, promptIO) {
|
|
617
|
+
if (!isInteractivePrompt(promptIO))
|
|
618
|
+
return false;
|
|
619
|
+
const io = clackIO(promptIO);
|
|
620
|
+
p.log.warn(`${pc.yellow('Edited by you, will be deleted (backup):')}\n${modifiedWithConsent.map((entry) => ` - ${entry.path}`).join('\n')}\n\nBackups → ${backupRoot}`, io);
|
|
621
|
+
const accepted = await p.confirm({
|
|
622
|
+
message: 'Continue with deletion + backup?',
|
|
623
|
+
active: 'Continue',
|
|
624
|
+
inactive: 'Cancel',
|
|
625
|
+
initialValue: false,
|
|
626
|
+
...io
|
|
627
|
+
});
|
|
628
|
+
if (p.isCancel(accepted))
|
|
629
|
+
return undefined;
|
|
630
|
+
return accepted;
|
|
631
|
+
}
|
|
632
|
+
function printUninstallPlan(deletable, modifiedWithConsent, skippedMissing, skippedUnsafe) {
|
|
633
|
+
console.log(`uninstall: ${deletable.length + modifiedWithConsent.length} file(s) to remove`);
|
|
634
|
+
for (const entry of deletable)
|
|
635
|
+
console.log(`- delete ${entry.platform} ${entry.kind} ${entry.name} -> ${entry.path}`);
|
|
636
|
+
for (const entry of modifiedWithConsent)
|
|
637
|
+
console.log(`- delete ${entry.platform} ${entry.kind} ${entry.name} -> ${entry.path} [backup -> ${entry.backupPath}]`);
|
|
638
|
+
for (const entry of skippedMissing)
|
|
639
|
+
console.log(`- skip missing ${entry.platform} ${entry.kind} ${entry.name} -> ${entry.path}`);
|
|
640
|
+
for (const entry of skippedUnsafe)
|
|
641
|
+
console.log(`- skip unsafe ${entry.platform} ${entry.kind} ${entry.name} -> ${entry.path}`);
|
|
642
|
+
}
|
|
643
|
+
async function runList(promptIO) {
|
|
644
|
+
const home = resolveHome(promptIO);
|
|
645
|
+
const summaries = await listInstalls(home);
|
|
646
|
+
if (summaries.length === 0) {
|
|
647
|
+
console.log('No Forge installs recorded.');
|
|
648
|
+
return 0;
|
|
649
|
+
}
|
|
650
|
+
for (const summary of summaries) {
|
|
651
|
+
console.log(formatInstallSummary(summary));
|
|
652
|
+
if (summary.driftWarning)
|
|
653
|
+
console.error(`Warning: ${summary.driftWarning}`);
|
|
654
|
+
}
|
|
655
|
+
return 0;
|
|
656
|
+
}
|
|
657
|
+
function formatInstallSummary(summary) {
|
|
658
|
+
const target = summary.scope === 'user' ? 'user' : `project ${summary.projectPath ?? '(unknown path)'}`;
|
|
659
|
+
const platforms = summary.platforms.length > 0 ? summary.platforms.join(', ') : 'none';
|
|
660
|
+
return `${target}: forge ${summary.forgeVersion}, ${summary.fileCount} file(s), platforms: ${platforms}, updated ${summary.updatedAt}`;
|
|
661
|
+
}
|
|
662
|
+
async function runConfigure(options, promptIO) {
|
|
663
|
+
const interactive = isInteractivePrompt(promptIO);
|
|
664
|
+
if (interactive)
|
|
665
|
+
p.intro(`${pc.bold('Forge AI')} ${pc.dim('configure')}`, clackIO(promptIO));
|
|
666
|
+
if (!options.scopeExplicit) {
|
|
667
|
+
if (!interactive) {
|
|
668
|
+
console.error('forge-ai configure needs --scope when not run interactively.');
|
|
669
|
+
return 1;
|
|
670
|
+
}
|
|
671
|
+
const scope = await p.select({
|
|
672
|
+
message: 'Configure which scope?',
|
|
673
|
+
initialValue: 'user',
|
|
674
|
+
options: [
|
|
675
|
+
{ value: 'user', label: 'User', hint: 'Available in every project' },
|
|
676
|
+
{ value: 'project', label: 'Project', hint: 'Only this repository' }
|
|
677
|
+
],
|
|
678
|
+
...clackIO(promptIO)
|
|
679
|
+
});
|
|
680
|
+
if (p.isCancel(scope)) {
|
|
681
|
+
p.cancel('Cancelled', clackIO(promptIO));
|
|
682
|
+
return 1;
|
|
683
|
+
}
|
|
684
|
+
options.scope = scope;
|
|
685
|
+
}
|
|
686
|
+
const cwd = process.cwd();
|
|
687
|
+
const home = resolveHome(promptIO);
|
|
688
|
+
const now = new Date();
|
|
689
|
+
const manifestLocation = await resolveManifestLocation(options.scope, cwd, home);
|
|
690
|
+
const manifest = await loadManifest(manifestLocation.manifestPath);
|
|
691
|
+
const drift = await detectLegacyStateDrift(manifestLocation, home);
|
|
692
|
+
if (drift)
|
|
693
|
+
console.error(`Warning: ${drift}`);
|
|
694
|
+
if (!manifest || manifest.entries.length === 0) {
|
|
695
|
+
console.log(`Nothing installed for ${options.scope} scope — run \`forge-ai install\` first.`);
|
|
696
|
+
return 0;
|
|
697
|
+
}
|
|
698
|
+
const installedPlatforms = [...new Set(manifest.entries.map((entry) => entry.platform))];
|
|
699
|
+
const targetPlatforms = options.platformExplicit ? installedPlatforms.filter((platform) => platform === options.platform) : installedPlatforms;
|
|
700
|
+
if (targetPlatforms.length === 0) {
|
|
701
|
+
console.log(`${options.platform} is not installed for ${options.scope} scope.`);
|
|
702
|
+
return 0;
|
|
703
|
+
}
|
|
704
|
+
const source = options.sourceExplicit ? options.source : bundledSourceRoot();
|
|
705
|
+
const { artifacts } = await discoverArtifacts(source);
|
|
706
|
+
const prefsPath = modelPreferencesPath(manifestLocation);
|
|
707
|
+
let preferences = await loadModelPreferences(prefsPath);
|
|
708
|
+
if (options.modelExplicit || options.modelMapExplicit) {
|
|
709
|
+
const applied = applyModelFlags(preferences, options, artifacts);
|
|
710
|
+
if ('error' in applied) {
|
|
711
|
+
console.error(applied.error);
|
|
712
|
+
return 1;
|
|
713
|
+
}
|
|
714
|
+
preferences = applied.preferences;
|
|
715
|
+
}
|
|
716
|
+
else {
|
|
717
|
+
if (!interactive) {
|
|
718
|
+
console.error('forge-ai configure needs --model or --model-map when not run interactively.');
|
|
719
|
+
return 1;
|
|
720
|
+
}
|
|
721
|
+
const pairs = modelablePairs(artifacts, targetPlatforms);
|
|
722
|
+
if (pairs.length === 0) {
|
|
723
|
+
console.log('No installed agent on the selected scope supports a configurable model.');
|
|
724
|
+
return 0;
|
|
725
|
+
}
|
|
726
|
+
const discoveredOpenCodeModels = targetPlatforms.includes('opencode') ? discoverOpenCodeModels(cwd) : undefined;
|
|
727
|
+
for (const { platform, artifact } of pairs) {
|
|
728
|
+
const current = getModelPreference(preferences, platform, artifact.name) ?? artifact[platform]?.model;
|
|
729
|
+
const chosen = await promptForModelValue(platform, artifact.name, current, discoveredOpenCodeModels, promptIO);
|
|
730
|
+
if (chosen === undefined) {
|
|
731
|
+
p.cancel('Cancelled', clackIO(promptIO));
|
|
732
|
+
return 1;
|
|
733
|
+
}
|
|
734
|
+
preferences = setModelPreference(preferences, platform, artifact.name, chosen);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
await saveModelPreferences(prefsPath, preferences);
|
|
738
|
+
const backupRoot = resolveBackupRoot(manifestLocation, now);
|
|
739
|
+
let files = [];
|
|
740
|
+
let diagnostics = [];
|
|
741
|
+
for (const platform of targetPlatforms) {
|
|
742
|
+
const result = await buildWritePlan({ source, platform, scope: options.scope, cwd, home, manifest, backupRoot, checkCollisions: true, modelPreferences: preferences });
|
|
743
|
+
files = files.concat(result.files);
|
|
744
|
+
diagnostics = diagnostics.concat(result.diagnostics);
|
|
745
|
+
}
|
|
746
|
+
console.log(`configure: ${files.length} file(s) to update`);
|
|
747
|
+
for (const file of files)
|
|
748
|
+
console.log(`- ${file.platform} ${file.kind} ${file.name} -> ${file.path}${statusSuffix(file)}`);
|
|
749
|
+
for (const item of diagnostics)
|
|
750
|
+
console.log(formatDiagnostic(item));
|
|
751
|
+
if (hasErrors(diagnostics)) {
|
|
752
|
+
if (interactive)
|
|
753
|
+
p.outro(pc.red('Forge was not reconfigured.'), clackIO(promptIO));
|
|
754
|
+
return 1;
|
|
755
|
+
}
|
|
756
|
+
if (options.dryRun) {
|
|
757
|
+
if (interactive)
|
|
758
|
+
p.outro(pc.cyan('Dry run complete. Model preferences were saved; files were not written.'), clackIO(promptIO));
|
|
759
|
+
return 0;
|
|
760
|
+
}
|
|
761
|
+
await writeOutputs(files);
|
|
762
|
+
await saveManifest(manifestLocation.manifestPath, await buildManifest(manifestLocation, files, readPackageVersion(), now, manifest));
|
|
763
|
+
console.log(`Updated ${files.length} file(s) with new model preferences.`);
|
|
764
|
+
if (interactive)
|
|
765
|
+
p.outro(pc.green('Forge is reconfigured.'), clackIO(promptIO));
|
|
766
|
+
return 0;
|
|
767
|
+
}
|
|
299
768
|
function normalizeCommand(command) {
|
|
300
769
|
if (command === 'install' || command === 'i')
|
|
301
770
|
return 'install';
|
|
@@ -303,6 +772,12 @@ function normalizeCommand(command) {
|
|
|
303
772
|
return 'update';
|
|
304
773
|
if (command === 'validate')
|
|
305
774
|
return 'validate';
|
|
775
|
+
if (command === 'uninstall')
|
|
776
|
+
return 'uninstall';
|
|
777
|
+
if (command === 'list' || command === 'ls')
|
|
778
|
+
return 'list';
|
|
779
|
+
if (command === 'configure')
|
|
780
|
+
return 'configure';
|
|
306
781
|
if (command === 'self-update')
|
|
307
782
|
return 'self-update';
|
|
308
783
|
return undefined;
|
|
@@ -344,11 +819,15 @@ function readPackageVersion() {
|
|
|
344
819
|
}
|
|
345
820
|
}
|
|
346
821
|
function showUsage() {
|
|
347
|
-
console.log('Usage: forge-ai install [--platform opencode|claude|codex|grok|all] [--scope user|project] [--source <dir>] [--dry-run] [--force] [--yes]');
|
|
348
|
-
console.log(' forge-ai update [--platform opencode|claude|codex|grok|all] [--scope user|project] [--source <dir>] [--dry-run] [--no-prune] [--yes]');
|
|
822
|
+
console.log('Usage: forge-ai install [--platform opencode|claude|codex|grok|all] [--scope user|project] [--source <dir>] [--dry-run] [--force] [--yes] [--model <id>] [--model-map name=model,...]');
|
|
823
|
+
console.log(' forge-ai update [--platform opencode|claude|codex|grok|all] [--scope user|project] [--source <dir>] [--dry-run] [--no-prune] [--yes] [--model <id>] [--model-map name=model,...]');
|
|
824
|
+
console.log(' forge-ai uninstall [--platform opencode|claude|codex|grok|all] [--scope user|project] [--dry-run] [--force] [--yes]');
|
|
825
|
+
console.log(' forge-ai list');
|
|
826
|
+
console.log(' forge-ai configure [--platform opencode|claude|codex|grok] [--scope user|project] [--source <dir>] [--dry-run] [--model <id>] [--model-map name=model,...]');
|
|
349
827
|
console.log(' forge-ai validate [--platform opencode|claude|codex|grok|all] [--source <dir>]');
|
|
350
828
|
console.log(' forge-ai self-update [--to <version>] [--dry-run] [--skip-spec-update]');
|
|
351
829
|
console.log('');
|
|
830
|
+
console.log('--model and --model-map require an explicit single --platform (model ids are not portable across platforms).');
|
|
352
831
|
console.log('Global flags: --no-update-check (also FORGE_NO_UPDATE_CHECK=1 or CI=true)');
|
|
353
832
|
}
|
|
354
833
|
function printPlan(command, sourceCount, files, diagnostics, prunePlan) {
|
|
@@ -361,6 +840,8 @@ function printPlan(command, sourceCount, files, diagnostics, prunePlan) {
|
|
|
361
840
|
console.log(`- delete stale ${item.platform} ${item.kind} ${item.name} -> ${item.path} [backup -> ${item.backupPath}]`);
|
|
362
841
|
for (const item of prunePlan.skippedMissing)
|
|
363
842
|
console.log(`- skip missing ${item.platform} ${item.kind} ${item.name} -> ${item.path}`);
|
|
843
|
+
for (const item of prunePlan.skippedUnsafe)
|
|
844
|
+
console.log(`- skip unsafe ${item.platform} ${item.kind} ${item.name} -> ${item.path}`);
|
|
364
845
|
for (const item of diagnostics)
|
|
365
846
|
console.log(formatDiagnostic(item));
|
|
366
847
|
}
|