@guidobuilds/forge-ai 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +84 -0
- package/README.md +44 -57
- package/artifacts/forge/forge.md +7 -4
- package/artifacts/forge-adversary/forge-adversary.md +3 -0
- package/artifacts/forge-worker/forge-worker.md +4 -5
- package/artifacts/forge-worker-leaf/forge-worker-leaf.md +3 -0
- package/artifacts/using-forge/using-forge.md +2 -2
- package/bin/forge-ai.mjs +1 -1
- package/dist/src/adapters/claude-known.js +12 -2
- package/dist/src/adapters/codex-known.js +24 -0
- package/dist/src/adapters/codex.js +6 -1
- package/dist/src/adapters/grok-known.js +4 -0
- package/dist/src/adapters/grok.js +3 -0
- package/dist/src/adapters/opencode-known.js +15 -0
- package/dist/src/adapters/opencode.js +20 -4
- package/dist/src/adapters/shared.js +15 -1
- package/dist/src/cli.js +555 -49
- package/dist/src/compose.js +12 -0
- package/dist/src/dispatch-snippets.js +56 -0
- package/dist/src/executable-resolution.js +49 -0
- package/dist/src/index.js +0 -1
- package/dist/src/manifest.js +172 -14
- package/dist/src/model-discovery.js +200 -0
- package/dist/src/model-preferences.js +41 -0
- package/dist/src/opencode-discovery.js +5 -0
- package/dist/src/paths.js +55 -3
- package/dist/src/platform-capabilities.js +17 -0
- package/dist/src/processor.js +66 -16
- package/dist/src/self-update.js +50 -4
- package/dist/src/writer.js +10 -12
- package/package.json +2 -2
- package/dist/src/adapters/claude-plugin.js +0 -43
- package/dist/src/adapters/codex-plugin.js +0 -98
- package/dist/src/build-plugin.js +0 -69
|
@@ -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
package/dist/src/manifest.js
CHANGED
|
@@ -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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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';
|