@haposoft/cafekit 0.14.1 → 0.15.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.
Files changed (100) hide show
  1. package/README.md +86 -11
  2. package/bin/__tests__/codex-hooks.test.js +331 -0
  3. package/bin/__tests__/codex-native.test.js +458 -0
  4. package/bin/__tests__/installer-safety.test.js +259 -0
  5. package/bin/install.js +30 -8
  6. package/bin/lib/backup.js +82 -18
  7. package/bin/lib/codex-frontmatter.js +56 -0
  8. package/bin/lib/codex-install.js +195 -0
  9. package/bin/lib/context.js +65 -8
  10. package/bin/lib/copy-utils.js +1 -1
  11. package/bin/lib/i18n.js +12 -3
  12. package/bin/lib/managed-writer.js +17 -2
  13. package/bin/lib/manifest.js +21 -4
  14. package/bin/lib/opencode-install.js +21 -19
  15. package/bin/lib/path-safety.js +40 -0
  16. package/bin/lib/version-check.js +25 -12
  17. package/bin/phases/claude-runtime.js +60 -9
  18. package/bin/phases/codex-runtime.js +110 -0
  19. package/bin/phases/copy-payload.js +36 -16
  20. package/bin/phases/post-install.js +98 -48
  21. package/bin/phases/root-config.js +4 -2
  22. package/bin/phases/select-platform.js +105 -66
  23. package/bin/phases/summary.js +6 -1
  24. package/package.json +3 -2
  25. package/src/claude/CLAUDE.md +1 -1
  26. package/src/claude/agents/code-auditor.md +1 -1
  27. package/src/claude/agents/debugger.md +1 -0
  28. package/src/claude/agents/god-developer.md +10 -1
  29. package/src/claude/agents/inspector.md +1 -0
  30. package/src/claude/agents/spec-maker.md +1 -1
  31. package/src/claude/agents/test-runner.md +2 -2
  32. package/src/claude/hooks/__tests__/privacy-block.test.js +50 -10
  33. package/src/claude/hooks/__tests__/spec-gate.test.js +75 -1
  34. package/src/claude/hooks/__tests__/state.test.js +65 -0
  35. package/src/claude/hooks/docs-sync.cjs +15 -21
  36. package/src/claude/hooks/inspect-block.cjs +1 -1
  37. package/src/claude/hooks/lib/config.cjs +0 -5
  38. package/src/claude/hooks/privacy-block.cjs +39 -62
  39. package/src/claude/hooks/spec-gate.cjs +18 -7
  40. package/src/claude/hooks/spec-state.cjs +4 -0
  41. package/src/claude/hooks/state.cjs +13 -3
  42. package/src/claude/hooks/usage.cjs +6 -0
  43. package/src/claude/rules/orchestrator.md +2 -2
  44. package/src/claude/rules/skill-domain-routing.md +27 -86
  45. package/src/claude/rules/skill-workflow-routing.md +11 -82
  46. package/src/claude/rules/state-sync.md +1 -1
  47. package/src/claude/runtime.json +7 -7
  48. package/src/claude/scripts/generate-skill-catalog.cjs +8 -3
  49. package/src/claude/scripts/validate-spec-output.cjs +6 -0
  50. package/src/claude/skills/code-review/references/spec-compliance-review.md +1 -1
  51. package/src/claude/skills/develop/SKILL.md +26 -5
  52. package/src/claude/skills/develop/references/parallel-waves.md +112 -0
  53. package/src/claude/skills/develop/references/quality-gate.md +10 -2
  54. package/src/claude/skills/hotfix/references/parallel-patterns.md +1 -1
  55. package/src/claude/skills/inspect/SKILL.md +42 -113
  56. package/src/claude/skills/inspect/references/internal-inspection.md +16 -135
  57. package/src/claude/skills/specs/SKILL.md +134 -386
  58. package/src/claude/skills/specs/rules/tasks-generation.md +1 -1
  59. package/src/claude/skills/specs/templates/task.md +3 -1
  60. package/src/claude/skills/sync/SKILL.md +2 -2
  61. package/src/claude/skills/sync/references/sync-protocols.md +4 -4
  62. package/src/claude/skills/test/SKILL.md +1 -1
  63. package/src/claude/status.cjs +9 -6
  64. package/src/codex/AGENTS.md +30 -0
  65. package/src/codex/agents-gitignore +11 -0
  66. package/src/codex/gitignore +12 -0
  67. package/src/codex/hooks/agent.cjs +66 -0
  68. package/src/codex/hooks/docs-sync.cjs +66 -0
  69. package/src/codex/hooks/inspect-block.cjs +40 -0
  70. package/src/codex/hooks/lib/hook-context.cjs +93 -0
  71. package/src/codex/hooks/lib/inspect-paths.cjs +199 -0
  72. package/src/codex/hooks/lib/privacy-state.cjs +199 -0
  73. package/src/codex/hooks/lib/spec-receipt.cjs +78 -0
  74. package/src/codex/hooks/lib/spec-utils.cjs +50 -0
  75. package/src/codex/hooks/lib/state-store.cjs +166 -0
  76. package/src/codex/hooks/privacy-approval.cjs +59 -0
  77. package/src/codex/hooks/privacy-block.cjs +182 -0
  78. package/src/codex/hooks/rules.cjs +79 -0
  79. package/src/codex/hooks/session.cjs +114 -0
  80. package/src/codex/hooks/spec-gate.cjs +80 -0
  81. package/src/codex/hooks/spec-state.cjs +73 -0
  82. package/src/codex/hooks/state.cjs +157 -0
  83. package/src/codex/hooks/task-scaffold-guard.cjs +63 -0
  84. package/src/codex/hooks.json +122 -0
  85. package/src/codex/rules/hook-protocols.md +32 -0
  86. package/src/codex/rules/state-sync.md +41 -0
  87. package/src/codex/runtime.json +28 -0
  88. package/src/opencode/plugins/docs-sync.ts +10 -16
  89. package/src/claude/archive-command/code/SKILL.md +0 -55
  90. package/src/claude/archive-command/code/references/execution-loop.md +0 -21
  91. package/src/claude/archive-command/code.md +0 -17
  92. package/src/claude/archive-command/docs.md +0 -609
  93. package/src/claude/archive-command/review/codebase/parallel.md +0 -76
  94. package/src/claude/archive-command/spec-design.md +0 -247
  95. package/src/claude/archive-command/spec-init.md +0 -120
  96. package/src/claude/archive-command/spec-requirements.md +0 -138
  97. package/src/claude/archive-command/spec-status.md +0 -98
  98. package/src/claude/archive-command/spec-tasks.md +0 -173
  99. package/src/claude/archive-command/spec-validate.md +0 -118
  100. package/src/claude/archive-command/test.md +0 -8
