@guidobuilds/forge-ai 0.8.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/CHANGELOG.md CHANGED
@@ -9,6 +9,40 @@ Versions prior to 0.3.0 are not reconstructed here; see git history for earlier
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [0.9.0] - 2026-09-05
13
+
14
+ ### Added
15
+
16
+ - **Dynamic model discovery.** The per-agent model prompt (`install` and `configure`) now discovers
17
+ the models each harness's own CLI reports for that user, instead of relying only on curated lists,
18
+ via a shared `src/model-discovery.ts` module:
19
+ - one `ModelDiscoveryRunner` contract + `defaultRunner` (spawnSync + `resolveExecutable`, absolute-
20
+ path allowlist, no `cwd` passthrough), shared caps (5s timeout, 1 MB stdout, 500 models), a pure
21
+ `mergeLiveWithCurated(live, curated)` live-first/stable-order dedupe helper, and a
22
+ `discoverModels(platform, cwd)` dispatcher that returns `string[] | undefined` on any failure
23
+ (never throws). `src/opencode-discovery.ts` is now a backward-compatible re-export shim.
24
+ - **OpenCode** — unchanged behavior, now under the shared module + caps (`opencode`/`opencode2 models`).
25
+ - **Codex** — `codex debug models` JSON is parsed for `visibility === 'list'` slugs (`hide` entries
26
+ like `gpt-reserve`/`codex-auto-review` excluded), each slug trimmed + re-validated; curated
27
+ fallback on failure.
28
+ - **Grok** — `grok models` output is parsed for the `*` (default) / `-` (available) lines, ignoring
29
+ the `You are not authenticated.` banner; curated Forge aliases (`grok-build`, `grok-build-plan`,
30
+ `grok-composer-2.5-fast`, `inherit`) are merged in as extras on a successful live query.
31
+ - **Claude** — documented as having no CLI model-enumeration command; `discoverClaudeModels` always
32
+ returns `undefined`, so Claude stays curated + free text, never an empty/spurious list.
33
+ - `modelChoicesFor(platform, discovered)` — a discovery-aware per-platform funnel (live-first,
34
+ curated-fallback, never an empty list) and `withLiveLabels(values, liveSet)` — a pure helper that
35
+ suffixes a live-derived option's *label* with ` (live)` while keeping the select value bare.
36
+ - A single info diagnostic on non-Claude discovery failure
37
+ (`forge: model discovery for <platform> failed; using curated fallback.`), rendered inside the
38
+ clack frame when interactive, `console.error` otherwise.
39
+
40
+ ### Changed
41
+
42
+ - The curated `knownCodexModels` offline fallback now matches the observed list-visible
43
+ `codex debug models` catalog (`gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.5`,
44
+ `gpt-5.4-mini`) — suggestions-only, never a validation gate, updated only from fresh live evidence.
45
+
12
46
  ## [0.8.0] - 2026-09-03
13
47
 
14
48
  ### Added
package/README.md CHANGED
@@ -100,7 +100,7 @@ Install with the npm CLI:
100
100
  npx @guidobuilds/forge-ai install
101
101
  ```
102
102
 
103
- The installer prompts for the target agent platform, whether Forge should be installed globally for your user or locally for the current project, and — interactively — which model each agent should use: keep each agent's recommended default, or choose per agent. For OpenCode, the per-agent prompt shows the models you actually have configured (via `opencode models`, falling back to free-text entry if that command isn't available), not a generic list.
103
+ The installer prompts for the target agent platform, whether Forge should be installed globally for your user or locally for the current project, and — interactively — which model each agent should use: keep each agent's recommended default, or choose per agent. For OpenCode, Codex, and Grok, the per-agent prompt shows the models you actually have configured (via `opencode models`, `codex debug models`, or `grok models`, falling back to a curated list or free-text entry if the command isn't available), not a generic list.
104
104
 
105
105
  To choose models non-interactively (requires an explicit single `--platform` — model ids aren't portable across platforms):
106
106
 
@@ -298,7 +298,7 @@ Prints every recorded install — the user-scope one, if any, plus one line per
298
298
  npx @guidobuilds/forge-ai configure --platform claude --scope user
299
299
  ```
