@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.
@@ -0,0 +1,12 @@
1
+ import { resolveSnippet } from './dispatch-snippets.js';
2
+ // A `{{snippet:id}}` that is the entire content of its line is removed as a whole line when it
3
+ // resolves to '' (e.g. a Codex-only bullet in a shared list), instead of leaving a blank line.
4
+ const wholeLineSnippet = /^[ \t]*\{\{snippet:([a-z0-9-]+)\}\}[ \t]*\n?/gm;
5
+ const inlineSnippet = /\{\{snippet:([a-z0-9-]+)\}\}/g;
6
+ export function composeBody(body, platform) {
7
+ const withWholeLines = body.replace(wholeLineSnippet, (_match, id) => {
8
+ const text = resolveSnippet(id, platform);
9
+ return text === '' ? '' : `${text}\n`;
10
+ });
11
+ return withWholeLines.replace(inlineSnippet, (_match, id) => resolveSnippet(id, platform));
12
+ }
@@ -0,0 +1,56 @@
1
+ // Every instance of per-harness dispatch vocabulary in artifacts/*/*.md, found by mechanically
2
+ // grepping for Codex|Claude|OpenCode|Grok|spawn|task|Agent|DELEGATION_REQUESTS and classifying
3
+ // each hit (see .forge/harness-agnostic-adapters/design.md §5.5 / feature-list.json f4-t1). All
4
+ // five are `mechanism`-class: the difference is real (Codex's CLI agents have no spawn tool), not
5
+ // arbitrary wording. There are currently zero `policy`-class snippets — no instance of the
6
+ // invariant itself ("never do worker work inline", "leaves never spawn") varies by platform.
7
+ export const DISPATCH_SNIPPETS = {
8
+ 'worker-spawn-leaf-instruction': {
9
+ class: 'mechanism',
10
+ text: {
11
+ claude: 'Spawn `forge-worker-leaf` via `Agent`, passing `DISPATCH_DEPTH: 2`, `WORKER_ROLE: leaf`, `TASK_ID`, subgoal, constraints, and `files_hint`. Omit `DELEGATION_REQUESTS`.',
12
+ grok: 'Spawn `forge-worker-leaf` via `task`, passing `DISPATCH_DEPTH: 2`, `WORKER_ROLE: leaf`, `TASK_ID`, subgoal, constraints, and `files_hint`. Omit `DELEGATION_REQUESTS`.',
13
+ opencode: 'Spawn `forge-worker-leaf` via `task`, passing `DISPATCH_DEPTH: 2`, `WORKER_ROLE: leaf`, `TASK_ID`, subgoal, constraints, and `files_hint`. Omit `DELEGATION_REQUESTS`.',
14
+ codex: 'Return `DELEGATION_REQUESTS` for the orchestrator to fan out `forge-worker-leaf` dispatches, including `DISPATCH_DEPTH: 2`, `WORKER_ROLE: leaf`, `TASK_ID`, subgoal, constraints, and `files_hint`.'
15
+ }
16
+ },
17
+ 'who-spawns-leaf': {
18
+ class: 'mechanism',
19
+ text: {
20
+ claude: 'coordinators spawn it directly',
21
+ grok: 'coordinators spawn it directly',
22
+ opencode: 'coordinators spawn it directly',
23
+ codex: 'you fan out leaves yourself when a coordinator returns `DELEGATION_REQUESTS`, since coordinators cannot self-spawn there'
24
+ }
25
+ },
26
+ 'orchestrator-delegation-requests-handling': {
27
+ class: 'mechanism',
28
+ text: {
29
+ codex: 'On `DELEGATION_REQUESTS`, fan out `forge-worker-leaf` dispatches yourself.',
30
+ default: '`forge-worker` self-spawns `forge-worker-leaf` directly, so `DELEGATION_REQUESTS` should not normally appear here; fan out `forge-worker-leaf` dispatches yourself if it does.'
31
+ }
32
+ },
33
+ 'worker-must-spawn-or-delegate': {
34
+ class: 'mechanism',
35
+ text: {
36
+ codex: 'return `DELEGATION_REQUESTS`',
37
+ default: 'spawn leaves'
38
+ }
39
+ },
40
+ 'codex-orchestrator-dispatch-hint': {
41
+ class: 'mechanism',
42
+ text: {
43
+ codex: '- Codex: parse `DELEGATION_REQUESTS` and fan out `forge-worker-leaf` yourself',
44
+ default: ''
45
+ }
46
+ }
47
+ };
48
+ export function resolveSnippet(id, platform) {
49
+ const snippet = DISPATCH_SNIPPETS[id];
50
+ if (!snippet)
51
+ throw new Error(`Unknown dispatch snippet "${id}" referenced in a canonical artifact body`);
52
+ const text = snippet.text[platform] ?? snippet.text.default;
53
+ if (text === undefined)
54
+ throw new Error(`Dispatch snippet "${id}" has no text for platform "${platform}" and no default`);
55
+ return text;
56
+ }
@@ -0,0 +1,49 @@
1
+ import { accessSync, constants, realpathSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ function canonicalize(p) {
4
+ try {
5
+ return realpathSync(p);
6
+ }
7
+ catch {
8
+ return path.resolve(p);
9
+ }
10
+ }
11
+ function safeCandidate(candidate, cwd) {
12
+ try {
13
+ accessSync(candidate, constants.F_OK);
14
+ }
15
+ catch {
16
+ return undefined;
17
+ }
18
+ if (cwd) {
19
+ const resolvedCandidate = canonicalize(candidate);
20
+ const resolvedCwd = canonicalize(cwd);
21
+ const rel = path.relative(resolvedCwd, resolvedCandidate);
22
+ // At-or-inside cwd → reject, so a repo-local shim (e.g. node_modules/.bin) cannot shadow the
23
+ // real binary. Both sides canonicalized (realpath) to cover symlinked PATH entries/dotdirs.
24
+ if (!rel.startsWith('..') && !path.isAbsolute(rel))
25
+ return undefined;
26
+ }
27
+ return path.resolve(candidate);
28
+ }
29
+ // Portable PATH scan (separator path.delimiter: ':' POSIX, ';' Windows). Returns the absolute path
30
+ // to the first existing executable named `command` on PATH, or undefined — except any candidate
31
+ // that canonicalizes to at-or-inside `cwd`, which is rejected so a repo-local shim cannot shadow
32
+ // the real binary. An already-absolute `command` is treated as a direct candidate (existence + cwd
33
+ // check, no PATH re-scan).
34
+ // Windows note: existence only (access F_OK), NOT PATHEXT (.cmd/.bat/.exe) — by design, fail-closed:
35
+ // a Windows `opencode`/`pnpm` invoked via a .cmd wrapper will not resolve and discovery/self-update
36
+ // degrade to their safe fallback. Full PATHEXT support is a documented follow-up, not this change.
37
+ export function resolveExecutable(command, options = {}) {
38
+ if (path.isAbsolute(command))
39
+ return safeCandidate(command, options.cwd);
40
+ const pathEnv = options.path ?? process.env.PATH ?? '';
41
+ for (const dir of pathEnv.split(path.delimiter)) {
42
+ if (dir === '')
43
+ continue;
44
+ const resolved = safeCandidate(path.join(dir, command), options.cwd);
45
+ if (resolved)
46
+ return resolved;
47
+ }
48
+ return undefined;
49
+ }
package/dist/src/index.js CHANGED
@@ -1,4 +1,3 @@
1
1
  export * from './model.js';
2
2
  export * from './processor.js';
3
3
  export * from './writer.js';
4
- export * from './adapters/codex-plugin.js';
@@ -1,46 +1,163 @@
1
- import { createHash } from 'node:crypto';
2
- import { constants } from 'node:fs';
3
- import { access, mkdir, readFile, realpath, rm, writeFile } from 'node:fs/promises';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { constants, realpathSync } from 'node:fs';
3
+ import { access, cp, mkdir, readdir, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
+ export function legacyStateRoot(home) {
6
+ return path.join(home, '.forge-ai');
7
+ }
8
+ export function stateRoot(home) {
9
+ return path.join(home, '.forge', 'state');
10
+ }
11
+ // `~/.forge/state/` is a path nested under Forge's own namespace that nothing else creates, so its
12
+ // mere existence is a safe migration marker — unlike bare `~/.forge/`, which a dogfooded Forge run
13
+ // can create as a project feature ledger (index.md, <slug>/feature-list.json, ...) with no relation
14
+ // to install state. Nesting under `state/` means the two can never collide, migrated or not.
15
+ export async function migrateStateDirectory(home) {
16
+ const newRoot = stateRoot(home);
17
+ const oldRoot = legacyStateRoot(home);
18
+ if (await pathExists(newRoot))
19
+ return false;
20
+ if (!(await pathExists(oldRoot)))
21
+ return false;
22
+ await cp(oldRoot, newRoot, { recursive: true });
23
+ await writeFile(path.join(oldRoot, 'MIGRATED'), `Forge migrated its install state to ${newRoot} on ${new Date().toISOString()}.\nThis directory (${oldRoot}) is no longer read by Forge and can be deleted once you've confirmed the new location works.\n`, 'utf8');
24
+ return true;
25
+ }
26
+ async function pathExists(target) {
27
+ try {
28
+ await access(target);
29
+ return true;
30
+ }
31
+ catch {
32
+ return false;
33
+ }
34
+ }
5
35
  export async function resolveManifestLocation(scope, cwd = process.cwd(), home) {
6
- const stateRoot = path.join(home, '.forge-ai');
36
+ const migrated = await migrateStateDirectory(home);
37
+ if (migrated)
38
+ console.log(`Migrated Forge install state from ${legacyStateRoot(home)} to ${stateRoot(home)}.`);
39
+ const root = stateRoot(home);
7
40
  if (scope === 'user')
8
- return { stateRoot, manifestPath: path.join(stateRoot, 'user-manifest.json'), scope };
41
+ return { stateRoot: root, manifestPath: path.join(root, 'user-manifest.json'), scope };
9
42
  const projectPath = await canonicalProjectPath(cwd);
10
43
  const projectPathHash = hashProjectPath(projectPath);
11
- return { stateRoot, manifestPath: path.join(stateRoot, 'projects', projectPathHash, 'manifest.json'), scope, projectPath, projectPathHash };
44
+ return { stateRoot: root, manifestPath: path.join(root, 'projects', projectPathHash, 'manifest.json'), scope, projectPath, projectPathHash };
45
+ }
46
+ // A cached old CLI (npx resolves versions independently per invocation) can keep writing to
47
+ // ~/.forge-ai/ after migration while a newer CLI writes to ~/.forge/state/, producing two live
48
+ // checksum ledgers over the same installed files. Detect it by timestamp rather than silently
49
+ // trusting whichever manifest happens to load — the fix is telling the user, not auto-merging.
50
+ export async function detectLegacyStateDrift(location, home) {
51
+ const legacyPath = location.manifestPath.replace(stateRoot(home), legacyStateRoot(home));
52
+ if (legacyPath === location.manifestPath)
53
+ return undefined;
54
+ const [legacy, current] = await Promise.all([loadManifest(legacyPath), loadManifest(location.manifestPath)]);
55
+ if (!legacy || !current)
56
+ return undefined;
57
+ if (new Date(legacy.updatedAt).getTime() <= new Date(current.updatedAt).getTime())
58
+ return undefined;
59
+ return `An older Forge install at ${legacyPath} was updated more recently (${legacy.updatedAt}) than ${location.manifestPath} (${current.updatedAt}). An old cached CLI may still be writing there — re-run install/update with this CLI to resync, or remove ${legacyStateRoot(home)} once you've confirmed it's no longer used.`;
60
+ }
61
+ export async function listInstalls(home) {
62
+ await migrateStateDirectory(home);
63
+ const root = stateRoot(home);
64
+ const summaries = [];
65
+ const userManifestPath = path.join(root, 'user-manifest.json');
66
+ const userManifest = await loadManifest(userManifestPath);
67
+ if (userManifest)
68
+ summaries.push(await summarize(userManifest, { stateRoot: root, manifestPath: userManifestPath, scope: 'user' }, home));
69
+ let projectHashes = [];
70
+ try {
71
+ projectHashes = await readdir(path.join(root, 'projects'));
72
+ }
73
+ catch (error) {
74
+ if (error.code !== 'ENOENT')
75
+ throw error;
76
+ }
77
+ for (const hash of projectHashes) {
78
+ const manifestPath = path.join(root, 'projects', hash, 'manifest.json');
79
+ const manifest = await loadManifest(manifestPath);
80
+ if (manifest)
81
+ summaries.push(await summarize(manifest, { stateRoot: root, manifestPath, scope: 'project', projectPathHash: hash }, home));
82
+ }
83
+ return summaries;
84
+ }
85
+ async function summarize(manifest, location, home) {
86
+ const platforms = [...new Set(manifest.entries.map((entry) => entry.platform))].sort();
87
+ const driftWarning = await detectLegacyStateDrift(location, home);
88
+ return { scope: manifest.scope, projectPath: manifest.projectPath, forgeVersion: manifest.forgeVersion, updatedAt: manifest.updatedAt, platforms, fileCount: manifest.entries.length, driftWarning };
12
89
  }
13
90
  export async function loadManifest(manifestPath) {
91
+ let raw;
14
92
  try {
15
- return JSON.parse(await readFile(manifestPath, 'utf8'));
93
+ raw = JSON.parse(await readFile(manifestPath, 'utf8'));
16
94
  }
17
95
  catch (error) {
18
96
  if (error.code === 'ENOENT')
19
97
  return undefined;
98
+ if (error instanceof SyntaxError) {
99
+ console.error(`state file corrupt at ${manifestPath}; re-run \`forge-ai install\` to regenerate it.`);
100
+ return undefined;
101
+ }
20
102
  throw error;
21
103
  }
104
+ // Validate shape BEFORE accessing fields — parseable non-objects ("hi", [1,2], 42, null) throw
105
+ // TypeError (not SyntaxError) on `raw.schemaVersion`, so shape-mismatch must be caught here.
106
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw) || !Array.isArray(raw.entries)) {
107
+ console.error(`state file corrupt at ${manifestPath}; re-run \`forge-ai install\` to regenerate it.`);
108
+ return undefined;
109
+ }
110
+ if (raw.schemaVersion === 1)
111
+ return upgradeManifestV1(raw);
112
+ return raw;
22
113
  }
