@guidobuilds/forge-ai 0.7.0 → 0.8.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 +50 -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 +6 -2
- package/dist/src/adapters/codex-known.js +9 -0
- package/dist/src/adapters/codex.js +6 -1
- 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 +500 -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-preferences.js +41 -0
- package/dist/src/opencode-discovery.js +32 -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,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,32 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { resolveExecutable } from './executable-resolution.js';
|
|
3
|
+
const defaultRunner = (command, args, options) => {
|
|
4
|
+
const resolved = resolveExecutable(command, { cwd: options.cwd });
|
|
5
|
+
if (!resolved)
|
|
6
|
+
return { status: null, stdout: '' };
|
|
7
|
+
try {
|
|
8
|
+
const result = spawnSync(resolved, args, { timeout: options.timeoutMs, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
9
|
+
return { status: result.error ? null : result.status, stdout: result.stdout ?? '' };
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return { status: null, stdout: '' };
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
const modelLinePattern = /^[A-Za-z0-9][A-Za-z0-9._-]*\/\S+$/;
|
|
16
|
+
// Returns undefined (not an empty array) on any failure — binary absent, non-zero exit, or no
|
|
17
|
+
// parseable lines — so callers can fall back to a free-text prompt instead of showing an empty
|
|
18
|
+
// choice list.
|
|
19
|
+
export function discoverOpenCodeModels(cwd, timeoutMs = 5000, runner = defaultRunner) {
|
|
20
|
+
for (const command of ['opencode', 'opencode2']) {
|
|
21
|
+
const result = runner(command, ['models'], { cwd, timeoutMs });
|
|
22
|
+
if (result.status !== 0)
|
|
23
|
+
continue;
|
|
24
|
+
const lines = result.stdout
|
|
25
|
+
.split('\n')
|
|
26
|
+
.map((line) => line.trim())
|
|
27
|
+
.filter((line) => modelLinePattern.test(line));
|
|
28
|
+
if (lines.length > 0)
|
|
29
|
+
return lines;
|
|
30
|
+
}
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
package/dist/src/paths.js
CHANGED
|
@@ -1,12 +1,30 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
1
2
|
import os from 'node:os';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
export function resolveOutputPath(platform, kind, scope, name, cwd = process.cwd(), home = os.homedir()) {
|
|
4
5
|
const base = scope === 'user' ? userBase(platform, kind, home) : projectBase(platform, kind, cwd);
|
|
6
|
+
return path.join(base, outputFileName(platform, kind, name));
|
|
7
|
+
}
|
|
8
|
+
function outputFileName(platform, kind, name) {
|
|
5
9
|
return kind === 'agent' && platform === 'codex'
|
|
6
|
-
?
|
|
10
|
+
? `${name}.toml`
|
|
7
11
|
: kind === 'agent'
|
|
8
|
-
?
|
|
9
|
-
: path.join(
|
|
12
|
+
? `${name}.md`
|
|
13
|
+
: path.join(name, 'SKILL.md');
|
|
14
|
+
}
|
|
15
|
+
// OpenCode v1 (the `opencode` binary) and the v2 preview (`opencode2`) resolve user-scope
|
|
16
|
+
// agents/skills from different directories — v1 from `~/.config/opencode/`, v2 from `~/.opencode/`
|
|
17
|
+
// (confirmed empirically via `opencode2 debug config`; v2 does not read `~/.config/opencode` at
|
|
18
|
+
// all). `userBase` above returns the v1 path, which stays the default single-target path used
|
|
19
|
+
// everywhere else; this returns v2's equivalent so callers can additionally target it when it's
|
|
20
|
+
// actually present (see `resolveOpenCodeUserV2Path` / `openCodeUserRoots` usage in processor.ts —
|
|
21
|
+
// gated on the directory actually existing, not written blindly).
|
|
22
|
+
export function openCodeUserRoots(home) {
|
|
23
|
+
return { v1: path.join(home, '.config', 'opencode'), v2: path.join(home, '.opencode') };
|
|
24
|
+
}
|
|
25
|
+
export function resolveOpenCodeUserV2Path(kind, name, home) {
|
|
26
|
+
const base = path.join(openCodeUserRoots(home).v2, kind === 'agent' ? 'agents' : 'skills');
|
|
27
|
+
return path.join(base, outputFileName('opencode', kind, name));
|
|
10
28
|
}
|
|
11
29
|
function userBase(platform, kind, home) {
|
|
12
30
|
if (platform === 'opencode')
|
|
@@ -26,3 +44,37 @@ function projectBase(platform, kind, cwd) {
|
|
|
26
44
|
return path.join(cwd, '.grok', kind === 'agent' ? 'agents' : 'skills');
|
|
27
45
|
return kind === 'agent' ? path.join(cwd, '.codex', 'agents') : path.join(cwd, '.agents', 'skills');
|
|
28
46
|
}
|
|
47
|
+
const ALL_PLATFORMS = ['opencode', 'claude', 'codex', 'grok'];
|
|
48
|
+
const ALL_KINDS = ['agent', 'skill'];
|
|
49
|
+
function canonicalizePath(p) {
|
|
50
|
+
try {
|
|
51
|
+
return realpathSync(p);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return path.resolve(p);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// Single source of truth for the install roots a manifest entry's `path` must live inside.
|
|
58
|
+
// Derived from userBase/projectBase/openCodeUserRoots (never hand-enumerated) so it cannot drift
|
|
59
|
+
// from the real install locations. home/cwd/projectPath are canonicalized (realpath) so a
|
|
60
|
+
// symlinked ~/.claude or project dir does not cause false rejections. Project scope uses the
|
|
61
|
+
// manifest's projectPath (the realpath recorded at install), not the caller's current cwd.
|
|
62
|
+
export function allowedInstallRoots(scope, home, cwd, projectPath) {
|
|
63
|
+
const roots = [];
|
|
64
|
+
if (scope === 'user') {
|
|
65
|
+
const canonicalHome = canonicalizePath(home);
|
|
66
|
+
for (const platform of ALL_PLATFORMS) {
|
|
67
|
+
for (const kind of ALL_KINDS)
|
|
68
|
+
roots.push(userBase(platform, kind, canonicalHome));
|
|
69
|
+
}
|
|
70
|
+
roots.push(path.join(openCodeUserRoots(canonicalHome).v2, 'agents'), path.join(openCodeUserRoots(canonicalHome).v2, 'skills'));
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
const canonicalProject = canonicalizePath(projectPath ?? cwd);
|
|
74
|
+
for (const platform of ALL_PLATFORMS) {
|
|
75
|
+
for (const kind of ALL_KINDS)
|
|
76
|
+
roots.push(projectBase(platform, kind, canonicalProject));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return [...new Set(roots)];
|
|
80
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Verified per-platform dispatch capability (see .forge/harness-agnostic-adapters/decisions.md).
|
|
2
|
+
// Codex's CLI-installed agents have no spawn tool wired; the coordinator returns DELEGATION_REQUESTS
|
|
3
|
+
// for the orchestrator to fan out instead of self-spawning.
|
|
4
|
+
export const PLATFORM_CAPABILITIES = {
|
|
5
|
+
claude: { supportsAgentSpawn: true, spawnToolName: 'Agent' },
|
|
6
|
+
grok: { supportsAgentSpawn: true, spawnToolName: 'task' },
|
|
7
|
+
opencode: { supportsAgentSpawn: true, spawnToolName: 'task' },
|
|
8
|
+
codex: { supportsAgentSpawn: false }
|
|
9
|
+
};
|
|
10
|
+
// Every platform's agent-kind renderer emits `model`. Only Claude's skill renderer does
|
|
11
|
+
// (renderClaudeSkill) — Grok/OpenCode/Codex skills silently discard it
|
|
12
|
+
// (GROK_SKILL_MODEL_IGNORED / OPENCODE_SKILL_MODEL_IGNORED / CODEX_SKILL_MODEL_IGNORED). Keyed on
|
|
13
|
+
// *effective* kind, not canonical kind, since effective kind is already per-platform
|
|
14
|
+
// (e.g. `forge` is kind: agent on Claude/OpenCode/Codex but kind: skill on Grok).
|
|
15
|
+
export function supportsModel(platform, effectiveKind) {
|
|
16
|
+
return effectiveKind === 'agent' || platform === 'claude';
|
|
17
|
+
}
|