@guidobuilds/forge-ai 0.2.0 → 0.5.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
+ }
@@ -1,20 +1,20 @@
1
1
  import { stringifyYaml } from '../frontmatter.js';
2
2
  import { diagnostic } from '../diagnostics.js';
3
- export function renderOpenCodeAgent(agent) {
4
- const fm = { description: agent.description };
5
- if (agent.opencode?.mode)
6
- fm.mode = agent.opencode.mode;
7
- if (agent.opencode?.model)
8
- fm.model = agent.opencode.model;
9
- if (agent.opencode?.permissions)
10
- fm.permission = agent.opencode.permissions;
11
- return { content: `${stringifyYaml(fm)}${agent.definition}\n`, diagnostics: [] };
3
+ export function renderOpenCodeAgent(artifact) {
4
+ const fm = { description: artifact.description };
5
+ if (artifact.opencode?.mode)
6
+ fm.mode = artifact.opencode.mode;
7
+ if (artifact.opencode?.model)
8
+ fm.model = artifact.opencode.model;
9
+ if (artifact.opencode?.permissions)
10
+ fm.permission = artifact.opencode.permissions;
11
+ return { content: `${stringifyYaml(fm)}${artifact.body}\n`, diagnostics: [] };
12
12
  }
13
- export function renderOpenCodeSkill(skill) {
13
+ export function renderOpenCodeSkill(artifact) {
14
14
  const diagnostics = [];
15
- if (skill.opencode?.permissions)
16
- diagnostics.push(diagnostic('info', 'OPENCODE_SKILL_PERMISSIONS_IGNORED', `OpenCode skill permissions are not emitted for ${skill.name}`, { platform: 'opencode' }));
17
- if (skill.opencode?.model)
18
- diagnostics.push(diagnostic('info', 'OPENCODE_SKILL_MODEL_IGNORED', `OpenCode skill model is not emitted for ${skill.name}`, { platform: 'opencode' }));
19
- return { content: `${stringifyYaml({ name: skill.name, description: skill.description })}${skill.instructions}\n`, diagnostics };
15
+ if (artifact.opencode?.permissions)
16
+ diagnostics.push(diagnostic('info', 'OPENCODE_SKILL_PERMISSIONS_IGNORED', `OpenCode skill permissions are not emitted for ${artifact.name}`, { platform: 'opencode' }));
17
+ if (artifact.opencode?.model)
18
+ diagnostics.push(diagnostic('info', 'OPENCODE_SKILL_MODEL_IGNORED', `OpenCode skill model is not emitted for ${artifact.name}`, { platform: 'opencode' }));
19
+ return { content: `${stringifyYaml({ name: artifact.name, description: artifact.description })}${artifact.body}\n`, diagnostics };
20
20
  }
@@ -1,8 +1,13 @@
1
1
  export function isRecord(value) {
2
2
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
3
3
  }
4
+ const toolNamePattern = /^[A-Za-z0-9_*-]+$/;
5
+ const patternBodyPattern = /^[^,\n]+$/;
4
6
  export function stringList(value) {
5
- return Array.isArray(value) && value.every((item) => typeof item === 'string' && /^[A-Za-z0-9_*.,:-]+$/.test(item)) ? value : undefined;
7
+ return Array.isArray(value) && value.every((item) => typeof item === 'string' && toolNamePattern.test(item)) ? value : undefined;
8
+ }
9
+ export function patternList(value) {
10
+ return Array.isArray(value) && value.every((item) => typeof item === 'string' && item.length > 0 && patternBodyPattern.test(item)) ? value : undefined;
6
11
  }
7
12
  export function tomlString(value) {
8
13
  return JSON.stringify(value);
package/dist/src/cli.js CHANGED
@@ -6,9 +6,13 @@ import os from 'node:os';
6
6
  import path from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import { formatDiagnostic, hasErrors } from './diagnostics.js';
9
- import { buildManifest, classifyPruneEntries, loadManifest, pruneEntries, resolveManifestLocation, saveManifest, staleEntries } from './manifest.js';
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';
14
+ import { hasPendingDecisions } from './model.js';
15
+ const emptyPrunePlan = { deletable: [], modifiedWithConsent: [], skippedMissing: [] };
12
16
  export async function main(argv = process.argv.slice(2), promptIO = {}) {
13
17
  if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
14
18
  showUsage();
@@ -30,88 +34,129 @@ export async function main(argv = process.argv.slice(2), promptIO = {}) {
30
34
  showUsage();
31
35
  return 1;
32
36
  }
33
- const install = command === 'install' || command === 'update';
34
- if (install && !options.sourceExplicit)
35
- options.source = bundledSourceRoot();
36
- if (command === 'update' || options.yes)
37
- options.force = true;
38
- const interactive = install && isInteractivePrompt(promptIO);
39
- if (interactive)
40
- p.intro(`${pc.bold('Forge AI')} ${pc.dim(command === 'update' ? 'updater' : 'installer')}`, clackIO(promptIO));
41
- if (install) {
42
- const prompted = await promptForMissingInstallOptions(options, promptIO);
43
- if (!prompted) {
44
- if (interactive)
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
+ });
44
+ }
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
+ }
64
+ }
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);
75
+ }
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) {
45
99
  p.cancel('Cancelled', clackIO(promptIO));
46
- return 1;
100
+ return 1;
101
+ }
102
+ if (!accepted) {
103
+ p.outro(pc.yellow('Forge was not installed.'), clackIO(promptIO));
104
+ return 1;
105
+ }
47
106
  }