23
- export async function buildManifest(location, files, now = new Date()) {
114
+ function upgradeManifestV1(manifest) {
115
+ return {
116
+ ...manifest,
117
+ schemaVersion: 2,
118
+ forgeVersion: 'unknown',
119
+ entries: manifest.entries.map((entry) => ({ ...entry, forgeVersion: 'unknown' }))
120
+ };
121
+ }
122
+ export async function buildManifest(location, files, forgeVersion, now = new Date(), oldManifest) {
24
123
  const entries = await Promise.all(files.map(async (file) => ({
25
124
  platform: file.platform,
26
125
  kind: file.kind,
27
126
  name: file.name,
28
127
  path: file.path,
29
128
  sourcePath: file.sourcePath,
30
- checksum: sha256(await readFile(file.path, 'utf8'))
129
+ checksum: sha256(await readFile(file.path, 'utf8')),
130
+ forgeVersion
31
131
  })));
132
+ // Entries for platforms this run didn't touch (e.g. a prior `install --platform claude` run,
133
+ // followed by `install --platform opencode`) are carried forward rather than dropped, so
134
+ // previously-installed files never silently reclassify as foreign on a later run.
135
+ const touchedPlatforms = new Set(files.map((file) => file.platform));
136
+ const carriedForward = (oldManifest?.entries ?? []).filter((entry) => !touchedPlatforms.has(entry.platform));
32
137
  return {
33
- schemaVersion: 1,
138
+ schemaVersion: 2,
34
139
  scope: location.scope,
35
140
  projectPath: location.projectPath,
36
141
  projectPathHash: location.projectPathHash,
142
+ forgeVersion,
37
143
  updatedAt: now.toISOString(),
38
- entries
144
+ entries: [...carriedForward, ...entries]
39
145
  };
40
146
  }
147
+ export async function writeFileAtomic(filePath, content) {
148
+ const tmpPath = path.join(path.dirname(filePath), `.${path.basename(filePath)}.tmp-${randomUUID()}`);
149
+ try {
150
+ await writeFile(tmpPath, content, 'utf8');
151
+ await rename(tmpPath, filePath);
152
+ }
153
+ catch (error) {
154
+ await rm(tmpPath, { force: true }).catch(() => { });
155
+ throw error;
156
+ }
157
+ }
41
158
  export async function saveManifest(manifestPath, manifest) {
42
159
  await mkdir(path.dirname(manifestPath), { recursive: true });
43
- await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
160
+ await writeFileAtomic(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
44
161
  }
45
162
  export function lookupEntryByPath(manifest, filePath) {
46
163
  return manifest?.entries.find((entry) => entry.path === filePath);
@@ -51,10 +168,14 @@ export function staleEntries(oldManifest, files) {
51
168
  const currentPaths = new Set(files.map((file) => file.path));
52
169
  return oldManifest.entries.filter((entry) => !currentPaths.has(entry.path));
53
170
  }
54
- export async function classifyPruneEntries(entries) {
171
+ export async function classifyPruneEntries(entries, roots) {
55
172
  const deletable = [];
56
173
  const skipped = [];
57
174
  for (const entry of entries) {
175
+ if (!isSafeManifestEntry(entry, roots)) {
176
+ skipped.push({ ...entry, reason: 'unsafe-path' });
177
+ continue;
178
+ }
58
179
  let content;
59
180
  try {
60
181
  content = await readFile(entry.path, 'utf8');
@@ -73,8 +194,10 @@ export async function classifyPruneEntries(entries) {
73
194
  }
74
195
  return { deletable, skipped };
75
196
  }
76
- export async function pruneEntries(entries) {
197
+ export async function pruneEntries(entries, roots) {
77
198
  for (const entry of entries) {
199
+ if (!isSafeManifestEntry(entry, roots))
200
+ continue; // defense in depth: never rm an out-of-scope path
78
201
  if (entry.backupPath) {
79
202
  try {
80
203
  const content = await readFile(entry.path, 'utf8');
@@ -109,6 +232,41 @@ export async function backupFile(backupPath, content) {
109
232
  export function sha256(content) {
110
233
  return createHash('sha256').update(content).digest('hex');
111
234
  }
235
+ const CHECKSUM_PATTERN = /^[0-9a-f]{64}$/;
236
+ function canonicalizePath(p) {
237
+ try {
238
+ return realpathSync(p);
239
+ }
240
+ catch {
241
+ return path.resolve(p);
242
+ }
243
+ }
244
+ // True when `p` is absolute and canonicalizes (realpath) to inside one of the already-canonical
245
+ // `roots` (canonical→canonical comparison; path.relative resolves `..` internally).
246
+ export function isSafeManifestPath(p, roots) {
247
+ if (!path.isAbsolute(p))
248
+ return false;
249
+ const resolved = canonicalizePath(p);
250
+ return roots.some((root) => {
251
+ const rel = path.relative(root, resolved);
252
+ return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
253
+ });
254
+ }
255
+ function hasValidEntrySchema(entry) {
256
+ if (typeof entry !== 'object' || entry === null)
257
+ return false;
258
+ const e = entry;
259
+ return (typeof e.platform === 'string' &&
260
+ typeof e.kind === 'string' &&
261
+ typeof e.name === 'string' &&
262
+ typeof e.path === 'string' &&
263
+ typeof e.sourcePath === 'string' &&
264
+ typeof e.checksum === 'string' &&
265
+ CHECKSUM_PATTERN.test(e.checksum));
266
+ }
267
+ export function isSafeManifestEntry(entry, roots) {
268
+ return hasValidEntrySchema(entry) && isSafeManifestPath(entry.path, roots);
269
+ }
112
270
  export function hashProjectPath(projectPath) {
113
271
  return sha256(projectPath).slice(0, 32);
114
272
  }
@@ -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
+ }
@@ -0,0 +1,41 @@
1
+ import { mkdir, readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { writeFileAtomic } from './manifest.js';
4
+ // Lives beside the manifest, not inside it: ManifestEntry is rebuilt from scratch on every
5
+ // install/update run (see buildManifest), so a model choice stored there would be silently lost
6
+ // on the next run. This file is read-modified-written independently and merged into the render
7
+ // pipeline as an override, never derived from the manifest.
8
+ export function modelPreferencesPath(location) {
9
+ const filename = location.scope === 'user' ? 'user-model-preferences.json' : 'model-preferences.json';
10
+ return path.join(path.dirname(location.manifestPath), filename);
11
+ }
12
+ export async function loadModelPreferences(prefsPath) {
13
+ let raw;
14
+ try {
15
+ raw = JSON.parse(await readFile(prefsPath, 'utf8'));
16
+ }
17
+ catch (error) {
18
+ if (error.code === 'ENOENT')
19
+ return {};
20
+ if (error instanceof SyntaxError) {
21
+ console.error(`state file corrupt at ${prefsPath}; re-run \`forge-ai install\` to regenerate it.`);
22
+ return {};
23
+ }
24
+ throw error;
25
+ }
26
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
27
+ console.error(`state file corrupt at ${prefsPath}; re-run \`forge-ai install\` to regenerate it.`);
28
+ return {};
29
+ }
30
+ return raw;
31
+ }
32
+ export async function saveModelPreferences(prefsPath, prefs) {
33
+ await mkdir(path.dirname(prefsPath), { recursive: true });
34
+ await writeFileAtomic(prefsPath, `${JSON.stringify(prefs, null, 2)}\n`);
35
+ }
36
+ export function setModelPreference(prefs, platform, name, model) {
37
+ return { ...prefs, [platform]: { ...prefs[platform], [name]: model } };
38
+ }
39
+ export function getModelPreference(prefs, platform, name) {
40
+ return prefs[platform]?.[name];
41
+ }
@@ -0,0 +1,5 @@
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';