@@ -0,0 +1,259 @@
1
+ 'use strict';
2
+
3
+ const assert = require('node:assert/strict');
4
+ const fs = require('node:fs');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+ const { test } = require('node:test');
8
+
9
+ const backup = require('../lib/backup');
10
+ const {
11
+ copyOpenCodeAgentsMdFile,
12
+ normalizeOpenCodeBody
13
+ } = require('../lib/opencode-install');
14
+ const { copyClaudeMdFile } = require('../phases/claude-runtime');
15
+ const { configureAddressing, patchRuntimeLocale } = require('../phases/post-install');
16
+
17
+ const START = '<!-- CAFEKIT CLAUDE START -->';
18
+ const END = '<!-- CAFEKIT CLAUDE END -->';
19
+ const TEMPLATE = fs.readFileSync(path.join(__dirname, '../../src/claude/CLAUDE.md'), 'utf8');
20
+
21
+ function inTempProject(run) {
22
+ const originalCwd = process.cwd();
23
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-installer-safety-'));
24
+ process.chdir(root);
25
+ try {
26
+ return run(root);
27
+ } finally {
28
+ process.chdir(originalCwd);
29
+ fs.rmSync(root, { recursive: true, force: true });
30
+ }
31
+ }
32
+
33
+ function installContext(pruned) {
34
+ return {
35
+ dryRun: false,
36
+ options: { forceOverwrite: true },
37
+ ownership: { '.claude': { files: {} } },
38
+ trackers: {
39
+ claude: {
40
+ recorded: () => null,
41
+ record: () => assert.fail('root CLAUDE.md must not be recorded'),
42
+ prune: (target) => pruned.push(target)
43
+ }
44
+ },
45
+ ui: { detail() {}, warn() {}, success() {} },
46
+ results: {
47
+ copied: 0,
48
+ updated: 0,
49
+ unchanged: 0,
50
+ preserved: 0,
51
+ missingDependencies: 0,
52
+ preservedFiles: []
53
+ }
54
+ };
55
+ }
56
+
57
+ test('managed CLAUDE block replacement preserves user bytes under force overwrite', () => {
58
+ inTempProject(() => {
59
+ const before = '# User preface\n\n';
60
+ const after = '\n\n## User tail\nKeep this exactly.\n';
61
+ fs.writeFileSync('CLAUDE.md', `${before}${START}\nold managed text\n${END}${after}`);
62
+
63
+ const pruned = [];
64
+ copyClaudeMdFile(installContext(pruned), 'claude');
65
+
66
+ const content = fs.readFileSync('CLAUDE.md', 'utf8');
67
+ assert.ok(content.startsWith(before));
68
+ assert.ok(content.endsWith(after));
69
+ assert.ok(content.includes(TEMPLATE.trimEnd()));
70
+ assert.equal(content.match(new RegExp(START, 'g')).length, 1);
71
+ assert.deepEqual(pruned, ['../CLAUDE.md']);
72
+ });
73
+ });
74
+
75
+ test('legacy pristine CLAUDE template migrates to one managed block', () => {
76
+ inTempProject(() => {
77
+ fs.writeFileSync('CLAUDE.md', TEMPLATE);
78
+ const pruned = [];
79
+
80
+ copyClaudeMdFile(installContext(pruned), 'claude');
81
+
82
+ const content = fs.readFileSync('CLAUDE.md', 'utf8');
83
+ assert.equal(content, `${START}\n${TEMPLATE.trimEnd()}\n${END}\n`);
84
+ assert.equal(content.match(new RegExp(START, 'g')).length, 1);
85
+ assert.deepEqual(pruned, ['../CLAUDE.md']);
86
+ });
87
+ });
88
+
89
+ test('addressing changes remain inside the managed CLAUDE block', () => {
90
+ inTempProject(() => {
91
+ const userSection = '## Addressing (Context Overflow Indicator)\n\nUser-owned wording.';
92
+ const managedSection = '## Addressing (Context Overflow Indicator)\n\nOld managed wording.';
93
+ fs.writeFileSync(
94
+ 'CLAUDE.md',
95
+ `${userSection}\n\n${START}\n${managedSection}\n${END}\n`
96
+ );
97
+ const ctx = {
98
+ platforms: ['claude'],
99
+ ui: { success() {}, warn() {} },
100
+ t: () => 'updated'
101
+ };
102
+
103
+ configureAddressing(ctx, 'anh');
104
+
105
+ const content = fs.readFileSync('CLAUDE.md', 'utf8');
106
+ assert.ok(content.startsWith(`${userSection}\n\n${START}`));
107
+ assert.ok(content.includes('Claude Code always addresses the user as "anh"'));
108
+ });
109
+ });
110
+
111
+ test('OpenCode force refresh preserves user and Codex AGENTS blocks', () => {
112
+ inTempProject(() => {
113
+ const userContent = '# User instructions\n\nKeep this exact.\n';
114
+ const codexBlock = '<!-- CAFEKIT CODEX START -->\nCodex managed\n<!-- CAFEKIT CODEX END -->\n';
115
+ fs.writeFileSync('AGENTS.md', `${userContent}\n${codexBlock}`);
116
+ const results = { copied: 0, updated: 0, skipped: 0, missingDependencies: 0 };
117
+
118
+ copyOpenCodeAgentsMdFile('opencode', results, { upgrade: true });
119
+
120
+ const content = fs.readFileSync('AGENTS.md', 'utf8');
121
+ assert.ok(content.startsWith(userContent));
122
+ assert.ok(content.includes(codexBlock.trim()));
123
+ assert.equal((content.match(/<!-- CAFEKIT OPENCODE START -->/g) || []).length, 1);
124
+ assert.equal(results.updated, 1);
125
+ });
126
+ });
127
+
128
+ test('legacy unmarked OpenCode AGENTS content is wrapped without duplication', () => {
129
+ inTempProject(() => {
130
+ const source = path.join(__dirname, '../../src/opencode/AGENTS.md');
131
+ const legacy = normalizeOpenCodeBody(fs.readFileSync(source, 'utf8'));
132
+ const codexBlock = '<!-- CAFEKIT CODEX START -->\nCodex managed\n<!-- CAFEKIT CODEX END -->\n';
133
+ fs.writeFileSync('AGENTS.md', `${legacy}\n${codexBlock}`);
134
+ const results = { copied: 0, updated: 0, skipped: 0, missingDependencies: 0 };
135
+
136
+ copyOpenCodeAgentsMdFile('opencode', results, { upgrade: true });
137
+
138
+ const content = fs.readFileSync('AGENTS.md', 'utf8');
139
+ assert.equal((content.match(/<!-- CAFEKIT OPENCODE START -->/g) || []).length, 1);
140
+ assert.equal((content.match(/^# AGENTS\.md$/gm) || []).length, 1);
141
+ assert.ok(content.includes(codexBlock.trim()));
142
+ });
143
+ });
144
+
145
+ test('locale patch does not claim ownership of a preserved user runtime', () => {
146
+ inTempProject(() => {
147
+ fs.mkdirSync('.codex', { recursive: true });
148
+ fs.writeFileSync(
149
+ '.codex/runtime.json',
150
+ `${JSON.stringify({ custom: true, locale: { responseLanguage: 'English' } })}\n`
151
+ );
152
+ const ctx = {
153
+ platforms: ['codex'],
154
+ locale: 'Tiếng Việt',
155
+ lang: 'vi',
156
+ trackers: {
157
+ codex: {
158
+ keyFor: () => '.codex/runtime.json',
159
+ recorded: () => null,
160
+ record: () => assert.fail('preserved runtime must not become installer-owned')
161
+ }
162
+ }
163
+ };
164
+
165
+ patchRuntimeLocale(ctx);
166
+
167
+ const runtime = JSON.parse(fs.readFileSync('.codex/runtime.json', 'utf8'));
168
+ assert.equal(runtime.custom, true);
169
+ assert.equal(runtime.locale.responseLanguage, 'Tiếng Việt');
170
+ });
171
+ });
172
+
173
+ test('backup restores existing targets and removes targets absent before the run', () => {
174
+ inTempProject(() => {
175
+ fs.mkdirSync('.claude/nested', { recursive: true });
176
+ fs.writeFileSync('.claude/nested/runtime.txt', 'before\n');
177
+ fs.writeFileSync('CLAUDE.md', 'user instructions\n');
178
+
179
+ const backupDir = backup.snapshot(
180
+ ['.claude', 'CLAUDE.md', '.gitignore'],
181
+ '20260729-test'
182
+ );
183
+ const metadata = JSON.parse(
184
+ fs.readFileSync(path.join(backupDir, 'snapshot.json'), 'utf8')
185
+ );
186
+ assert.deepEqual(metadata.targets, [
187
+ { target: '.claude', existed: true },
188
+ { target: 'CLAUDE.md', existed: true },
189
+ { target: '.gitignore', existed: false }
190
+ ]);
191
+
192
+ fs.writeFileSync('.claude/nested/runtime.txt', 'after\n');
193
+ fs.writeFileSync('CLAUDE.md', 'overwritten\n');
194
+ fs.writeFileSync('.gitignore', 'generated\n');
195
+ backup.restore(backupDir);
196
+
197
+ assert.equal(fs.readFileSync('.claude/nested/runtime.txt', 'utf8'), 'before\n');
198
+ assert.equal(fs.readFileSync('CLAUDE.md', 'utf8'), 'user instructions\n');
199
+ assert.equal(fs.existsSync('.gitignore'), false);
200
+ assert.equal(fs.existsSync(path.join(backupDir, 'snapshot.json')), true);
201
+ });
202
+ });
203
+
204
+ test('backup preserves nested dependency symlinks without following them', () => {
205
+ inTempProject(() => {
206
+ fs.mkdirSync('.agents/skills/demo/.venv/bin', { recursive: true });
207
+ fs.mkdirSync('toolchain', { recursive: true });
208
+ fs.writeFileSync('toolchain/python3', 'project-owned target\n');
209
+ const linkPath = '.agents/skills/demo/.venv/bin/python3';
210
+ const linkTarget = '../../../../../toolchain/python3';
211
+ fs.symlinkSync(linkTarget, linkPath);
212
+
213
+ const backupDir = backup.snapshot(['.agents'], '20260729-nested-symlink');
214
+ const backupLink = path.join(backupDir, 'data', linkPath);
215
+ assert.equal(fs.lstatSync(backupLink).isSymbolicLink(), true);
216
+ assert.equal(fs.readlinkSync(backupLink), linkTarget);
217
+
218
+ fs.rmSync('.agents', { recursive: true, force: true });
219
+ backup.restore(backupDir);
220
+
221
+ assert.equal(fs.lstatSync(linkPath).isSymbolicLink(), true);
222
+ assert.equal(fs.readlinkSync(linkPath), linkTarget);
223
+ assert.equal(fs.readFileSync(linkPath, 'utf8'), 'project-owned target\n');
224
+ });
225
+ });
226
+
227
+ test('backup rejects traversal targets before snapshot or restore deletion', () => {
228
+ inTempProject(() => {
229
+ assert.throws(
230
+ () => backup.snapshot(['../outside'], '20260729-test'),
231
+ /Unsafe backup target/
232
+ );
233
+ });
234
+ });
235
+
236
+ test('managed targets and snapshots reject project symlink traversal', () => {
237
+ inTempProject((root) => {
238
+ fs.mkdirSync('external', { recursive: true });
239
+ fs.symlinkSync(
240
+ path.join(root, 'external'),
241
+ '.agents',
242
+ process.platform === 'win32' ? 'junction' : 'dir'
243
+ );
244
+
245
+ const { createTracker } = require('../lib/manifest');
246
+ const tracker = createTracker('.codex', 'test', {
247
+ recordRoot: '.',
248
+ allowedRoots: ['.codex', '.agents']
249
+ });
250
+ assert.throws(
251
+ () => tracker.keyFor('.agents/skills/specs/SKILL.md'),
252
+ /Refusing to follow symlinked managed path/
253
+ );
254
+ assert.throws(
255
+ () => backup.snapshot(['.agents'], '20260729-symlink-test'),
256
+ /Refusing to follow symlinked managed path/
257
+ );
258
+ });
259
+ });
package/bin/install.js CHANGED
@@ -32,6 +32,7 @@ const {
32
32
  } = require('./phases/claude-runtime');
33
33
  const { mergeClaudeSettings } = require('./phases/claude-settings');
34
34
  const { installOpenCodeRuntime } = require('./phases/opencode-runtime');
35
+ const { installCodexRuntime } = require('./phases/codex-runtime');
35
36
  const { writePlatformVersionMetadata } = require('./phases/write-metadata');
36
37
  const { checkVersions } = require('./lib/version-check');
37
38
  const { ensureGitignore } = require('./phases/root-config');
@@ -46,7 +47,11 @@ function installPlatform(ctx, platformKey) {
46
47
 
47
48
  // Read ownership baseline + start a fresh tracker for this platform.
48
49
  ctx.ownership[platform.folder] = manifestLib.read(platform.folder);
49
- ctx.trackers[platformKey] = manifestLib.createTracker(platform.folder, packageJson.version);
50
+ ctx.trackers[platformKey] = manifestLib.createTracker(
51
+ platform.folder,
52
+ packageJson.version,
53
+ platform.ownership
54
+ );
50
55
 
51
56
  const before = { copied: ctx.results.copied, updated: ctx.results.updated, skills: ctx.results.installedSkills };
52
57
  ctx.ui.startSpinner(ctx.t('installingPlatform', { name: platform.name }));
@@ -66,6 +71,10 @@ function installPlatform(ctx, platformKey) {
66
71
  installOpenCodeRuntime(ctx, platformKey);
67
72
  }
68
73
 
74
+ if (platformKey === 'codex') {
75
+ installCodexRuntime(ctx, platformKey);
76
+ }
77
+
69
78
  writePlatformVersionMetadata(ctx, platformKey);
70
79
 
71
80
  const wrote = (ctx.results.copied - before.copied) + (ctx.results.updated - before.updated);
@@ -74,7 +83,7 @@ function installPlatform(ctx, platformKey) {
74
83
  name: platform.name, files: wrote, skills
75
84
  }));
76
85
 
77
- ctx.results.targets.push(platform.commandsDir);
86
+ ctx.results.targets.push(platform.commandsDir || platform.skillsDir || platform.folder);
78
87
  }
79
88
 
80
89
  function printHelp() {
@@ -83,10 +92,11 @@ function printHelp() {
83
92
  Usage: npx @haposoft/cafekit [options]
84
93
 
85
94
  Installs CafeKit skills, agents, rules and runtime into the current project
86
- (.claude/ and/or .opencode/). Re-runs are selective: managed files are updated,
95
+ for Claude Code, OpenCode, and/or Codex CLI. Re-runs are selective: managed files are updated,
87
96
  your edits are preserved.
88
97
 
89
98
  Options:
99
+ --platform <id[,id]> Select claude, opencode, or codex explicitly
90
100
  --dry-run Preview changes; write nothing
91
101
  --force-overwrite Overwrite user-modified managed files (backup kept)
92
102
  -u, --upgrade, -f, --force Alias of --force-overwrite
@@ -133,7 +143,7 @@ async function main() {
133
143
  await resolvePlatforms(ctx);
134
144
  if (ctx.cancelled) { lock.release(); process.exit(0); }
135
145
 
136
- // Version check: same → exit, downgrade → confirm, upgrade → version picker
146
+ // Version check: same → selective refresh, downgrade → confirm, upgrade → picker
137
147
  await checkVersions(ctx);
138
148
  if (ctx.cancelled) { lock.release(); process.exit(0); }
139
149
 
@@ -158,8 +168,10 @@ async function main() {
158
168
  // Capture platform folders AND the root files the pipeline mutates
159
169
  // (CLAUDE.md, .gitignore) so a mid-run failure rolls back cleanly.
160
170
  if (!ctx.dryRun) {
161
- const folders = ctx.platforms.map((key) => PLATFORMS[key].folder);
162
- ctx.backupDir = backup.snapshot([...folders, 'CLAUDE.md', '.gitignore'], ctx.runId);
171
+ const targets = ctx.platforms.flatMap((key) => (
172
+ PLATFORMS[key].backupTargets || [PLATFORMS[key].folder]
173
+ ));
174
+ ctx.backupDir = backup.snapshot([...targets, '.gitignore'], ctx.runId);
163
175
  }
164
176
 
165
177
  for (const platformKey of ctx.platforms) {
@@ -169,12 +181,22 @@ async function main() {
169
181
  ensureGitignore(ctx);
170
182
  await runPostInstall(ctx);
171
183
  await setupSkillDeps(ctx);
172
- await setupRtk(ctx);
184
+ if (ctx.platforms.includes('claude')) {
185
+ await setupRtk(ctx);
186
+ }
187
+
188
+ // Phase handlers may report recoverable-looking errors through counters
189
+ // instead of throwing. Treat them as transactional failure so the snapshot
190
+ // is restored before any success summary or backup pruning occurs.
191
+ if (ctx.results.errors > 0) {
192
+ throw new Error(`Installer reported ${ctx.results.errors} error${ctx.results.errors === 1 ? '' : 's'}`);
193
+ }
194
+
173
195
  printSummary(ctx);
174
196
 
175
197
  if (!ctx.dryRun) backup.prune(3);
176
198
 
177
- exitCode = ctx.results.errors > 0 ? 1 : 0;
199
+ exitCode = 0;
178
200
  } catch (error) {
179
201
  const log = ctx && ctx.ui ? ctx.ui : { error: (m) => console.error(m) };
180
202
  const t = ctx ? ctx.t : (k) => k;
package/bin/lib/backup.js CHANGED
@@ -12,16 +12,54 @@
12
12
 
13
13
  const fs = require('fs');
14
14
  const path = require('path');
15
+ const { assertNoSymlinkPath } = require('./path-safety');
15
16
 
16
17
  const BACKUP_ROOT = '.cafekit-backup';
18
+ const SNAPSHOT_METADATA = 'snapshot.json';
19
+ const SNAPSHOT_DATA = 'data';
20
+
21
+ function validateTarget(target) {
22
+ if (typeof target !== 'string' || !target || target.includes('\0')) {
23
+ throw new Error('Backup target must be a non-empty relative path');
24
+ }
25
+
26
+ // Validate both separator styles so a snapshot created on one platform
27
+ // cannot become unsafe when restored on another.
28
+ const portable = target.replace(/\\/g, '/');
29
+ const parts = portable.split('/');
30
+ if (path.isAbsolute(target) || portable.startsWith('/') || parts.includes('..') || parts.includes('')) {
31
+ throw new Error(`Unsafe backup target: ${target}`);
32
+ }
33
+ if (portable === '.' || portable === BACKUP_ROOT || portable.startsWith(`${BACKUP_ROOT}/`)) {
34
+ throw new Error(`Unsafe backup target: ${target}`);
35
+ }
36
+ return parts.join(path.sep);
37
+ }
38
+
39
+ function validateRunId(runId) {
40
+ if (typeof runId !== 'string' || !runId || runId === '.' || runId === '..' || /[\\/]/.test(runId)) {
41
+ throw new Error('Backup runId must be a single path segment');
42
+ }
43
+ return runId;
44
+ }
17
45
 
18
46
  /**
19
47
  * Recursively copy a directory tree (verbatim, no transforms). Used for
20
48
  * snapshot and restore. Skips if the source does not exist.
21
49
  */
22
50
  function copyTree(src, dest) {
23
- if (!fs.existsSync(src)) return;
24
- const stat = fs.statSync(src);
51
+ let stat;
52
+ try {
53
+ stat = fs.lstatSync(src);
54
+ } catch (error) {
55
+ if (error.code === 'ENOENT') return;
56
+ throw error;
57
+ }
58
+ if (stat.isSymbolicLink()) {
59
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
60
+ fs.symlinkSync(fs.readlinkSync(src), dest);
61
+ return;
62
+ }
25
63
  if (stat.isDirectory()) {
26
64
  fs.mkdirSync(dest, { recursive: true });
27
65
  for (const entry of fs.readdirSync(src)) {
@@ -40,42 +78,68 @@ function copyTree(src, dest) {
40
78
  }
41
79
 
42
80
  /**
43
- * Snapshot the given platform folders (relative to cwd, e.g. ['.claude']).
44
- * Only existing folders are captured. Returns the backup directory path, or
45
- * null if nothing existed to back up (fresh install → no rollback target).
81
+ * Snapshot explicit targets (relative to cwd, e.g. ['.claude', 'CLAUDE.md']).
82
+ * Every target is recorded, including targets absent before the run.
46
83
  *
47
84
  * @param {string[]} folders
48
85
  * @param {string} runId unique id for this run (timestamp-based)
49
86
  */
50
87
  function snapshot(folders, runId) {
51
- const existing = folders.filter((f) => fs.existsSync(f));
52
- if (existing.length === 0) return null;
53
-
54
- const backupDir = path.join(BACKUP_ROOT, runId);
88
+ const targets = [...new Set(folders.map(validateTarget))];
89
+ const backupDir = path.join(BACKUP_ROOT, validateRunId(runId));
90
+ const dataDir = path.join(backupDir, SNAPSHOT_DATA);
91
+ assertNoSymlinkPath(BACKUP_ROOT);
55
92
  fs.mkdirSync(backupDir, { recursive: true });
56
93
 
57
- for (const folder of existing) {
58
- copyTree(folder, path.join(backupDir, folder));
94
+ const records = [];
95
+ for (const target of targets) {
96
+ assertNoSymlinkPath(target);
97
+ const existed = fs.existsSync(target);
98
+ records.push({ target, existed });
99
+ if (existed) copyTree(target, path.join(dataDir, target));
59
100
  }
101
+ fs.writeFileSync(
102
+ path.join(backupDir, SNAPSHOT_METADATA),
103
+ `${JSON.stringify({ schemaVersion: 1, targets: records }, null, 2)}\n`,
104
+ 'utf8'
105
+ );
60
106
 
61
107
  return backupDir;
62
108
  }
63
109
 
64
110
  /**
65
- * Restore a snapshot back over the working tree. For each captured folder we
66
- * remove the current (possibly half-written) copy and replace it with the
67
- * snapshot, returning the tree to its pre-run state.
111
+ * Restore every explicit target to its pre-run state. Existing targets are
112
+ * replaced from backup; targets absent before the run are removed.
68
113
  *
69
114
  * @param {string} backupDir path returned by snapshot()
70
115
  */
71
116
  function restore(backupDir) {
72
117
  if (!backupDir || !fs.existsSync(backupDir)) return;
73
118
 
74
- for (const folder of fs.readdirSync(backupDir)) {
75
- const target = folder; // backup mirrors cwd-relative folder names
76
- const source = path.join(backupDir, folder);
119
+ const metadataPath = path.join(backupDir, SNAPSHOT_METADATA);
120
+ const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8'));
121
+ if (metadata?.schemaVersion !== 1 || !Array.isArray(metadata.targets)) {
122
+ throw new Error(`Invalid backup metadata: ${metadataPath}`);
123
+ }
124
+
125
+ for (const record of metadata.targets) {
126
+ if (!record || typeof record.existed !== 'boolean') {
127
+ throw new Error(`Invalid backup target metadata: ${metadataPath}`);
128
+ }
129
+ const target = validateTarget(record.target);
130
+ const source = path.join(backupDir, SNAPSHOT_DATA, target);
131
+ let sourceExists = true;
132
+ try {
133
+ fs.lstatSync(source);
134
+ } catch (error) {
135
+ if (error.code === 'ENOENT') sourceExists = false;
136
+ else throw error;
137
+ }
138
+ if (record.existed && !sourceExists) {
139
+ throw new Error(`Backup data missing for target: ${target}`);
140
+ }
77
141
  fs.rmSync(target, { recursive: true, force: true });
78
- copyTree(source, target);
142
+ if (record.existed) copyTree(source, target);
79
143
  }
80
144
  }
81
145
 
@@ -0,0 +1,56 @@
1
+ 'use strict';
2
+
3
+ function unquote(value) {
4
+ if (
5
+ (value.startsWith('"') && value.endsWith('"')) ||
6
+ (value.startsWith("'") && value.endsWith("'"))
7
+ ) {
8
+ return value.slice(1, -1);
9
+ }
10
+ return value;
11
+ }
12
+
13
+ function foldedScalar(lines, literal) {
14
+ const nonBlank = lines.filter((line) => line.trim());
15
+ const indent = nonBlank.length
16
+ ? Math.min(...nonBlank.map((line) => line.match(/^\s*/)[0].length))
17
+ : 0;
18
+ const normalized = lines.map((line) => line.slice(Math.min(indent, line.length)).trimEnd());
19
+ if (literal) return normalized.join('\n').trim();
20
+ return normalized.join('\n').replace(/\n(?=\S)/g, ' ').replace(/\n{3,}/g, '\n\n').trim();
21
+ }
22
+
23
+ function parseHeader(header) {
24
+ const frontmatter = {};
25
+ const lines = header.split(/\r?\n/);
26
+
27
+ for (let index = 0; index < lines.length; index++) {
28
+ const match = lines[index].match(/^([a-zA-Z0-9_-]+):(?:\s*(.*))?$/);
29
+ if (!match) continue;
30
+ const [, key, raw = ''] = match;
31
+
32
+ if (/^[>|][+-]?$/.test(raw)) {
33
+ const continuation = [];
34
+ while (index + 1 < lines.length && (/^\s/.test(lines[index + 1]) || !lines[index + 1])) {
35
+ continuation.push(lines[++index]);
36
+ }
37
+ frontmatter[key] = foldedScalar(continuation, raw.startsWith('|'));
38
+ } else {
39
+ frontmatter[key] = unquote(raw.trim());
40
+ }
41
+ }
42
+
43
+ return frontmatter;
44
+ }
45
+
46
+ function splitFrontmatter(content) {
47
+ const source = String(content);
48
+ const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
49
+ if (!match) return { frontmatter: {}, body: source };
50
+ return {
51
+ frontmatter: parseHeader(match[1]),
52
+ body: source.slice(match[0].length)
53
+ };
54
+ }
55
+
56
+ module.exports = { splitFrontmatter };