48
- }
49
- const cwd = process.cwd();
50
- const home = resolveHome(promptIO);
51
- let plan = await buildWritePlan({ source: options.source, platform: options.platform, scope: options.scope, cwd, home, checkCollisions: install && !options.dryRun, force: options.force });
52
- if (install && !options.dryRun && !options.force && canOfferUpdate(plan.diagnostics)) {
53
- const accepted = await promptForUpdate(plan, promptIO);
54
- if (accepted === undefined) {
107
+ printPlan(command, plan.sourceCount, plan.files, plan.diagnostics, prunePlan);
108
+ if (hasErrors(plan.diagnostics)) {
55
109
  if (interactive)
56
- p.cancel('Cancelled', clackIO(promptIO));
110
+ p.outro(pc.red('Forge was not installed.'), clackIO(promptIO));
57
111
  return 1;
58
112
  }
59
- if (accepted) {
60
- options.force = true;
61
- plan = await buildWritePlan({ source: options.source, platform: options.platform, scope: options.scope, cwd, home, checkCollisions: true, force: true });
62
- }
63
- }
64
- let prunePlan = { deletable: [], skipped: [] };
65
- let manifestLocation;
66
- if (install) {
67
- manifestLocation = await resolveManifestLocation(options.scope, cwd, home);
68
- const oldManifest = await loadManifest(manifestLocation.manifestPath);
69
- if (command === 'update' && options.prune)
70
- prunePlan = await classifyPruneEntries(staleEntries(oldManifest, plan.files));
71
- }
72
- printPlan(command, plan.sourceCount, plan.files, plan.diagnostics, prunePlan);
73
- if (hasErrors(plan.diagnostics)) {
74
- if (interactive)
75
- p.outro(pc.red('Forge was not installed.'), clackIO(promptIO));
76
- return 1;
77
- }
78
- if (install && !options.dryRun) {
79
- if (interactive) {
80
- const spinner = p.spinner(clackIO(promptIO));
81
- spinner.start(options.force ? 'Updating Forge files' : 'Installing Forge files');
82
- 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 {
83
130
  await writeOutputs(plan.files);
84
131
  if (command === 'update' && options.prune)
85
- await pruneEntries(prunePlan.deletable);
86
- await saveManifest(manifestLocation.manifestPath, buildManifest(manifestLocation, plan.files));
87
- spinner.stop(`Wrote ${plan.files.length} file(s).`);
88
- }
89
- catch (error) {
90
- spinner.error('Failed to write Forge files');
91
- throw error;
132
+ await pruneEntries([...prunePlan.deletable, ...prunePlan.modifiedWithConsent]);
133
+ await saveManifest(manifestLocation.manifestPath, await buildManifest(manifestLocation, plan.files));
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}.`);
92
139
  }
93
140
  }
94
- else {
95
- await writeOutputs(plan.files);
96
- if (command === 'update' && options.prune)
97
- await pruneEntries(prunePlan.deletable);
98
- await saveManifest(manifestLocation.manifestPath, buildManifest(manifestLocation, plan.files));
99
- console.log(`Wrote ${plan.files.length} file(s).`);
100
- if (command === 'update' && options.prune && prunePlan.deletable.length > 0)
101
- console.log(`Deleted ${prunePlan.deletable.length} stale file(s).`);
102
- 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));
103
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;
104
148
  }
105
- else if (install && interactive) {
106
- p.log.info(`Dry run only. ${plan.files.length} file(s) would be written.`, clackIO(promptIO));
107
- }
108
- if (install && interactive) {
109
- 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
+ }
110
156
  }
111
- return 0;
112
157
  }
113
158
  function parseArgs(argv) {
114
- 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 };
115
160
  for (let index = 1; index < argv.length; index += 1) {
116
161
  const arg = argv[index];
117
162
  if (arg === '--dry-run')
@@ -122,6 +167,16 @@ function parseArgs(argv) {
122
167
  options.prune = false;
123
168
  else if (arg === '--yes' || arg === '-y')
124
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
+ }
125
180
  else if (arg === '--platform') {
126
181
  const value = argv[++index];
127
182
  const platform = value ? parsePlatform(value) : undefined;
@@ -154,8 +209,26 @@ function parseArgs(argv) {
154
209
  return { error: '--no-prune is only accepted for update' };
155
210
  if (command === 'validate' && (options.dryRun || options.force || options.yes || options.scopeExplicit))
156
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' };
157
216
  return { options };
158
217
  }
218
+ async function classifyPrune(oldManifest, files, backupRoot, scope, cwd, home) {
219
+ const stale = staleEntries(oldManifest, files);
220
+ const classified = await classifyPruneEntries(stale);
221
+ const anchor = scope === 'user' ? home : cwd;
222
+ const modifiedWithConsent = [];
223
+ const skippedMissing = [];
224
+ for (const item of classified.skipped) {
225
+ if (item.reason === 'checksum-mismatch')
226
+ modifiedWithConsent.push({ ...item, backupPath: resolveBackupPath(backupRoot, item.path, anchor) });
227
+ else
228
+ skippedMissing.push(item);
229
+ }
230
+ return { deletable: classified.deletable, modifiedWithConsent, skippedMissing };
231
+ }
159
232
  async function promptForMissingInstallOptions(options, promptIO) {
160
233
  if (options.yes)
161
234
  return true;
@@ -169,10 +242,11 @@ async function promptForMissingInstallOptions(options, promptIO) {
169
242
  message: 'Install Forge for which coding agent?',
170
243
  initialValue: 'all',
171
244
  options: [
172
- { 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' },
173
246
  { value: 'opencode', label: 'OpenCode' },
174
247
  { value: 'codex', label: 'Codex' },
175
- { value: 'claude', label: 'Claude Code' }
248
+ { value: 'claude', label: 'Claude Code' },
249
+ { value: 'grok', label: 'Grok Build' }
176
250
  ],
177
251
  ...io
178
252
  });
@@ -196,18 +270,27 @@ async function promptForMissingInstallOptions(options, promptIO) {
196
270
  }
197
271
  return true;
198
272
  }
199
- async function promptForUpdate(plan, promptIO) {
273
+ async function promptForUpdate(plan, prunePlan, backupRoot, promptIO) {
200
274
  if (!isInteractivePrompt(promptIO))
201
275
  return false;
202
- const existing = plan.diagnostics.filter((item) => item.code === 'DESTINATION_EXISTS');
203
- const count = existing.length;
204
- p.log.warn(`${count} Forge output${count === 1 ? '' : 's'} already exist.`, clackIO(promptIO));
276
+ const io = clackIO(promptIO);
277
+ const sections = [];
278
+ if (plan.pending.modifiedOverwrites.length > 0) {
279
+ sections.push(`${pc.yellow('Edited by you, will be overwritten (backup):')}\n${plan.pending.modifiedOverwrites.map((file) => ` - ${file.path}`).join('\n')}`);
280
+ }
281
+ if (prunePlan.modifiedWithConsent.length > 0) {
282
+ sections.push(`${pc.yellow('Edited by you, will be deleted (backup):')}\n${prunePlan.modifiedWithConsent.map((entry) => ` - ${entry.path}`).join('\n')}`);
283
+ }
284
+ if (plan.pending.foreignOverwrites.length > 0) {
285
+ sections.push(`${pc.yellow('Untracked files in Forge install paths, will be overwritten:')}\n${plan.pending.foreignOverwrites.map((file) => ` - ${file.path}`).join('\n')}`);
286
+ }
287
+ p.log.warn(`The following actions need your confirmation:\n\n${sections.join('\n\n')}\n\nBackups → ${backupRoot ?? '(none)'}`, io);
205
288
  const accepted = await p.confirm({
206
- message: 'Update the existing Forge files?',
207
- active: 'Update',
289
+ message: 'Continue with overwrites + backups?',
290
+ active: 'Continue',
208
291
  inactive: 'Cancel',
209
292
  initialValue: false,
210
- ...clackIO(promptIO)
293
+ ...io
211
294
  });
212
295
  if (p.isCancel(accepted))
213
296
  return undefined;
@@ -220,11 +303,22 @@ function normalizeCommand(command) {
220
303
  return 'update';
221
304
  if (command === 'validate')
222
305
  return 'validate';
306
+ if (command === 'self-update')
307
+ return 'self-update';
223
308
  return undefined;
224
309
  }
225
- function canOfferUpdate(diagnostics) {
226
- const errors = diagnostics.filter((item) => item.severity === 'error');
227
- return errors.length > 0 && errors.every((item) => item.code === 'DESTINATION_EXISTS');
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;
228
322
  }
229
323
  function isInteractivePrompt(promptIO) {
230
324
  const env = promptIO.env ?? process.env;
@@ -250,23 +344,39 @@ function readPackageVersion() {
250
344
  }
251
345
  }
252
346
  function showUsage() {
253
- console.log('Usage: forge-ai install [--platform opencode|claude|codex|all] [--scope user|project] [--source <dir>] [--dry-run] [--force] [--yes]');
254
- console.log(' forge-ai update [--platform opencode|claude|codex|all] [--scope user|project] [--source <dir>] [--dry-run] [--no-prune] [--yes]');
255
- 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)');
256
353
  }
257
- function printPlan(command, sourceCount, files, diagnostics, prunePlan = { deletable: [], skipped: [] }) {
354
+ function printPlan(command, sourceCount, files, diagnostics, prunePlan) {
258
355
  console.log(`${command}: ${sourceCount} source(s), ${files.length} output(s)`);
259
356
  for (const file of files)
260
- console.log(`- ${file.platform} ${file.kind} ${file.name} -> ${file.path}`);
261
- for (const file of prunePlan.deletable)
262
- console.log(`- delete stale ${file.platform} ${file.kind} ${file.name} -> ${file.path}`);
263
- for (const file of prunePlan.skipped) {
264
- if (file.reason === 'checksum-mismatch')
265
- console.log(`warning CHECKSUM_MISMATCH: Skipping stale managed file with local changes ${file.path}`);
266
- }
357
+ console.log(`- ${file.platform} ${file.kind} ${file.name} -> ${file.path}${statusSuffix(file)}`);
358
+ for (const item of prunePlan.deletable)
359
+ console.log(`- delete stale ${item.platform} ${item.kind} ${item.name} -> ${item.path}`);
360
+ for (const item of prunePlan.modifiedWithConsent)
361
+ console.log(`- delete stale ${item.platform} ${item.kind} ${item.name} -> ${item.path} [backup -> ${item.backupPath}]`);
362
+ for (const item of prunePlan.skippedMissing)
363
+ console.log(`- skip missing ${item.platform} ${item.kind} ${item.name} -> ${item.path}`);
267
364
  for (const item of diagnostics)
268
365
  console.log(formatDiagnostic(item));
269
366
  }
367
+ function statusSuffix(file) {
368
+ if (file.status === 'managed-modified' && file.backupPath)
369
+ return ` [overwrite, backup -> ${file.backupPath}]`;
370
+ if (file.status === 'managed-modified')
371
+ return ' [overwrite]';
372
+ if (file.status === 'foreign')
373
+ return ' [foreign overwrite]';
374
+ if (file.status === 'managed-unmodified')
375
+ return ' [refresh]';
376
+ if (file.status === 'new')
377
+ return ' [new]';
378
+ return '';
379
+ }
270
380
  if (import.meta.url === `file://${process.argv[1]}`) {
271
381
  main().then((code) => { process.exitCode = code; }, (error) => { console.error(error); process.exitCode = 1; });
272
382
  }
@@ -6,35 +6,33 @@ export async function discoverSources(source) {
6
6
  const root = path.resolve(source);
7
7
  const sources = [];
8
8
  const diagnostics = [];
9
- await discoverAgents(root, sources, diagnostics);
10
- await discoverSkills(root, sources, diagnostics);
9
+ await discoverArtifacts(root, sources, diagnostics);
11
10
  if (sources.length === 0)
12
- diagnostics.push(diagnostic('error', 'NO_SOURCES', 'No canonical agents or skills found', { sourcePath: root }));
11
+ diagnostics.push(diagnostic('error', 'NO_SOURCES', 'No canonical artifacts found', { sourcePath: root }));
13
12
  sources.sort((a, b) => a.sourcePath.localeCompare(b.sourcePath));
14
13
  return { sources, diagnostics };
15
14
  }
16
- async function discoverAgents(root, sources, diagnostics) {
17
- const dir = path.join(root, 'agents');
18
- for (const entry of await safeReaddir(dir)) {
19
- if (!entry.isFile() || !entry.name.endsWith('.md'))
20
- continue;
21
- const sourcePath = path.join(dir, entry.name);
22
- await readSource('agent', sourcePath, path.basename(entry.name, '.md'), sources, diagnostics);
23
- }
24
- }
25
- async function discoverSkills(root, sources, diagnostics) {
26
- const dir = path.join(root, 'skills');
15
+ async function discoverArtifacts(root, sources, diagnostics) {
16
+ const dir = path.join(root, 'artifacts');
27
17
  for (const entry of await safeReaddir(dir)) {
28
18
  if (!entry.isDirectory())
29
19
  continue;
30
- const sourcePath = path.join(dir, entry.name, 'SKILL.md');
31
- await readSource('skill', sourcePath, entry.name, sources, diagnostics);
20
+ const name = entry.name;
21
+ const artifactDir = path.join(dir, name);
22
+ const mainFile = `${name}.md`;
23
+ const supportFiles = (await safeReaddir(artifactDir))
24
+ .filter((file) => file.isFile() && file.name !== mainFile)
25
+ .map((file) => path.join(artifactDir, file.name));
26
+ await readSource(path.join(artifactDir, mainFile), name, supportFiles, sources, diagnostics);
32
27
  }
33
28
  }
34
- async function readSource(kind, sourcePath, expectedName, sources, diagnostics) {
29
+ async function readSource(sourcePath, expectedName, supportFiles, sources, diagnostics) {
35
30
  try {
36
31
  const parsed = parseFrontmatter(await readFile(sourcePath, 'utf8'));
37
- sources.push({ kind, sourcePath, expectedName, data: parsed.data, body: parsed.body });
32
+ sources.push({ sourcePath, expectedName, data: parsed.data, body: parsed.body, supportFiles: supportFiles.length > 0 ? supportFiles : undefined });
33
+ if (supportFiles.length > 0) {
34
+ diagnostics.push(diagnostic('info', 'SUPPORT_FILES_NOT_COPIED', `Support files alongside ${expectedName} are not copied yet`, { sourcePath }));
35
+ }
38
36
  }
39
37
  catch (error) {
40
38
  diagnostics.push(diagnostic('error', 'PARSE_ERROR', error instanceof Error ? error.message : String(error), { sourcePath }));
@@ -1,5 +1,6 @@
1
+ import { parse, stringify } from 'yaml';
1
2
  export function parseFrontmatter(content) {
2
- const normalized = content.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n');
3
+ const normalized = content.replace(/^/, '').replace(/\r\n/g, '\n');
3
4
  if (!normalized.startsWith('---\n')) {
4
5
  return { data: {}, body: normalized.trim() };
5
6
  }
@@ -8,74 +9,13 @@ export function parseFrontmatter(content) {
8
9
  throw new Error('Missing closing frontmatter delimiter');
9
10
  const rawYaml = normalized.slice(4, end);
10
11
  const body = normalized.slice(normalized.indexOf('\n', end + 1) + 1).trim();
11
- return { data: parseSimpleYaml(rawYaml), body };
12
- }
13
- export function parseSimpleYaml(input) {
14
- const root = {};
15
- const stack = [{ indent: -1, object: root }];
16
- const lines = input.split('\n');
17
- for (let index = 0; index < lines.length; index += 1) {
18
- const raw = lines[index];
19
- if (!raw.trim() || raw.trimStart().startsWith('#'))
20
- continue;
21
- const indent = raw.match(/^ */)?.[0].length ?? 0;
22
- const trimmed = raw.trim();
23
- const match = trimmed.match(/^([A-Za-z0-9_-]+):(?:\s*(.*))?$/);
24
- if (!match)
25
- throw new Error(`Invalid YAML at line ${index + 1}`);
26
- while (stack.length > 1 && indent <= stack[stack.length - 1].indent)
27
- stack.pop();
28
- const parent = stack[stack.length - 1].object;
29
- const key = match[1];
30
- const value = match[2] ?? '';
31
- if (value === '') {
32
- const child = {};
33
- parent[key] = child;
34
- stack.push({ indent, object: child });
35
- }
36
- else {
37
- parent[key] = parseScalar(value);
38
- }
39
- }
40
- return root;
41
- }
42
- function parseScalar(value) {
43
- const trimmed = value.trim();
44
- if (trimmed === 'true')
45
- return true;
46
- if (trimmed === 'false')
47
- return false;
48
- if (trimmed === 'null')
49
- return null;
50
- if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
51
- const inner = trimmed.slice(1, -1).trim();
52
- if (!inner)
53
- return [];
54
- return inner.split(',').map((item) => String(parseScalar(item.trim())));
55
- }
56
- if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
57
- return trimmed.slice(1, -1);
12
+ const parsed = rawYaml.trim() === '' ? {} : parse(rawYaml);
13
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
14
+ throw new Error('Frontmatter must be a YAML mapping');
58
15
  }
59
- return trimmed;
16
+ return { data: parsed, body };
60
17
  }
61
18
  export function stringifyYaml(data) {
62
- const lines = Object.entries(data).flatMap(([key, value]) => stringifyYamlValue(key, value, 0));
63
- return `---\n${lines.join('\n')}\n---\n\n`;
64
- }
65
- function stringifyYamlValue(key, value, indent) {
66
- const prefix = ' '.repeat(indent);
67
- if (value && typeof value === 'object' && !Array.isArray(value)) {
68
- return [`${prefix}${key}:`, ...Object.entries(value).flatMap(([childKey, childValue]) => stringifyYamlValue(childKey, childValue, indent + 2))];
69
- }
70
- if (Array.isArray(value))
71
- return [`${prefix}${key}: [${value.map(formatScalar).join(', ')}]`];
72
- return [`${prefix}${key}: ${formatScalar(value)}`];
73
- }
74
- function formatScalar(value) {
75
- if (typeof value === 'boolean')
76
- return value ? 'true' : 'false';
77
- if (typeof value === 'number')
78
- return String(value);
79
- const text = String(value ?? '');
80
- return /^[A-Za-z0-9_./,@* -]+$/.test(text) && text !== '' ? text : JSON.stringify(text);
19
+ const yaml = stringify(data, { lineWidth: 0, defaultKeyType: 'PLAIN' });
20
+ return `---\n${yaml}---\n\n`;
81
21
  }