@guidobuilds/forge-ai 0.1.0 → 0.3.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 +60 -0
- package/README.md +14 -8
- package/{agents → artifacts/forge}/forge.md +15 -5
- package/artifacts/forge-grill/forge-grill.md +93 -0
- package/{skills/forge-worker/SKILL.md → artifacts/forge-worker/forge-worker.md} +22 -2
- package/{skills/using-forge/SKILL.md → artifacts/using-forge/using-forge.md} +1 -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/opencode.js +15 -15
- package/dist/src/adapters/shared.js +6 -1
- package/dist/src/cli.js +112 -24
- package/dist/src/discovery.js +16 -18
- package/dist/src/frontmatter.js +8 -68
- package/dist/src/manifest.js +134 -0
- package/dist/src/model.js +3 -0
- package/dist/src/processor.js +88 -45
- package/dist/src/writer.js +13 -1
- package/package.json +12 -12
- package/agents/forge-worker.md +0 -60
- package/skills/forge-build/SKILL.md +0 -80
- package/skills/forge-design/SKILL.md +0 -104
- package/skills/forge-explore/SKILL.md +0 -65
- package/skills/forge-helper/SKILL.md +0 -46
- package/skills/forge-plan/SKILL.md +0 -81
package/dist/src/cli.js
CHANGED
|
@@ -2,11 +2,15 @@
|
|
|
2
2
|
import * as p from '@clack/prompts';
|
|
3
3
|
import pc from 'picocolors';
|
|
4
4
|
import { readFileSync } from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
5
6
|
import path from 'node:path';
|
|
6
7
|
import { fileURLToPath } from 'node:url';
|
|
7
8
|
import { formatDiagnostic, hasErrors } from './diagnostics.js';
|
|
9
|
+
import { buildManifest, classifyPruneEntries, loadManifest, pruneEntries, resolveBackupPath, resolveBackupRoot, resolveManifestLocation, saveManifest, staleEntries } from './manifest.js';
|
|
8
10
|
import { buildWritePlan, parsePlatform, parseScope } from './processor.js';
|
|
9
11
|
import { writeOutputs } from './writer.js';
|
|
12
|
+
import { hasPendingDecisions } from './model.js';
|
|
13
|
+
const emptyPrunePlan = { deletable: [], modifiedWithConsent: [], skippedMissing: [] };
|
|
10
14
|
export async function main(argv = process.argv.slice(2), promptIO = {}) {
|
|
11
15
|
if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
|
|
12
16
|
showUsage();
|
|
@@ -44,20 +48,49 @@ export async function main(argv = process.argv.slice(2), promptIO = {}) {
|
|
|
44
48
|
return 1;
|
|
45
49
|
}
|
|
46
50
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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);
|
|
75
|
+
}
|
|
76
|
+
const needsConfirm = install && !options.force && (hasPendingDecisions(plan.pending) || prunePlan.modifiedWithConsent.length > 0);
|
|
77
|
+
if (install && !options.dryRun && needsConfirm) {
|
|
78
|
+
if (!interactive) {
|
|
79
|
+
printPlan(command, plan.sourceCount, plan.files, plan.diagnostics, prunePlan);
|
|
80
|
+
console.error('Forge needs your decision on edited or untracked files; re-run with --yes or --force to accept overwrites + backups.');
|
|
81
|
+
return 1;
|
|
82
|
+
}
|
|
83
|
+
const accepted = await promptForUpdate(plan, prunePlan, backupRoot, promptIO);
|
|
50
84
|
if (accepted === undefined) {
|
|
51
|
-
|
|
52
|
-
p.cancel('Cancelled', clackIO(promptIO));
|
|
85
|
+
p.cancel('Cancelled', clackIO(promptIO));
|
|
53
86
|
return 1;
|
|
54
87
|
}
|
|
55
|
-
if (accepted) {
|
|
56
|
-
|
|
57
|
-
|
|
88
|
+
if (!accepted) {
|
|
89
|
+
p.outro(pc.yellow('Forge was not installed.'), clackIO(promptIO));
|
|
90
|
+
return 1;
|
|
58
91
|
}
|
|
59
92
|
}
|
|
60
|
-
printPlan(command, plan.sourceCount, plan.files, plan.diagnostics);
|
|
93
|
+
printPlan(command, plan.sourceCount, plan.files, plan.diagnostics, prunePlan);
|
|
61
94
|
if (hasErrors(plan.diagnostics)) {
|
|
62
95
|
if (interactive)
|
|
63
96
|
p.outro(pc.red('Forge was not installed.'), clackIO(promptIO));
|
|
@@ -69,6 +102,9 @@ export async function main(argv = process.argv.slice(2), promptIO = {}) {
|
|
|
69
102
|
spinner.start(options.force ? 'Updating Forge files' : 'Installing Forge files');
|
|
70
103
|
try {
|
|
71
104
|
await writeOutputs(plan.files);
|
|
105
|
+
if (command === 'update' && options.prune)
|
|
106
|
+
await pruneEntries([...prunePlan.deletable, ...prunePlan.modifiedWithConsent]);
|
|
107
|
+
await saveManifest(manifestLocation.manifestPath, await buildManifest(manifestLocation, plan.files));
|
|
72
108
|
spinner.stop(`Wrote ${plan.files.length} file(s).`);
|
|
73
109
|
}
|
|
74
110
|
catch (error) {
|
|
@@ -78,7 +114,14 @@ export async function main(argv = process.argv.slice(2), promptIO = {}) {
|
|
|
78
114
|
}
|
|
79
115
|
else {
|
|
80
116
|
await writeOutputs(plan.files);
|
|
117
|
+
if (command === 'update' && options.prune)
|
|
118
|
+
await pruneEntries([...prunePlan.deletable, ...prunePlan.modifiedWithConsent]);
|
|
119
|
+
await saveManifest(manifestLocation.manifestPath, await buildManifest(manifestLocation, plan.files));
|
|
81
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}.`);
|
|
82
125
|
}
|
|
83
126
|
}
|
|
84
127
|
else if (install && interactive) {
|
|
@@ -90,13 +133,15 @@ export async function main(argv = process.argv.slice(2), promptIO = {}) {
|
|
|
90
133
|
return 0;
|
|
91
134
|
}
|
|
92
135
|
function parseArgs(argv) {
|
|
93
|
-
const options = { command: argv[0], platform: 'all', scope: 'user', source: '.', dryRun: false, force: false, yes: false, platformExplicit: false, scopeExplicit: false, sourceExplicit: false };
|
|
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 };
|
|
94
137
|
for (let index = 1; index < argv.length; index += 1) {
|
|
95
138
|
const arg = argv[index];
|
|
96
139
|
if (arg === '--dry-run')
|
|
97
140
|
options.dryRun = true;
|
|
98
141
|
else if (arg === '--force')
|
|
99
142
|
options.force = true;
|
|
143
|
+
else if (arg === '--no-prune')
|
|
144
|
+
options.prune = false;
|
|
100
145
|
else if (arg === '--yes' || arg === '-y')
|
|
101
146
|
options.yes = true;
|
|
102
147
|
else if (arg === '--platform') {
|
|
@@ -127,10 +172,26 @@ function parseArgs(argv) {
|
|
|
127
172
|
}
|
|
128
173
|
}
|
|
129
174
|
const command = normalizeCommand(options.command);
|
|
175
|
+
if (command !== 'update' && !options.prune)
|
|
176
|
+
return { error: '--no-prune is only accepted for update' };
|
|
130
177
|
if (command === 'validate' && (options.dryRun || options.force || options.yes || options.scopeExplicit))
|
|
131
178
|
return { error: 'validate only accepts --platform and --source' };
|
|
132
179
|
return { options };
|
|
133
180
|
}
|
|
181
|
+
async function classifyPrune(oldManifest, files, backupRoot, scope, cwd, home) {
|
|
182
|
+
const stale = staleEntries(oldManifest, files);
|
|
183
|
+
const classified = await classifyPruneEntries(stale);
|
|
184
|
+
const anchor = scope === 'user' ? home : cwd;
|
|
185
|
+
const modifiedWithConsent = [];
|
|
186
|
+
const skippedMissing = [];
|
|
187
|
+
for (const item of classified.skipped) {
|
|
188
|
+
if (item.reason === 'checksum-mismatch')
|
|
189
|
+
modifiedWithConsent.push({ ...item, backupPath: resolveBackupPath(backupRoot, item.path, anchor) });
|
|
190
|
+
else
|
|
191
|
+
skippedMissing.push(item);
|
|
192
|
+
}
|
|
193
|
+
return { deletable: classified.deletable, modifiedWithConsent, skippedMissing };
|
|
194
|
+
}
|
|
134
195
|
async function promptForMissingInstallOptions(options, promptIO) {
|
|
135
196
|
if (options.yes)
|
|
136
197
|
return true;
|
|
@@ -171,18 +232,27 @@ async function promptForMissingInstallOptions(options, promptIO) {
|
|
|
171
232
|
}
|
|
172
233
|
return true;
|
|
173
234
|
}
|
|
174
|
-
async function promptForUpdate(plan, promptIO) {
|
|
235
|
+
async function promptForUpdate(plan, prunePlan, backupRoot, promptIO) {
|
|
175
236
|
if (!isInteractivePrompt(promptIO))
|
|
176
237
|
return false;
|
|
177
|
-
const
|
|
178
|
-
const
|
|
179
|
-
|
|
238
|
+
const io = clackIO(promptIO);
|
|
239
|
+
const sections = [];
|
|
240
|
+
if (plan.pending.modifiedOverwrites.length > 0) {
|
|
241
|
+
sections.push(`${pc.yellow('Edited by you, will be overwritten (backup):')}\n${plan.pending.modifiedOverwrites.map((file) => ` - ${file.path}`).join('\n')}`);
|
|
242
|
+
}
|
|
243
|
+
if (prunePlan.modifiedWithConsent.length > 0) {
|
|
244
|
+
sections.push(`${pc.yellow('Edited by you, will be deleted (backup):')}\n${prunePlan.modifiedWithConsent.map((entry) => ` - ${entry.path}`).join('\n')}`);
|
|
245
|
+
}
|
|
246
|
+
if (plan.pending.foreignOverwrites.length > 0) {
|
|
247
|
+
sections.push(`${pc.yellow('Untracked files in Forge install paths, will be overwritten:')}\n${plan.pending.foreignOverwrites.map((file) => ` - ${file.path}`).join('\n')}`);
|
|
248
|
+
}
|
|
249
|
+
p.log.warn(`The following actions need your confirmation:\n\n${sections.join('\n\n')}\n\nBackups → ${backupRoot ?? '(none)'}`, io);
|
|
180
250
|
const accepted = await p.confirm({
|
|
181
|
-
message: '
|
|
182
|
-
active: '
|
|
251
|
+
message: 'Continue with overwrites + backups?',
|
|
252
|
+
active: 'Continue',
|
|
183
253
|
inactive: 'Cancel',
|
|
184
254
|
initialValue: false,
|
|
185
|
-
...
|
|
255
|
+
...io
|
|
186
256
|
});
|
|
187
257
|
if (p.isCancel(accepted))
|
|
188
258
|
return undefined;
|
|
@@ -197,10 +267,6 @@ function normalizeCommand(command) {
|
|
|
197
267
|
return 'validate';
|
|
198
268
|
return undefined;
|
|
199
269
|
}
|
|
200
|
-
function canOfferUpdate(diagnostics) {
|
|
201
|
-
const errors = diagnostics.filter((item) => item.severity === 'error');
|
|
202
|
-
return errors.length > 0 && errors.every((item) => item.code === 'DESTINATION_EXISTS');
|
|
203
|
-
}
|
|
204
270
|
function isInteractivePrompt(promptIO) {
|
|
205
271
|
const env = promptIO.env ?? process.env;
|
|
206
272
|
const interactive = promptIO.isInteractive ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
@@ -209,6 +275,9 @@ function isInteractivePrompt(promptIO) {
|
|
|
209
275
|
function clackIO(promptIO) {
|
|
210
276
|
return { input: promptIO.input, output: promptIO.output };
|
|
211
277
|
}
|
|
278
|
+
function resolveHome(promptIO) {
|
|
279
|
+
return promptIO.env?.HOME || os.homedir();
|
|
280
|
+
}
|
|
212
281
|
function bundledSourceRoot() {
|
|
213
282
|
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
214
283
|
}
|
|
@@ -223,16 +292,35 @@ function readPackageVersion() {
|
|
|
223
292
|
}
|
|
224
293
|
function showUsage() {
|
|
225
294
|
console.log('Usage: forge-ai install [--platform opencode|claude|codex|all] [--scope user|project] [--source <dir>] [--dry-run] [--force] [--yes]');
|
|
226
|
-
console.log(' forge-ai update [--platform opencode|claude|codex|all] [--scope user|project] [--source <dir>] [--dry-run] [--yes]');
|
|
295
|
+
console.log(' forge-ai update [--platform opencode|claude|codex|all] [--scope user|project] [--source <dir>] [--dry-run] [--no-prune] [--yes]');
|
|
227
296
|
console.log(' forge-ai validate [--platform opencode|claude|codex|all] [--source <dir>]');
|
|
228
297
|
}
|
|
229
|
-
function printPlan(command, sourceCount, files, diagnostics) {
|
|
298
|
+
function printPlan(command, sourceCount, files, diagnostics, prunePlan) {
|
|
230
299
|
console.log(`${command}: ${sourceCount} source(s), ${files.length} output(s)`);
|
|
231
300
|
for (const file of files)
|
|
232
|
-
console.log(`- ${file.platform} ${file.kind} ${file.name} -> ${file.path}`);
|
|
301
|
+
console.log(`- ${file.platform} ${file.kind} ${file.name} -> ${file.path}${statusSuffix(file)}`);
|
|
302
|
+
for (const item of prunePlan.deletable)
|
|
303
|
+
console.log(`- delete stale ${item.platform} ${item.kind} ${item.name} -> ${item.path}`);
|
|
304
|
+
for (const item of prunePlan.modifiedWithConsent)
|
|
305
|
+
console.log(`- delete stale ${item.platform} ${item.kind} ${item.name} -> ${item.path} [backup -> ${item.backupPath}]`);
|
|
306
|
+
for (const item of prunePlan.skippedMissing)
|
|
307
|
+
console.log(`- skip missing ${item.platform} ${item.kind} ${item.name} -> ${item.path}`);
|
|
233
308
|
for (const item of diagnostics)
|
|
234
309
|
console.log(formatDiagnostic(item));
|
|
235
310
|
}
|
|
311
|
+
function statusSuffix(file) {
|
|
312
|
+
if (file.status === 'managed-modified' && file.backupPath)
|
|
313
|
+
return ` [overwrite, backup -> ${file.backupPath}]`;
|
|
314
|
+
if (file.status === 'managed-modified')
|
|
315
|
+
return ' [overwrite]';
|
|
316
|
+
if (file.status === 'foreign')
|
|
317
|
+
return ' [foreign overwrite]';
|
|
318
|
+
if (file.status === 'managed-unmodified')
|
|
319
|
+
return ' [refresh]';
|
|
320
|
+
if (file.status === 'new')
|
|
321
|
+
return ' [new]';
|
|
322
|
+
return '';
|
|
323
|
+
}
|
|
236
324
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
237
325
|
main().then((code) => { process.exitCode = code; }, (error) => { console.error(error); process.exitCode = 1; });
|
|
238
326
|
}
|
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
|
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { access, mkdir, readFile, realpath, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
export async function resolveManifestLocation(scope, cwd = process.cwd(), home) {
|
|
6
|
+
const stateRoot = path.join(home, '.forge-ai');
|
|
7
|
+
if (scope === 'user')
|
|
8
|
+
return { stateRoot, manifestPath: path.join(stateRoot, 'user-manifest.json'), scope };
|
|
9
|
+
const projectPath = await canonicalProjectPath(cwd);
|
|
10
|
+
const projectPathHash = hashProjectPath(projectPath);
|
|
11
|
+
return { stateRoot, manifestPath: path.join(stateRoot, 'projects', projectPathHash, 'manifest.json'), scope, projectPath, projectPathHash };
|
|
12
|
+
}
|
|
13
|
+
export async function loadManifest(manifestPath) {
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(await readFile(manifestPath, 'utf8'));
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
if (error.code === 'ENOENT')
|
|
19
|
+
return undefined;
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export async function buildManifest(location, files, now = new Date()) {
|
|
24
|
+
const entries = await Promise.all(files.map(async (file) => ({
|
|
25
|
+
platform: file.platform,
|
|
26
|
+
kind: file.kind,
|
|
27
|
+
name: file.name,
|
|
28
|
+
path: file.path,
|
|
29
|
+
sourcePath: file.sourcePath,
|
|
30
|
+
checksum: sha256(await readFile(file.path, 'utf8'))
|
|
31
|
+
})));
|
|
32
|
+
return {
|
|
33
|
+
schemaVersion: 1,
|
|
34
|
+
scope: location.scope,
|
|
35
|
+
projectPath: location.projectPath,
|
|
36
|
+
projectPathHash: location.projectPathHash,
|
|
37
|
+
updatedAt: now.toISOString(),
|
|
38
|
+
entries
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
export async function saveManifest(manifestPath, manifest) {
|
|
42
|
+
await mkdir(path.dirname(manifestPath), { recursive: true });
|
|
43
|
+
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
|
44
|
+
}
|
|
45
|
+
export function lookupEntryByPath(manifest, filePath) {
|
|
46
|
+
return manifest?.entries.find((entry) => entry.path === filePath);
|
|
47
|
+
}
|
|
48
|
+
export function staleEntries(oldManifest, files) {
|
|
49
|
+
if (!oldManifest)
|
|
50
|
+
return [];
|
|
51
|
+
const currentPaths = new Set(files.map((file) => file.path));
|
|
52
|
+
return oldManifest.entries.filter((entry) => !currentPaths.has(entry.path));
|
|
53
|
+
}
|
|
54
|
+
export async function classifyPruneEntries(entries) {
|
|
55
|
+
const deletable = [];
|
|
56
|
+
const skipped = [];
|
|
57
|
+
for (const entry of entries) {
|
|
58
|
+
let content;
|
|
59
|
+
try {
|
|
60
|
+
content = await readFile(entry.path, 'utf8');
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
if (error.code === 'ENOENT')
|
|
64
|
+
skipped.push({ ...entry, reason: 'missing' });
|
|
65
|
+
else
|
|
66
|
+
throw error;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (sha256(content) === entry.checksum)
|
|
70
|
+
deletable.push(entry);
|
|
71
|
+
else
|
|
72
|
+
skipped.push({ ...entry, reason: 'checksum-mismatch' });
|
|
73
|
+
}
|
|
74
|
+
return { deletable, skipped };
|
|
75
|
+
}
|
|
76
|
+
export async function pruneEntries(entries) {
|
|
77
|
+
for (const entry of entries) {
|
|
78
|
+
if (entry.backupPath) {
|
|
79
|
+
try {
|
|
80
|
+
const content = await readFile(entry.path, 'utf8');
|
|
81
|
+
await backupFile(entry.backupPath, content);
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
if (error.code !== 'ENOENT')
|
|
85
|
+
throw error;
|
|
86
|
+
// Source file is already gone; nothing to back up.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
await rm(entry.path, { force: true });
|
|
90
|
+
if (entry.kind === 'skill')
|
|
91
|
+
await removeEmptyParent(path.dirname(entry.path));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
export function resolveBackupRoot(location, now) {
|
|
95
|
+
const scopeKey = location.scope === 'user' ? 'user' : path.join('projects', location.projectPathHash ?? 'unknown');
|
|
96
|
+
return path.join(location.stateRoot, 'backups', scopeKey, isoTimestamp(now));
|
|
97
|
+
}
|
|
98
|
+
export function resolveBackupPath(backupRoot, originalAbsolutePath, anchor) {
|
|
99
|
+
const rel = path.relative(anchor, originalAbsolutePath);
|
|
100
|
+
const safe = rel.startsWith('..') || path.isAbsolute(rel)
|
|
101
|
+
? originalAbsolutePath.replace(/^[\/\\]+/, '')
|
|
102
|
+
: rel;
|
|
103
|
+
return path.join(backupRoot, safe);
|
|
104
|
+
}
|
|
105
|
+
export async function backupFile(backupPath, content) {
|
|
106
|
+
await mkdir(path.dirname(backupPath), { recursive: true });
|
|
107
|
+
await writeFile(backupPath, content, 'utf8');
|
|
108
|
+
}
|
|
109
|
+
export function sha256(content) {
|
|
110
|
+
return createHash('sha256').update(content).digest('hex');
|
|
111
|
+
}
|
|
112
|
+
export function hashProjectPath(projectPath) {
|
|
113
|
+
return sha256(projectPath).slice(0, 32);
|
|
114
|
+
}
|
|
115
|
+
function isoTimestamp(now) {
|
|
116
|
+
return now.toISOString().replace(/[:.]/g, '-');
|
|
117
|
+
}
|
|
118
|
+
async function canonicalProjectPath(cwd) {
|
|
119
|
+
try {
|
|
120
|
+
return await realpath(cwd);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return path.resolve(cwd);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
async function removeEmptyParent(directory) {
|
|
127
|
+
try {
|
|
128
|
+
await access(directory, constants.F_OK);
|
|
129
|
+
await rm(directory);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
// Directory does not exist or is not empty; both are safe to ignore.
|
|
133
|
+
}
|
|
134
|
+
}
|
package/dist/src/model.js
CHANGED
|
@@ -2,3 +2,6 @@ export const platforms = ['opencode', 'claude', 'codex'];
|
|
|
2
2
|
export function isPlatform(value) {
|
|
3
3
|
return platforms.includes(value);
|
|
4
4
|
}
|
|
5
|
+
export function hasPendingDecisions(pending) {
|
|
6
|
+
return pending.modifiedOverwrites.length > 0 || pending.foreignOverwrites.length > 0;
|
|
7
|
+
}
|