@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.
@@ -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 { resolveOutputPath } from './paths.js';
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 buildWritePlan(options) {
33
- const { sources, diagnostics } = await discoverSources(options.source);
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 source of sources) {
37
- const converted = convertSource(source, options.source);
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: source.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
- files.push(renderFile(platform, effectiveKind, artifact, options, diagnostics));
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: sources.length };
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(artifact) : platform === 'claude' ? renderClaudeAgent(artifact) : platform === 'grok' ? renderGrokAgent(artifact) : renderCodexAgent(artifact)
154
- : platform === 'opencode' ? renderOpenCodeSkill(artifact) : platform === 'claude' ? renderClaudeSkill(artifact) : platform === 'grok' ? renderGrokSkill(artifact) : renderCodexSkill(artifact);
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' && backupRoot) {
164
- file.backupPath = resolveBackupPath(backupRoot, file.path, anchor);
165
- pending.modifiedOverwrites.push(file);
166
- diagnostics.push(diagnostic('warning', 'MANAGED_FILE_OVERWRITE', `Will overwrite locally edited Forge file ${file.path}; backup → ${file.backupPath}`, { platform: file.platform }));
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
- return sha256(content) === entry.checksum ? 'managed-unmodified' : 'managed-modified';
239
+ const checksum = sha256(content);
240
+ return checksum === entry.checksum ? { status: 'managed-unmodified' } : { status: 'managed-modified', checksum };
187
241
  }
@@ -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 cmd = buildUpdateCommand(method, options.version ?? 'latest');
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 updateResult = spawner(cmd.command, cmd.args);
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 = spawner('forge-ai', ['update']);
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 result = spawnSync(command, args, { stdio: 'inherit' });
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
  }
@@ -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
- if (file.backupPath) {
6
+ let existing;
7
+ if (file.expectedChecksum || file.backupPath) {
7
8
  try {
8
- const existing = await readFile(file.path, 'utf8');
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.6.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
  }