@guidobuilds/forge-ai 0.3.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,41 @@
1
+ import { stringifyYaml } from '../frontmatter.js';
2
+ import { diagnostic } from '../diagnostics.js';
3
+ import { isKnownGrokModel, isKnownGrokTool } from './grok-known.js';
4
+ import { isRecord, patternList, stringList } from './shared.js';
5
+ export function renderGrokAgent(artifact) {
6
+ const diagnostics = [];
7
+ const fm = { name: artifact.name, description: artifact.description };
8
+ if (artifact.grok?.model) {
9
+ fm.model = artifact.grok.model;
10
+ if (!isKnownGrokModel(artifact.grok.model)) {
11
+ diagnostics.push(diagnostic('warning', 'GROK_UNKNOWN_MODEL', `Unknown Grok model "${artifact.grok.model}" for ${artifact.name}`, { platform: 'grok' }));
12
+ }
13
+ }
14
+ const permissions = artifact.grok?.permissions;
15
+ const tools = isRecord(permissions) ? stringList(permissions.tools) : stringList(permissions);
16
+ if (tools) {
17
+ fm.tools = tools; // YAML sequence — Grok expects tools as a list, not comma-joined
18
+ for (const tool of tools) {
19
+ if (!isKnownGrokTool(tool)) {
20
+ diagnostics.push(diagnostic('warning', 'GROK_UNKNOWN_TOOL', `Unknown Grok tool "${tool}" for ${artifact.name}`, { platform: 'grok' }));
21
+ }
22
+ }
23
+ }
24
+ else if (permissions !== undefined) {
25
+ diagnostics.push(diagnostic('info', 'GROK_AGENT_TOOLS_IGNORED', `Grok agent permissions must be a tools string list for ${artifact.name}`, { platform: 'grok' }));
26
+ }
27
+ const disallowedTools = isRecord(permissions) ? patternList(permissions['disallowedTools']) : undefined;
28
+ if (disallowedTools)
29
+ fm.disallowedTools = disallowedTools;
30
+ return { content: `${stringifyYaml(fm)}${artifact.body}\n`, diagnostics };
31
+ }
32
+ export function renderGrokSkill(artifact) {
33
+ const diagnostics = [];
34
+ if (artifact.grok?.permissions !== undefined) {
35
+ diagnostics.push(diagnostic('info', 'GROK_SKILL_PERMISSIONS_IGNORED', `Grok skill permissions are not emitted for ${artifact.name}`, { platform: 'grok' }));
36
+ }
37
+ if (artifact.grok?.model) {
38
+ diagnostics.push(diagnostic('info', 'GROK_SKILL_MODEL_IGNORED', `Grok skill model is not emitted for ${artifact.name}`, { platform: 'grok' }));
39
+ }
40
+ return { content: `${stringifyYaml({ name: artifact.name, description: artifact.description })}${artifact.body}\n`, diagnostics };
41
+ }
package/dist/src/cli.js CHANGED
@@ -8,6 +8,8 @@ import { fileURLToPath } from 'node:url';
8
8
  import { formatDiagnostic, hasErrors } from './diagnostics.js';
9
9
  import { buildManifest, classifyPruneEntries, loadManifest, pruneEntries, resolveBackupPath, resolveBackupRoot, resolveManifestLocation, saveManifest, staleEntries } from './manifest.js';
10
10
  import { buildWritePlan, parsePlatform, parseScope } from './processor.js';
11
+ import { runSelfUpdate } from './self-update.js';
12
+ import { checkLatestVersion, formatVersionNotice } from './version-check.js';
11
13
  import { writeOutputs } from './writer.js';
12
14
  import { hasPendingDecisions } from './model.js';
13
15
  const emptyPrunePlan = { deletable: [], modifiedWithConsent: [], skippedMissing: [] };
@@ -32,108 +34,129 @@ export async function main(argv = process.argv.slice(2), promptIO = {}) {
32
34
  showUsage();
33
35
  return 1;
34
36
  }
