@guidobuilds/forge-ai 0.6.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 +57 -0
- package/README.md +45 -12
- 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 +505 -24
- 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/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 +76 -22
- package/dist/src/self-update.js +50 -4
- package/dist/src/writer.js +10 -5
- package/package.json +3 -2
package/dist/src/processor.js
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import { access, readFile } from 'node:fs/promises';
|
|
2
2
|
import { constants } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { renderClaudeAgent, renderClaudeSkill } from './adapters/claude.js';
|
|
5
6
|
import { renderCodexAgent, renderCodexSkill } from './adapters/codex.js';
|
|
6
7
|
import { renderGrokAgent, renderGrokSkill } from './adapters/grok.js';
|
|
7
8
|
import { renderOpenCodeAgent, renderOpenCodeSkill } from './adapters/opencode.js';
|
|
9
|
+
import { composeBody } from './compose.js';
|
|
8
10
|
import { diagnostic } from './diagnostics.js';
|
|
9
11
|
import { discoverSources } from './discovery.js';
|
|
10
12
|
import { lookupEntryByPath, resolveBackupPath, sha256 } from './manifest.js';
|
|
11
|
-
import {
|
|
13
|
+
import { getModelPreference } from './model-preferences.js';
|
|
14
|
+
import { openCodeUserRoots, resolveOpenCodeUserV2Path, resolveOutputPath } from './paths.js';
|
|
15
|
+
import { supportsModel } from './platform-capabilities.js';
|
|
12
16
|
import { isPlatform, platforms } from './model.js';
|
|
13
17
|
const namePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
14
18
|
const platformKeys = new Set(['claude', 'opencode', 'codex', 'grok']);
|
|
@@ -29,29 +33,39 @@ export function parsePlatform(value) {
|
|
|
29
33
|
export function parseScope(value) {
|
|
30
34
|
return value === 'user' || value === 'project' ? value : undefined;
|
|
31
35
|
}
|
|
32
|
-
export async function
|
|
33
|
-
const { sources, diagnostics } = await discoverSources(
|
|
36
|
+
export async function discoverArtifacts(source) {
|
|
37
|
+
const { sources, diagnostics } = await discoverSources(source);
|
|
34
38
|
const artifacts = [];
|
|
35
39
|
const seen = new Set();
|
|
36
|
-
for (const
|
|
37
|
-
const converted = convertSource(
|
|
40
|
+
for (const item of sources) {
|
|
41
|
+
const converted = convertSource(item, source);
|
|
38
42
|
diagnostics.push(...converted.diagnostics);
|
|
39
43
|
if (!converted.item)
|
|
40
44
|
continue;
|
|
41
45
|
if (seen.has(converted.item.name)) {
|
|
42
|
-
diagnostics.push(diagnostic('error', 'DUPLICATE_NAME', `Duplicate artifact name ${converted.item.name}`, { sourcePath:
|
|
46
|
+
diagnostics.push(diagnostic('error', 'DUPLICATE_NAME', `Duplicate artifact name ${converted.item.name}`, { sourcePath: item.sourcePath }));
|
|
43
47
|
continue;
|
|
44
48
|
}
|
|
45
49
|
seen.add(converted.item.name);
|
|
46
50
|
artifacts.push(converted.item);
|
|
47
51
|
}
|
|
52
|
+
return { artifacts, diagnostics, sourceCount: sources.length };
|
|
53
|
+
}
|
|
54
|
+
export async function buildWritePlan(options) {
|
|
55
|
+
const { artifacts, diagnostics, sourceCount } = await discoverArtifacts(options.source);
|
|
48
56
|
const files = [];
|
|
49
57
|
const pending = { modifiedOverwrites: [], foreignOverwrites: [] };
|
|
50
58
|
if (!diagnostics.some((item) => item.severity === 'error')) {
|
|
51
59
|
for (const platform of resolvePlatforms(options.platform)) {
|
|
52
60
|
for (const artifact of artifacts) {
|
|
53
61
|
const effectiveKind = artifact[platform]?.kind ?? artifact.kind;
|
|
54
|
-
|
|
62
|
+
const rendered = renderFile(platform, effectiveKind, artifact, options, diagnostics);
|
|
63
|
+
if (platform === 'opencode' && options.scope === 'user') {
|
|
64
|
+
files.push(...await expandOpenCodeUserTargets(rendered, options.home ?? homedir(), diagnostics));
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
files.push(rendered);
|
|
68
|
+
}
|
|
55
69
|
}
|
|
56
70
|
}
|
|
57
71
|
files.sort((a, b) => `${a.platform}:${a.kind}:${a.name}`.localeCompare(`${b.platform}:${b.kind}:${b.name}`));
|
|
@@ -60,7 +74,7 @@ export async function buildWritePlan(options) {
|
|
|
60
74
|
diagnostics.push(...await classifyDestinations(files, options.manifest, options.backupRoot, anchor, pending));
|
|
61
75
|
}
|
|
62
76
|
}
|
|
63
|
-
return { files, diagnostics, pending, sourceCount
|
|
77
|
+
return { files, diagnostics, pending, sourceCount };
|
|
64
78
|
}
|
|
65
79
|
function convertSource(source, sourceRoot) {
|
|
66
80
|
const diagnostics = [];
|
|
@@ -122,10 +136,6 @@ function convertSource(source, sourceRoot) {
|
|
|
122
136
|
diagnostics.push(diagnostic('error', 'INVALID_KIND', 'artifact kind must be one of agent, skill', { sourcePath: source.sourcePath }));
|
|
123
137
|
if (!source.body.trim())
|
|
124
138
|
diagnostics.push(diagnostic('error', 'EMPTY_BODY', 'artifact body is required', { sourcePath: source.sourcePath }));
|
|
125
|
-
const bodyLines = source.body.split('\n').length;
|
|
126
|
-
const bodyBudget = bodyLineBudgets[name ?? source.expectedName] ?? defaultBodyBudget;
|
|
127
|
-
if (bodyLines > bodyBudget)
|
|
128
|
-
diagnostics.push(diagnostic('info', 'BODY_OVER_BUDGET', `artifact body is ${bodyLines} lines (soft budget ${bodyBudget}); keep always-loaded harness files small`, { sourcePath: source.sourcePath }));
|
|
129
139
|
if (!name || !description || !artifactKinds.has(kind) || !source.body.trim() || diagnostics.some((item) => item.severity === 'error'))
|
|
130
140
|
return { diagnostics };
|
|
131
141
|
return {
|
|
@@ -149,21 +159,64 @@ function productConfig(value) {
|
|
|
149
159
|
: undefined;
|
|
150
160
|
}
|
|
151
161
|
function renderFile(platform, kind, artifact, options, diagnostics) {
|
|
162
|
+
const composedBody = composeBody(artifact.body, platform);
|
|
163
|
+
const bodyLines = composedBody.split('\n').length;
|
|
164
|
+
const bodyBudget = bodyLineBudgets[artifact.name] ?? defaultBodyBudget;
|
|
165
|
+
if (bodyLines > bodyBudget)
|
|
166
|
+
diagnostics.push(diagnostic('info', 'BODY_OVER_BUDGET', `${platform} ${artifact.name} body is ${bodyLines} lines (soft budget ${bodyBudget}); keep always-loaded harness files small`, { sourcePath: artifact.sourcePath, platform }));
|
|
167
|
+
let composed = { ...artifact, body: composedBody };
|
|
168
|
+
const modelOverride = options.modelPreferences && supportsModel(platform, kind) ? getModelPreference(options.modelPreferences, platform, artifact.name) : undefined;
|
|
169
|
+
if (modelOverride)
|
|
170
|
+
composed = { ...composed, [platform]: { ...composed[platform], model: modelOverride } };
|
|
152
171
|
const rendered = kind === 'agent'
|
|
153
|
-
? platform === 'opencode' ? renderOpenCodeAgent(
|
|
154
|
-
: platform === 'opencode' ? renderOpenCodeSkill(
|
|
172
|
+
? platform === 'opencode' ? renderOpenCodeAgent(composed) : platform === 'claude' ? renderClaudeAgent(composed) : platform === 'grok' ? renderGrokAgent(composed) : renderCodexAgent(composed)
|
|
173
|
+
: platform === 'opencode' ? renderOpenCodeSkill(composed) : platform === 'claude' ? renderClaudeSkill(composed) : platform === 'grok' ? renderGrokSkill(composed) : renderCodexSkill(composed);
|
|
155
174
|
diagnostics.push(...rendered.diagnostics);
|
|
156
175
|
return { platform, kind, scope: options.scope, name: artifact.name, sourcePath: artifact.sourcePath, path: resolveOutputPath(platform, kind, options.scope, artifact.name, options.cwd, options.home), content: rendered.content };
|
|
157
176
|
}
|
|
177
|
+
// `renderFile` above always computes OpenCode's v1 path (`~/.config/opencode/...`) for user scope
|
|
178
|
+
// — that stays the default target. v1 and the v2 preview (`opencode2`) read user-scope
|
|
179
|
+
// agents/skills from different directories (see src/paths.ts), and a machine can have either,
|
|
180
|
+
// both, or — before OpenCode has ever run — neither. Writing v2's directory unconditionally would
|
|
181
|
+
// silently create config for a generation that isn't actually installed; skipping v1 whenever v2
|
|
182
|
+
// is present would regress the long-supported default. So: write to each generation's directory
|
|
183
|
+
// only if it already exists on disk, and if neither does, keep today's behavior (write v1) rather
|
|
184
|
+
// than silently installing nothing.
|
|
185
|
+
async function expandOpenCodeUserTargets(base, home, diagnostics) {
|
|
186
|
+
const roots = openCodeUserRoots(home);
|
|
187
|
+
const [v1Exists, v2Exists] = await Promise.all([pathExists(roots.v1), pathExists(roots.v2)]);
|
|
188
|
+
if (!v1Exists && !v2Exists) {
|
|
189
|
+
diagnostics.push(diagnostic('info', 'OPENCODE_USER_ROOT_NOT_FOUND', `Neither ${roots.v1} (v1) nor ${roots.v2} (v2) exists yet; installing to the v1 default. Run OpenCode at least once, then re-run \`forge-ai install\`/\`update\`, to also cover whichever generation you actually use.`, { platform: 'opencode', sourcePath: base.sourcePath }));
|
|
190
|
+
return [base];
|
|
191
|
+
}
|
|
192
|
+
const files = [];
|
|
193
|
+
if (v1Exists)
|
|
194
|
+
files.push(base);
|
|
195
|
+
if (v2Exists)
|
|
196
|
+
files.push({ ...base, path: resolveOpenCodeUserV2Path(base.kind, base.name, home) });
|
|
197
|
+
return files;
|
|
198
|
+
}
|
|
199
|
+
async function pathExists(target) {
|
|
200
|
+
try {
|
|
201
|
+
await access(target, constants.F_OK);
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
158
208
|
async function classifyDestinations(files, manifest, backupRoot, anchor, pending) {
|
|
159
209
|
const diagnostics = [];
|
|
160
210
|
for (const file of files) {
|
|
161
|
-
const status = await classifyFile(file.path, manifest);
|
|
211
|
+
const { status, checksum } = await classifyFile(file.path, manifest);
|
|
162
212
|
file.status = status;
|
|
163
|
-
if (status === 'managed-modified'
|
|
164
|
-
file.
|
|
165
|
-
|
|
166
|
-
|
|
213
|
+
if (status === 'managed-modified') {
|
|
214
|
+
file.expectedChecksum = checksum;
|
|
215
|
+
if (backupRoot) {
|
|
216
|
+
file.backupPath = resolveBackupPath(backupRoot, file.path, anchor);
|
|
217
|
+
pending.modifiedOverwrites.push(file);
|
|
218
|
+
diagnostics.push(diagnostic('warning', 'MANAGED_FILE_OVERWRITE', `Will overwrite locally edited Forge file ${file.path}; backup → ${file.backupPath}`, { platform: file.platform }));
|
|
219
|
+
}
|
|
167
220
|
}
|
|
168
221
|
else if (status === 'foreign') {
|
|
169
222
|
pending.foreignOverwrites.push(file);
|
|
@@ -177,11 +230,12 @@ async function classifyFile(filePath, manifest) {
|
|
|
177
230
|
await access(filePath, constants.F_OK);
|
|
178
231
|
}
|
|
179
232
|
catch {
|
|
180
|
-
return 'new';
|
|
233
|
+
return { status: 'new' };
|
|
181
234
|
}
|
|
182
235
|
const entry = lookupEntryByPath(manifest, filePath);
|
|
183
236
|
if (!entry)
|
|
184
|
-
return 'foreign';
|
|
237
|
+
return { status: 'foreign' };
|
|
185
238
|
const content = await readFile(filePath, 'utf8');
|
|
186
|
-
|
|
239
|
+
const checksum = sha256(content);
|
|
240
|
+
return checksum === entry.checksum ? { status: 'managed-unmodified' } : { status: 'managed-modified', checksum };
|
|
187
241
|
}
|
package/dist/src/self-update.js
CHANGED
|
@@ -1,6 +1,31 @@
|
|
|
1
1
|
import { realpathSync } from 'node:fs';
|
|
2
2
|
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { resolveExecutable } from './executable-resolution.js';
|
|
3
5
|
const PACKAGE = '@guidobuilds/forge-ai';
|
|
6
|
+
const TRUSTED_COMMANDS = new Set(['pnpm', 'npm', 'forge-ai']);
|
|
7
|
+
// --to allowlist: the literal 'latest' or a semver X.Y.Z (optional -prerelease / +build), optionally
|
|
8
|
+
// 'v'-prefixed. Rejects ranges (^1.0.0, ~1.0.0), git URLs, and any other dist-tag, so no arbitrary
|
|
9
|
+
// spec ever reaches `npm/pnpm install -g`.
|
|
10
|
+
export function isValidVersionSpec(value) {
|
|
11
|
+
return value === 'latest' || /^v?\d+\.\d+\.\d+([-+].*)?$/.test(value);
|
|
12
|
+
}
|
|
13
|
+
// 'v0.4.0' -> '0.4.0'; leaves 'latest' and bare semvers untouched.
|
|
14
|
+
export function normalizeVersionSpec(value) {
|
|
15
|
+
return value.replace(/^v(?=\d)/, '');
|
|
16
|
+
}
|
|
17
|
+
function resolveAndValidate(command, resolver, log) {
|
|
18
|
+
const resolved = resolver(command);
|
|
19
|
+
if (!resolved) {
|
|
20
|
+
log(`Could not resolve a trusted ${command} binary; refusing to run.`);
|
|
21
|
+
return { ok: false };
|
|
22
|
+
}
|
|
23
|
+
if (!TRUSTED_COMMANDS.has(path.basename(resolved))) {
|
|
24
|
+
log(`Refusing to run ${resolved}: unexpected binary name (expected ${[...TRUSTED_COMMANDS].join(', ')}).`);
|
|
25
|
+
return { ok: false };
|
|
26
|
+
}
|
|
27
|
+
return { ok: true, resolved };
|
|
28
|
+
}
|
|
4
29
|
export function detectInstallMethod(realPath) {
|
|
5
30
|
if (/[/\\]\.npm[/\\]_npx[/\\]/.test(realPath))
|
|
6
31
|
return 'npx';
|
|
@@ -31,6 +56,7 @@ export async function runSelfUpdate(options) {
|
|
|
31
56
|
const log = options.log ?? ((message) => console.log(message));
|
|
32
57
|
const resolver = options.realPathResolver ?? ((p) => realpathSync(p));
|
|
33
58
|
const spawner = options.spawner ?? defaultSpawner;
|
|
59
|
+
const resolveCommand = options.resolveCommand ?? ((command) => resolveExecutable(command, { cwd: process.cwd() }));
|
|
34
60
|
let realPath;
|
|
35
61
|
try {
|
|
36
62
|
realPath = resolver(options.binaryPath);
|
|
@@ -42,7 +68,12 @@ export async function runSelfUpdate(options) {
|
|
|
42
68
|
// fall back to the resolved real path (catches standard global installs whose bin dir is generic).
|
|
43
69
|
const symlinkMethod = detectInstallMethod(options.binaryPath);
|
|
44
70
|
const method = symlinkMethod !== 'unknown' ? symlinkMethod : detectInstallMethod(realPath);
|
|
45
|
-
const
|
|
71
|
+
const version = normalizeVersionSpec(options.version ?? 'latest');
|
|
72
|
+
if (!isValidVersionSpec(version)) {
|
|
73
|
+
log(`Invalid --to ${options.version}; expected a semver or "latest".`);
|
|
74
|
+
return 1;
|
|
75
|
+
}
|
|
76
|
+
const cmd = buildUpdateCommand(method, version);
|
|
46
77
|
log(`Detected install: ${cmd.description} at ${realPath}`);
|
|
47
78
|
if (cmd.instructions) {
|
|
48
79
|
log(cmd.instructions);
|
|
@@ -53,7 +84,16 @@ export async function runSelfUpdate(options) {
|
|
|
53
84
|
log('(dry-run, not executing)');
|
|
54
85
|
return 0;
|
|
55
86
|
}
|
|
56
|
-
const
|
|
87
|
+
const spawn = (command, args) => {
|
|
88
|
+
if (options.resolveCommand) {
|
|
89
|
+
const checked = resolveAndValidate(command, resolveCommand, log);
|
|
90
|
+
if (!checked.ok)
|
|
91
|
+
return { status: 1 };
|
|
92
|
+
return spawner(checked.resolved, args);
|
|
93
|
+
}
|
|
94
|
+
return spawner(command, args);
|
|
95
|
+
};
|
|
96
|
+
const updateResult = spawn(cmd.command, cmd.args);
|
|
57
97
|
if (updateResult.status !== 0) {
|
|
58
98
|
log(`CLI update failed with exit code ${updateResult.status}`);
|
|
59
99
|
return updateResult.status ?? 1;
|
|
@@ -61,10 +101,16 @@ export async function runSelfUpdate(options) {
|
|
|
61
101
|
if (options.skipSpecUpdate)
|
|
62
102
|
return 0;
|
|
63
103
|
log('\nApplying spec kit with the updated CLI...');
|
|
64
|
-
const specResult =
|
|
104
|
+
const specResult = spawn('forge-ai', ['update']);
|
|
65
105
|
return specResult.status ?? 1;
|
|
66
106
|
}
|
|
107
|
+
// Trust boundary: package managers are developer-facing binaries resolved to an absolute path via
|
|
108
|
+
// resolveExecutable (which rejects candidates inside the current working tree) and checked against
|
|
109
|
+
// the { pnpm, npm, forge-ai } basename allowlist — never a bare name that PATH/cwd could shadow.
|
|
67
110
|
function defaultSpawner(command, args) {
|
|
68
|
-
const
|
|
111
|
+
const checked = resolveAndValidate(command, (c) => resolveExecutable(c, { cwd: process.cwd() }), (m) => console.error(m));
|
|
112
|
+
if (!checked.ok)
|
|
113
|
+
return { status: 1 };
|
|
114
|
+
const result = spawnSync(checked.resolved, args, { stdio: 'inherit' });
|
|
69
115
|
return { status: result.status };
|
|
70
116
|
}
|
package/dist/src/writer.js
CHANGED
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { backupFile } from './manifest.js';
|
|
3
|
+
import { backupFile, sha256 } from './manifest.js';
|
|
4
4
|
export async function writeOutputs(files) {
|
|
5
5
|
for (const file of files) {
|
|
6
|
-
|
|
6
|
+
let existing;
|
|
7
|
+
if (file.expectedChecksum || file.backupPath) {
|
|
7
8
|
try {
|
|
8
|
-
|
|
9
|
-
await backupFile(file.backupPath, existing);
|
|
9
|
+
existing = await readFile(file.path, 'utf8');
|
|
10
10
|
}
|
|
11
11
|
catch (error) {
|
|
12
12
|
if (error.code !== 'ENOENT')
|
|
13
13
|
throw error;
|
|
14
|
-
// Original file disappeared between classification and write; no backup needed.
|
|
15
14
|
}
|
|
16
15
|
}
|
|
16
|
+
if (file.expectedChecksum && existing !== undefined && sha256(existing) !== file.expectedChecksum) {
|
|
17
|
+
throw new Error(`File ${file.path} changed after classification; aborting to avoid overwriting recent edits.`);
|
|
18
|
+
}
|
|
19
|
+
if (file.backupPath && existing !== undefined) {
|
|
20
|
+
await backupFile(file.backupPath, existing);
|
|
21
|
+
}
|
|
17
22
|
await mkdir(path.dirname(file.path), { recursive: true });
|
|
18
23
|
await writeFile(file.path, file.content, 'utf8');
|
|
19
24
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@guidobuilds/forge-ai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Forge AI framework",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"build": "tsc -p tsconfig.build.json",
|
|
32
32
|
"build:test": "tsc -p tsconfig.json",
|
|
33
33
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
34
|
-
"test": "npm run build:test && node --test dist/tests/*.test.js"
|
|
34
|
+
"test": "npm run build:test && node --test dist/tests/*.test.js",
|
|
35
|
+
"generate-fixtures": "npm run build && node scripts/generate-fixtures.mjs"
|
|
35
36
|
}
|
|
36
37
|
}
|