@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 +34 -0
- package/README.md +2 -2
- package/dist/src/adapters/claude-known.js +6 -0
- package/dist/src/adapters/codex-known.js +20 -5
- package/dist/src/adapters/grok-known.js +4 -0
- package/dist/src/cli.js +66 -11
- package/dist/src/model-discovery.js +200 -0
- package/dist/src/opencode-discovery.js +5 -32
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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 {
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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';
|