35
- const install = command === 'install' || command === 'update';
36
- if (install && !options.sourceExplicit)
37
- options.source = bundledSourceRoot();
38
- if (command === 'update' || options.yes)
39
- options.force = true;
40
- const interactive = install && isInteractivePrompt(promptIO);
41
- if (interactive)
42
- p.intro(`${pc.bold('Forge AI')} ${pc.dim(command === 'update' ? 'updater' : 'installer')}`, clackIO(promptIO));
43
- if (install) {
44
- const prompted = await promptForMissingInstallOptions(options, promptIO);
45
- if (!prompted) {
46
- if (interactive)
47
- p.cancel('Cancelled', clackIO(promptIO));
48
- return 1;
49
- }
50
- }
51
- const cwd = process.cwd();
52
- const home = resolveHome(promptIO);
53
- const now = new Date();
54
- let manifestLocation;
55
- let oldManifest;
56
- let backupRoot;
57
- if (install) {
58
- manifestLocation = await resolveManifestLocation(options.scope, cwd, home);
59
- oldManifest = await loadManifest(manifestLocation.manifestPath);
60
- backupRoot = resolveBackupRoot(manifestLocation, now);
61
- }
62
- const plan = await buildWritePlan({
63
- source: options.source,
64
- platform: options.platform,
65
- scope: options.scope,
66
- cwd,
67
- home,
68
- manifest: oldManifest,
69
- backupRoot,
70
- checkCollisions: install,
71
- });
72
- let prunePlan = emptyPrunePlan;
73
- if (install && command === 'update' && options.prune) {
74
- prunePlan = await classifyPrune(oldManifest, plan.files, backupRoot, options.scope, cwd, home);
37
+ if (command === 'self-update') {
38
+ return runSelfUpdate({
39
+ binaryPath: process.argv[1] ?? bundledSourceRoot(),
40
+ version: options.targetVersion,
41
+ dryRun: options.dryRun,
42
+ skipSpecUpdate: options.skipSpecUpdate,
43
+ });
75
44
  }
76
- const needsConfirm = install && !options.force && (hasPendingDecisions(plan.pending) || prunePlan.modifiedWithConsent.length > 0);
77
- if (install && !options.dryRun && needsConfirm) {
78
- if (!interactive) {
79
- printPlan(command, plan.sourceCount, plan.files, plan.diagnostics, prunePlan);
80
- console.error('Forge needs your decision on edited or untracked files; re-run with --yes or --force to accept overwrites + backups.');
81
- return 1;
45
+ const versionCheckPromise = shouldCheckForUpdates(options, promptIO)
46
+ ? checkLatestVersion({ current: readPackageVersion(), cachePath: path.join(resolveHome(promptIO), '.forge-ai', 'version-check.json') }).catch(() => undefined)
47
+ : Promise.resolve(undefined);
48
+ try {
49
+ const install = command === 'install' || command === 'update';
50
+ if (install && !options.sourceExplicit)
51
+ options.source = bundledSourceRoot();
52
+ if (command === 'update' || options.yes)
53
+ options.force = true;
54
+ const interactive = install && isInteractivePrompt(promptIO);
55
+ if (interactive)
56
+ p.intro(`${pc.bold('Forge AI')} ${pc.dim(command === 'update' ? 'updater' : 'installer')}`, clackIO(promptIO));
57
+ if (install) {
58
+ const prompted = await promptForMissingInstallOptions(options, promptIO);
59
+ if (!prompted) {
60
+ if (interactive)
61
+ p.cancel('Cancelled', clackIO(promptIO));
62
+ return 1;
63
+ }
82
64
  }
83
- const accepted = await promptForUpdate(plan, prunePlan, backupRoot, promptIO);
84
- if (accepted === undefined) {
85
- p.cancel('Cancelled', clackIO(promptIO));
86
- return 1;
65
+ const cwd = process.cwd();
66
+ const home = resolveHome(promptIO);
67
+ const now = new Date();
68
+ let manifestLocation;
69
+ let oldManifest;
70
+ let backupRoot;
71
+ if (install) {
72
+ manifestLocation = await resolveManifestLocation(options.scope, cwd, home);
73
+ oldManifest = await loadManifest(manifestLocation.manifestPath);
74
+ backupRoot = resolveBackupRoot(manifestLocation, now);
87
75
  }
88
- if (!accepted) {
89
- p.outro(pc.yellow('Forge was not installed.'), clackIO(promptIO));
76
+ const plan = await buildWritePlan({
77
+ source: options.source,
78
+ platform: options.platform,
79
+ scope: options.scope,
80
+ cwd,
81
+ home,
82
+ manifest: oldManifest,
83
+ backupRoot,
84
+ checkCollisions: install,
85
+ });
86
+ let prunePlan = emptyPrunePlan;
87
+ if (install && command === 'update' && options.prune) {
88
+ prunePlan = await classifyPrune(oldManifest, plan.files, backupRoot, options.scope, cwd, home);
89
+ }
90
+ const needsConfirm = install && !options.force && (hasPendingDecisions(plan.pending) || prunePlan.modifiedWithConsent.length > 0);
91
+ if (install && !options.dryRun && needsConfirm) {
92
+ if (!interactive) {
93
+ printPlan(command, plan.sourceCount, plan.files, plan.diagnostics, prunePlan);
94
+ console.error('Forge needs your decision on edited or untracked files; re-run with --yes or --force to accept overwrites + backups.');
95
+ return 1;
96
+ }
97
+ const accepted = await promptForUpdate(plan, prunePlan, backupRoot, promptIO);
98
+ if (accepted === undefined) {
99
+ p.cancel('Cancelled', clackIO(promptIO));
100
+ return 1;
101
+ }
102
+ if (!accepted) {
103
+ p.outro(pc.yellow('Forge was not installed.'), clackIO(promptIO));
104
+ return 1;
105
+ }
106
+ }
107
+ printPlan(command, plan.sourceCount, plan.files, plan.diagnostics, prunePlan);
108
+ if (hasErrors(plan.diagnostics)) {
109
+ if (interactive)
110
+ p.outro(pc.red('Forge was not installed.'), clackIO(promptIO));
90
111
  return 1;
91
112
  }
92
- }
93
- printPlan(command, plan.sourceCount, plan.files, plan.diagnostics, prunePlan);
94
- if (hasErrors(plan.diagnostics)) {
95
- if (interactive)
96
- p.outro(pc.red('Forge was not installed.'), clackIO(promptIO));
97
- return 1;
98
- }
99
- if (install && !options.dryRun) {
100
- if (interactive) {
101
- const spinner = p.spinner(clackIO(promptIO));
102
- spinner.start(options.force ? 'Updating Forge files' : 'Installing Forge files');
103
- try {
113
+ if (install && !options.dryRun) {
114
+ if (interactive) {
115
+ const spinner = p.spinner(clackIO(promptIO));
116
+ spinner.start(options.force ? 'Updating Forge files' : 'Installing Forge files');
117
+ try {
118
+ await writeOutputs(plan.files);
119
+ if (command === 'update' && options.prune)
120
+ await pruneEntries([...prunePlan.deletable, ...prunePlan.modifiedWithConsent]);
121
+ await saveManifest(manifestLocation.manifestPath, await buildManifest(manifestLocation, plan.files));
122
+ spinner.stop(`Wrote ${plan.files.length} file(s).`);
123
+ }
124
+ catch (error) {
125
+ spinner.error('Failed to write Forge files');
126
+ throw error;
127
+ }
128
+ }
129
+ else {
104
130
  await writeOutputs(plan.files);
105
131
  if (command === 'update' && options.prune)
106
132
  await pruneEntries([...prunePlan.deletable, ...prunePlan.modifiedWithConsent]);
107
133
  await saveManifest(manifestLocation.manifestPath, await buildManifest(manifestLocation, plan.files));
108
- spinner.stop(`Wrote ${plan.files.length} file(s).`);
109
- }
110
- catch (error) {
111
- spinner.error('Failed to write Forge files');
112
- throw error;
134
+ console.log(`Wrote ${plan.files.length} file(s).`);
135
+ const totalDeleted = prunePlan.deletable.length + prunePlan.modifiedWithConsent.length;
136
+ if (command === 'update' && options.prune && totalDeleted > 0)
137
+ console.log(`Deleted ${totalDeleted} stale file(s).`);
138
+ console.log(`Updated manifest ${manifestLocation.manifestPath}.`);
113
139
  }
114
140
  }
115
- else {
116
- await writeOutputs(plan.files);
117
- if (command === 'update' && options.prune)
118
- await pruneEntries([...prunePlan.deletable, ...prunePlan.modifiedWithConsent]);
119
- await saveManifest(manifestLocation.manifestPath, await buildManifest(manifestLocation, plan.files));
120
- console.log(`Wrote ${plan.files.length} file(s).`);
121
- const totalDeleted = prunePlan.deletable.length + prunePlan.modifiedWithConsent.length;
122
- if (command === 'update' && options.prune && totalDeleted > 0)
123
- console.log(`Deleted ${totalDeleted} stale file(s).`);
124
- console.log(`Updated manifest ${manifestLocation.manifestPath}.`);
141
+ else if (install && interactive) {
142
+ p.log.info(`Dry run only. ${plan.files.length} file(s) would be written.`, clackIO(promptIO));
125
143
  }
144
+ if (install && interactive) {
145
+ p.outro(options.dryRun ? pc.cyan('Dry run complete.') : pc.green('Forge is ready.'), clackIO(promptIO));
146
+ }
147
+ return 0;
126
148
  }
127
- else if (install && interactive) {
128
- p.log.info(`Dry run only. ${plan.files.length} file(s) would be written.`, clackIO(promptIO));
129
- }
130
- if (install && interactive) {
131
- p.outro(options.dryRun ? pc.cyan('Dry run complete.') : pc.green('Forge is ready.'), clackIO(promptIO));
149
+ finally {
150
+ const result = await versionCheckPromise;
151
+ if (result) {
152
+ const notice = formatVersionNotice(result);
153
+ if (notice)
154
+ console.log(notice);
155
+ }
132
156
  }
133
- return 0;
134
157
  }
135
158
  function parseArgs(argv) {
136
- const options = { command: argv[0], platform: 'all', scope: 'user', source: '.', dryRun: false, force: false, prune: true, yes: false, platformExplicit: false, scopeExplicit: false, sourceExplicit: false };
159
+ const options = { command: argv[0], platform: 'all', scope: 'user', source: '.', dryRun: false, force: false, prune: true, yes: false, noUpdateCheck: false, skipSpecUpdate: false, platformExplicit: false, scopeExplicit: false, sourceExplicit: false };
137
160
  for (let index = 1; index < argv.length; index += 1) {
138
161
  const arg = argv[index];
139
162
  if (arg === '--dry-run')
@@ -144,6 +167,16 @@ function parseArgs(argv) {
144
167
  options.prune = false;
145
168
  else if (arg === '--yes' || arg === '-y')
146
169
  options.yes = true;
170
+ else if (arg === '--no-update-check')
171
+ options.noUpdateCheck = true;
172
+ else if (arg === '--skip-spec-update')
173
+ options.skipSpecUpdate = true;
174
+ else if (arg === '--to') {
175
+ const value = argv[++index];
176
+ if (!value)
177
+ return { error: 'Missing --to value' };
178
+ options.targetVersion = value;
179
+ }
147
180
  else if (arg === '--platform') {
148
181
  const value = argv[++index];
149
182
  const platform = value ? parsePlatform(value) : undefined;
@@ -176,6 +209,10 @@ function parseArgs(argv) {
176
209
  return { error: '--no-prune is only accepted for update' };
177
210
  if (command === 'validate' && (options.dryRun || options.force || options.yes || options.scopeExplicit))
178
211
  return { error: 'validate only accepts --platform and --source' };
212
+ if (command !== 'self-update' && (options.targetVersion !== undefined || options.skipSpecUpdate))
213
+ return { error: '--to and --skip-spec-update are only accepted for self-update' };
214
+ if (command === 'self-update' && (options.platformExplicit || options.scopeExplicit || options.sourceExplicit || options.force || options.yes))
215
+ return { error: 'self-update only accepts --to, --dry-run, --skip-spec-update' };
179
216
  return { options };
180
217
  }
181
218
  async function classifyPrune(oldManifest, files, backupRoot, scope, cwd, home) {
@@ -205,10 +242,11 @@ async function promptForMissingInstallOptions(options, promptIO) {
205
242
  message: 'Install Forge for which coding agent?',
206
243
  initialValue: 'all',
207
244
  options: [
208
- { value: 'all', label: 'All supported agents', hint: 'OpenCode, Codex, and Claude Code' },
245
+ { value: 'all', label: 'All supported agents', hint: 'OpenCode, Codex, Claude Code, and Grok Build' },
209
246
  { value: 'opencode', label: 'OpenCode' },
210
247
  { value: 'codex', label: 'Codex' },
211
- { value: 'claude', label: 'Claude Code' }
248
+ { value: 'claude', label: 'Claude Code' },
249
+ { value: 'grok', label: 'Grok Build' }
212
250
  ],
213
251
  ...io
214
252
  });
@@ -265,8 +303,23 @@ function normalizeCommand(command) {
265
303
  return 'update';
266
304
  if (command === 'validate')
267
305
  return 'validate';
306
+ if (command === 'self-update')
307
+ return 'self-update';
268
308
  return undefined;
269
309
  }
310
+ function shouldCheckForUpdates(options, promptIO) {
311
+ if (options.noUpdateCheck)
312
+ return false;
313
+ const env = promptIO.env ?? process.env;
314
+ if (env.CI === 'true')
315
+ return false;
316
+ if (env.FORGE_NO_UPDATE_CHECK === '1' || env.FORGE_NO_UPDATE_CHECK === 'true')
317
+ return false;
318
+ // Skip in non-interactive runs (pipes, CI, scripts): the notice is a UX nudge for terminal users.
319
+ if (!isInteractivePrompt(promptIO))
320
+ return false;
321
+ return true;
322
+ }
270
323
  function isInteractivePrompt(promptIO) {
271
324
  const env = promptIO.env ?? process.env;
272
325
  const interactive = promptIO.isInteractive ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
@@ -291,9 +344,12 @@ function readPackageVersion() {
291
344
  }
292
345
  }
293
346
  function showUsage() {
294
- console.log('Usage: forge-ai install [--platform opencode|claude|codex|all] [--scope user|project] [--source <dir>] [--dry-run] [--force] [--yes]');
295
- console.log(' forge-ai update [--platform opencode|claude|codex|all] [--scope user|project] [--source <dir>] [--dry-run] [--no-prune] [--yes]');
296
- console.log(' forge-ai validate [--platform opencode|claude|codex|all] [--source <dir>]');
347
+ console.log('Usage: forge-ai install [--platform opencode|claude|codex|grok|all] [--scope user|project] [--source <dir>] [--dry-run] [--force] [--yes]');
348
+ console.log(' forge-ai update [--platform opencode|claude|codex|grok|all] [--scope user|project] [--source <dir>] [--dry-run] [--no-prune] [--yes]');
349
+ console.log(' forge-ai validate [--platform opencode|claude|codex|grok|all] [--source <dir>]');
350
+ console.log(' forge-ai self-update [--to <version>] [--dry-run] [--skip-spec-update]');
351
+ console.log('');
352
+ console.log('Global flags: --no-update-check (also FORGE_NO_UPDATE_CHECK=1 or CI=true)');
297
353
  }
298
354
  function printPlan(command, sourceCount, files, diagnostics, prunePlan) {
299
355
  console.log(`${command}: ${sourceCount} source(s), ${files.length} output(s)`);
package/dist/src/model.js CHANGED
@@ -1,4 +1,4 @@
1
- export const platforms = ['opencode', 'claude', 'codex'];
1
+ export const platforms = ['opencode', 'claude', 'codex', 'grok'];
2
2
  export function isPlatform(value) {
3
3
  return platforms.includes(value);
4
4
  }
package/dist/src/paths.js CHANGED
@@ -13,6 +13,8 @@ function userBase(platform, kind, home) {
13
13
  return path.join(home, '.config', 'opencode', kind === 'agent' ? 'agents' : 'skills');
14
14
  if (platform === 'claude')
15
15
  return path.join(home, '.claude', kind === 'agent' ? 'agents' : 'skills');
16
+ if (platform === 'grok')
17
+ return path.join(home, '.grok', kind === 'agent' ? 'agents' : 'skills');
16
18
  return kind === 'agent' ? path.join(home, '.codex', 'agents') : path.join(home, '.agents', 'skills');
17
19
  }
18
20
  function projectBase(platform, kind, cwd) {
@@ -20,5 +22,7 @@ function projectBase(platform, kind, cwd) {
20
22
  return path.join(cwd, '.opencode', kind === 'agent' ? 'agents' : 'skills');
21
23
  if (platform === 'claude')
22
24
  return path.join(cwd, '.claude', kind === 'agent' ? 'agents' : 'skills');
25
+ if (platform === 'grok')
26
+ return path.join(cwd, '.grok', kind === 'agent' ? 'agents' : 'skills');
23
27
  return kind === 'agent' ? path.join(cwd, '.codex', 'agents') : path.join(cwd, '.agents', 'skills');
24
28
  }
@@ -3,6 +3,7 @@ import { constants } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { renderClaudeAgent, renderClaudeSkill } from './adapters/claude.js';
5
5
  import { renderCodexAgent, renderCodexSkill } from './adapters/codex.js';
6
+ import { renderGrokAgent, renderGrokSkill } from './adapters/grok.js';
6
7
  import { renderOpenCodeAgent, renderOpenCodeSkill } from './adapters/opencode.js';
7
8
  import { diagnostic } from './diagnostics.js';
8
9
  import { discoverSources } from './discovery.js';
@@ -10,12 +11,15 @@ import { lookupEntryByPath, resolveBackupPath, sha256 } from './manifest.js';
10
11
  import { resolveOutputPath } from './paths.js';
11
12
  import { isPlatform, platforms } from './model.js';
12
13
  const namePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
13
- const platformKeys = new Set(['claude', 'opencode', 'codex']);
14
- const allowedTopLevel = new Set(['name', 'description', 'kind', 'claude', 'opencode', 'codex']);
14
+ const platformKeys = new Set(['claude', 'opencode', 'codex', 'grok']);
15
+ const allowedTopLevel = new Set(['name', 'description', 'kind', 'claude', 'opencode', 'codex', 'grok']);
15
16
  const allowedProductKeys = new Set(['permissions', 'model', 'kind']);
16
17
  const allowedOpenCodeKeys = new Set([...allowedProductKeys, 'mode']);
18
+ const allowedClaudeKeys = new Set([...allowedProductKeys, 'when_to_use', 'user-invocable']);
17
19
  const openCodeModes = new Set(['primary', 'subagent', 'all']);
18
20
  const artifactKinds = new Set(['agent', 'skill']);
21
+ const defaultBodyBudget = 200;
22
+ const bodyLineBudgets = { forge: 90, 'using-forge': 240, 'forge-worker': 340, 'forge-worker-leaf': 280, 'forge-grill': 120, 'forge-adversary': 200 };
19
23
  export function resolvePlatforms(platform) {
20
24
  return platform === 'all' ? platforms : [platform];
21
25
  }
@@ -78,7 +82,7 @@ function convertSource(source, sourceRoot) {
78
82
  }
79
83
  const record = config;
80
84
  for (const key of Object.keys(record)) {
81
- const allowedKeys = platform === 'opencode' ? allowedOpenCodeKeys : allowedProductKeys;
85
+ const allowedKeys = platform === 'opencode' ? allowedOpenCodeKeys : platform === 'claude' ? allowedClaudeKeys : allowedProductKeys;
82
86
  if (!allowedKeys.has(key))
83
87
  diagnostics.push(diagnostic('error', 'UNSUPPORTED_PLATFORM_FIELD', `${platform}.${key} is not supported in the MVP`, { sourcePath: source.sourcePath, platform: platform }));
84
88
  }
@@ -88,6 +92,12 @@ function convertSource(source, sourceRoot) {
88
92
  if ('kind' in record && !artifactKinds.has(record.kind)) {
89
93
  diagnostics.push(diagnostic('error', 'INVALID_PLATFORM_KIND', `${platform}.kind must be one of agent, skill`, { sourcePath: source.sourcePath, platform: platform }));
90
94
  }
95
+ if (platform === 'claude' && 'when_to_use' in record && typeof record.when_to_use !== 'string') {
96
+ diagnostics.push(diagnostic('error', 'INVALID_CLAUDE_WHEN_TO_USE', 'claude.when_to_use must be a string', { sourcePath: source.sourcePath, platform: 'claude' }));
97
+ }
98
+ if (platform === 'claude' && 'user-invocable' in record && typeof record['user-invocable'] !== 'boolean') {
99
+ diagnostics.push(diagnostic('error', 'INVALID_CLAUDE_USER_INVOCABLE', 'claude.user-invocable must be a boolean', { sourcePath: source.sourcePath, platform: 'claude' }));
100
+ }
91
101
  if (platform === 'opencode' && 'mode' in record) {
92
102
  if (!openCodeModes.has(record.mode)) {
93
103
  diagnostics.push(diagnostic('error', 'INVALID_OPENCODE_MODE', 'opencode.mode must be one of primary, subagent, all', { sourcePath: source.sourcePath, platform: 'opencode' }));
@@ -112,6 +122,10 @@ function convertSource(source, sourceRoot) {
112
122
  diagnostics.push(diagnostic('error', 'INVALID_KIND', 'artifact kind must be one of agent, skill', { sourcePath: source.sourcePath }));
113
123
  if (!source.body.trim())
114
124
  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 }));
115
129
  if (!name || !description || !artifactKinds.has(kind) || !source.body.trim() || diagnostics.some((item) => item.severity === 'error'))
116
130
  return { diagnostics };
117
131
  return {
@@ -124,17 +138,20 @@ function convertSource(source, sourceRoot) {
124
138
  sourcePath: path.relative(path.resolve(sourceRoot), source.sourcePath),
125
139
  claude: productConfig(data.claude),
126
140
  opencode: productConfig(data.opencode),
127
- codex: productConfig(data.codex)
141
+ codex: productConfig(data.codex),
142
+ grok: productConfig(data.grok)
128
143
  }
129
144
  };
130
145
  }
131
146
  function productConfig(value) {
132
- return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
147
+ return value && typeof value === 'object' && !Array.isArray(value)
148
+ ? value
149
+ : undefined;
133
150
  }
134
151
  function renderFile(platform, kind, artifact, options, diagnostics) {
135
152
  const rendered = kind === 'agent'
136
- ? platform === 'opencode' ? renderOpenCodeAgent(artifact) : platform === 'claude' ? renderClaudeAgent(artifact) : renderCodexAgent(artifact)
137
- : platform === 'opencode' ? renderOpenCodeSkill(artifact) : platform === 'claude' ? renderClaudeSkill(artifact) : renderCodexSkill(artifact);
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);
138
155
  diagnostics.push(...rendered.diagnostics);
139
156
  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 };
140
157
  }
@@ -0,0 +1,70 @@
1
+ import { realpathSync } from 'node:fs';
2
+ import { spawnSync } from 'node:child_process';
3
+ const PACKAGE = '@guidobuilds/forge-ai';
4
+ export function detectInstallMethod(realPath) {
5
+ if (/[/\\]\.npm[/\\]_npx[/\\]/.test(realPath))
6
+ return 'npx';
7
+ if (/[/\\]pnpm[/\\]/.test(realPath))
8
+ return 'pnpm-global';
9
+ if (/[/\\]homebrew[/\\]/i.test(realPath) && /[/\\]node_modules[/\\]/.test(realPath))
10
+ return 'npm-global-homebrew';
11
+ if (/[/\\]node_modules[/\\]/.test(realPath))
12
+ return 'npm-global';
13
+ return 'unknown';
14
+ }
15
+ export function buildUpdateCommand(method, version = 'latest') {
16
+ const target = `${PACKAGE}@${version}`;
17
+ switch (method) {
18
+ case 'pnpm-global':
19
+ return { command: 'pnpm', args: ['add', '-g', target, '--prefer-online'], description: 'pnpm global' };
20
+ case 'npm-global':
21
+ return { command: 'npm', args: ['install', '-g', target], description: 'npm global' };
22
+ case 'npm-global-homebrew':
23
+ return { command: '/opt/homebrew/bin/npm', args: ['install', '-g', target], description: 'npm global (Homebrew)' };
24
+ case 'npx':
25
+ return { command: '', args: [], description: 'npx (no global install)', instructions: `No global install to update. Re-run with: npx ${target} update` };
26
+ case 'unknown':
27
+ return { command: '', args: [], description: 'unknown install method', instructions: `Could not detect install method. Update manually: pnpm add -g ${target} --prefer-online` };
28
+ }
29
+ }
30
+ export async function runSelfUpdate(options) {
31
+ const log = options.log ?? ((message) => console.log(message));
32
+ const resolver = options.realPathResolver ?? ((p) => realpathSync(p));
33
+ const spawner = options.spawner ?? defaultSpawner;
34
+ let realPath;
35
+ try {
36
+ realPath = resolver(options.binaryPath);
37
+ }
38
+ catch {
39
+ realPath = options.binaryPath;
40
+ }
41
+ // Try the symlink path first (catches `pnpm link --global` and similar dev setups);
42
+ // fall back to the resolved real path (catches standard global installs whose bin dir is generic).
43
+ const symlinkMethod = detectInstallMethod(options.binaryPath);
44
+ const method = symlinkMethod !== 'unknown' ? symlinkMethod : detectInstallMethod(realPath);
45
+ const cmd = buildUpdateCommand(method, options.version ?? 'latest');
46
+ log(`Detected install: ${cmd.description} at ${realPath}`);
47
+ if (cmd.instructions) {
48
+ log(cmd.instructions);
49
+ return 1;
50
+ }
51
+ log(`Running: ${cmd.command} ${cmd.args.join(' ')}`);
52
+ if (options.dryRun) {
53
+ log('(dry-run, not executing)');
54
+ return 0;
55
+ }
56
+ const updateResult = spawner(cmd.command, cmd.args);
57
+ if (updateResult.status !== 0) {
58
+ log(`CLI update failed with exit code ${updateResult.status}`);
59
+ return updateResult.status ?? 1;
60
+ }
61
+ if (options.skipSpecUpdate)
62
+ return 0;
63
+ log('\nApplying spec kit with the updated CLI...');
64
+ const specResult = spawner('forge-ai', ['update']);
65
+ return specResult.status ?? 1;
66
+ }
67
+ function defaultSpawner(command, args) {
68
+ const result = spawnSync(command, args, { stdio: 'inherit' });
69
+ return { status: result.status };
70
+ }
@@ -0,0 +1,75 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ const REGISTRY_URL = 'https://registry.npmjs.org/@guidobuilds/forge-ai/latest';
4
+ const DEFAULT_TTL_MS = 60 * 60 * 1000;
5
+ const DEFAULT_FETCH_TIMEOUT_MS = 1500;
6
+ export async function checkLatestVersion(options) {
7
+ const ttl = options.ttlMs ?? DEFAULT_TTL_MS;
8
+ const now = options.now ?? new Date();
9
+ const cached = await readCache(options.cachePath);
10
+ if (cached && now.getTime() - new Date(cached.checkedAt).getTime() < ttl) {
11
+ return makeResult(options.current, cached.latest);
12
+ }
13
+ const fetched = await fetchLatest(options.registryUrl ?? REGISTRY_URL, options.fetcher ?? fetch, options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS);
14
+ if (fetched === undefined)
15
+ return cached ? makeResult(options.current, cached.latest) : undefined;
16
+ await writeCache(options.cachePath, { checkedAt: now.toISOString(), latest: fetched });
17
+ return makeResult(options.current, fetched);
18
+ }
19
+ export function formatVersionNotice(result) {
20
+ if (!result.isOutdated)
21
+ return '';
22
+ return `forge-ai v${result.current} (v${result.latest} available — run \`forge-ai self-update\` to upgrade)`;
23
+ }
24
+ export function compareSemver(a, b) {
25
+ const pa = a.split('.').map((part) => parseInt(part, 10) || 0);
26
+ const pb = b.split('.').map((part) => parseInt(part, 10) || 0);
27
+ for (let index = 0; index < Math.max(pa.length, pb.length); index += 1) {
28
+ const diff = (pa[index] ?? 0) - (pb[index] ?? 0);
29
+ if (diff !== 0)
30
+ return diff;
31
+ }
32
+ return 0;
33
+ }
34
+ function makeResult(current, latest) {
35
+ return { current, latest, isOutdated: compareSemver(current, latest) < 0 };
36
+ }
37
+ async function fetchLatest(url, fetcher, timeoutMs) {
38
+ const controller = new AbortController();
39
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
40
+ try {
41
+ const response = await fetcher(url, { signal: controller.signal });
42
+ if (!response.ok)
43
+ return undefined;
44
+ const json = await response.json();
45
+ return typeof json.version === 'string' ? json.version : undefined;
46
+ }
47
+ catch {
48
+ return undefined;
49
+ }
50
+ finally {
51
+ clearTimeout(timer);
52
+ }
53
+ }
54
+ async function readCache(cachePath) {
55
+ try {
56
+ const content = await readFile(cachePath, 'utf8');
57
+ const parsed = JSON.parse(content);
58
+ if (typeof parsed.checkedAt === 'string' && typeof parsed.latest === 'string') {
59
+ return { checkedAt: parsed.checkedAt, latest: parsed.latest };
60
+ }
61
+ }
62
+ catch {
63
+ // Cache missing or malformed; treat as no cache.
64
+ }
65
+ return undefined;
66
+ }
67
+ async function writeCache(cachePath, data) {
68
+ try {
69
+ await mkdir(path.dirname(cachePath), { recursive: true });
70
+ await writeFile(cachePath, `${JSON.stringify(data)}\n`, 'utf8');
71
+ }
72
+ catch {
73
+ // Cache write failures are non-fatal.
74
+ }
75
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guidobuilds/forge-ai",
3
- "version": "0.3.0",
3
+ "version": "0.6.0",
4
4
  "description": "Forge AI framework",
5
5
  "license": "MIT",
6
6
  "type": "module",