300
300
 
301
- Change which model an already-installed agent uses without a full reinstall. Interactively, it walks every agent on the given scope/platform that supports a model (using OpenCode's live `opencode models` output where applicable, same as `install`). Non-interactively, pass `--model <id>` or `--model-map name=model,...` (same rules as `install`: an explicit single `--platform`, no `--force`/`--yes` — the point of the command is to make a choice, not skip one). Only the affected files are rewritten; the manifest and every other installed platform are left untouched.
301
+ Change which model an already-installed agent uses without a full reinstall. Interactively, it walks every agent on the given scope/platform that supports a model (using each platform's live model discovery where applicable, same as `install`). Non-interactively, pass `--model <id>` or `--model-map name=model,...` (same rules as `install`: an explicit single `--platform`, no `--force`/`--yes` — the point of the command is to make a choice, not skip one). Only the affected files are rewritten; the manifest and every other installed platform are left untouched.
302
302
 
303
303
  ## Project Status
304
304
 
@@ -24,6 +24,12 @@ export const agentAllowlistPattern = /^Agent\([A-Za-z0-9_-]+(?:,\s*[A-Za-z0-9_-]
24
24
  export function isKnownClaudeTool(name) {
25
25
  return knownClaudeTools.has(name) || mcpToolPattern.test(name) || agentAllowlistPattern.test(name);
26
26
  }
27
+ // Claude Code has NO CLI model-enumeration command: `claude --help` exposes only `--model` /
28
+ // `--fallback-model` flags and a non-scriptable in-TUI `/model`. There is no `claude models` /
29
+ // `claude list`. So `discoverClaudeModels` always returns undefined (see src/model-discovery.ts) and
30
+ // the model-choice funnel always falls back to this curated set plus the free-text `Custom…` escape —
31
+ // never an empty/spurious list. Do NOT add a fake dynamic source here; the `discoverModels` dispatch
32
+ // keeps a `claude` branch so a real source could be added later without touching callers.
27
33
  export const knownClaudeModels = new Set([
28
34
  'sonnet',
29
35
  'opus',
@@ -1,8 +1,23 @@
1
- // Codex's current model-id list was not verified against live docs at the time this was written
2
- // (unlike Claude/Grok/OpenCode, which have a confirmed format). Rather than invent a shape we
3
- // haven't checked, this accepts any non-empty, whitespace-free value and lets Codex itself be the
4
- // real check the same "don't guess" discipline this codebase applies elsewhere. Tighten this
5
- // once Codex's actual model-id format is verified against its docs.
1
+ // Codex's model-id set changes over time and is account/plan dependent, so `isKnownCodexModel`
2
+ // stays deliberately permissive: it accepts any non-empty, whitespace-free value and lets Codex
3
+ // itself be the real check the same "don't guess" discipline this codebase applies elsewhere.
4
+ // This is a safety net against empty/whitespace values, NOT a validation gate on the model list.
5
+ //
6
+ // `knownCodexModels` is the OFFLINE FALLBACK presented only when live `codex debug models` discovery
7
+ // fails (absent binary / non-zero exit / non-JSON / over-cap / timeout, or always on Windows fail-
8
+ // closed). It is suggestions-only (the prompt also offers a `Custom…` free-text escape) and is NOT a
9
+ // validation gate. Its ids are the list-visible, user-selectable catalog observed from a real
10
+ // `codex debug models` run on the reference machine (explore.md §2 / verification.md):
11
+ // gpt-5.6-sol, gpt-5.6-terra, gpt-5.6-luna, gpt-5.5, gpt-5.4-mini
12
+ // If the fallback drifts to ids outside this observed set, a failed live discovery degrades to models
13
+ // the installed codex may not recognize — update it only from fresh live evidence, never by guessing.
14
+ export const knownCodexModels = new Set([
15
+ 'gpt-5.6-sol',
16
+ 'gpt-5.6-terra',
17
+ 'gpt-5.6-luna',
18
+ 'gpt-5.5',
19
+ 'gpt-5.4-mini'
20
+ ]);
6
21
  const codexModelPattern = /^\S+$/;
7
22
  export function isKnownCodexModel(value) {
8
23
  return codexModelPattern.test(value);
@@ -24,6 +24,10 @@ export const knownGrokTools = new Set([
24
24
  export function isKnownGrokTool(name) {
25
25
  return knownGrokTools.has(name);
26
26
  }
27
+ // These Forge-specific aliases are NOT advertised by `grok models` — the live catalog only lists the
28
+ // binary's known set (e.g. `grok-4.6`, `grok-4.5`). They are valid config values today, so dynamic
29
+ // discovery (see src/model-discovery.ts::discoverGrokModels) merges them in as extras on a successful
30
+ // live query and uses them as the whole fallback on failure. They must never be silently dropped.
27
31
  export const knownGrokModels = new Set([
28
32
  'inherit',
29
33
  'grok-build',
package/dist/src/cli.js CHANGED
@@ -7,11 +7,12 @@ import path from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import { rm } from 'node:fs/promises';
9
9
  import { knownClaudeModels } from './adapters/claude-known.js';
10
+ import { knownCodexModels } from './adapters/codex-known.js';
10
11
  import { knownGrokModels } from './adapters/grok-known.js';
11
12
  import { formatDiagnostic, hasErrors } from './diagnostics.js';
12
13
  import { buildManifest, classifyPruneEntries, detectLegacyStateDrift, listInstalls, loadManifest, pruneEntries, resolveBackupPath, resolveBackupRoot, resolveManifestLocation, saveManifest, stateRoot, staleEntries } from './manifest.js';
13
14
  import { getModelPreference, loadModelPreferences, modelPreferencesPath, saveModelPreferences, setModelPreference } from './model-preferences.js';
14
- import { discoverOpenCodeModels } from './opencode-discovery.js';
15
+ import { discoverModels, mergeLiveWithCurated } from './model-discovery.js';
15
16
  import { allowedInstallRoots } from './paths.js';
16
17
  import { supportsModel } from './platform-capabilities.js';
17
18
  import { buildWritePlan, discoverArtifacts, parsePlatform, parseScope, resolvePlatforms } from './processor.js';
@@ -394,10 +395,11 @@ async function promptForModelSelection(options, promptIO, cwd, home) {
394
395
  }
395
396
  const manifestLocation = await resolveManifestLocation(options.scope, cwd, home);
396
397
  let preferences = await loadModelPreferences(modelPreferencesPath(manifestLocation));
397
- const discoveredOpenCodeModels = targetPlatforms.includes('opencode') ? discoverOpenCodeModels(cwd) : undefined;
398
+ const modelablePlatforms = [...new Set(pairs.map((pair) => pair.platform))];
399
+ const discovered = discoverModelChoices(modelablePlatforms, cwd, promptIO);
398
400
  for (const { platform, artifact } of pairs) {
399
401
  const current = getModelPreference(preferences, platform, artifact.name) ?? artifact[platform]?.model;
400
- const chosen = await promptForModelValue(platform, artifact.name, current, discoveredOpenCodeModels, promptIO);
402
+ const chosen = await promptForModelValue(platform, artifact.name, current, discovered, promptIO);
401
403
  if (chosen === undefined)
402
404
  return false;
403
405
  preferences = setModelPreference(preferences, platform, artifact.name, chosen);
@@ -406,18 +408,45 @@ async function promptForModelSelection(options, promptIO, cwd, home) {
406
408
  return true;
407
409
  }
408
410
  const CUSTOM_MODEL_VALUE = '__custom__';
409
- async function promptForModelValue(platform, artifactName, current, discoveredOpenCodeModels, promptIO) {
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) {
410
441
  const io = clackIO(promptIO);
411
- const knownChoices = platform === 'opencode' && discoveredOpenCodeModels ? discoveredOpenCodeModels
412
- : platform === 'claude' ? [...knownClaudeModels]
413
- : platform === 'grok' ? [...knownGrokModels]
414
- : undefined;
442
+ const knownChoices = modelChoicesFor(platform, discovered);
415
443
  if (knownChoices && knownChoices.length > 0) {
416
444
  const initialValue = current && knownChoices.includes(current) ? current : knownChoices[0];
445
+ const liveSet = new Set(discovered[platform] ?? []);
417
446
  const choice = await p.select({
418
447
  message: `Model for \`${artifactName}\` (${platform})`,
419
448
  initialValue,
420
- options: [...knownChoices.map((value) => ({ value, label: value })), { value: CUSTOM_MODEL_VALUE, label: 'Custom…' }],
449
+ options: [...withLiveLabels(knownChoices, liveSet), { value: CUSTOM_MODEL_VALUE, label: 'Custom…' }],
421
450
  ...io
422
451
  });
423
452
  if (p.isCancel(choice))
@@ -435,6 +464,31 @@ async function promptForModelValue(platform, artifactName, current, discoveredOp
435
464
  return undefined;
436
465
  return text;
437
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
+ }
438
492
  function parseModelMap(value) {
439
493
  const result = {};
440
494
  for (const pair of value.split(',')) {
@@ -723,10 +777,11 @@ async function runConfigure(options, promptIO) {
723
777
  console.log('No installed agent on the selected scope supports a configurable model.');
724
778
  return 0;
725
779
  }
726
- const discoveredOpenCodeModels = targetPlatforms.includes('opencode') ? discoverOpenCodeModels(cwd) : undefined;
780
+ const modelablePlatforms = [...new Set(pairs.map((pair) => pair.platform))];
781
+ const discovered = discoverModelChoices(modelablePlatforms, cwd, promptIO);
727
782
  for (const { platform, artifact } of pairs) {
728
783
  const current = getModelPreference(preferences, platform, artifact.name) ?? artifact[platform]?.model;
729
- const chosen = await promptForModelValue(platform, artifact.name, current, discoveredOpenCodeModels, promptIO);
784
+ const chosen = await promptForModelValue(platform, artifact.name, current, discovered, promptIO);
730
785
  if (chosen === undefined) {
731
786
  p.cancel('Cancelled', clackIO(promptIO));
732
787
  return 1;
@@ -0,0 +1,200 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { resolveExecutable } from './executable-resolution.js';
3
+ import { isKnownCodexModel } from './adapters/codex-known.js';
4
+ import { isKnownGrokModel } from './adapters/grok-known.js';
5
+ export const MODEL_DISCOVERY_TIMEOUT_MS = 5000;
6
+ export const MODEL_DISCOVERY_MAX_STDOUT_BYTES = 1024 * 1024; // Codex live is ~327 KB — 1 MB headroom.
7
+ export const MODEL_DISCOVERY_MAX_MODELS = 500;
8
+ // defaultRunner: spawnSync + resolveExecutable (moved here from opencode-discovery so the module graph
9
+ // stays acyclic and the runner/caps live in one place). Binaries are resolved to absolute paths and
10
+ // any candidate inside cwd (a repo-local shim) is rejected by resolveExecutable. On Windows there is
11
+ // no PATHEXT, so `.cmd`/`.bat`/`.exe` wrappers won't resolve and discovery degrades to the curated
12
+ // fallback — fail-closed, documented, not fixed here.
13
+ export const defaultRunner = (command, args, options) => {
14
+ const resolved = resolveExecutable(command, { cwd: options.cwd });
15
+ if (!resolved)
16
+ return { status: null, stdout: '' };
17
+ try {
18
+ const result = spawnSync(resolved, args, { timeout: options.timeoutMs, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
19
+ return { status: result.error ? null : result.status, stdout: result.stdout ?? '' };
20
+ }
21
+ catch {
22
+ return { status: null, stdout: '' };
23
+ }
24
+ };
25
+ function exceedsStdoutCap(stdout) {
26
+ // Byte-accurate: compare Buffer.byteLength (UTF-8 bytes), not `.length` (UTF-16 code units).
27
+ return Buffer.byteLength(stdout, 'utf8') > MODEL_DISCOVERY_MAX_STDOUT_BYTES;
28
+ }
29
+ function dedupeStable(values) {
30
+ const seen = new Set();
31
+ const out = [];
32
+ for (const value of values) {
33
+ if (!seen.has(value)) {
34
+ seen.add(value);
35
+ out.push(value);
36
+ }
37
+ }
38
+ return out;
39
+ }
40
+ // ============================================================================
41
+ // OpenCode — `opencode` / `opencode2 models` (preserved behavior, caps added)
42
+ // ============================================================================
43
+ // `opencode models` prints exactly the models this user can actually pick — filtered to whichever
44
+ // providers have credentials (env var, stored auth, or a config/plugin-declared provider), not the
45
+ // full models.dev catalog. This is deliberately NOT config-file parsing: OpenCode's config schema
46
+ // differs between v1 (`provider`, singular) and the v2 preview (`providers`, plural), env-var- and
47
+ // credential-connected providers appear in neither file, and OPENCODE_CONFIG_DIR can relocate the
48
+ // whole config tree — the CLI itself is the only thing that resolves all of that correctly.
49
+ // Tries `opencode` (v1) then `opencode2` (v2 preview). v2 gained a real `models` command in
50
+ // `anomalyco/opencode` commit 30d14000 (2026-08-06); any v2 build older than that has no such
51
+ // subcommand and treats `models` as a positional `<directory>` (crash) — caught here regardless.
52
+ const modelLinePattern = /^[A-Za-z0-9][A-Za-z0-9._-]*\/\S+$/;
53
+ export function discoverOpenCodeModels(cwd, timeoutMs = MODEL_DISCOVERY_TIMEOUT_MS, runner = defaultRunner) {
54
+ for (const command of ['opencode', 'opencode2']) {
55
+ const result = runner(command, ['models'], { cwd, timeoutMs });
56
+ if (result.status !== 0)
57
+ continue;
58
+ if (exceedsStdoutCap(result.stdout))
59
+ return undefined;
60
+ const lines = result.stdout
61
+ .split('\n')
62
+ .map((line) => line.trim())
63
+ .filter((line) => modelLinePattern.test(line));
64
+ if (lines.length > 0)
65
+ return lines.slice(0, MODEL_DISCOVERY_MAX_MODELS);
66
+ }
67
+ return undefined;
68
+ }
69
+ // ============================================================================
70
+ // Codex — `codex debug models` (merged catalog; `--bundled` deferred, documented)
71
+ // ============================================================================
72
+ // Codex has no stable offline model enum. `codex debug models` prints the *merged* catalog (the
73
+ // remote-refreshed set Codex sees, ~327 KB) as JSON `{ "models": [...] }`; each entry has `slug`,
74
+ // `display_name`, `visibility` (`list` | `hide`), `context_window`, `priority`, `description`, and
75
+ // `supported_reasoning_levels`. User-selectable ids are `visibility === 'list'`; each slug is then
76
+ // trimmed and re-validated through `isKnownCodexModel` so empty/whitespace ids can never leak through.
77
+ //
78
+ // Bundled-vs-merged decision: we default to the MERGED catalog (remote refresh) under the shared
79
+ // timeout because it is closer to what the user can actually select. `--bundled` (offline-deterministic,
80
+ // skips the remote refresh) is a documented follow-up, NOT built: a `--bundled` re-run on timeout would
81
+ // double latency, and the curated `knownCodexModels` offline fallback already covers the deterministic
82
+ // case. Caveat (openai/codex issue 33146): the merged catalog can diverge from the TUI `/model` picker
83
+ // (stale `~/.codex/models_cache.json`) — mitigated by suggestions-only UX + the `Custom…` free-text
84
+ // escape. `supported_reasoning_levels` is deliberately discarded (model-id-only selection).
85
+ export function discoverCodexModels(cwd, timeoutMs = MODEL_DISCOVERY_TIMEOUT_MS, runner = defaultRunner) {
86
+ const result = runner('codex', ['debug', 'models'], { cwd, timeoutMs });
87
+ if (result.status !== 0)
88
+ return undefined;
89
+ if (exceedsStdoutCap(result.stdout))
90
+ return undefined;
91
+ let parsed;
92
+ try {
93
+ parsed = JSON.parse(result.stdout);
94
+ }
95
+ catch {
96
+ return undefined;
97
+ }
98
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
99
+ return undefined;
100
+ const models = parsed.models;
101
+ if (!Array.isArray(models))
102
+ return undefined;
103
+ const slugs = [];
104
+ for (const entry of models) {
105
+ if (typeof entry !== 'object' || entry === null)
106
+ continue;
107
+ const candidate = entry;
108
+ if (candidate.visibility !== 'list')
109
+ continue;
110
+ if (typeof candidate.slug !== 'string')
111
+ continue;
112
+ // Trim and re-validate each slug through the permissive per-platform validator, mirroring the grok
113
+ // parser's discipline (explore.md §4): an empty/whitespace-only id must never leak into the choices.
114
+ const id = candidate.slug.trim();
115
+ if (!isKnownCodexModel(id))
116
+ continue;
117
+ slugs.push(id);
118
+ }
119
+ const deduped = dedupeStable(slugs);
120
+ if (deduped.length === 0)
121
+ return undefined;
122
+ return deduped.slice(0, MODEL_DISCOVERY_MAX_MODELS);
123
+ }
124
+ // ============================================================================
125
+ // Grok — `grok models` (banner-ignoring, `isKnownGrokModel`-validated)
126
+ // ============================================================================
127
+ // `grok models` output can carry a `You are not authenticated.` banner before the model list; the list
128
+ // uses `* <model> (default)` for the default and `- <model>` for available. The banner is noise and
129
+ // never a model line.
130
+ //
131
+ // Fusion policy: `grok models` advertises the binary's known catalog (e.g. `grok-4.6`, `grok-4.5`) but
132
+ // does NOT advertise the Forge-specific aliases the curated set carries (`grok-build`, `grok-build-plan`,
133
+ // `grok-composer-2.5-fast`, `inherit`). Those are valid config values today, so on a successful live
134
+ // query they are merged in as extras (live-first, deduped, stable order via `mergeLiveWithCurated`); on
135
+ // failure the curated set is the whole fallback. We must not silently drop them on a live query.
136
+ const grokModelLinePattern = /^[*\-]\s+(\S+)/;
137
+ export function discoverGrokModels(cwd, timeoutMs = MODEL_DISCOVERY_TIMEOUT_MS, runner = defaultRunner) {
138
+ const result = runner('grok', ['models'], { cwd, timeoutMs });
139
+ if (result.status !== 0)
140
+ return undefined;
141
+ if (exceedsStdoutCap(result.stdout))
142
+ return undefined;
143
+ const out = [];
144
+ const seen = new Set();
145
+ for (const line of result.stdout.split('\n')) {
146
+ const match = grokModelLinePattern.exec(line.trim());
147
+ if (!match)
148
+ continue;
149
+ const id = match[1];
150
+ if (!isKnownGrokModel(id))
151
+ continue;
152
+ if (!seen.has(id)) {
153
+ seen.add(id);
154
+ out.push(id);
155
+ }
156
+ }
157
+ if (out.length === 0)
158
+ return undefined;
159
+ return out.slice(0, MODEL_DISCOVERY_MAX_MODELS);
160
+ }
161
+ // ============================================================================
162
+ // Claude — no dynamic source (honest undefined)
163
+ // ============================================================================
164
+ // Claude Code has NO CLI model-enumeration command: `claude --help` exposes only `--model` /
165
+ // `--fallback-model` flags and a non-scriptable in-TUI `/model`. There is no `claude models` /
166
+ // `claude list`. So this always returns undefined by design — the curated `knownClaudeModels` set plus
167
+ // the free-text `Custom…` escape is the honest fallback. Do NOT add a fake dynamic source here; the
168
+ // `discoverModels` dispatch keeps a `claude` branch so a real source could be added later without
169
+ // touching the caller.
170
+ export function discoverClaudeModels(cwd, timeoutMs = MODEL_DISCOVERY_TIMEOUT_MS, runner = defaultRunner) {
171
+ return undefined;
172
+ }
173
+ // ============================================================================
174
+ // Dispatcher — one call per platform. Returns undefined on ANY failure → caller falls back.
175
+ // ============================================================================
176
+ export function discoverModels(platform, cwd, runner = defaultRunner, timeoutMs = MODEL_DISCOVERY_TIMEOUT_MS) {
177
+ switch (platform) {
178
+ case 'opencode': return discoverOpenCodeModels(cwd, timeoutMs, runner);
179
+ case 'codex': return discoverCodexModels(cwd, timeoutMs, runner);
180
+ case 'grok': return discoverGrokModels(cwd, timeoutMs, runner);
181
+ case 'claude': return discoverClaudeModels(cwd, timeoutMs, runner);
182
+ default: return undefined;
183
+ }
184
+ }
185
+ // ============================================================================
186
+ // Fusion helper — live-first, then curated extras not already present, deduped,
187
+ // order preserved. Generic over the curated iterable so this module does not
188
+ // depend on any particular curated set.
189
+ // ============================================================================
190
+ export function mergeLiveWithCurated(live, curated) {
191
+ const seen = new Set(live);
192
+ const out = [...live];
193
+ for (const item of curated) {
194
+ if (!seen.has(item)) {
195
+ seen.add(item);
196
+ out.push(item);
197
+ }
198
+ }
199
+ return out;
200
+ }
@@ -1,32 +1,5 @@
1
- import { spawnSync } from 'node:child_process';
2
- import { resolveExecutable } from './executable-resolution.js';
3
- const defaultRunner = (command, args, options) => {
4
- const resolved = resolveExecutable(command, { cwd: options.cwd });
5
- if (!resolved)
6
- return { status: null, stdout: '' };
7
- try {
8
- const result = spawnSync(resolved, args, { timeout: options.timeoutMs, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
9
- return { status: result.error ? null : result.status, stdout: result.stdout ?? '' };
10
- }
11
- catch {
12
- return { status: null, stdout: '' };
13
- }
14
- };
15
- const modelLinePattern = /^[A-Za-z0-9][A-Za-z0-9._-]*\/\S+$/;
16
- // Returns undefined (not an empty array) on any failure — binary absent, non-zero exit, or no
17
- // parseable lines — so callers can fall back to a free-text prompt instead of showing an empty
18
- // choice list.
19
- export function discoverOpenCodeModels(cwd, timeoutMs = 5000, runner = defaultRunner) {
20
- for (const command of ['opencode', 'opencode2']) {
21
- const result = runner(command, ['models'], { cwd, timeoutMs });
22
- if (result.status !== 0)
23
- continue;
24
- const lines = result.stdout
25
- .split('\n')
26
- .map((line) => line.trim())
27
- .filter((line) => modelLinePattern.test(line));
28
- if (lines.length > 0)
29
- return lines;
30
- }
31
- return undefined;
32
- }
1
+ // Backward-compatible re-export shim. The canonical discovery module is `src/model-discovery.ts`;
2
+ // this file exists only to preserve the stable `src/opencode-discovery.js` import path used by
3
+ // `src/cli.ts` and the tests. The runner abstraction, shared caps, `discoverOpenCodeModels`, and the
4
+ // platform dispatcher now live in `src/model-discovery.ts`.
5
+ export { defaultRunner, discoverOpenCodeModels, MODEL_DISCOVERY_TIMEOUT_MS, MODEL_DISCOVERY_MAX_STDOUT_BYTES, MODEL_DISCOVERY_MAX_MODELS } from './model-discovery.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guidobuilds/forge-ai",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Forge AI framework",
5
5
  "license": "MIT",
6
6
  "type": "module",