claudeup 4.19.0 → 4.22.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 (180) hide show
  1. package/bin/claudeup.js +43 -19
  2. package/package.json +14 -19
  3. package/scripts/build-binaries.ts +77 -0
  4. package/scripts/check-optional-deps.ts +49 -0
  5. package/scripts/test-isolated.ts +43 -0
  6. package/src/__tests__/cli-router.test.ts +65 -0
  7. package/src/__tests__/conventions-integration.test.ts +745 -0
  8. package/src/__tests__/conventions-manager.test.ts +1172 -0
  9. package/src/__tests__/doctor-bins.test.ts +35 -0
  10. package/src/__tests__/doctor.test.ts +450 -0
  11. package/src/__tests__/dual-write-prevention.test.ts +14 -4
  12. package/src/__tests__/file-locking.test.ts +208 -0
  13. package/src/__tests__/gitignore-prerun.test.ts +1 -0
  14. package/src/__tests__/install-command.test.ts +81 -0
  15. package/src/__tests__/install-plan.test.ts +98 -0
  16. package/src/__tests__/manifest.test.ts +136 -0
  17. package/src/__tests__/marketplace-refresh.test.ts +267 -0
  18. package/src/__tests__/plugin-requires.test.ts +133 -0
  19. package/src/__tests__/plugin-setup.test.ts +7 -0
  20. package/src/__tests__/profile-command.test.ts +116 -0
  21. package/src/__tests__/profile-materializer.test.ts +82 -0
  22. package/src/__tests__/profile-sync.test.ts +136 -0
  23. package/src/__tests__/registry-version-resolution.test.ts +74 -0
  24. package/src/__tests__/resolve-executable.test.ts +42 -0
  25. package/src/__tests__/resolver.test.ts +218 -0
  26. package/src/__tests__/symlink-manager.test.ts +99 -0
  27. package/src/__tests__/toolchain.test.ts +56 -0
  28. package/src/cli/claude.ts +21 -0
  29. package/src/cli/doctor.ts +132 -0
  30. package/src/cli/install.ts +360 -0
  31. package/src/cli/profile.ts +166 -0
  32. package/src/cli/router.ts +107 -0
  33. package/src/cli/update.ts +85 -0
  34. package/src/data/cli-tools.ts +7 -7
  35. package/src/data/gitignore-defaults.ts +4 -0
  36. package/src/data/marketplaces.ts +12 -0
  37. package/src/data/predefined-profiles.ts +3 -4
  38. package/src/main.tsx +11 -154
  39. package/src/prerunner/index.ts +62 -27
  40. package/src/services/claude-cli.ts +19 -22
  41. package/src/services/claude-settings.ts +99 -26
  42. package/src/services/conventions-manager.ts +865 -0
  43. package/src/services/doctor-bins.ts +49 -0
  44. package/src/services/doctor.ts +509 -0
  45. package/src/services/install-plan.ts +107 -0
  46. package/src/services/manifest.ts +166 -0
  47. package/src/services/marketplace-refresh.ts +173 -0
  48. package/src/services/plugin-manager.ts +7 -1
  49. package/src/services/plugin-requires.ts +215 -0
  50. package/src/services/plugin-version-check.ts +9 -4
  51. package/src/services/profile-materializer.ts +61 -0
  52. package/src/services/profile-sync.ts +150 -0
  53. package/src/services/resolver.ts +293 -0
  54. package/src/services/symlink-manager.ts +146 -0
  55. package/src/services/toolchain.ts +97 -0
  56. package/src/services/version-check.ts +10 -11
  57. package/src/types/index.ts +138 -31
  58. package/src/ui/App.tsx +0 -4
  59. package/src/ui/screens/CliToolsScreen.tsx +11 -3
  60. package/src/ui/screens/index.ts +0 -1
  61. package/src/ui/state/reducer.ts +0 -101
  62. package/src/ui/state/types.ts +0 -35
  63. package/src/utils/command-utils.ts +23 -0
  64. package/src/utils/file-locking.ts +144 -0
  65. package/src/data/alias-flags.js +0 -205
  66. package/src/data/cli-tools.js +0 -123
  67. package/src/data/gitignore-defaults.js +0 -24
  68. package/src/data/gitignore-reasons.js +0 -97
  69. package/src/data/gitignore-templates.js +0 -21
  70. package/src/data/marketplaces.js +0 -138
  71. package/src/data/mcp-servers.js +0 -509
  72. package/src/data/predefined-profiles.js +0 -248
  73. package/src/data/settings-catalog.js +0 -625
  74. package/src/data/skill-repos.js +0 -160
  75. package/src/data/statuslines.js +0 -159
  76. package/src/data/statuslines.ts +0 -188
  77. package/src/index.js +0 -4
  78. package/src/index.ts +0 -5
  79. package/src/main.js +0 -170
  80. package/src/prerunner/index.js +0 -252
  81. package/src/services/alias-settings.js +0 -51
  82. package/src/services/alias-shell-writer.js +0 -1018
  83. package/src/services/alias-store.js +0 -129
  84. package/src/services/claude-cli.js +0 -189
  85. package/src/services/claude-runner.js +0 -28
  86. package/src/services/claude-settings.js +0 -1223
  87. package/src/services/gitignore-detector.js +0 -155
  88. package/src/services/gitignore-fixer.js +0 -231
  89. package/src/services/gitignore-prerun.js +0 -46
  90. package/src/services/gitignore-resolver.js +0 -143
  91. package/src/services/gitignore-service.js +0 -117
  92. package/src/services/local-marketplace.js +0 -339
  93. package/src/services/marketplace-fetcher.js +0 -96
  94. package/src/services/marketplace-sync.js +0 -94
  95. package/src/services/mcp-registry.js +0 -87
  96. package/src/services/plugin-manager.js +0 -473
  97. package/src/services/plugin-mcp-config.js +0 -177
  98. package/src/services/plugin-setup.js +0 -499
  99. package/src/services/plugin-version-check.js +0 -262
  100. package/src/services/profiles.js +0 -161
  101. package/src/services/settings-manager.js +0 -243
  102. package/src/services/skills-manager.js +0 -393
  103. package/src/services/skillsmp-client.js +0 -87
  104. package/src/services/update-cache.js +0 -52
  105. package/src/services/version-check.js +0 -99
  106. package/src/types/gitignore.js +0 -6
  107. package/src/types/index.js +0 -1
  108. package/src/ui/App.js +0 -355
  109. package/src/ui/adapters/pluginsAdapter.js +0 -139
  110. package/src/ui/adapters/settingsAdapter.js +0 -111
  111. package/src/ui/adapters/skillsAdapter.js +0 -154
  112. package/src/ui/components/CategoryHeader.js +0 -9
  113. package/src/ui/components/EmptyFilterState.js +0 -4
  114. package/src/ui/components/FlagDetailEditor.js +0 -0
  115. package/src/ui/components/ScopeIndicator.js +0 -30
  116. package/src/ui/components/ScrollableList.js +0 -36
  117. package/src/ui/components/SearchInput.js +0 -19
  118. package/src/ui/components/StyledText.js +0 -39
  119. package/src/ui/components/TabBar.js +0 -19
  120. package/src/ui/components/layout/FooterHints.js +0 -29
  121. package/src/ui/components/layout/Panel.js +0 -6
  122. package/src/ui/components/layout/ProgressBar.js +0 -14
  123. package/src/ui/components/layout/ScopeTabs.js +0 -6
  124. package/src/ui/components/layout/ScreenLayout.js +0 -16
  125. package/src/ui/components/layout/index.js +0 -5
  126. package/src/ui/components/modals/ConfirmModal.js +0 -14
  127. package/src/ui/components/modals/InputModal.js +0 -5
  128. package/src/ui/components/modals/LoadingModal.js +0 -17
  129. package/src/ui/components/modals/MessageModal.js +0 -16
  130. package/src/ui/components/modals/ModalContainer.js +0 -137
  131. package/src/ui/components/modals/SelectModal.js +0 -18
  132. package/src/ui/components/modals/VersionMismatchModal.js +0 -36
  133. package/src/ui/components/modals/index.js +0 -6
  134. package/src/ui/components/primitives/ActionHints.js +0 -13
  135. package/src/ui/components/primitives/DetailSection.js +0 -7
  136. package/src/ui/components/primitives/KeyValueLine.js +0 -8
  137. package/src/ui/components/primitives/ListCategoryRow.js +0 -8
  138. package/src/ui/components/primitives/MetaText.js +0 -8
  139. package/src/ui/components/primitives/ScopeDetail.js +0 -32
  140. package/src/ui/components/primitives/ScopeSquares.js +0 -11
  141. package/src/ui/components/primitives/SelectableRow.js +0 -5
  142. package/src/ui/components/primitives/index.js +0 -8
  143. package/src/ui/hooks/index.js +0 -4
  144. package/src/ui/hooks/useAsyncData.js +0 -77
  145. package/src/ui/hooks/useGitignoreModal.js +0 -74
  146. package/src/ui/hooks/useKeyboard.js +0 -13
  147. package/src/ui/hooks/useKeyboardHandler.js +0 -39
  148. package/src/ui/hooks/useMismatchModal.js +0 -77
  149. package/src/ui/registry.js +0 -1
  150. package/src/ui/renderers/cliToolRenderers.js +0 -54
  151. package/src/ui/renderers/gitignoreRenderers.js +0 -46
  152. package/src/ui/renderers/mcpRenderers.js +0 -26
  153. package/src/ui/renderers/pluginRenderers.js +0 -124
  154. package/src/ui/renderers/profileRenderers.js +0 -177
  155. package/src/ui/renderers/settingsRenderers.js +0 -73
  156. package/src/ui/renderers/skillRenderers.js +0 -138
  157. package/src/ui/screens/AliasScreen.js +0 -1111
  158. package/src/ui/screens/CliToolsScreen.js +0 -338
  159. package/src/ui/screens/EnvVarsScreen.js +0 -152
  160. package/src/ui/screens/GitignoreScreen.js +0 -328
  161. package/src/ui/screens/McpRegistryScreen.js +0 -238
  162. package/src/ui/screens/McpScreen.js +0 -176
  163. package/src/ui/screens/ModelSelectorScreen.js +0 -296
  164. package/src/ui/screens/ModelSelectorScreen.tsx +0 -444
  165. package/src/ui/screens/PluginsScreen.js +0 -769
  166. package/src/ui/screens/ProfilesScreen.js +0 -298
  167. package/src/ui/screens/SkillsScreen.js +0 -549
  168. package/src/ui/screens/StatusLineScreen.js +0 -206
  169. package/src/ui/screens/StatusLineScreen.tsx +0 -416
  170. package/src/ui/screens/index.js +0 -10
  171. package/src/ui/state/AnimationContext.js +0 -34
  172. package/src/ui/state/AppContext.js +0 -162
  173. package/src/ui/state/DimensionsContext.js +0 -71
  174. package/src/ui/state/reducer.js +0 -547
  175. package/src/ui/state/types.js +0 -1
  176. package/src/ui/theme.js +0 -47
  177. package/src/utils/clipboard.js +0 -56
  178. package/src/utils/command-utils.js +0 -19
  179. package/src/utils/fuzzy-search.js +0 -101
  180. package/src/utils/string-utils.js +0 -62
