@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.
- package/CHANGELOG.md +107 -0
- package/README.md +125 -4
- package/artifacts/forge/forge.md +84 -0
- package/artifacts/forge-adversary/forge-adversary.md +122 -0
- package/{skills/forge-grill/SKILL.md → artifacts/forge-grill/forge-grill.md} +3 -1
- package/artifacts/forge-worker/forge-worker.md +257 -0
- package/artifacts/using-forge/using-forge.md +187 -0
- package/dist/src/adapters/claude-known.js +31 -0
- package/dist/src/adapters/claude.js +33 -21
- package/dist/src/adapters/codex.js +14 -14
- package/dist/src/adapters/grok-known.js +37 -0
- package/dist/src/adapters/grok.js +41 -0
- package/dist/src/adapters/opencode.js +15 -15
- package/dist/src/adapters/shared.js +6 -1
- package/dist/src/cli.js +202 -92
- package/dist/src/discovery.js +16 -18
- package/dist/src/frontmatter.js +8 -68
- package/dist/src/manifest.js +42 -9
- package/dist/src/model.js +4 -1
- package/dist/src/paths.js +4 -0
- package/dist/src/processor.js +94 -44
- package/dist/src/self-update.js +70 -0
- package/dist/src/version-check.js +75 -0
- package/dist/src/writer.js +13 -1
- package/package.json +12 -12
- package/agents/forge-worker.md +0 -60
- package/agents/forge.md +0 -61
- package/skills/forge-worker/SKILL.md +0 -132
- package/skills/using-forge/SKILL.md +0 -119
|
@@ -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(
|
|
4
|
-
const fm = { description:
|
|
5
|
-
if (
|
|
6
|
-
fm.mode =
|
|
7
|
-
if (
|
|
8
|
-
fm.model =
|
|
9
|
-
if (
|
|
10
|
-
fm.permission =
|
|
11
|
-
return { content: `${stringifyYaml(fm)}${
|
|
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(
|
|
13
|
+
export function renderOpenCodeSkill(artifact) {
|
|
14
14
|
const diagnostics = [];
|
|
15
|
-
if (
|
|
16
|
-
diagnostics.push(diagnostic('info', 'OPENCODE_SKILL_PERMISSIONS_IGNORED', `OpenCode skill permissions are not emitted for ${
|
|
17
|
-
if (
|
|
18
|
-
diagnostics.push(diagnostic('info', 'OPENCODE_SKILL_MODEL_IGNORED', `OpenCode skill model is not emitted for ${
|
|
19
|
-
return { content: `${stringifyYaml({ name:
|
|
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' &&
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
110
|
+
p.outro(pc.red('Forge was not installed.'), clackIO(promptIO));
|
|
57
111
|
return 1;
|
|
58
112
|
}
|
|
59
|
-
if (
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
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
|
|
203
|
-
const
|
|
204
|
-
|
|
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: '
|
|
207
|
-
active: '
|
|
289
|
+
message: 'Continue with overwrites + backups?',
|
|
290
|
+
active: 'Continue',
|
|
208
291
|
inactive: 'Cancel',
|
|
209
292
|
initialValue: false,
|
|
210
|
-
...
|
|
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
|
|
226
|
-
|
|
227
|
-
|
|
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
|
|
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
|
|
262
|
-
console.log(`- delete stale ${
|
|
263
|
-
for (const
|
|
264
|
-
|
|
265
|
-
|
|
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
|
}
|
package/dist/src/discovery.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
17
|
-
const dir = path.join(root, '
|
|
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
|
|
31
|
-
|
|
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(
|
|
29
|
+
async function readSource(sourcePath, expectedName, supportFiles, sources, diagnostics) {
|
|
35
30
|
try {
|
|
36
31
|
const parsed = parseFrontmatter(await readFile(sourcePath, 'utf8'));
|
|
37
|
-
sources.push({
|
|
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 }));
|
package/dist/src/frontmatter.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { parse, stringify } from 'yaml';
|
|
1
2
|
export function parseFrontmatter(content) {
|
|
2
|
-
const normalized = content.replace(
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
16
|
+
return { data: parsed, body };
|
|
60
17
|
}
|
|
61
18
|
export function stringifyYaml(data) {
|
|
62
|
-
const
|
|
63
|
-
return `---\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
|
}
|