@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/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
- let plan = await buildWritePlan({ source: options.source, platform: options.platform, scope: options.scope, checkCollisions: install && !options.dryRun, force: options.force });
48
- if (install && !options.dryRun && !options.force && canOfferUpdate(plan.diagnostics)) {
49
- const accepted = await promptForUpdate(plan, promptIO);
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
- if (interactive)
52
- p.cancel('Cancelled', clackIO(promptIO));
85
+ p.cancel('Cancelled', clackIO(promptIO));
53
86
  return 1;
54
87
  }
55
- if (accepted) {
56
- options.force = true;
57
- plan = await buildWritePlan({ source: options.source, platform: options.platform, scope: options.scope, checkCollisions: true, force: true });
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 existing = plan.diagnostics.filter((item) => item.code === 'DESTINATION_EXISTS');
178
- const count = existing.length;
179
- p.log.warn(`${count} Forge output${count === 1 ? '' : 's'} already exist.`, clackIO(promptIO));
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: 'Update the existing Forge files?',
182
- active: 'Update',
251
+ message: 'Continue with overwrites + backups?',
252
+ active: 'Continue',
183
253
  inactive: 'Cancel',
184
254
  initialValue: false,
185
- ...clackIO(promptIO)
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
  }
@@ -6,35 +6,33 @@ export async function discoverSources(source) {
6
6
  const root = path.resolve(source);
7
7
  const sources = [];
8
8
  const diagnostics = [];
9
- await discoverAgents(root, sources, diagnostics);
10
- await discoverSkills(root, sources, diagnostics);
9
+ await discoverArtifacts(root, sources, diagnostics);
11
10
  if (sources.length === 0)
12
- diagnostics.push(diagnostic('error', 'NO_SOURCES', 'No canonical agents or skills found', { sourcePath: root }));
11
+ diagnostics.push(diagnostic('error', 'NO_SOURCES', 'No canonical artifacts found', { sourcePath: root }));
13
12
  sources.sort((a, b) => a.sourcePath.localeCompare(b.sourcePath));
14
13
  return { sources, diagnostics };
15
14
  }
