@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.
- package/CHANGELOG.md +72 -0
- package/README.md +126 -3
- package/artifacts/forge/forge.md +28 -3
- package/artifacts/forge-adversary/forge-adversary.md +124 -0
- package/artifacts/forge-grill/forge-grill.md +9 -2
- package/artifacts/forge-worker/forge-worker.md +168 -10
- package/artifacts/forge-worker-leaf/forge-worker-leaf.md +93 -0
- package/artifacts/using-forge/using-forge.md +122 -4
- package/dist/src/adapters/claude-known.js +1 -0
- package/dist/src/adapters/claude.js +10 -2
- package/dist/src/adapters/grok-known.js +37 -0
- package/dist/src/adapters/grok.js +41 -0
- package/dist/src/cli.js +147 -91
- package/dist/src/model.js +1 -1
- package/dist/src/paths.js +4 -0
- package/dist/src/processor.js +24 -7
- package/dist/src/self-update.js +70 -0
- package/dist/src/version-check.js +75 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|
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
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
|
}
|
package/dist/src/processor.js
CHANGED
|
@@ -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)
|
|
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
|
+
}
|