@@ -0,0 +1,1172 @@
1
+ /**
2
+ * Unit tests for conventions-manager service
3
+ *
4
+ * Tests cover:
5
+ * - ensureGitignoreEntries: adds missing, skips existing, creates file if absent
6
+ * - removeGitignoreEntries: removes present, no-ops for absent
7
+ * - checkGitignoreEntries: correctly identifies missing and present
8
+ * - parseClaudeMdContent: well-formed, missing END, duplicates, code blocks
9
+ * - injectClaudeMdSection: insert new, update stale, skip current, refuse on parse errors
10
+ * - removeClaudeMdSection: removes existing, no-op for absent
11
+ * - listClaudeMdSections: lists all managed sections
12
+ * - resolvePluginConventions: reads conventions from plugin.json, returns null if absent
13
+ * - applyConventions: end-to-end for both gitignore and CLAUDE.md
14
+ * - removeConventions: end-to-end CLAUDE.md removal
15
+ * - Idempotency: calling applyConventions twice yields identical result
16
+ * - Malformed markers: duplicate sections, missing END, markers in code blocks
17
+ */
18
+
19
+ import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
20
+ import { mkdtemp, rm, writeFile, readFile, mkdir } from 'node:fs/promises';
21
+ import { tmpdir } from 'node:os';
22
+ import { join } from 'node:path';
23
+ import {
24
+ ensureGitignoreEntries,
25
+ removeGitignoreEntries,
26
+ checkGitignoreEntries,
27
+ parseClaudeMdContent,
28
+ parseClaudeMdSections,
29
+ injectClaudeMdSection,
30
+ removeClaudeMdSection,
31
+ listClaudeMdSections,
32
+ resolvePluginConventions,
33
+ resolveGlobalGitignorePath,
34
+ readConventionTemplate,
35
+ applyConventions,
36
+ removeConventions,
37
+ } from '../services/conventions-manager.js';
38
+
39
+ describe('conventions-manager', () => {
40
+ let testDir: string;
41
+
42
+ beforeEach(async () => {
43
+ testDir = await mkdtemp(join(tmpdir(), 'conventions-test-'));
44
+ });
45
+
46
+ afterEach(async () => {
47
+ await rm(testDir, { recursive: true, force: true });
48
+ });
49
+
50
+ // ============================================================
51
+ // ensureGitignoreEntries
52
+ // ============================================================
53
+
54
+ describe('ensureGitignoreEntries', () => {
55
+ it('should add missing entries to existing .gitignore', async () => {
56
+ const gitignorePath = join(testDir, '.gitignore');
57
+ await writeFile(gitignorePath, 'node_modules/\n.env\n');
58
+
59
+ const result = await ensureGitignoreEntries(gitignorePath, ['.mnemex/', '.claudemem/']);
60
+
61
+ expect(result.modified).toBe(true);
62
+ expect(result.added).toEqual(['.mnemex/', '.claudemem/']);
63
+
64
+ const content = await readFile(gitignorePath, 'utf-8');
65
+ expect(content).toContain('.mnemex/');
66
+ expect(content).toContain('.claudemem/');
67
+ expect(content).toContain('node_modules/');
68
+ });
69
+
70
+ it('should skip entries that already exist', async () => {
71
+ const gitignorePath = join(testDir, '.gitignore');
72
+ await writeFile(gitignorePath, 'node_modules/\n.mnemex/\n');
73
+
74
+ const result = await ensureGitignoreEntries(gitignorePath, ['.mnemex/', '.claudemem/']);
75
+
76
+ expect(result.modified).toBe(true);
77
+ expect(result.added).toEqual(['.claudemem/']);
78
+ });
79
+
80
+ it('should return modified=false when all entries already exist', async () => {
81
+ const gitignorePath = join(testDir, '.gitignore');
82
+ await writeFile(gitignorePath, 'node_modules/\n.mnemex/\n.claudemem/\n');
83
+
84
+ const result = await ensureGitignoreEntries(gitignorePath, ['.mnemex/', '.claudemem/']);
85
+
86
+ expect(result.modified).toBe(false);
87
+ expect(result.added).toEqual([]);
88
+ });
89
+
90
+ it('should create file if absent', async () => {
91
+ const gitignorePath = join(testDir, '.gitignore');
92
+
93
+ const result = await ensureGitignoreEntries(gitignorePath, ['.mnemex/']);
94
+
95
+ expect(result.modified).toBe(true);
96
+ expect(result.added).toEqual(['.mnemex/']);
97
+
98
+ const content = await readFile(gitignorePath, 'utf-8');
99
+ expect(content).toContain('.mnemex/');
100
+ });
101
+
102
+ it('should add comment header when provided', async () => {
103
+ const gitignorePath = join(testDir, '.gitignore');
104
+
105
+ const result = await ensureGitignoreEntries(
106
+ gitignorePath,
107
+ ['.mnemex/'],
108
+ '# Code Analysis plugin',
109
+ );
110
+
111
+ expect(result.modified).toBe(true);
112
+ const content = await readFile(gitignorePath, 'utf-8');
113
+ expect(content).toContain('# Code Analysis plugin');
114
+ expect(content).toContain('.mnemex/');
115
+ });
116
+
117
+ it('should not duplicate comment if already present', async () => {
118
+ const gitignorePath = join(testDir, '.gitignore');
119
+ await writeFile(gitignorePath, '# Code Analysis plugin\n.mnemex/\n');
120
+
121
+ await ensureGitignoreEntries(
122
+ gitignorePath,
123
+ ['.claudemem/'],
124
+ '# Code Analysis plugin',
125
+ );
126
+
127
+ const content = await readFile(gitignorePath, 'utf-8');
128
+ const commentCount = (content.match(/# Code Analysis plugin/g) || []).length;
129
+ expect(commentCount).toBe(1);
130
+ });
131
+
132
+ it('should handle empty entries array', async () => {
133
+ const gitignorePath = join(testDir, '.gitignore');
134
+ await writeFile(gitignorePath, 'node_modules/\n');
135
+
136
+ const result = await ensureGitignoreEntries(gitignorePath, []);
137
+
138
+ expect(result.modified).toBe(false);
139
+ expect(result.added).toEqual([]);
140
+ });
141
+ });
142
+
143
+ // ============================================================
144
+ // removeGitignoreEntries
145
+ // ============================================================
146
+
147
+ describe('removeGitignoreEntries', () => {
148
+ it('should remove present entries', async () => {
149
+ const gitignorePath = join(testDir, '.gitignore');
150
+ await writeFile(gitignorePath, 'node_modules/\n.mnemex/\n.claudemem/\n');
151
+
152
+ const result = await removeGitignoreEntries(gitignorePath, ['.mnemex/']);
153
+
154
+ expect(result.modified).toBe(true);
155
+ expect(result.removed).toEqual(['.mnemex/']);
156
+
157
+ const content = await readFile(gitignorePath, 'utf-8');
158
+ expect(content).not.toContain('.mnemex/');
159
+ expect(content).toContain('.claudemem/');
160
+ });
161
+
162
+ it('should no-op for entries not present', async () => {
163
+ const gitignorePath = join(testDir, '.gitignore');
164
+ await writeFile(gitignorePath, 'node_modules/\n');
165
+
166
+ const result = await removeGitignoreEntries(gitignorePath, ['.mnemex/']);
167
+
168
+ expect(result.modified).toBe(false);
169
+ expect(result.removed).toEqual([]);
170
+ });
171
+
172
+ it('should handle non-existent file', async () => {
173
+ const gitignorePath = join(testDir, '.gitignore');
174
+
175
+ const result = await removeGitignoreEntries(gitignorePath, ['.mnemex/']);
176
+
177
+ expect(result.modified).toBe(false);
178
+ expect(result.removed).toEqual([]);
179
+ });
180
+ });
181
+
182
+ // ============================================================
183
+ // checkGitignoreEntries
184
+ // ============================================================
185
+
186
+ describe('checkGitignoreEntries', () => {
187
+ it('should identify present and missing entries', async () => {
188
+ const gitignorePath = join(testDir, '.gitignore');
189
+ await writeFile(gitignorePath, 'node_modules/\n.mnemex/\n');
190
+
191
+ const result = await checkGitignoreEntries(gitignorePath, ['.mnemex/', '.claudemem/']);
192
+
193
+ expect(result.present).toEqual(['.mnemex/']);
194
+ expect(result.missing).toEqual(['.claudemem/']);
195
+ });
196
+
197
+ it('should handle non-existent file (all missing)', async () => {
198
+ const gitignorePath = join(testDir, '.gitignore');
199
+
200
+ const result = await checkGitignoreEntries(gitignorePath, ['.mnemex/']);
201
+
202
+ expect(result.present).toEqual([]);
203
+ expect(result.missing).toEqual(['.mnemex/']);
204
+ });
205
+
206
+ it('should ignore comments and blank lines in gitignore', async () => {
207
+ const gitignorePath = join(testDir, '.gitignore');
208
+ await writeFile(gitignorePath, '# comment\n\n.mnemex/\n');
209
+
210
+ const result = await checkGitignoreEntries(gitignorePath, ['.mnemex/', '# comment']);
211
+
212
+ expect(result.present).toEqual(['.mnemex/']);
213
+ // Comments are not patterns
214
+ expect(result.missing).toEqual(['# comment']);
215
+ });
216
+ });
217
+
218
+ // ============================================================
219
+ // parseClaudeMdContent (state machine parser)
220
+ // ============================================================
221
+
222
+ describe('parseClaudeMdContent', () => {
223
+ it('should parse well-formed single section', () => {
224
+ const content = [
225
+ '# Project CLAUDE.md',
226
+ '',
227
+ '<!-- BEGIN magus:code-analysis v1.0.0 -->',
228
+ '## Code Analysis Conventions',
229
+ '',
230
+ '> Managed by plugin',
231
+ '<!-- END magus:code-analysis -->',
232
+ '',
233
+ 'Other content',
234
+ ].join('\n');
235
+
236
+ const result = parseClaudeMdContent(content);
237
+
238
+ expect(result.errors).toEqual([]);
239
+ expect(result.sections).toHaveLength(1);
240
+ expect(result.sections[0].section).toBe('code-analysis');
241
+ expect(result.sections[0].version).toBe('1.0.0');
242
+ expect(result.sections[0].content).toContain('Code Analysis Conventions');
243
+ expect(result.sections[0].startLine).toBe(3);
244
+ expect(result.sections[0].endLine).toBe(7);
245
+ });
246
+
247
+ it('should parse multiple sections', () => {
248
+ const content = [
249
+ '<!-- BEGIN magus:code-analysis v1.0.0 -->',
250
+ 'Section 1 content',
251
+ '<!-- END magus:code-analysis -->',
252
+ '',
253
+ '<!-- BEGIN magus:terminal v2.0.0 -->',
254
+ 'Section 2 content',
255
+ '<!-- END magus:terminal -->',
256
+ ].join('\n');
257
+
258
+ const result = parseClaudeMdContent(content);
259
+
260
+ expect(result.errors).toEqual([]);
261
+ expect(result.sections).toHaveLength(2);
262
+ expect(result.sections[0].section).toBe('code-analysis');
263
+ expect(result.sections[1].section).toBe('terminal');
264
+ });
265
+
266
+ it('should detect missing END marker', () => {
267
+ const content = [
268
+ '<!-- BEGIN magus:code-analysis v1.0.0 -->',
269
+ 'Some content without an end marker',
270
+ ].join('\n');
271
+
272
+ const result = parseClaudeMdContent(content);
273
+
274
+ expect(result.errors).toHaveLength(1);
275
+ expect(result.errors[0].type).toBe('missing_end');
276
+ expect(result.errors[0]).toHaveProperty('section', 'code-analysis');
277
+ });
278
+
279
+ it('should detect duplicate sections', () => {
280
+ const content = [
281
+ '<!-- BEGIN magus:code-analysis v1.0.0 -->',
282
+ 'First',
283
+ '<!-- END magus:code-analysis -->',
284
+ '',
285
+ '<!-- BEGIN magus:code-analysis v1.0.0 -->',
286
+ 'Second',
287
+ '<!-- END magus:code-analysis -->',
288
+ ].join('\n');
289
+
290
+ const result = parseClaudeMdContent(content);
291
+
292
+ const dupError = result.errors.find((e) => e.type === 'duplicate_section');
293
+ expect(dupError).toBeDefined();
294
+ expect(dupError).toHaveProperty('section', 'code-analysis');
295
+ });
296
+
297
+ it('should ignore markers inside code blocks', () => {
298
+ const content = [
299
+ '# Example',
300
+ '```markdown',
301
+ '<!-- BEGIN magus:example v1.0.0 -->',
302
+ 'This is just an example',
303
+ '<!-- END magus:example -->',
304
+ '```',
305
+ '',
306
+ 'Regular content',
307
+ ].join('\n');
308
+
309
+ const result = parseClaudeMdContent(content);
310
+
311
+ // The markers inside code blocks should not be parsed as real sections
312
+ const realSections = result.sections.filter((s) => !s.insideCodeBlock);
313
+ expect(realSections).toHaveLength(0);
314
+
315
+ // Should report marker_in_code_block error
316
+ const codeBlockError = result.errors.find((e) => e.type === 'marker_in_code_block');
317
+ expect(codeBlockError).toBeDefined();
318
+ });
319
+
320
+ it('should handle empty content', () => {
321
+ const result = parseClaudeMdContent('');
322
+
323
+ expect(result.sections).toEqual([]);
324
+ expect(result.errors).toEqual([]);
325
+ });
326
+
327
+ it('should handle content with no markers', () => {
328
+ const content = '# Just a CLAUDE.md\n\nWith regular content\n';
329
+
330
+ const result = parseClaudeMdContent(content);
331
+
332
+ expect(result.sections).toEqual([]);
333
+ expect(result.errors).toEqual([]);
334
+ });
335
+
336
+ it('should detect missing END when a new BEGIN appears', () => {
337
+ const content = [
338
+ '<!-- BEGIN magus:first v1.0.0 -->',
339
+ 'First content',
340
+ '<!-- BEGIN magus:second v1.0.0 -->',
341
+ 'Second content',
342
+ '<!-- END magus:second -->',
343
+ ].join('\n');
344
+
345
+ const result = parseClaudeMdContent(content);
346
+
347
+ const missingEnd = result.errors.find(
348
+ (e) => e.type === 'missing_end' && e.section === 'first',
349
+ );
350
+ expect(missingEnd).toBeDefined();
351
+ });
352
+
353
+ it('should detect mismatched END markers', () => {
354
+ const content = [
355
+ '<!-- BEGIN magus:alpha v1.0.0 -->',
356
+ 'Alpha content',
357
+ '<!-- END magus:beta -->',
358
+ 'More content',
359
+ ].join('\n');
360
+
361
+ const result = parseClaudeMdContent(content);
362
+
363
+ const mismatchError = result.errors.find((e) => e.type === 'mismatched_end');
364
+ expect(mismatchError).toBeDefined();
365
+ expect(mismatchError).toHaveProperty('section', 'beta');
366
+ expect(mismatchError).toHaveProperty('expected', 'alpha');
367
+
368
+ // The section should remain unclosed (also produces missing_end)
369
+ const missingEnd = result.errors.find((e) => e.type === 'missing_end');
370
+ expect(missingEnd).toBeDefined();
371
+ expect(missingEnd).toHaveProperty('section', 'alpha');
372
+ });
373
+
374
+ it('should handle mixed backtick/tilde fences correctly', () => {
375
+ // A ~~~ block inside a ``` block should not close the ``` block
376
+ const content = [
377
+ '```',
378
+ '<!-- BEGIN magus:inside-backtick v1.0.0 -->',
379
+ '~~~',
380
+ 'still inside backtick code block',
381
+ '~~~',
382
+ '<!-- END magus:inside-backtick -->',
383
+ '```',
384
+ '',
385
+ '<!-- BEGIN magus:real v1.0.0 -->',
386
+ 'Real content',
387
+ '<!-- END magus:real -->',
388
+ ].join('\n');
389
+
390
+ const result = parseClaudeMdContent(content);
391
+
392
+ // The markers inside the ``` block should be flagged as in code block
393
+ const codeBlockErrors = result.errors.filter((e) => e.type === 'marker_in_code_block');
394
+ expect(codeBlockErrors.length).toBeGreaterThanOrEqual(1);
395
+
396
+ // The real section outside the code block should parse correctly
397
+ const realSection = result.sections.find((s) => s.section === 'real');
398
+ expect(realSection).toBeDefined();
399
+ expect(realSection!.content).toBe('Real content');
400
+ });
401
+
402
+ it('should not close backtick fence with tilde fence', () => {
403
+ // ``` opened, ~~~ should not close it
404
+ const content = [
405
+ '```',
406
+ '~~~',
407
+ '<!-- BEGIN magus:test v1.0.0 -->',
408
+ 'Content',
409
+ '<!-- END magus:test -->',
410
+ '~~~',
411
+ '```',
412
+ ].join('\n');
413
+
414
+ const result = parseClaudeMdContent(content);
415
+
416
+ // The markers are inside the ``` code block (~~~ doesn't close it)
417
+ const codeBlockErrors = result.errors.filter((e) => e.type === 'marker_in_code_block');
418
+ expect(codeBlockErrors.length).toBeGreaterThanOrEqual(1);
419
+
420
+ // No real sections should be parsed
421
+ const realSections = result.sections.filter((s) => !s.insideCodeBlock);
422
+ expect(realSections).toHaveLength(0);
423
+ });
424
+
425
+ it('should parse version with prerelease tag', () => {
426
+ const content = [
427
+ '<!-- BEGIN magus:test-plugin v1.0.0-beta.1 -->',
428
+ 'Beta content',
429
+ '<!-- END magus:test-plugin -->',
430
+ ].join('\n');
431
+
432
+ const result = parseClaudeMdContent(content);
433
+
434
+ expect(result.errors).toEqual([]);
435
+ expect(result.sections).toHaveLength(1);
436
+ expect(result.sections[0].version).toBe('1.0.0-beta.1');
437
+ });
438
+ });
439
+
440
+ // ============================================================
441
+ // parseClaudeMdSections (file-reading wrapper)
442
+ // ============================================================
443
+
444
+ describe('parseClaudeMdSections', () => {
445
+ it('should parse file from disk', async () => {
446
+ const claudeMdPath = join(testDir, 'CLAUDE.md');
447
+ const content = [
448
+ '# Project',
449
+ '',
450
+ '<!-- BEGIN magus:code-analysis v1.0.0 -->',
451
+ 'Conventions here',
452
+ '<!-- END magus:code-analysis -->',
453
+ ].join('\n');
454
+ await writeFile(claudeMdPath, content);
455
+
456
+ const result = await parseClaudeMdSections(claudeMdPath);
457
+
458
+ expect(result.sections).toHaveLength(1);
459
+ expect(result.errors).toEqual([]);
460
+ });
461
+
462
+ it('should return empty result for non-existent file', async () => {
463
+ const claudeMdPath = join(testDir, 'CLAUDE.md');
464
+
465
+ const result = await parseClaudeMdSections(claudeMdPath);
466
+
467
+ expect(result.sections).toEqual([]);
468
+ expect(result.errors).toEqual([]);
469
+ });
470
+ });
471
+
472
+ // ============================================================
473
+ // injectClaudeMdSection
474
+ // ============================================================
475
+
476
+ describe('injectClaudeMdSection', () => {
477
+ it('should insert new section into empty file', async () => {
478
+ const claudeMdPath = join(testDir, 'CLAUDE.md');
479
+
480
+ const result = await injectClaudeMdSection(
481
+ claudeMdPath,
482
+ 'code-analysis',
483
+ '1.0.0',
484
+ '## Code Analysis\n\nPrivate paths here',
485
+ );
486
+
487
+ expect(result.action).toBe('inserted');
488
+
489
+ const content = await readFile(claudeMdPath, 'utf-8');
490
+ expect(content).toContain('<!-- BEGIN magus:code-analysis v1.0.0 -->');
491
+ expect(content).toContain('## Code Analysis');
492
+ expect(content).toContain('<!-- END magus:code-analysis -->');
493
+ });
494
+
495
+ it('should insert new section into existing file', async () => {
496
+ const claudeMdPath = join(testDir, 'CLAUDE.md');
497
+ await writeFile(claudeMdPath, '# Project\n\nExisting content\n');
498
+
499
+ const result = await injectClaudeMdSection(
500
+ claudeMdPath,
501
+ 'code-analysis',
502
+ '1.0.0',
503
+ 'New content',
504
+ );
505
+
506
+ expect(result.action).toBe('inserted');
507
+
508
+ const content = await readFile(claudeMdPath, 'utf-8');
509
+ expect(content).toContain('# Project');
510
+ expect(content).toContain('Existing content');
511
+ expect(content).toContain('<!-- BEGIN magus:code-analysis v1.0.0 -->');
512
+ });
513
+
514
+ it('should update section when version changes', async () => {
515
+ const claudeMdPath = join(testDir, 'CLAUDE.md');
516
+ const initial = [
517
+ '# Project',
518
+ '',
519
+ '<!-- BEGIN magus:code-analysis v1.0.0 -->',
520
+ 'Old content',
521
+ '<!-- END magus:code-analysis -->',
522
+ ].join('\n');
523
+ await writeFile(claudeMdPath, initial);
524
+
525
+ const result = await injectClaudeMdSection(
526
+ claudeMdPath,
527
+ 'code-analysis',
528
+ '2.0.0',
529
+ 'New content',
530
+ );
531
+
532
+ expect(result.action).toBe('updated');
533
+ expect(result.previousVersion).toBe('1.0.0');
534
+
535
+ const content = await readFile(claudeMdPath, 'utf-8');
536
+ expect(content).toContain('v2.0.0');
537
+ expect(content).toContain('New content');
538
+ expect(content).not.toContain('Old content');
539
+ expect(content).not.toContain('v1.0.0');
540
+ });
541
+
542
+ it('should skip when version is unchanged', async () => {
543
+ const claudeMdPath = join(testDir, 'CLAUDE.md');
544
+ const initial = [
545
+ '<!-- BEGIN magus:code-analysis v1.0.0 -->',
546
+ 'Current content',
547
+ '<!-- END magus:code-analysis -->',
548
+ ].join('\n');
549
+ await writeFile(claudeMdPath, initial);
550
+
551
+ const result = await injectClaudeMdSection(
552
+ claudeMdPath,
553
+ 'code-analysis',
554
+ '1.0.0',
555
+ 'Different content but same version',
556
+ );
557
+
558
+ expect(result.action).toBe('unchanged');
559
+
560
+ // File should not be modified
561
+ const content = await readFile(claudeMdPath, 'utf-8');
562
+ expect(content).toContain('Current content');
563
+ });
564
+
565
+ it('should refuse when file has parse errors', async () => {
566
+ const claudeMdPath = join(testDir, 'CLAUDE.md');
567
+ const malformed = [
568
+ '<!-- BEGIN magus:broken v1.0.0 -->',
569
+ 'Content without end marker',
570
+ ].join('\n');
571
+ await writeFile(claudeMdPath, malformed);
572
+
573
+ const result = await injectClaudeMdSection(
574
+ claudeMdPath,
575
+ 'code-analysis',
576
+ '1.0.0',
577
+ 'New content',
578
+ );
579
+
580
+ expect(result.action).toBe('error');
581
+ expect(result.error).toContain('malformed markers');
582
+ });
583
+
584
+ it('should refuse when file has duplicate sections', async () => {
585
+ const claudeMdPath = join(testDir, 'CLAUDE.md');
586
+ const duplicate = [
587
+ '<!-- BEGIN magus:code-analysis v1.0.0 -->',
588
+ 'First',
589
+ '<!-- END magus:code-analysis -->',
590
+ '',
591
+ '<!-- BEGIN magus:code-analysis v1.0.0 -->',
592
+ 'Second',
593
+ '<!-- END magus:code-analysis -->',
594
+ ].join('\n');
595
+ await writeFile(claudeMdPath, duplicate);
596
+
597
+ const result = await injectClaudeMdSection(
598
+ claudeMdPath,
599
+ 'terminal',
600
+ '1.0.0',
601
+ 'Terminal content',
602
+ );
603
+
604
+ expect(result.action).toBe('error');
605
+ expect(result.error).toContain('duplicate');
606
+ });
607
+ });
608
+
609
+ // ============================================================
610
+ // removeClaudeMdSection
611
+ // ============================================================
612
+
613
+ describe('removeClaudeMdSection', () => {
614
+ it('should remove existing section', async () => {
615
+ const claudeMdPath = join(testDir, 'CLAUDE.md');
616
+ const content = [
617
+ '# Project',
618
+ '',
619
+ '<!-- BEGIN magus:code-analysis v1.0.0 -->',
620
+ 'Conventions here',
621
+ '<!-- END magus:code-analysis -->',
622
+ '',
623
+ 'Other content',
624
+ ].join('\n');
625
+ await writeFile(claudeMdPath, content);
626
+
627
+ const result = await removeClaudeMdSection(claudeMdPath, 'code-analysis');
628
+
629
+ expect(result.removed).toBe(true);
630
+
631
+ const newContent = await readFile(claudeMdPath, 'utf-8');
632
+ expect(newContent).not.toContain('BEGIN magus:code-analysis');
633
+ expect(newContent).not.toContain('END magus:code-analysis');
634
+ expect(newContent).toContain('# Project');
635
+ expect(newContent).toContain('Other content');
636
+ });
637
+
638
+ it('should no-op for absent section', async () => {
639
+ const claudeMdPath = join(testDir, 'CLAUDE.md');
640
+ await writeFile(claudeMdPath, '# Project\n\nSome content\n');
641
+
642
+ const result = await removeClaudeMdSection(claudeMdPath, 'code-analysis');
643
+
644
+ expect(result.removed).toBe(false);
645
+ });
646
+
647
+ it('should no-op for non-existent file', async () => {
648
+ const claudeMdPath = join(testDir, 'CLAUDE.md');
649
+
650
+ const result = await removeClaudeMdSection(claudeMdPath, 'code-analysis');
651
+
652
+ expect(result.removed).toBe(false);
653
+ });
654
+
655
+ it('should refuse mutation on malformed files', async () => {
656
+ const claudeMdPath = join(testDir, 'CLAUDE.md');
657
+ const malformed = [
658
+ '<!-- BEGIN magus:broken v1.0.0 -->',
659
+ 'Content without end marker',
660
+ '',
661
+ '<!-- BEGIN magus:target v1.0.0 -->',
662
+ 'Target content',
663
+ '<!-- END magus:target -->',
664
+ ].join('\n');
665
+ await writeFile(claudeMdPath, malformed);
666
+
667
+ const result = await removeClaudeMdSection(claudeMdPath, 'target');
668
+
669
+ expect(result.removed).toBe(false);
670
+ expect(result.error).toContain('malformed markers');
671
+ });
672
+
673
+ it('should allow force removal on malformed files', async () => {
674
+ const claudeMdPath = join(testDir, 'CLAUDE.md');
675
+ const content = [
676
+ '<!-- BEGIN magus:good v1.0.0 -->',
677
+ 'Good content',
678
+ '<!-- END magus:good -->',
679
+ '',
680
+ '<!-- BEGIN magus:good v2.0.0 -->',
681
+ 'Duplicate',
682
+ '<!-- END magus:good -->',
683
+ ].join('\n');
684
+ await writeFile(claudeMdPath, content);
685
+
686
+ // Without force, should refuse
687
+ const refuseResult = await removeClaudeMdSection(claudeMdPath, 'good');
688
+ expect(refuseResult.removed).toBe(false);
689
+ expect(refuseResult.error).toBeTruthy();
690
+
691
+ // With force, should proceed
692
+ const forceResult = await removeClaudeMdSection(claudeMdPath, 'good', { force: true });
693
+ expect(forceResult.removed).toBe(true);
694
+ });
695
+
696
+ it('should preserve other sections when removing one', async () => {
697
+ const claudeMdPath = join(testDir, 'CLAUDE.md');
698
+ const content = [
699
+ '<!-- BEGIN magus:code-analysis v1.0.0 -->',
700
+ 'First section',
701
+ '<!-- END magus:code-analysis -->',
702
+ '',
703
+ '<!-- BEGIN magus:terminal v2.0.0 -->',
704
+ 'Second section',
705
+ '<!-- END magus:terminal -->',
706
+ ].join('\n');
707
+ await writeFile(claudeMdPath, content);
708
+
709
+ await removeClaudeMdSection(claudeMdPath, 'code-analysis');
710
+
711
+ const newContent = await readFile(claudeMdPath, 'utf-8');
712
+ expect(newContent).not.toContain('code-analysis');
713
+ expect(newContent).toContain('<!-- BEGIN magus:terminal v2.0.0 -->');
714
+ expect(newContent).toContain('Second section');
715
+ });
716
+ });
717
+
718
+ // ============================================================
719
+ // listClaudeMdSections
720
+ // ============================================================
721
+
722
+ describe('listClaudeMdSections', () => {
723
+ it('should list all managed sections', async () => {
724
+ const claudeMdPath = join(testDir, 'CLAUDE.md');
725
+ const content = [
726
+ '<!-- BEGIN magus:code-analysis v1.0.0 -->',
727
+ 'First',
728
+ '<!-- END magus:code-analysis -->',
729
+ '',
730
+ '<!-- BEGIN magus:terminal v2.1.0 -->',
731
+ 'Second',
732
+ '<!-- END magus:terminal -->',
733
+ ].join('\n');
734
+ await writeFile(claudeMdPath, content);
735
+
736
+ const sections = await listClaudeMdSections(claudeMdPath);
737
+
738
+ expect(sections).toHaveLength(2);
739
+ expect(sections[0].section).toBe('code-analysis');
740
+ expect(sections[0].version).toBe('1.0.0');
741
+ expect(sections[1].section).toBe('terminal');
742
+ expect(sections[1].version).toBe('2.1.0');
743
+ });
744
+
745
+ it('should return empty array for file without sections', async () => {
746
+ const claudeMdPath = join(testDir, 'CLAUDE.md');
747
+ await writeFile(claudeMdPath, '# Just a readme\n');
748
+
749
+ const sections = await listClaudeMdSections(claudeMdPath);
750
+
751
+ expect(sections).toEqual([]);
752
+ });
753
+ });
754
+
755
+ // ============================================================
756
+ // resolvePluginConventions
757
+ // ============================================================
758
+
759
+ describe('resolvePluginConventions', () => {
760
+ it('should read conventions from plugin.json', async () => {
761
+ const pluginDir = join(testDir, 'my-plugin');
762
+ await mkdir(pluginDir, { recursive: true });
763
+ await writeFile(
764
+ join(pluginDir, 'plugin.json'),
765
+ JSON.stringify({
766
+ name: 'my-plugin',
767
+ version: '1.0.0',
768
+ conventions: {
769
+ gitignore: { project: ['.mnemex/'] },
770
+ claudemd: {
771
+ section: 'my-plugin',
772
+ template: './templates/claude-md-conventions.md',
773
+ version: '1.0.0',
774
+ },
775
+ },
776
+ }),
777
+ );
778
+
779
+ const result = await resolvePluginConventions(pluginDir);
780
+
781
+ expect(result).not.toBeNull();
782
+ expect(result!.gitignore!.project).toEqual(['.mnemex/']);
783
+ expect(result!.claudemd!.section).toBe('my-plugin');
784
+ });
785
+
786
+ it('should return null when plugin has no conventions field', async () => {
787
+ const pluginDir = join(testDir, 'simple-plugin');
788
+ await mkdir(pluginDir, { recursive: true });
789
+ await writeFile(
790
+ join(pluginDir, 'plugin.json'),
791
+ JSON.stringify({ name: 'simple-plugin', version: '1.0.0' }),
792
+ );
793
+
794
+ const result = await resolvePluginConventions(pluginDir);
795
+
796
+ expect(result).toBeNull();
797
+ });
798
+
799
+ it('should return null when plugin.json does not exist', async () => {
800
+ const pluginDir = join(testDir, 'missing-plugin');
801
+
802
+ const result = await resolvePluginConventions(pluginDir);
803
+
804
+ expect(result).toBeNull();
805
+ });
806
+ });
807
+
808
+ // ============================================================
809
+ // readConventionTemplate
810
+ // ============================================================
811
+
812
+ describe('readConventionTemplate', () => {
813
+ it('should read a template within the plugin directory', async () => {
814
+ const pluginDir = join(testDir, 'my-plugin');
815
+ await mkdir(join(pluginDir, 'templates'), { recursive: true });
816
+ await writeFile(join(pluginDir, 'templates', 'conventions.md'), 'Template content');
817
+
818
+ const content = await readConventionTemplate(pluginDir, './templates/conventions.md');
819
+
820
+ expect(content).toBe('Template content');
821
+ });
822
+
823
+ it('should reject path traversal attempts', async () => {
824
+ const pluginDir = join(testDir, 'my-plugin');
825
+ await mkdir(pluginDir, { recursive: true });
826
+
827
+ await expect(
828
+ readConventionTemplate(pluginDir, './../../etc/passwd'),
829
+ ).rejects.toThrow('Template path escapes plugin directory');
830
+ });
831
+
832
+ it('should reject path traversal with ../ in the middle', async () => {
833
+ const pluginDir = join(testDir, 'my-plugin');
834
+ await mkdir(pluginDir, { recursive: true });
835
+
836
+ await expect(
837
+ readConventionTemplate(pluginDir, './templates/../../secret.txt'),
838
+ ).rejects.toThrow('Template path escapes plugin directory');
839
+ });
840
+ });
841
+
842
+ // ============================================================
843
+ // resolveGlobalGitignorePath
844
+ // ============================================================
845
+
846
+ describe('resolveGlobalGitignorePath', () => {
847
+ it('should return a path (from git config or fallback)', async () => {
848
+ const result = await resolveGlobalGitignorePath();
849
+
850
+ expect(result).toBeTruthy();
851
+ expect(typeof result).toBe('string');
852
+ });
853
+ });
854
+
855
+ // ============================================================
856
+ // applyConventions (end-to-end)
857
+ // ============================================================
858
+
859
+ describe('applyConventions', () => {
860
+ let pluginDir: string;
861
+ let projectDir: string;
862
+
863
+ beforeEach(async () => {
864
+ pluginDir = join(testDir, 'plugin');
865
+ projectDir = join(testDir, 'project');
866
+ await mkdir(pluginDir, { recursive: true });
867
+ await mkdir(join(pluginDir, 'templates'), { recursive: true });
868
+ await mkdir(projectDir, { recursive: true });
869
+ });
870
+
871
+ it('should apply both gitignore and CLAUDE.md conventions', async () => {
872
+ await writeFile(
873
+ join(pluginDir, 'templates', 'conventions.md'),
874
+ '## My Plugin\n\nConventions here',
875
+ );
876
+ await writeFile(
877
+ join(pluginDir, 'plugin.json'),
878
+ JSON.stringify({
879
+ name: 'my-plugin',
880
+ version: '1.0.0',
881
+ conventions: {
882
+ gitignore: { project: ['.mnemex/'] },
883
+ claudemd: {
884
+ section: 'my-plugin',
885
+ template: './templates/conventions.md',
886
+ version: '1.0.0',
887
+ },
888
+ },
889
+ }),
890
+ );
891
+
892
+ const result = await applyConventions(pluginDir, projectDir);
893
+
894
+ expect(result.gitignore.modified).toBe(true);
895
+ expect(result.gitignore.added).toEqual(['.mnemex/']);
896
+ expect(result.claudemd.action).toBe('inserted');
897
+ expect(result.claudemd.section).toBe('my-plugin');
898
+
899
+ // Verify files
900
+ const gitignore = await readFile(join(projectDir, '.gitignore'), 'utf-8');
901
+ expect(gitignore).toContain('.mnemex/');
902
+
903
+ const claudemd = await readFile(join(projectDir, 'CLAUDE.md'), 'utf-8');
904
+ expect(claudemd).toContain('<!-- BEGIN magus:my-plugin v1.0.0 -->');
905
+ });
906
+
907
+ it('should no-op for null conventions', async () => {
908
+ // Plugin with no conventions field
909
+ await writeFile(
910
+ join(pluginDir, 'plugin.json'),
911
+ JSON.stringify({ name: 'my-plugin', version: '1.0.0' }),
912
+ );
913
+
914
+ const result = await applyConventions(pluginDir, projectDir);
915
+
916
+ expect(result.gitignore.modified).toBe(false);
917
+ expect(result.claudemd.action).toBe('skipped');
918
+ });
919
+
920
+ it('should handle gitignore-only conventions', async () => {
921
+ await writeFile(
922
+ join(pluginDir, 'plugin.json'),
923
+ JSON.stringify({
924
+ name: 'my-plugin',
925
+ version: '1.0.0',
926
+ conventions: {
927
+ gitignore: { project: ['.mnemex/'] },
928
+ },
929
+ }),
930
+ );
931
+
932
+ const result = await applyConventions(pluginDir, projectDir);
933
+
934
+ expect(result.gitignore.modified).toBe(true);
935
+ expect(result.claudemd.action).toBe('skipped');
936
+ });
937
+
938
+ it('should handle claudemd-only conventions', async () => {
939
+ await writeFile(join(pluginDir, 'templates', 'conventions.md'), 'Template content');
940
+ await writeFile(
941
+ join(pluginDir, 'plugin.json'),
942
+ JSON.stringify({
943
+ name: 'my-plugin',
944
+ version: '1.0.0',
945
+ conventions: {
946
+ claudemd: {
947
+ section: 'my-plugin',
948
+ template: './templates/conventions.md',
949
+ version: '1.0.0',
950
+ },
951
+ },
952
+ }),
953
+ );
954
+
955
+ const result = await applyConventions(pluginDir, projectDir);
956
+
957
+ expect(result.gitignore.modified).toBe(false);
958
+ expect(result.claudemd.action).toBe('inserted');
959
+ });
960
+ });
961
+
962
+ // ============================================================
963
+ // removeConventions (end-to-end)
964
+ // ============================================================
965
+
966
+ describe('removeConventions', () => {
967
+ it('should remove CLAUDE.md section on uninstall', async () => {
968
+ const projectDir = join(testDir, 'project');
969
+ await mkdir(projectDir, { recursive: true });
970
+
971
+ const claudemd = [
972
+ '# Project',
973
+ '',
974
+ '<!-- BEGIN magus:my-plugin v1.0.0 -->',
975
+ 'Plugin content',
976
+ '<!-- END magus:my-plugin -->',
977
+ ].join('\n');
978
+ await writeFile(join(projectDir, 'CLAUDE.md'), claudemd);
979
+
980
+ const result = await removeConventions('my-plugin', projectDir);
981
+
982
+ expect(result.claudemd.removed).toBe(true);
983
+
984
+ const content = await readFile(join(projectDir, 'CLAUDE.md'), 'utf-8');
985
+ expect(content).not.toContain('my-plugin');
986
+ });
987
+
988
+ it('should no-op when section does not exist', async () => {
989
+ const projectDir = join(testDir, 'project');
990
+ await mkdir(projectDir, { recursive: true });
991
+ await writeFile(join(projectDir, 'CLAUDE.md'), '# Project\n');
992
+
993
+ const result = await removeConventions('nonexistent', projectDir);
994
+
995
+ expect(result.claudemd.removed).toBe(false);
996
+ });
997
+ });
998
+
999
+ // ============================================================
1000
+ // Idempotency
1001
+ // ============================================================
1002
+
1003
+ describe('idempotency', () => {
1004
+ it('applyConventions twice should yield identical result', async () => {
1005
+ const pluginDir = join(testDir, 'plugin');
1006
+ const projectDir = join(testDir, 'project');
1007
+ await mkdir(pluginDir, { recursive: true });
1008
+ await mkdir(join(pluginDir, 'templates'), { recursive: true });
1009
+ await mkdir(projectDir, { recursive: true });
1010
+
1011
+ await writeFile(join(pluginDir, 'templates', 'conventions.md'), 'Template content');
1012
+ await writeFile(
1013
+ join(pluginDir, 'plugin.json'),
1014
+ JSON.stringify({
1015
+ name: 'my-plugin',
1016
+ version: '1.0.0',
1017
+ conventions: {
1018
+ gitignore: { project: ['.mnemex/', '.claudemem/'] },
1019
+ claudemd: {
1020
+ section: 'my-plugin',
1021
+ template: './templates/conventions.md',
1022
+ version: '1.0.0',
1023
+ },
1024
+ },
1025
+ }),
1026
+ );
1027
+
1028
+ // First apply
1029
+ await applyConventions(pluginDir, projectDir);
1030
+ const gitignoreAfterFirst = await readFile(join(projectDir, '.gitignore'), 'utf-8');
1031
+ const claudemdAfterFirst = await readFile(join(projectDir, 'CLAUDE.md'), 'utf-8');
1032
+
1033
+ // Second apply
1034
+ const result2 = await applyConventions(pluginDir, projectDir);
1035
+
1036
+ expect(result2.gitignore.modified).toBe(false);
1037
+ expect(result2.gitignore.added).toEqual([]);
1038
+ expect(result2.claudemd.action).toBe('unchanged');
1039
+
1040
+ // File contents should be identical
1041
+ const gitignoreAfterSecond = await readFile(join(projectDir, '.gitignore'), 'utf-8');
1042
+ const claudemdAfterSecond = await readFile(join(projectDir, 'CLAUDE.md'), 'utf-8');
1043
+
1044
+ expect(gitignoreAfterSecond).toBe(gitignoreAfterFirst);
1045
+ expect(claudemdAfterSecond).toBe(claudemdAfterFirst);
1046
+ });
1047
+
1048
+ it('ensureGitignoreEntries twice should be idempotent', async () => {
1049
+ const gitignorePath = join(testDir, '.gitignore');
1050
+
1051
+ await ensureGitignoreEntries(gitignorePath, ['.mnemex/', '.claudemem/']);
1052
+ const first = await readFile(gitignorePath, 'utf-8');
1053
+
1054
+ const result2 = await ensureGitignoreEntries(gitignorePath, ['.mnemex/', '.claudemem/']);
1055
+ const second = await readFile(gitignorePath, 'utf-8');
1056
+
1057
+ expect(result2.modified).toBe(false);
1058
+ expect(second).toBe(first);
1059
+ });
1060
+ });
1061
+
1062
+ // ============================================================
1063
+ // Malformed markers
1064
+ // ============================================================
1065
+
1066
+ describe('malformed markers', () => {
1067
+ it('should detect missing END marker at EOF', () => {
1068
+ const content = [
1069
+ '# Project',
1070
+ '<!-- BEGIN magus:test v1.0.0 -->',
1071
+ 'Content goes on forever...',
1072
+ ].join('\n');
1073
+
1074
+ const result = parseClaudeMdContent(content);
1075
+
1076
+ expect(result.errors.some((e) => e.type === 'missing_end')).toBe(true);
1077
+ });
1078
+
1079
+ it('should detect duplicate section names', () => {
1080
+ const content = [
1081
+ '<!-- BEGIN magus:test v1.0.0 -->',
1082
+ 'First',
1083
+ '<!-- END magus:test -->',
1084
+ '<!-- BEGIN magus:test v2.0.0 -->',
1085
+ 'Second',
1086
+ '<!-- END magus:test -->',
1087
+ ].join('\n');
1088
+
1089
+ const result = parseClaudeMdContent(content);
1090
+
1091
+ const dupErrors = result.errors.filter((e) => e.type === 'duplicate_section');
1092
+ expect(dupErrors).toHaveLength(1);
1093
+ expect(dupErrors[0]).toHaveProperty('section', 'test');
1094
+ });
1095
+
1096
+ it('should detect markers inside triple-backtick code blocks', () => {
1097
+ const content = [
1098
+ '```',
1099
+ '<!-- BEGIN magus:example v1.0.0 -->',
1100
+ 'code example',
1101
+ '<!-- END magus:example -->',
1102
+ '```',
1103
+ ].join('\n');
1104
+
1105
+ const result = parseClaudeMdContent(content);
1106
+
1107
+ const codeBlockErrors = result.errors.filter((e) => e.type === 'marker_in_code_block');
1108
+ expect(codeBlockErrors).toHaveLength(1);
1109
+ });
1110
+
1111
+ it('should handle nested code fences correctly', () => {
1112
+ const content = [
1113
+ '````',
1114
+ '```',
1115
+ '<!-- BEGIN magus:nested v1.0.0 -->',
1116
+ 'nested code',
1117
+ '<!-- END magus:nested -->',
1118
+ '```',
1119
+ '````',
1120
+ ].join('\n');
1121
+
1122
+ // The outer ```` opens a code block with 4 backticks.
1123
+ // The inner ``` (3 backticks) is too short to close it per CommonMark.
1124
+ // So the markers are inside the code block.
1125
+ const result = parseClaudeMdContent(content);
1126
+
1127
+ // Should at minimum not crash
1128
+ expect(result).toBeDefined();
1129
+
1130
+ // The markers should be detected as inside a code block
1131
+ const codeBlockErrors = result.errors.filter((e) => e.type === 'marker_in_code_block');
1132
+ expect(codeBlockErrors.length).toBeGreaterThanOrEqual(1);
1133
+ });
1134
+
1135
+ it('injectClaudeMdSection should refuse on malformed file', async () => {
1136
+ const claudeMdPath = join(testDir, 'CLAUDE.md');
1137
+ // Missing END marker
1138
+ await writeFile(claudeMdPath, '<!-- BEGIN magus:broken v1.0.0 -->\nContent');
1139
+
1140
+ const result = await injectClaudeMdSection(
1141
+ claudeMdPath,
1142
+ 'new-section',
1143
+ '1.0.0',
1144
+ 'New content',
1145
+ );
1146
+
1147
+ expect(result.action).toBe('error');
1148
+ expect(result.error).toBeTruthy();
1149
+ });
1150
+
1151
+ it('injectClaudeMdSection should refuse when marker is inside code block', async () => {
1152
+ const claudeMdPath = join(testDir, 'CLAUDE.md');
1153
+ const content = [
1154
+ '```',
1155
+ '<!-- BEGIN magus:example v1.0.0 -->',
1156
+ 'code',
1157
+ '<!-- END magus:example -->',
1158
+ '```',
1159
+ ].join('\n');
1160
+ await writeFile(claudeMdPath, content);
1161
+
1162
+ const result = await injectClaudeMdSection(
1163
+ claudeMdPath,
1164
+ 'new-section',
1165
+ '1.0.0',
1166
+ 'New content',
1167
+ );
1168
+
1169
+ expect(result.action).toBe('error');
1170
+ });
1171
+ });
1172
+ });