@guidobuilds/forge-ai 0.7.0 → 0.9.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,22 @@ 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 { knownCodexModels } from './adapters/codex-known.js';
11
+ import { knownGrokModels } from './adapters/grok-known.js';
8
12
  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';
13
+ import { buildManifest, classifyPruneEntries, detectLegacyStateDrift, listInstalls, loadManifest, pruneEntries, resolveBackupPath, resolveBackupRoot, resolveManifestLocation, saveManifest, stateRoot, staleEntries } from './manifest.js';
14
+ import { getModelPreference, loadModelPreferences, modelPreferencesPath, saveModelPreferences, setModelPreference } from './model-preferences.js';
15
+ import { discoverModels, mergeLiveWithCurated } from './model-discovery.js';
16
+ import { allowedInstallRoots } from './paths.js';
17
+ import { supportsModel } from './platform-capabilities.js';
18
+ import { buildWritePlan, discoverArtifacts, parsePlatform, parseScope, resolvePlatforms } from './processor.js';
19
+ import { isValidVersionSpec, normalizeVersionSpec, runSelfUpdate } from './self-update.js';
13
20
  import { checkLatestVersion, formatVersionNotice } from './version-check.js';
14
21
  import { writeOutputs } from './writer.js';
15
22
  import { hasPendingDecisions } from './model.js';
16
- const emptyPrunePlan = { deletable: [], modifiedWithConsent: [], skippedMissing: [] };
23
+ const emptyPrunePlan = { deletable: [], modifiedWithConsent: [], skippedMissing: [], skippedUnsafe: [] };
17
24
  export async function main(argv = process.argv.slice(2), promptIO = {}) {
18
25
  if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
19
26
  showUsage();
@@ -43,19 +50,19 @@ export async function main(argv = process.argv.slice(2), promptIO = {}) {
43
50
  skipSpecUpdate: options.skipSpecUpdate,
44
51
  });
45
52
  }
53
+ if (command === 'uninstall') {
54
+ return runUninstall(options, promptIO);
55
+ }
56
+ if (command === 'list') {
57
+ return runList(promptIO);
58
+ }
59
+ if (command === 'configure') {
60
+ return runConfigure(options, promptIO);
61
+ }
46
62
  const versionCheckPromise = shouldCheckForUpdates(options, promptIO)
47
- ? checkLatestVersion({ current: readPackageVersion(), cachePath: path.join(resolveHome(promptIO), '.forge-ai', 'version-check.json') }).catch(() => undefined)
63
+ ? checkLatestVersion({ current: readPackageVersion(), cachePath: path.join(stateRoot(resolveHome(promptIO)), 'version-check.json') }).catch(() => undefined)
48
64
  : Promise.resolve(undefined);
49
65
  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
66
  const install = command === 'install' || command === 'update';
60
67
  if (install && !options.sourceExplicit)
61
68
  options.source = bundledSourceRoot();