16
- async function discoverAgents(root, sources, diagnostics) {
17
- const dir = path.join(root, 'agents');
18
- for (const entry of await safeReaddir(dir)) {
19
- if (!entry.isFile() || !entry.name.endsWith('.md'))
20
- continue;
21
- const sourcePath = path.join(dir, entry.name);
22
- await readSource('agent', sourcePath, path.basename(entry.name, '.md'), sources, diagnostics);
23
- }
24
- }
25
- async function discoverSkills(root, sources, diagnostics) {
26
- const dir = path.join(root, 'skills');
15
+ async function discoverArtifacts(root, sources, diagnostics) {
16
+ const dir = path.join(root, 'artifacts');
27
17
  for (const entry of await safeReaddir(dir)) {
28
18
  if (!entry.isDirectory())
29
19
  continue;
30
- const sourcePath = path.join(dir, entry.name, 'SKILL.md');
31
- await readSource('skill', sourcePath, entry.name, sources, diagnostics);
20
+ const name = entry.name;
21
+ const artifactDir = path.join(dir, name);
22
+ const mainFile = `${name}.md`;
23
+ const supportFiles = (await safeReaddir(artifactDir))
24
+ .filter((file) => file.isFile() && file.name !== mainFile)
25
+ .map((file) => path.join(artifactDir, file.name));
26
+ await readSource(path.join(artifactDir, mainFile), name, supportFiles, sources, diagnostics);
32
27
  }
33
28
  }
34
- async function readSource(kind, sourcePath, expectedName, sources, diagnostics) {
29
+ async function readSource(sourcePath, expectedName, supportFiles, sources, diagnostics) {
35
30
  try {
36
31
  const parsed = parseFrontmatter(await readFile(sourcePath, 'utf8'));
37
- sources.push({ kind, sourcePath, expectedName, data: parsed.data, body: parsed.body });
32
+ sources.push({ sourcePath, expectedName, data: parsed.data, body: parsed.body, supportFiles: supportFiles.length > 0 ? supportFiles : undefined });
33
+ if (supportFiles.length > 0) {
34
+ diagnostics.push(diagnostic('info', 'SUPPORT_FILES_NOT_COPIED', `Support files alongside ${expectedName} are not copied yet`, { sourcePath }));
35
+ }
38
36
  }
39
37
  catch (error) {
40
38
  diagnostics.push(diagnostic('error', 'PARSE_ERROR', error instanceof Error ? error.message : String(error), { sourcePath }));
@@ -1,5 +1,6 @@
1
+ import { parse, stringify } from 'yaml';
1
2
  export function parseFrontmatter(content) {
2
- const normalized = content.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n');
3
+ const normalized = content.replace(/^/, '').replace(/\r\n/g, '\n');
3
4
  if (!normalized.startsWith('---\n')) {
4
5
  return { data: {}, body: normalized.trim() };
5
6
  }
@@ -8,74 +9,13 @@ export function parseFrontmatter(content) {
8
9
  throw new Error('Missing closing frontmatter delimiter');
9
10
  const rawYaml = normalized.slice(4, end);
10
11
  const body = normalized.slice(normalized.indexOf('\n', end + 1) + 1).trim();
11
- return { data: parseSimpleYaml(rawYaml), body };
12
- }
13
- export function parseSimpleYaml(input) {
14
- const root = {};
15
- const stack = [{ indent: -1, object: root }];
16
- const lines = input.split('\n');
17
- for (let index = 0; index < lines.length; index += 1) {
18
- const raw = lines[index];
19
- if (!raw.trim() || raw.trimStart().startsWith('#'))
20
- continue;
21
- const indent = raw.match(/^ */)?.[0].length ?? 0;
22
- const trimmed = raw.trim();
23
- const match = trimmed.match(/^([A-Za-z0-9_-]+):(?:\s*(.*))?$/);
24
- if (!match)
25
- throw new Error(`Invalid YAML at line ${index + 1}`);
26
- while (stack.length > 1 && indent <= stack[stack.length - 1].indent)
27
- stack.pop();
28
- const parent = stack[stack.length - 1].object;
29
- const key = match[1];
30
- const value = match[2] ?? '';
31
- if (value === '') {
32
- const child = {};
33
- parent[key] = child;
34
- stack.push({ indent, object: child });
35
- }
36
- else {
37
- parent[key] = parseScalar(value);
38
- }
39
- }
40
- return root;
41
- }
42
- function parseScalar(value) {
43
- const trimmed = value.trim();
44
- if (trimmed === 'true')
45
- return true;
46
- if (trimmed === 'false')
47
- return false;
48
- if (trimmed === 'null')
49
- return null;
50
- if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
51
- const inner = trimmed.slice(1, -1).trim();
52
- if (!inner)
53
- return [];
54
- return inner.split(',').map((item) => String(parseScalar(item.trim())));
55
- }
56
- if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
57
- return trimmed.slice(1, -1);
12
+ const parsed = rawYaml.trim() === '' ? {} : parse(rawYaml);
13
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
14
+ throw new Error('Frontmatter must be a YAML mapping');
58
15
  }
59
- return trimmed;
16
+ return { data: parsed, body };
60
17
  }
61
18
  export function stringifyYaml(data) {
62
- const lines = Object.entries(data).flatMap(([key, value]) => stringifyYamlValue(key, value, 0));
63
- return `---\n${lines.join('\n')}\n---\n\n`;
64
- }
65
- function stringifyYamlValue(key, value, indent) {
66
- const prefix = ' '.repeat(indent);
67
- if (value && typeof value === 'object' && !Array.isArray(value)) {
68
- return [`${prefix}${key}:`, ...Object.entries(value).flatMap(([childKey, childValue]) => stringifyYamlValue(childKey, childValue, indent + 2))];
69
- }
70
- if (Array.isArray(value))
71
- return [`${prefix}${key}: [${value.map(formatScalar).join(', ')}]`];
72
- return [`${prefix}${key}: ${formatScalar(value)}`];
73
- }
74
- function formatScalar(value) {
75
- if (typeof value === 'boolean')
76
- return value ? 'true' : 'false';
77
- if (typeof value === 'number')
78
- return String(value);
79
- const text = String(value ?? '');
80
- return /^[A-Za-z0-9_./,@* -]+$/.test(text) && text !== '' ? text : JSON.stringify(text);
19
+ const yaml = stringify(data, { lineWidth: 0, defaultKeyType: 'PLAIN' });
20
+ return `---\n${yaml}---\n\n`;
81
21
  }
@@ -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
+ }