@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/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 { buildWritePlan, parsePlatform, parseScope } from './processor.js';
11
- import { runSelfUpdate } from './self-update.js';
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), '.forge-ai', 'version-check.json') }).catch(() => undefined)
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
- prunePlan = await classifyPrune(oldManifest, plan.files, backupRoot, options.scope, cwd, home);
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
- options.targetVersion = value;
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
  }