@guidobuilds/forge-ai 0.7.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,15 +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 { runBuildPlugin } from './build-plugin.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';
13
19
  import { checkLatestVersion, formatVersionNotice } from './version-check.js';
14
20
  import { writeOutputs } from './writer.js';
15
21
  import { hasPendingDecisions } from './model.js';
16
- const emptyPrunePlan = { deletable: [], modifiedWithConsent: [], skippedMissing: [] };
22
+ const emptyPrunePlan = { deletable: [], modifiedWithConsent: [], skippedMissing: [], skippedUnsafe: [] };
17
23
  export async function main(argv = process.argv.slice(2), promptIO = {}) {
18
24
  if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
19
25
  showUsage();
@@ -43,19 +49,19 @@ export async function main(argv = process.argv.slice(2), promptIO = {}) {
43
49
  skipSpecUpdate: options.skipSpecUpdate,
44
50
  });
45
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
+ }
46
61
  const versionCheckPromise = shouldCheckForUpdates(options, promptIO)
47
- ? 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)
48
63
  : Promise.resolve(undefined);
49
64
  try {
50
- if (command === 'build-plugin') {
51
- return await runBuildPlugin({
52
- source: options.sourceExplicit ? options.source : bundledSourceRoot(),
53
- outDir: options.out,
54
- target: options.target,
55
- dryRun: options.dryRun,
56
- force: options.force,
57
- });
58
- }
59
65
  const install = command === 'install' || command === 'update';
60
66
  if (install && !options.sourceExplicit)
61
67
  options.source = bundledSourceRoot();
@@ -64,24 +70,44 @@ export async function main(argv = process.argv.slice(2), promptIO = {}) {
64
70
  const interactive = install && isInteractivePrompt(promptIO);
65
71
  if (interactive)
66
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();
67
76
  if (install) {
68
- const prompted = await promptForMissingInstallOptions(options, promptIO);
77
+ const prompted = await promptForMissingInstallOptions(options, promptIO, cwd, home);
69
78
  if (!prompted) {
70
79
  if (interactive)
71
80
  p.cancel('Cancelled', clackIO(promptIO));
72
81
  return 1;
73
82
  }
74
83
  }
75
- const cwd = process.cwd();
76
- const home = resolveHome(promptIO);
77
- const now = new Date();
78
84
  let manifestLocation;
79
85
  let oldManifest;
80
86
  let backupRoot;
87
+ let modelPreferences;
81
88
  if (install) {
82
89
  manifestLocation = await resolveManifestLocation(options.scope, cwd, home);
83
90
  oldManifest = await loadManifest(manifestLocation.manifestPath);
84
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
+ }
85
111
  }
86
112
  const plan = await buildWritePlan({
87
113
  source: options.source,
@@ -92,10 +118,13 @@ export async function main(argv = process.argv.slice(2), promptIO = {}) {
92
118
  manifest: oldManifest,
93
119
  backupRoot,
94
120
  checkCollisions: install,
121
+ modelPreferences,
95
122
  });
96
123
  let prunePlan = emptyPrunePlan;
124
+ let pruneRoots = [];
97
125
  if (install && command === 'update' && options.prune) {
98
- 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);
99
128
  }
100
129
  const needsConfirm = install && !options.force && (hasPendingDecisions(plan.pending) || prunePlan.modifiedWithConsent.length > 0);
101
130
  if (install && !options.dryRun && needsConfirm) {
@@ -127,8 +156,8 @@ export async function main(argv = process.argv.slice(2), promptIO = {}) {
127
156
  try {
128
157
  await writeOutputs(plan.files);
129
158
  if (command === 'update' && options.prune)
130
- await pruneEntries([...prunePlan.deletable, ...prunePlan.modifiedWithConsent]);
131
- 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));
132
161
  spinner.stop(`Wrote ${plan.files.length} file(s).`);
133
162
  }
134
163
  catch (error) {
@@ -139,8 +168,8 @@ export async function main(argv = process.argv.slice(2), promptIO = {}) {
139
168
  else {
140
169
  await writeOutputs(plan.files);
141
170
  if (command === 'update' && options.prune)
142
- await pruneEntries([...prunePlan.deletable, ...prunePlan.modifiedWithConsent]);
143
- 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));
144
173
  console.log(`Wrote ${plan.files.length} file(s).`);
145
174
  const totalDeleted = prunePlan.deletable.length + prunePlan.modifiedWithConsent.length;
146
175
  if (command === 'update' && options.prune && totalDeleted > 0)
@@ -166,7 +195,7 @@ export async function main(argv = process.argv.slice(2), promptIO = {}) {
166
195
  }
167
196
  }
168
197
  function parseArgs(argv) {
169
- const options = { command: argv[0], platform: 'all', scope: 'user', source: '.', out: path.join(process.cwd(), 'forge-plugin'), target: 'claude', outExplicit: false, 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 };
170
199
  for (let index = 1; index < argv.length; index += 1) {
171
200
  const arg = argv[index];
172
201
  if (arg === '--dry-run')
@@ -185,7 +214,9 @@ function parseArgs(argv) {
185
214
  const value = argv[++index];
186
215
  if (!value)
187
216
  return { error: 'Missing --to value' };
188
- options.targetVersion = value;
217
+ if (!isValidVersionSpec(value))
218
+ return { error: `Invalid --to ${value}; expected a semver or "latest"` };
219
+ options.targetVersion = normalizeVersionSpec(value);
189
220
  }
190
221
  else if (arg === '--platform') {
191
222
  const value = argv[++index];
@@ -210,20 +241,22 @@ function parseArgs(argv) {
210
241
  options.source = value;
211
242
  options.sourceExplicit = true;
212
243
  }
213
- else if (arg === '--out') {
244
+ else if (arg === '--model') {
214
245
  const value = argv[++index];
215
246
  if (!value)
216
- return { error: 'Missing --out value' };
217
- options.out = value;
218
- options.outExplicit = true;
247
+ return { error: 'Missing --model value' };
248
+ options.model = value;
249
+ options.modelExplicit = true;
219
250
  }
220
- else if (arg === '--target') {
251
+ else if (arg === '--model-map') {
221
252
  const value = argv[++index];
222
- if (value !== 'claude' && value !== 'codex')
223
- return { error: `Invalid --target ${value ?? ''}; expected claude or codex` };
224
- options.target = value;
225
- if (!options.outExplicit)
226
- options.out = path.join(process.cwd(), value === 'claude' ? 'forge-plugin' : 'plugins/forge');
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;
227
260
  }
228
261
  else {
229
262
  return { error: `Unknown argument ${arg}` };
@@ -236,33 +269,45 @@ function parseArgs(argv) {
236
269
  return { error: 'validate only accepts --platform and --source' };
237
270
  if (command !== 'self-update' && (options.targetVersion !== undefined || options.skipSpecUpdate))
238
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)' };
239
276
  if (command === 'self-update' && (options.platformExplicit || options.scopeExplicit || options.sourceExplicit || options.force || options.yes))
240
277
  return { error: 'self-update only accepts --to, --dry-run, --skip-spec-update' };
241
- if (command === 'build-plugin' && (options.platformExplicit || options.scopeExplicit || options.yes))
242
- return { error: 'build-plugin only accepts --target, --source, --out, --dry-run, --force' };
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' };
243
284
  return { options };
244
285
  }
245
- async function classifyPrune(oldManifest, files, backupRoot, scope, cwd, home) {
286
+ async function classifyPrune(oldManifest, files, backupRoot, scope, cwd, home, roots) {
246
287
  const stale = staleEntries(oldManifest, files);
247
- const classified = await classifyPruneEntries(stale);
288
+ const classified = await classifyPruneEntries(stale, roots);
248
289
  const anchor = scope === 'user' ? home : cwd;
249
290
  const modifiedWithConsent = [];
250
291
  const skippedMissing = [];
292
+ const skippedUnsafe = [];
251
293
  for (const item of classified.skipped) {
252
294
  if (item.reason === 'checksum-mismatch')
253
295
  modifiedWithConsent.push({ ...item, backupPath: resolveBackupPath(backupRoot, item.path, anchor) });
296
+ else if (item.reason === 'unsafe-path')
297
+ skippedUnsafe.push(item);
254
298
  else
255
299
  skippedMissing.push(item);
256
300
  }
257
- return { deletable: classified.deletable, modifiedWithConsent, skippedMissing };
301
+ return { deletable: classified.deletable, modifiedWithConsent, skippedMissing, skippedUnsafe };
258
302
  }
259
- async function promptForMissingInstallOptions(options, promptIO) {
303
+ async function promptForMissingInstallOptions(options, promptIO, cwd, home) {
260
304
  if (options.yes)
261
305
  return true;
262
- if (options.platformExplicit && options.scopeExplicit)
263
- return true;
264
306
  if (!isInteractivePrompt(promptIO))
265
307
  return true;
308
+ const modelFlagsGiven = options.modelExplicit || options.modelMapExplicit;
309
+ if (options.platformExplicit && options.scopeExplicit && modelFlagsGiven)
310
+ return true;
266
311
  const io = clackIO(promptIO);
267
312
  if (!options.platformExplicit) {
268
313
  const platform = await p.select({
@@ -295,8 +340,132 @@ async function promptForMissingInstallOptions(options, promptIO) {
295
340
  return false;
296
341
  options.scope = scope;
297
342
  }
343
+ if (!modelFlagsGiven) {
344
+ return promptForModelSelection(options, promptIO, cwd, home);
345
+ }
346
+ return true;
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;
298
406
  return true;
299
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
+ }
300
469
  async function promptForUpdate(plan, prunePlan, backupRoot, promptIO) {
301
470
  if (!isInteractivePrompt(promptIO))
302
471
  return false;
@@ -323,6 +492,279 @@ async function promptForUpdate(plan, prunePlan, backupRoot, promptIO) {
323
492
  return undefined;
324
493
  return accepted;
325
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
+ }
326
768
  function normalizeCommand(command) {
327
769
  if (command === 'install' || command === 'i')
328
770
  return 'install';
@@ -330,10 +772,14 @@ function normalizeCommand(command) {
330
772
  return 'update';
331
773
  if (command === 'validate')
332
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';
333
781
  if (command === 'self-update')
334
782
  return 'self-update';
335
- if (command === 'build-plugin')
336
- return 'build-plugin';
337
783
  return undefined;
338
784
  }
339
785
  function shouldCheckForUpdates(options, promptIO) {
@@ -373,12 +819,15 @@ function readPackageVersion() {
373
819
  }
374
820
  }
375
821
  function showUsage() {
376
- console.log('Usage: forge-ai install [--platform opencode|claude|codex|grok|all] [--scope user|project] [--source <dir>] [--dry-run] [--force] [--yes]');
377
- 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,...]');
378
827
  console.log(' forge-ai validate [--platform opencode|claude|codex|grok|all] [--source <dir>]');
379
828
  console.log(' forge-ai self-update [--to <version>] [--dry-run] [--skip-spec-update]');
380
- console.log(' forge-ai build-plugin [--target claude|codex] [--out <dir>] [--source <dir>] [--dry-run] [--force]');
381
829
  console.log('');
830
+ console.log('--model and --model-map require an explicit single --platform (model ids are not portable across platforms).');
382
831
  console.log('Global flags: --no-update-check (also FORGE_NO_UPDATE_CHECK=1 or CI=true)');
383
832
  }
384
833
  function printPlan(command, sourceCount, files, diagnostics, prunePlan) {
@@ -391,6 +840,8 @@ function printPlan(command, sourceCount, files, diagnostics, prunePlan) {
391
840
  console.log(`- delete stale ${item.platform} ${item.kind} ${item.name} -> ${item.path} [backup -> ${item.backupPath}]`);
392
841
  for (const item of prunePlan.skippedMissing)
393
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}`);
394
845
  for (const item of diagnostics)
395
846
  console.log(formatDiagnostic(item));
396
847
  }