claudeup 4.18.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 (191) 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__/alias-adopt.test.ts +364 -0
  7. package/src/__tests__/alias-parser.test.ts +92 -0
  8. package/src/__tests__/alias-shell-writer.test.ts +7 -0
  9. package/src/__tests__/alias-store.test.ts +77 -0
  10. package/src/__tests__/cli-router.test.ts +65 -0
  11. package/src/__tests__/conventions-integration.test.ts +745 -0
  12. package/src/__tests__/conventions-manager.test.ts +1172 -0
  13. package/src/__tests__/doctor-bins.test.ts +35 -0
  14. package/src/__tests__/doctor.test.ts +450 -0
  15. package/src/__tests__/dual-write-prevention.test.ts +14 -4
  16. package/src/__tests__/file-locking.test.ts +208 -0
  17. package/src/__tests__/gitignore-prerun.test.ts +1 -0
  18. package/src/__tests__/install-command.test.ts +81 -0
  19. package/src/__tests__/install-plan.test.ts +98 -0
  20. package/src/__tests__/manifest.test.ts +136 -0
  21. package/src/__tests__/marketplace-refresh.test.ts +267 -0
  22. package/src/__tests__/plugin-requires.test.ts +133 -0
  23. package/src/__tests__/plugin-setup.test.ts +118 -0
  24. package/src/__tests__/profile-command.test.ts +116 -0
  25. package/src/__tests__/profile-materializer.test.ts +82 -0
  26. package/src/__tests__/profile-sync.test.ts +136 -0
  27. package/src/__tests__/registry-version-resolution.test.ts +74 -0
  28. package/src/__tests__/resolve-executable.test.ts +42 -0
  29. package/src/__tests__/resolver.test.ts +218 -0
  30. package/src/__tests__/symlink-manager.test.ts +99 -0
  31. package/src/__tests__/toolchain.test.ts +56 -0
  32. package/src/cli/claude.ts +21 -0
  33. package/src/cli/doctor.ts +132 -0
  34. package/src/cli/install.ts +360 -0
  35. package/src/cli/profile.ts +166 -0
  36. package/src/cli/router.ts +107 -0
  37. package/src/cli/update.ts +85 -0
  38. package/src/data/alias-flags.ts +11 -1
  39. package/src/data/cli-tools.ts +7 -7
  40. package/src/data/gitignore-defaults.ts +4 -0
  41. package/src/data/marketplaces.ts +12 -0
  42. package/src/data/predefined-profiles.ts +3 -4
  43. package/src/main.tsx +11 -154
  44. package/src/prerunner/index.ts +62 -27
  45. package/src/services/alias-shell-writer.ts +382 -8
  46. package/src/services/alias-store.ts +60 -0
  47. package/src/services/claude-cli.ts +19 -22
  48. package/src/services/claude-settings.ts +99 -26
  49. package/src/services/conventions-manager.ts +865 -0
  50. package/src/services/doctor-bins.ts +49 -0
  51. package/src/services/doctor.ts +509 -0
  52. package/src/services/install-plan.ts +107 -0
  53. package/src/services/manifest.ts +166 -0
  54. package/src/services/marketplace-refresh.ts +173 -0
  55. package/src/services/plugin-manager.ts +7 -1
  56. package/src/services/plugin-requires.ts +215 -0
  57. package/src/services/plugin-setup.ts +61 -4
  58. package/src/services/plugin-version-check.ts +9 -4
  59. package/src/services/profile-materializer.ts +61 -0
  60. package/src/services/profile-sync.ts +150 -0
  61. package/src/services/resolver.ts +293 -0
  62. package/src/services/symlink-manager.ts +146 -0
  63. package/src/services/toolchain.ts +97 -0
  64. package/src/services/version-check.ts +10 -11
  65. package/src/types/index.ts +138 -31
  66. package/src/ui/App.tsx +16 -11
  67. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  68. package/src/ui/components/modals/ConfirmModal.tsx +1 -1
  69. package/src/ui/screens/AliasScreen.tsx +491 -359
  70. package/src/ui/screens/CliToolsScreen.tsx +11 -3
  71. package/src/ui/screens/PluginsScreen.tsx +3 -1
  72. package/src/ui/screens/index.ts +0 -1
  73. package/src/ui/state/reducer.ts +6 -102
  74. package/src/ui/state/types.ts +8 -35
  75. package/src/utils/command-utils.ts +23 -0
  76. package/src/utils/file-locking.ts +144 -0
  77. package/src/data/alias-flags.js +0 -196
  78. package/src/data/cli-tools.js +0 -123
  79. package/src/data/gitignore-defaults.js +0 -24
  80. package/src/data/gitignore-reasons.js +0 -97
  81. package/src/data/gitignore-templates.js +0 -21
  82. package/src/data/marketplaces.js +0 -138
  83. package/src/data/mcp-servers.js +0 -509
  84. package/src/data/predefined-profiles.js +0 -248
  85. package/src/data/settings-catalog.js +0 -625
  86. package/src/data/skill-repos.js +0 -160
  87. package/src/data/statuslines.js +0 -159
  88. package/src/data/statuslines.ts +0 -188
  89. package/src/index.js +0 -4
  90. package/src/index.ts +0 -5
  91. package/src/main.js +0 -170
  92. package/src/prerunner/index.js +0 -252
  93. package/src/services/alias-settings.js +0 -51
  94. package/src/services/alias-shell-writer.js +0 -764
  95. package/src/services/alias-store.js +0 -77
  96. package/src/services/claude-cli.js +0 -189
  97. package/src/services/claude-runner.js +0 -28
  98. package/src/services/claude-settings.js +0 -1223
  99. package/src/services/gitignore-detector.js +0 -155
  100. package/src/services/gitignore-fixer.js +0 -231
  101. package/src/services/gitignore-prerun.js +0 -46
  102. package/src/services/gitignore-resolver.js +0 -143
  103. package/src/services/gitignore-service.js +0 -117
  104. package/src/services/local-marketplace.js +0 -339
  105. package/src/services/marketplace-fetcher.js +0 -96
  106. package/src/services/marketplace-sync.js +0 -94
  107. package/src/services/mcp-registry.js +0 -87
  108. package/src/services/plugin-manager.js +0 -473
  109. package/src/services/plugin-mcp-config.js +0 -177
  110. package/src/services/plugin-setup.js +0 -444
  111. package/src/services/plugin-version-check.js +0 -262
  112. package/src/services/profiles.js +0 -161
  113. package/src/services/settings-manager.js +0 -243
  114. package/src/services/skills-manager.js +0 -393
  115. package/src/services/skillsmp-client.js +0 -87
  116. package/src/services/update-cache.js +0 -52
  117. package/src/services/version-check.js +0 -99
  118. package/src/types/gitignore.js +0 -6
  119. package/src/types/index.js +0 -1
  120. package/src/ui/App.js +0 -346
  121. package/src/ui/adapters/pluginsAdapter.js +0 -139
  122. package/src/ui/adapters/settingsAdapter.js +0 -111
  123. package/src/ui/adapters/skillsAdapter.js +0 -154
  124. package/src/ui/components/CategoryHeader.js +0 -9
  125. package/src/ui/components/EmptyFilterState.js +0 -4
  126. package/src/ui/components/ScopeIndicator.js +0 -30
  127. package/src/ui/components/ScrollableList.js +0 -36
  128. package/src/ui/components/SearchInput.js +0 -19
  129. package/src/ui/components/StyledText.js +0 -39
  130. package/src/ui/components/TabBar.js +0 -19
  131. package/src/ui/components/layout/FooterHints.js +0 -29
  132. package/src/ui/components/layout/Panel.js +0 -6
  133. package/src/ui/components/layout/ProgressBar.js +0 -14
  134. package/src/ui/components/layout/ScopeTabs.js +0 -6
  135. package/src/ui/components/layout/ScreenLayout.js +0 -16
  136. package/src/ui/components/layout/index.js +0 -5
  137. package/src/ui/components/modals/ConfirmModal.js +0 -14
  138. package/src/ui/components/modals/InputModal.js +0 -5
  139. package/src/ui/components/modals/LoadingModal.js +0 -17
  140. package/src/ui/components/modals/MessageModal.js +0 -16
  141. package/src/ui/components/modals/ModalContainer.js +0 -137
  142. package/src/ui/components/modals/SelectModal.js +0 -18
  143. package/src/ui/components/modals/VersionMismatchModal.js +0 -36
  144. package/src/ui/components/modals/index.js +0 -6
  145. package/src/ui/components/primitives/ActionHints.js +0 -13
  146. package/src/ui/components/primitives/DetailSection.js +0 -7
  147. package/src/ui/components/primitives/KeyValueLine.js +0 -8
  148. package/src/ui/components/primitives/ListCategoryRow.js +0 -8
  149. package/src/ui/components/primitives/MetaText.js +0 -8
  150. package/src/ui/components/primitives/ScopeDetail.js +0 -32
  151. package/src/ui/components/primitives/ScopeSquares.js +0 -11
  152. package/src/ui/components/primitives/SelectableRow.js +0 -5
  153. package/src/ui/components/primitives/index.js +0 -8
  154. package/src/ui/hooks/index.js +0 -4
  155. package/src/ui/hooks/useAsyncData.js +0 -77
  156. package/src/ui/hooks/useGitignoreModal.js +0 -74
  157. package/src/ui/hooks/useKeyboard.js +0 -13
  158. package/src/ui/hooks/useKeyboardHandler.js +0 -39
  159. package/src/ui/hooks/useMismatchModal.js +0 -77
  160. package/src/ui/registry.js +0 -1
  161. package/src/ui/renderers/cliToolRenderers.js +0 -54
  162. package/src/ui/renderers/gitignoreRenderers.js +0 -46
  163. package/src/ui/renderers/mcpRenderers.js +0 -26
  164. package/src/ui/renderers/pluginRenderers.js +0 -124
  165. package/src/ui/renderers/profileRenderers.js +0 -177
  166. package/src/ui/renderers/settingsRenderers.js +0 -73
  167. package/src/ui/renderers/skillRenderers.js +0 -138
  168. package/src/ui/screens/AliasScreen.js +0 -1008
  169. package/src/ui/screens/CliToolsScreen.js +0 -338
  170. package/src/ui/screens/EnvVarsScreen.js +0 -152
  171. package/src/ui/screens/GitignoreScreen.js +0 -328
  172. package/src/ui/screens/McpRegistryScreen.js +0 -238
  173. package/src/ui/screens/McpScreen.js +0 -176
  174. package/src/ui/screens/ModelSelectorScreen.js +0 -296
  175. package/src/ui/screens/ModelSelectorScreen.tsx +0 -444
  176. package/src/ui/screens/PluginsScreen.js +0 -766
  177. package/src/ui/screens/ProfilesScreen.js +0 -298
  178. package/src/ui/screens/SkillsScreen.js +0 -549
  179. package/src/ui/screens/StatusLineScreen.js +0 -206
  180. package/src/ui/screens/StatusLineScreen.tsx +0 -416
  181. package/src/ui/screens/index.js +0 -10
  182. package/src/ui/state/AnimationContext.js +0 -34
  183. package/src/ui/state/AppContext.js +0 -162
  184. package/src/ui/state/DimensionsContext.js +0 -71
  185. package/src/ui/state/reducer.js +0 -543
  186. package/src/ui/state/types.js +0 -1
  187. package/src/ui/theme.js +0 -47
  188. package/src/utils/clipboard.js +0 -56
  189. package/src/utils/command-utils.js +0 -19
  190. package/src/utils/fuzzy-search.js +0 -101
  191. 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
+ });