@@ -64,24 +71,44 @@ export async function main(argv = process.argv.slice(2), promptIO = {}) {
64
71
  const interactive = install && isInteractivePrompt(promptIO);
65
72
  if (interactive)
66
73
  p.intro(`${pc.bold('Forge AI')} ${pc.dim(command === 'update' ? 'updater' : 'installer')}`, clackIO(promptIO));
74
+ const cwd = process.cwd();
75
+ const home = resolveHome(promptIO);
76
+ const now = new Date();
67
77
  if (install) {
68
- const prompted = await promptForMissingInstallOptions(options, promptIO);
78
+ const prompted = await promptForMissingInstallOptions(options, promptIO, cwd, home);
69
79
  if (!prompted) {
70
80
  if (interactive)
71
81
  p.cancel('Cancelled', clackIO(promptIO));
72
82
  return 1;
73
83
  }
74
84
  }
75
- const cwd = process.cwd();
76
- const home = resolveHome(promptIO);
77
- const now = new Date();
78
85
  let manifestLocation;
79
86
  let oldManifest;
80
87
  let backupRoot;
88
+ let modelPreferences;
81
89
  if (install) {
82
90
  manifestLocation = await resolveManifestLocation(options.scope, cwd, home);
83
91
  oldManifest = await loadManifest(manifestLocation.manifestPath);
84
92
  backupRoot = resolveBackupRoot(manifestLocation, now);
93
+ const drift = await detectLegacyStateDrift(manifestLocation, home);
94
+ if (drift)
95
+ console.error(`Warning: ${drift}`);
96
+ const prefsPath = modelPreferencesPath(manifestLocation);
97
+ modelPreferences = await loadModelPreferences(prefsPath);
98
+ if (options.modelExplicit || options.modelMapExplicit) {
99
+ const { artifacts } = await discoverArtifacts(options.source);
100
+ const applied = applyModelFlags(modelPreferences, options, artifacts);
101
+ if ('error' in applied) {
102
+ console.error(applied.error);
103
+ return 1;
104
+ }
105
+ modelPreferences = applied.preferences;
106
+ await saveModelPreferences(prefsPath, modelPreferences);
107
+ }
108
+ else if (options.pendingModelPreferences) {
109
+ modelPreferences = options.pendingModelPreferences;
110
+ await saveModelPreferences(prefsPath, modelPreferences);
111
+ }
85
112
  }
86
113
  const plan = await buildWritePlan({
87
114
  source: options.source,
@@ -92,10 +119,13 @@ export async function main(argv = process.argv.slice(2), promptIO = {}) {
92
119
  manifest: oldManifest,
93
120
  backupRoot,
94
121
  checkCollisions: install,
122
+ modelPreferences,
95
123
  });
96
124
  let prunePlan = emptyPrunePlan;
125
+ let pruneRoots = [];
97
126
  if (install && command === 'update' && options.prune) {
98
- prunePlan = await classifyPrune(oldManifest, plan.files, backupRoot, options.scope, cwd, home);
127
+ pruneRoots = allowedInstallRoots(options.scope, home, cwd, oldManifest?.projectPath);
128
+ prunePlan = await classifyPrune(oldManifest, plan.files, backupRoot, options.scope, cwd, home, pruneRoots);
99
129
  }
100
130
  const needsConfirm = install && !options.force && (hasPendingDecisions(plan.pending) || prunePlan.modifiedWithConsent.length > 0);
101
131
  if (install && !options.dryRun && needsConfirm) {
@@ -127,8 +157,8 @@ export async function main(argv = process.argv.slice(2), promptIO = {}) {
127
157
  try {
128
158
  await writeOutputs(plan.files);
129
159
  if (command === 'update' && options.prune)
130
- await pruneEntries([...prunePlan.deletable, ...prunePlan.modifiedWithConsent]);
131
- await saveManifest(manifestLocation.manifestPath, await buildManifest(manifestLocation, plan.files));
160
+ await pruneEntries([...prunePlan.deletable, ...prunePlan.modifiedWithConsent], pruneRoots);
161
+ await saveManifest(manifestLocation.manifestPath, await buildManifest(manifestLocation, plan.files, readPackageVersion(), now, oldManifest));
132
162
  spinner.stop(`Wrote ${plan.files.length} file(s).`);
133
163
  }
134
164
  catch (error) {
@@ -139,8 +169,8 @@ export async function main(argv = process.argv.slice(2), promptIO = {}) {
139
169
  else {
140
170
  await writeOutputs(plan.files);
141
171
  if (command === 'update' && options.prune)
142
- await pruneEntries([...prunePlan.deletable, ...prunePlan.modifiedWithConsent]);
143
- await saveManifest(manifestLocation.manifestPath, await buildManifest(manifestLocation, plan.files));
172
+ await pruneEntries([...prunePlan.deletable, ...prunePlan.modifiedWithConsent], pruneRoots);
173
+ await saveManifest(manifestLocation.manifestPath, await buildManifest(manifestLocation, plan.files, readPackageVersion(), now, oldManifest));
144
174
  console.log(`Wrote ${plan.files.length} file(s).`);
145
175
  const totalDeleted = prunePlan.deletable.length + prunePlan.modifiedWithConsent.length;
146
176
  if (command === 'update' && options.prune && totalDeleted > 0)
@@ -166,7 +196,7 @@ export async function main(argv = process.argv.slice(2), promptIO = {}) {
166
196
  }
167
197
  }
168
198
  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 };
199
+ 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
200
  for (let index = 1; index < argv.length; index += 1) {
171
201
  const arg = argv[index];
172
202
  if (arg === '--dry-run')
@@ -185,7 +215,9 @@ function parseArgs(argv) {
185
215
  const value = argv[++index];
186
216
  if (!value)
187
217
  return { error: 'Missing --to value' };
188
- options.targetVersion = value;
218
+ if (!isValidVersionSpec(value))
219
+ return { error: `Invalid --to ${value}; expected a semver or "latest"` };
220
+ options.targetVersion = normalizeVersionSpec(value);
189
221
  }
190
222
  else if (arg === '--platform') {
191
223
  const value = argv[++index];
@@ -210,20 +242,22 @@ function parseArgs(argv) {
210
242
  options.source = value;
211
243
  options.sourceExplicit = true;
212
244
  }
213
- else if (arg === '--out') {
245
+ else if (arg === '--model') {
214
246
  const value = argv[++index];
215
247
  if (!value)
216
- return { error: 'Missing --out value' };
217
- options.out = value;
218
- options.outExplicit = true;
248
+ return { error: 'Missing --model value' };
249
+ options.model = value;
250
+ options.modelExplicit = true;
219
251
  }
220
- else if (arg === '--target') {
252
+ else if (arg === '--model-map') {
221
253
  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');
254
+ if (!value)
255
+ return { error: 'Missing --model-map value' };
256
+ const modelMap = parseModelMap(value);
257
+ if (!modelMap)
258
+ return { error: `Invalid --model-map ${value}; expected name=model,name2=model2` };
259
+ options.modelMap = modelMap;
260
+ options.modelMapExplicit = true;
227
261
  }
228
262
  else {
229
263
  return { error: `Unknown argument ${arg}` };
@@ -236,33 +270,45 @@ function parseArgs(argv) {
236
270
  return { error: 'validate only accepts --platform and --source' };
237
271
  if (command !== 'self-update' && (options.targetVersion !== undefined || options.skipSpecUpdate))
238
272
  return { error: '--to and --skip-spec-update are only accepted for self-update' };
273
+ if ((options.modelExplicit || options.modelMapExplicit) && (command !== 'install' && command !== 'update' && command !== 'configure'))
274
+ return { error: '--model and --model-map are only accepted for install, update, and configure' };
275
+ if ((options.modelExplicit || options.modelMapExplicit) && (!options.platformExplicit || options.platform === 'all'))
276
+ return { error: '--model and --model-map require an explicit single --platform (model ids are not portable across platforms)' };
239
277
  if (command === 'self-update' && (options.platformExplicit || options.scopeExplicit || options.sourceExplicit || options.force || options.yes))
240
278
  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' };
279
+ if (command === 'uninstall' && options.sourceExplicit)
280
+ return { error: 'uninstall does not accept --source' };
281
+ if (command === 'list' && (options.platformExplicit || options.scopeExplicit || options.sourceExplicit || options.dryRun || options.force || options.yes))
282
+ return { error: 'list does not accept any flags' };
283
+ if (command === 'configure' && (options.force || options.yes))
284
+ return { error: 'configure does not accept --force or --yes; use --model/--model-map for non-interactive use' };
243
285
  return { options };
244
286
  }
245
- async function classifyPrune(oldManifest, files, backupRoot, scope, cwd, home) {
287
+ async function classifyPrune(oldManifest, files, backupRoot, scope, cwd, home, roots) {
246
288
  const stale = staleEntries(oldManifest, files);
247
- const classified = await classifyPruneEntries(stale);
289
+ const classified = await classifyPruneEntries(stale, roots);
248
290
  const anchor = scope === 'user' ? home : cwd;
249
291
  const modifiedWithConsent = [];
250
292
  const skippedMissing = [];
293
+ const skippedUnsafe = [];
251
294
  for (const item of classified.skipped) {
252
295
  if (item.reason === 'checksum-mismatch')
253
296
  modifiedWithConsent.push({ ...item, backupPath: resolveBackupPath(backupRoot, item.path, anchor) });
297
+ else if (item.reason === 'unsafe-path')
298
+ skippedUnsafe.push(item);
254
299
  else
255
300
  skippedMissing.push(item);
256
301
  }
257
- return { deletable: classified.deletable, modifiedWithConsent, skippedMissing };
302
+ return { deletable: classified.deletable, modifiedWithConsent, skippedMissing, skippedUnsafe };
258
303
  }
259
- async function promptForMissingInstallOptions(options, promptIO) {
304
+ async function promptForMissingInstallOptions(options, promptIO, cwd, home) {
260
305
  if (options.yes)
261
306
  return true;
262
- if (options.platformExplicit && options.scopeExplicit)
263
- return true;
264
307
  if (!isInteractivePrompt(promptIO))
265
308
  return true;
309
+ const modelFlagsGiven = options.modelExplicit || options.modelMapExplicit;
310
+ if (options.platformExplicit && options.scopeExplicit && modelFlagsGiven)
311
+ return true;
266
312
  const io = clackIO(promptIO);
267
313
  if (!options.platformExplicit) {
268
314
  const platform = await p.select({
@@ -295,8 +341,185 @@ async function promptForMissingInstallOptions(options, promptIO) {
295
341
  return false;
296
342
  options.scope = scope;
297
343
  }
344
+ if (!modelFlagsGiven) {
345
+ return promptForModelSelection(options, promptIO, cwd, home);
346
+ }
347
+ return true;
348
+ }
349
+ function modelablePairs(artifacts, targetPlatforms) {
350
+ const pairs = [];
351
+ for (const platform of targetPlatforms) {
352
+ for (const artifact of artifacts) {
353
+ const effectiveKind = artifact[platform]?.kind ?? artifact.kind;
354
+ if (supportsModel(platform, effectiveKind))
355
+ pairs.push({ platform, artifact });
356
+ }
357
+ }
358
+ return pairs;
359
+ }
360
+ const CONFIRM_PROMPT_COUNT_ABOVE = 6;
361
+ // Returns false only on user cancellation (Ctrl+C mid-prompt) — declining the "choose per agent"
362
+ // path, or there being nothing to choose, both return true (proceed with defaults).
363
+ async function promptForModelSelection(options, promptIO, cwd, home) {
364
+ const io = clackIO(promptIO);
365
+ const { artifacts } = await discoverArtifacts(options.source);
366
+ const targetPlatforms = resolvePlatforms(options.platform);
367
+ const pairs = modelablePairs(artifacts, targetPlatforms);
368
+ if (pairs.length === 0)
369
+ return true;
370
+ const mode = await p.select({
371
+ message: 'Model selection',
372
+ initialValue: 'default',
373
+ options: [
374
+ { value: 'default', label: "Use each agent's recommended default" },
375
+ { value: 'per-agent', label: 'Choose per agent' }
376
+ ],
377
+ ...io
378
+ });
379
+ if (p.isCancel(mode))
380
+ return false;
381
+ if (mode === 'default')
382
+ return true;
383
+ if (pairs.length > CONFIRM_PROMPT_COUNT_ABOVE) {
384
+ const proceed = await p.confirm({
385
+ message: `This will ask ${pairs.length} questions (one per agent per platform). Continue?`,
386
+ active: 'Continue',
387
+ inactive: 'Use defaults instead',
388
+ initialValue: true,
389
+ ...io
390
+ });
391
+ if (p.isCancel(proceed))
392
+ return false;
393
+ if (!proceed)
394
+ return true;
395
+ }
396
+ const manifestLocation = await resolveManifestLocation(options.scope, cwd, home);
397
+ let preferences = await loadModelPreferences(modelPreferencesPath(manifestLocation));
398
+ const modelablePlatforms = [...new Set(pairs.map((pair) => pair.platform))];
399
+ const discovered = discoverModelChoices(modelablePlatforms, cwd, promptIO);
400
+ for (const { platform, artifact } of pairs) {
401
+ const current = getModelPreference(preferences, platform, artifact.name) ?? artifact[platform]?.model;
402
+ const chosen = await promptForModelValue(platform, artifact.name, current, discovered, promptIO);
403
+ if (chosen === undefined)
404
+ return false;
405
+ preferences = setModelPreference(preferences, platform, artifact.name, chosen);
406
+ }
407
+ options.pendingModelPreferences = preferences;
298
408
  return true;
299
409
  }
410
+ const CUSTOM_MODEL_VALUE = '__custom__';
411
+ // Pure label helper: suffixes a live-derived value's *label* with ` (live)` so the user can tell live
412
+ // options from curated/merged extras. The select **value** stays the bare model id, so stored
413
+ // preferences and `initialValue` matching are unchanged. Curated extras and the `Custom…` escape are
414
+ // never in the live set, so they are never suffixed.
415
+ export function withLiveLabels(values, liveSet) {
416
+ return values.map((value) => ({ value, label: liveSet.has(value) ? `${value} (live)` : value }));
417
+ }
418
+ // Curated model suggestions for the interactive per-agent model prompt, or undefined when there is
419
+ // no fixed list (the prompt then falls back to free text). Discovery results are live-first, highest
420
+ // priority. Each platform is an explicit branch — a missing branch fails loudly rather than silently
421
+ // producing a "no choices" path (see the codex-model-options regression in .forge/lessons.md):
422
+ // opencode: live, else free-text (undefined) — no curated set.
423
+ // codex: live, NO merge (the live catalog is the real account-filtered set; merging stale curated
424
+ // suggestions would present ids the user may not be able to select); curated on failure.
425
+ // grok: live + curated extras merged (dedupe, stable order) — `grok models` doesn't advertise the
426
+ // Forge-specific aliases, so they must survive a successful live query; curated on failure.
427
+ // claude: curated always — no dynamic source, never empty/undefined.
428
+ export function modelChoicesFor(platform, discovered = {}) {
429
+ const live = discovered[platform];
430
+ if (platform === 'opencode')
431
+ return live && live.length > 0 ? live : undefined;
432
+ if (platform === 'claude')
433
+ return [...knownClaudeModels];
434
+ if (platform === 'codex')
435
+ return live && live.length > 0 ? live : [...knownCodexModels];
436
+ if (platform === 'grok')
437
+ return live && live.length > 0 ? mergeLiveWithCurated(live, knownGrokModels) : [...knownGrokModels];
438
+ return undefined;
439
+ }
440
+ async function promptForModelValue(platform, artifactName, current, discovered, promptIO) {
441
+ const io = clackIO(promptIO);
442
+ const knownChoices = modelChoicesFor(platform, discovered);
443
+ if (knownChoices && knownChoices.length > 0) {
444
+ const initialValue = current && knownChoices.includes(current) ? current : knownChoices[0];
445
+ const liveSet = new Set(discovered[platform] ?? []);
446
+ const choice = await p.select({
447
+ message: `Model for \`${artifactName}\` (${platform})`,
448
+ initialValue,
449
+ options: [...withLiveLabels(knownChoices, liveSet), { value: CUSTOM_MODEL_VALUE, label: 'Custom…' }],
450
+ ...io
451
+ });
452
+ if (p.isCancel(choice))
453
+ return undefined;
454
+ if (choice !== CUSTOM_MODEL_VALUE)
455
+ return choice;
456
+ }
457
+ const text = await p.text({
458
+ message: `Model for \`${artifactName}\` (${platform})`,
459
+ initialValue: current,
460
+ validate: (value) => (value && value.trim().length > 0 ? undefined : 'A model id is required'),
461
+ ...io
462
+ });
463
+ if (p.isCancel(text))
464
+ return undefined;
465
+ return text;
466
+ }
467
+ // Compute the per-platform discovery map once per CLI run, scoped to the deduped set of platforms that
468
+ // actually have a modelable pair in this run (so e.g. `install --platform all` with no Codex agent
469
+ // artifact never sparks a needless `codex debug models` spawn). Discovery is synchronous (spawnSync).
470
+ // A failed non-claude discovery emits a single info diagnostic. `claude` is skipped because it has no
471
+ // dynamic source by design (not because it failed). The diagnostic is rendered inside the clack frame
472
+ // (p.log.warn) when the run is interactive so it doesn't corrupt the TUI (raw console.error writes to
473
+ // stderr, the same terminal clack draws on, which clack does not redraw/clear — see lessons.md
474
+ // dynamic-model-discovery); console.error is only the non-interactive fallback.
475
+ function discoverModelChoices(platforms, cwd, promptIO) {
476
+ const discovered = {};
477
+ for (const platform of platforms) {
478
+ const models = discoverModels(platform, cwd);
479
+ if (models && models.length > 0) {
480
+ discovered[platform] = models;
481
+ }
482
+ else if (platform !== 'claude') {
483
+ const note = `forge: model discovery for ${platform} failed; using curated fallback.`;
484
+ if (isInteractivePrompt(promptIO))
485
+ p.log.warn(note, clackIO(promptIO));
486
+ else
487
+ console.error(note);
488
+ }
489
+ }
490
+ return discovered;
491
+ }
492
+ function parseModelMap(value) {
493
+ const result = {};
494
+ for (const pair of value.split(',')) {
495
+ const [name, model] = pair.split('=').map((part) => part?.trim());
496
+ if (!name || !model)
497
+ return undefined;
498
+ result[name] = model;
499
+ }
500
+ return Object.keys(result).length > 0 ? result : undefined;
501
+ }
502
+ function applyModelFlags(preferences, options, artifacts) {
503
+ const targetPlatforms = resolvePlatforms(options.platform);
504
+ const platform = targetPlatforms[0];
505
+ let next = preferences;
506
+ if (options.model) {
507
+ for (const artifact of artifacts) {
508
+ const effectiveKind = artifact[platform]?.kind ?? artifact.kind;
509
+ if (supportsModel(platform, effectiveKind))
510
+ next = setModelPreference(next, platform, artifact.name, options.model);
511
+ }
512
+ }
513
+ if (options.modelMap) {
514
+ const knownNames = new Set(artifacts.map((artifact) => artifact.name));
515
+ for (const [name, model] of Object.entries(options.modelMap)) {
516
+ if (!knownNames.has(name))
517
+ return { error: `--model-map references unknown artifact "${name}"` };
518
+ next = setModelPreference(next, platform, name, model);
519
+ }
520
+ }
521
+ return { preferences: next };
522
+ }
300
523
  async function promptForUpdate(plan, prunePlan, backupRoot, promptIO) {
301
524
  if (!isInteractivePrompt(promptIO))
302
525
  return false;
@@ -323,6 +546,280 @@ async function promptForUpdate(plan, prunePlan, backupRoot, promptIO) {
323
546
  return undefined;
324
547
  return accepted;
325
548
  }
549
+ async function runUninstall(options, promptIO) {
550
+ if (options.yes)
551
+ options.force = true;
552
+ const interactive = isInteractivePrompt(promptIO);
553
+ if (interactive)
554
+ p.intro(`${pc.bold('Forge AI')} ${pc.dim('uninstaller')}`, clackIO(promptIO));
555
+ const prompted = await promptForMissingUninstallOptions(options, promptIO);
556
+ if (!prompted) {
557
+ if (interactive)
558
+ p.cancel('Cancelled', clackIO(promptIO));
559
+ return 1;
560
+ }
561
+ const cwd = process.cwd();
562
+ const home = resolveHome(promptIO);
563
+ const now = new Date();
564
+ const manifestLocation = await resolveManifestLocation(options.scope, cwd, home);
565
+ const manifest = await loadManifest(manifestLocation.manifestPath);
566
+ const drift = await detectLegacyStateDrift(manifestLocation, home);
567
+ if (drift)
568
+ console.error(`Warning: ${drift}`);
569
+ if (!manifest || manifest.entries.length === 0) {
570
+ console.log(`Nothing to uninstall for ${options.scope} scope.`);
571
+ return 0;
572
+ }
573
+ const targeted = options.platform === 'all' ? manifest.entries : manifest.entries.filter((entry) => entry.platform === options.platform);
574
+ if (targeted.length === 0) {
575
+ console.log(`No ${options.platform} files recorded for this ${options.scope} install.`);
576
+ return 0;
577
+ }
578
+ const backupRoot = resolveBackupRoot(manifestLocation, now);
579
+ const anchor = options.scope === 'user' ? home : cwd;
580
+ const roots = allowedInstallRoots(options.scope, home, cwd, manifest.projectPath);
581
+ const classified = await classifyPruneEntries(targeted, roots);
582
+ const modifiedWithConsent = classified.skipped
583
+ .filter((item) => item.reason === 'checksum-mismatch')
584
+ .map((item) => ({ ...item, backupPath: resolveBackupPath(backupRoot, item.path, anchor) }));
585
+ const skippedMissing = classified.skipped.filter((item) => item.reason === 'missing');
586
+ const skippedUnsafe = classified.skipped.filter((item) => item.reason === 'unsafe-path');
587
+ const needsConfirm = !options.force && modifiedWithConsent.length > 0;
588
+ if (!options.dryRun && needsConfirm) {
589
+ if (!interactive) {
590
+ printUninstallPlan(classified.deletable, modifiedWithConsent, skippedMissing, skippedUnsafe);
591
+ console.error('Forge needs your decision on edited files; re-run with --yes or --force to accept deletion + backup.');
592
+ return 1;
593
+ }
594
+ const accepted = await promptForUninstall(classified.deletable, modifiedWithConsent, backupRoot, promptIO);
595
+ if (accepted === undefined) {
596
+ p.cancel('Cancelled', clackIO(promptIO));
597
+ return 1;
598
+ }
599
+ if (!accepted) {
600
+ p.outro(pc.yellow('Forge was not uninstalled.'), clackIO(promptIO));
601
+ return 1;
602
+ }
603
+ }
604
+ printUninstallPlan(classified.deletable, modifiedWithConsent, skippedMissing, skippedUnsafe);
605
+ if (options.dryRun) {
606
+ if (interactive)
607
+ p.outro(pc.cyan('Dry run complete.'), clackIO(promptIO));
608
+ return 0;
609
+ }
610
+ const toRemove = [...classified.deletable, ...modifiedWithConsent];
611
+ await pruneEntries(toRemove, roots);
612
+ const targetedPaths = new Set(targeted.map((entry) => entry.path));
613
+ const remaining = manifest.entries.filter((entry) => !targetedPaths.has(entry.path));
614
+ if (remaining.length === 0) {
615
+ await rm(manifestLocation.manifestPath, { force: true });
616
+ }
617
+ else {
618
+ await saveManifest(manifestLocation.manifestPath, { ...manifest, entries: remaining, updatedAt: now.toISOString() });
619
+ }
620
+ console.log(`Removed ${toRemove.length} file(s).`);
621
+ if (skippedMissing.length > 0)
622
+ console.log(`${skippedMissing.length} file(s) were already missing.`);
623
+ if (skippedUnsafe.length > 0)
624
+ console.error(`Warning: skipped ${skippedUnsafe.length} file(s) outside Forge's install roots (unsafe path); they were NOT removed.`);
625
+ if (interactive)
626
+ p.outro(pc.green('Forge was uninstalled.'), clackIO(promptIO));
627
+ return 0;
628
+ }
629
+ async function promptForMissingUninstallOptions(options, promptIO) {
630
+ if (options.yes)
631
+ return true;
632
+ if (options.platformExplicit && options.scopeExplicit)
633
+ return true;
634
+ if (!isInteractivePrompt(promptIO))
635
+ return true;
636
+ const io = clackIO(promptIO);
637
+ if (!options.platformExplicit) {
638
+ const platform = await p.select({
639
+ message: 'Uninstall Forge for which coding agent?',
640
+ initialValue: 'all',
641
+ options: [
642
+ { value: 'all', label: 'All supported agents', hint: 'OpenCode, Codex, Claude Code, and Grok Build' },
643
+ { value: 'opencode', label: 'OpenCode' },
644
+ { value: 'codex', label: 'Codex' },
645
+ { value: 'claude', label: 'Claude Code' },
646
+ { value: 'grok', label: 'Grok Build' }
647
+ ],
648
+ ...io
649
+ });
650
+ if (p.isCancel(platform))
651
+ return false;
652
+ options.platform = platform;
653
+ }
654
+ if (!options.scopeExplicit) {
655
+ const scope = await p.select({
656
+ message: 'Uninstall Forge from which scope?',
657
+ initialValue: 'user',
658
+ options: [
659
+ { value: 'user', label: 'User', hint: 'Installed for every project' },
660
+ { value: 'project', label: 'Project', hint: 'Only this repository' }
661
+ ],
662
+ ...io
663
+ });
664
+ if (p.isCancel(scope))
665
+ return false;
666
+ options.scope = scope;
667
+ }
668
+ return true;
669
+ }
670
+ async function promptForUninstall(deletable, modifiedWithConsent, backupRoot, promptIO) {
671
+ if (!isInteractivePrompt(promptIO))
672
+ return false;
673
+ const io = clackIO(promptIO);
674
+ p.log.warn(`${pc.yellow('Edited by you, will be deleted (backup):')}\n${modifiedWithConsent.map((entry) => ` - ${entry.path}`).join('\n')}\n\nBackups → ${backupRoot}`, io);
675
+ const accepted = await p.confirm({
676
+ message: 'Continue with deletion + backup?',
677
+ active: 'Continue',
678
+ inactive: 'Cancel',
679
+ initialValue: false,
680
+ ...io
681
+ });
682
+ if (p.isCancel(accepted))
683
+ return undefined;
684
+ return accepted;
685
+ }
686
+ function printUninstallPlan(deletable, modifiedWithConsent, skippedMissing, skippedUnsafe) {
687
+ console.log(`uninstall: ${deletable.length + modifiedWithConsent.length} file(s) to remove`);
688
+ for (const entry of deletable)
689
+ console.log(`- delete ${entry.platform} ${entry.kind} ${entry.name} -> ${entry.path}`);
690
+ for (const entry of modifiedWithConsent)
691
+ console.log(`- delete ${entry.platform} ${entry.kind} ${entry.name} -> ${entry.path} [backup -> ${entry.backupPath}]`);
692
+ for (const entry of skippedMissing)
693
+ console.log(`- skip missing ${entry.platform} ${entry.kind} ${entry.name} -> ${entry.path}`);
694
+ for (const entry of skippedUnsafe)
695
+ console.log(`- skip unsafe ${entry.platform} ${entry.kind} ${entry.name} -> ${entry.path}`);
696
+ }
697
+ async function runList(promptIO) {
698
+ const home = resolveHome(promptIO);
699
+ const summaries = await listInstalls(home);
700
+ if (summaries.length === 0) {
701
+ console.log('No Forge installs recorded.');
702
+ return 0;
703
+ }
704
+ for (const summary of summaries) {
705
+ console.log(formatInstallSummary(summary));
706
+ if (summary.driftWarning)
707
+ console.error(`Warning: ${summary.driftWarning}`);
708
+ }
709
+ return 0;
710
+ }
711
+ function formatInstallSummary(summary) {
712
+ const target = summary.scope === 'user' ? 'user' : `project ${summary.projectPath ?? '(unknown path)'}`;
713
+ const platforms = summary.platforms.length > 0 ? summary.platforms.join(', ') : 'none';
714
+ return `${target}: forge ${summary.forgeVersion}, ${summary.fileCount} file(s), platforms: ${platforms}, updated ${summary.updatedAt}`;
715
+ }
716
+ async function runConfigure(options, promptIO) {
717
+ const interactive = isInteractivePrompt(promptIO);
718
+ if (interactive)
719
+ p.intro(`${pc.bold('Forge AI')} ${pc.dim('configure')}`, clackIO(promptIO));
720
+ if (!options.scopeExplicit) {
721
+ if (!interactive) {
722
+ console.error('forge-ai configure needs --scope when not run interactively.');
723
+ return 1;
724
+ }
725
+ const scope = await p.select({
726
+ message: 'Configure which scope?',
727
+ initialValue: 'user',
728
+ options: [
729
+ { value: 'user', label: 'User', hint: 'Available in every project' },
730
+ { value: 'project', label: 'Project', hint: 'Only this repository' }
731
+ ],
732
+ ...clackIO(promptIO)
733
+ });
734
+ if (p.isCancel(scope)) {
735
+ p.cancel('Cancelled', clackIO(promptIO));
736
+ return 1;
737
+ }
738
+ options.scope = scope;
739
+ }
740
+ const cwd = process.cwd();
741
+ const home = resolveHome(promptIO);
742
+ const now = new Date();
743
+ const manifestLocation = await resolveManifestLocation(options.scope, cwd, home);
744
+ const manifest = await loadManifest(manifestLocation.manifestPath);
745
+ const drift = await detectLegacyStateDrift(manifestLocation, home);
746
+ if (drift)
747
+ console.error(`Warning: ${drift}`);
748
+ if (!manifest || manifest.entries.length === 0) {
749
+ console.log(`Nothing installed for ${options.scope} scope — run \`forge-ai install\` first.`);
750
+ return 0;
751
+ }
752
+ const installedPlatforms = [...new Set(manifest.entries.map((entry) => entry.platform))];
753
+ const targetPlatforms = options.platformExplicit ? installedPlatforms.filter((platform) => platform === options.platform) : installedPlatforms;
754
+ if (targetPlatforms.length === 0) {
755
+ console.log(`${options.platform} is not installed for ${options.scope} scope.`);
756
+ return 0;
757
+ }
758
+ const source = options.sourceExplicit ? options.source : bundledSourceRoot();
759
+ const { artifacts } = await discoverArtifacts(source);
760
+ const prefsPath = modelPreferencesPath(manifestLocation);
761
+ let preferences = await loadModelPreferences(prefsPath);
762
+ if (options.modelExplicit || options.modelMapExplicit) {
763
+ const applied = applyModelFlags(preferences, options, artifacts);
764
+ if ('error' in applied) {
765
+ console.error(applied.error);
766
+ return 1;
767
+ }
768
+ preferences = applied.preferences;
769
+ }
770
+ else {
771
+ if (!interactive) {
772
+ console.error('forge-ai configure needs --model or --model-map when not run interactively.');
773
+ return 1;
774
+ }
775
+ const pairs = modelablePairs(artifacts, targetPlatforms);
776
+ if (pairs.length === 0) {
777
+ console.log('No installed agent on the selected scope supports a configurable model.');
778
+ return 0;
779
+ }
780
+ const modelablePlatforms = [...new Set(pairs.map((pair) => pair.platform))];
781
+ const discovered = discoverModelChoices(modelablePlatforms, cwd, promptIO);
782
+ for (const { platform, artifact } of pairs) {
783
+ const current = getModelPreference(preferences, platform, artifact.name) ?? artifact[platform]?.model;
784
+ const chosen = await promptForModelValue(platform, artifact.name, current, discovered, promptIO);
785
+ if (chosen === undefined) {
786
+ p.cancel('Cancelled', clackIO(promptIO));
787
+ return 1;
788
+ }
789
+ preferences = setModelPreference(preferences, platform, artifact.name, chosen);
790
+ }
791
+ }
792
+ await saveModelPreferences(prefsPath, preferences);
793
+ const backupRoot = resolveBackupRoot(manifestLocation, now);
794
+ let files = [];
795
+ let diagnostics = [];
796
+ for (const platform of targetPlatforms) {
797
+ const result = await buildWritePlan({ source, platform, scope: options.scope, cwd, home, manifest, backupRoot, checkCollisions: true, modelPreferences: preferences });
798
+ files = files.concat(result.files);
799
+ diagnostics = diagnostics.concat(result.diagnostics);
800
+ }
801
+ console.log(`configure: ${files.length} file(s) to update`);
802
+ for (const file of files)
803
+ console.log(`- ${file.platform} ${file.kind} ${file.name} -> ${file.path}${statusSuffix(file)}`);
804
+ for (const item of diagnostics)
805
+ console.log(formatDiagnostic(item));
806
+ if (hasErrors(diagnostics)) {
807
+ if (interactive)
808
+ p.outro(pc.red('Forge was not reconfigured.'), clackIO(promptIO));
809
+ return 1;
810
+ }
811
+ if (options.dryRun) {
812
+ if (interactive)
813
+ p.outro(pc.cyan('Dry run complete. Model preferences were saved; files were not written.'), clackIO(promptIO));
814
+ return 0;
815
+ }
816
+ await writeOutputs(files);
817
+ await saveManifest(manifestLocation.manifestPath, await buildManifest(manifestLocation, files, readPackageVersion(), now, manifest));
818
+ console.log(`Updated ${files.length} file(s) with new model preferences.`);
819
+ if (interactive)
820
+ p.outro(pc.green('Forge is reconfigured.'), clackIO(promptIO));
821
+ return 0;
822
+ }
326
823
  function normalizeCommand(command) {
327
824
  if (command === 'install' || command === 'i')
328
825
  return 'install';
@@ -330,10 +827,14 @@ function normalizeCommand(command) {
330
827
  return 'update';
331
828
  if (command === 'validate')
332
829
  return 'validate';
830
+ if (command === 'uninstall')
831
+ return 'uninstall';
832
+ if (command === 'list' || command === 'ls')
833
+ return 'list';
834
+ if (command === 'configure')
835
+ return 'configure';
333
836
  if (command === 'self-update')
334
837
  return 'self-update';
335
- if (command === 'build-plugin')
336
- return 'build-plugin';
337
838
  return undefined;
338
839
  }
339
840
  function shouldCheckForUpdates(options, promptIO) {
@@ -373,12 +874,15 @@ function readPackageVersion() {
373
874
  }
374
875
  }
375
876
  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]');
877
+ 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,...]');
878
+ 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,...]');
879
+ console.log(' forge-ai uninstall [--platform opencode|claude|codex|grok|all] [--scope user|project] [--dry-run] [--force] [--yes]');
880
+ console.log(' forge-ai list');
881
+ console.log(' forge-ai configure [--platform opencode|claude|codex|grok] [--scope user|project] [--source <dir>] [--dry-run] [--model <id>] [--model-map name=model,...]');
378
882
  console.log(' forge-ai validate [--platform opencode|claude|codex|grok|all] [--source <dir>]');
379
883
  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
884
  console.log('');
885
+ console.log('--model and --model-map require an explicit single --platform (model ids are not portable across platforms).');
382
886
  console.log('Global flags: --no-update-check (also FORGE_NO_UPDATE_CHECK=1 or CI=true)');
383
887
  }
384
888
  function printPlan(command, sourceCount, files, diagnostics, prunePlan) {
@@ -391,6 +895,8 @@ function printPlan(command, sourceCount, files, diagnostics, prunePlan) {
391
895
  console.log(`- delete stale ${item.platform} ${item.kind} ${item.name} -> ${item.path} [backup -> ${item.backupPath}]`);
392
896
  for (const item of prunePlan.skippedMissing)
393
897
  console.log(`- skip missing ${item.platform} ${item.kind} ${item.name} -> ${item.path}`);
898
+ for (const item of prunePlan.skippedUnsafe)
899
+ console.log(`- skip unsafe ${item.platform} ${item.kind} ${item.name} -> ${item.path}`);
394
900
  for (const item of diagnostics)
395
901
  console.log(formatDiagnostic(item));
396
902
  }