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,745 @@
1
+ /**
2
+ * Integration tests for the Plugin Conventions System
3
+ *
4
+ * Tests the complete convention lifecycle through the PUBLIC API only.
5
+ * All operations use temporary directories -- no real project modification.
6
+ *
7
+ * Covers:
8
+ * 1. End-to-end convention lifecycle (install, verify, uninstall)
9
+ * 2. Idempotency (apply twice = same result)
10
+ * 3. Version upgrade (v1 -> v2 content update)
11
+ * 4. Multiple plugins coexisting
12
+ * 5. Doctor detection and fix
13
+ * 6. Malformed CLAUDE.md handling
14
+ * 7. Path classification (.gitignore entries)
15
+ *
16
+ * IMPLEMENTATION ISSUES FOUND:
17
+ * - applyConventions() silently skips all convention application despite valid
18
+ * plugin.json with conventions field. resolvePluginConventions() and
19
+ * readConventionTemplate() work correctly individually, but applyConventions()
20
+ * does not invoke them. Tests 1-A, 2-A, 3-A, 4-A, 20-A document this.
21
+ * - checkGitignoreEntries() returns { present: string[], missing: string[] }
22
+ * instead of Promise<string[]> per architecture spec. Tests adapted to use
23
+ * the actual .missing property. This is a minor API deviation (richer return).
24
+ */
25
+
26
+ import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
27
+ import { mkdtemp, rm, mkdir, writeFile, readFile } from 'node:fs/promises';
28
+ import { existsSync } from 'node:fs';
29
+ import { join } from 'node:path';
30
+ import { tmpdir } from 'node:os';
31
+
32
+ import {
33
+ // Gitignore operations
34
+ ensureGitignoreEntries,
35
+ checkGitignoreEntries,
36
+ // CLAUDE.md operations
37
+ parseClaudeMdSections,
38
+ injectClaudeMdSection,
39
+ removeClaudeMdSection,
40
+ listClaudeMdSections,
41
+ // Convention resolution
42
+ resolvePluginConventions,
43
+ readConventionTemplate,
44
+ // End-to-end convention application
45
+ applyConventions,
46
+ removeConventions,
47
+ // Types
48
+ type ParseResult,
49
+ } from '../services/conventions-manager.js';
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Helpers
53
+ // ---------------------------------------------------------------------------
54
+
55
+ /** Create an isolated temp directory for test operations */
56
+ async function createTempDir(): Promise<string> {
57
+ return mkdtemp(join(tmpdir(), 'conventions-integ-'));
58
+ }
59
+
60
+ /** Create a mock plugin directory with plugin.json and optional template */
61
+ async function createMockPlugin(
62
+ baseDir: string,
63
+ pluginName: string,
64
+ opts: {
65
+ gitignoreProject?: string[];
66
+ gitignoreGlobal?: string[];
67
+ claudemdSection?: string;
68
+ claudemdVersion?: string;
69
+ templateContent?: string;
70
+ },
71
+ ): Promise<string> {
72
+ const pluginDir = join(baseDir, pluginName);
73
+ await mkdir(pluginDir, { recursive: true });
74
+
75
+ const manifest: Record<string, unknown> = {
76
+ name: pluginName,
77
+ version: '1.0.0',
78
+ description: `Test plugin ${pluginName}`,
79
+ };
80
+
81
+ const conventions: Record<string, unknown> = {};
82
+
83
+ if (opts.gitignoreProject || opts.gitignoreGlobal) {
84
+ const gitignore: Record<string, string[]> = {};
85
+ if (opts.gitignoreProject) gitignore.project = opts.gitignoreProject;
86
+ if (opts.gitignoreGlobal) gitignore.global = opts.gitignoreGlobal;
87
+ conventions.gitignore = gitignore;
88
+ }
89
+
90
+ if (opts.claudemdSection && opts.claudemdVersion && opts.templateContent) {
91
+ conventions.claudemd = {
92
+ section: opts.claudemdSection,
93
+ template: './templates/claude-md-conventions.md',
94
+ version: opts.claudemdVersion,
95
+ };
96
+
97
+ // Create the template file
98
+ const templateDir = join(pluginDir, 'templates');
99
+ await mkdir(templateDir, { recursive: true });
100
+ await writeFile(
101
+ join(templateDir, 'claude-md-conventions.md'),
102
+ opts.templateContent,
103
+ 'utf-8',
104
+ );
105
+ }
106
+
107
+ if (Object.keys(conventions).length > 0) {
108
+ manifest.conventions = conventions;
109
+ }
110
+
111
+ await writeFile(
112
+ join(pluginDir, 'plugin.json'),
113
+ JSON.stringify(manifest, null, 2),
114
+ 'utf-8',
115
+ );
116
+
117
+ return pluginDir;
118
+ }
119
+
120
+ /**
121
+ * Simulate what applyConventions SHOULD do, using the working lower-level APIs.
122
+ * This lets us test the convention system end-to-end even though applyConventions
123
+ * has a bug (see IMPLEMENTATION ISSUES above).
124
+ */
125
+ async function applyConventionsManually(
126
+ pluginDir: string,
127
+ projectDir: string,
128
+ ): Promise<{
129
+ gitignore: { added: string[]; modified: boolean };
130
+ claudemd: { action: string; section?: string };
131
+ }> {
132
+ const conventions = await resolvePluginConventions(pluginDir);
133
+ if (!conventions) {
134
+ return { gitignore: { added: [], modified: false }, claudemd: { action: 'skipped' } };
135
+ }
136
+
137
+ let gitignoreResult = { added: [] as string[], modified: false };
138
+ if (conventions.gitignore?.project && conventions.gitignore.project.length > 0) {
139
+ const gitignorePath = join(projectDir, '.gitignore');
140
+ gitignoreResult = await ensureGitignoreEntries(gitignorePath, conventions.gitignore.project);
141
+ }
142
+
143
+ let claudemdResult: { action: string; section?: string } = { action: 'skipped' };
144
+ if (conventions.claudemd) {
145
+ const claudeMdPath = join(projectDir, 'CLAUDE.md');
146
+ const template = await readConventionTemplate(pluginDir, conventions.claudemd.template);
147
+ const injectResult = await injectClaudeMdSection(
148
+ claudeMdPath,
149
+ conventions.claudemd.section,
150
+ conventions.claudemd.version,
151
+ template,
152
+ );
153
+ claudemdResult = { action: injectResult.action, section: conventions.claudemd.section };
154
+ }
155
+
156
+ return { gitignore: gitignoreResult, claudemd: claudemdResult };
157
+ }
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // Tests
161
+ // ---------------------------------------------------------------------------
162
+
163
+ describe('Convention System Integration', () => {
164
+ let tempDir: string;
165
+ let projectDir: string;
166
+
167
+ beforeEach(async () => {
168
+ tempDir = await createTempDir();
169
+ projectDir = join(tempDir, 'project');
170
+ await mkdir(projectDir, { recursive: true });
171
+ });
172
+
173
+ afterEach(async () => {
174
+ if (existsSync(tempDir)) {
175
+ await rm(tempDir, { recursive: true, force: true });
176
+ }
177
+ });
178
+
179
+ // =========================================================================
180
+ // Category 1: End-to-End Convention Lifecycle
181
+ // =========================================================================
182
+
183
+ describe('End-to-End Convention Lifecycle', () => {
184
+ it('TEST-1: install plugin with conventions updates .gitignore', async () => {
185
+ const pluginDir = await createMockPlugin(tempDir, 'test-plugin', {
186
+ gitignoreProject: ['.mnemex/', '.claude/sessions/'],
187
+ claudemdSection: 'test-plugin',
188
+ claudemdVersion: '1.0.0',
189
+ templateContent: '## Test Plugin\n\nSome conventions here.\n',
190
+ });
191
+
192
+ const result = await applyConventionsManually(pluginDir, projectDir);
193
+
194
+ // Gitignore should have been modified
195
+ expect(result.gitignore.modified).toBe(true);
196
+ expect(result.gitignore.added).toContain('.mnemex/');
197
+ expect(result.gitignore.added).toContain('.claude/sessions/');
198
+
199
+ // Verify file on disk
200
+ const gitignorePath = join(projectDir, '.gitignore');
201
+ expect(existsSync(gitignorePath)).toBe(true);
202
+ const gitignoreContent = await readFile(gitignorePath, 'utf-8');
203
+ expect(gitignoreContent).toContain('.mnemex/');
204
+ expect(gitignoreContent).toContain('.claude/sessions/');
205
+ });
206
+
207
+ it('TEST-2: install plugin with conventions injects CLAUDE.md section', async () => {
208
+ const pluginDir = await createMockPlugin(tempDir, 'test-plugin', {
209
+ gitignoreProject: ['.mnemex/'],
210
+ claudemdSection: 'test-plugin',
211
+ claudemdVersion: '1.0.0',
212
+ templateContent: '## Test Plugin Conventions\n\nDo not commit `.mnemex/`.\n',
213
+ });
214
+
215
+ const result = await applyConventionsManually(pluginDir, projectDir);
216
+
217
+ // CLAUDE.md should have been injected
218
+ expect(result.claudemd.action).toBe('inserted');
219
+
220
+ // Verify file on disk
221
+ const claudeMdPath = join(projectDir, 'CLAUDE.md');
222
+ expect(existsSync(claudeMdPath)).toBe(true);
223
+ const claudeMdContent = await readFile(claudeMdPath, 'utf-8');
224
+ expect(claudeMdContent).toContain('<!-- BEGIN magus:test-plugin v1.0.0 -->');
225
+ expect(claudeMdContent).toContain('<!-- END magus:test-plugin -->');
226
+ expect(claudeMdContent).toContain('Test Plugin Conventions');
227
+ expect(claudeMdContent).toContain('Do not commit `.mnemex/`.');
228
+ });
229
+
230
+ it('TEST-3: uninstall removes CLAUDE.md section but keeps gitignore entries', async () => {
231
+ const pluginDir = await createMockPlugin(tempDir, 'test-plugin', {
232
+ gitignoreProject: ['.mnemex/', '.claude/sessions/'],
233
+ claudemdSection: 'test-plugin',
234
+ claudemdVersion: '1.0.0',
235
+ templateContent: '## Test Plugin Conventions\n\nContent here.\n',
236
+ });
237
+
238
+ // Apply conventions manually (since applyConventions has a bug)
239
+ await applyConventionsManually(pluginDir, projectDir);
240
+
241
+ // Verify both files exist
242
+ const gitignorePath = join(projectDir, '.gitignore');
243
+ const claudeMdPath = join(projectDir, 'CLAUDE.md');
244
+ expect(existsSync(gitignorePath)).toBe(true);
245
+ expect(existsSync(claudeMdPath)).toBe(true);
246
+
247
+ // Remove CLAUDE.md section directly (removeConventions has same bug as applyConventions)
248
+ const removeResult = await removeClaudeMdSection(claudeMdPath, 'test-plugin');
249
+ expect(removeResult.removed).toBe(true);
250
+
251
+ // CLAUDE.md section should be gone
252
+ const claudeMdContent = await readFile(claudeMdPath, 'utf-8');
253
+ expect(claudeMdContent).not.toContain('<!-- BEGIN magus:test-plugin');
254
+ expect(claudeMdContent).not.toContain('<!-- END magus:test-plugin -->');
255
+ expect(claudeMdContent).not.toContain('Test Plugin Conventions');
256
+
257
+ // .gitignore entries MUST still be present (by design)
258
+ const gitignoreContent = await readFile(gitignorePath, 'utf-8');
259
+ expect(gitignoreContent).toContain('.mnemex/');
260
+ expect(gitignoreContent).toContain('.claude/sessions/');
261
+ });
262
+ });
263
+
264
+ // =========================================================================
265
+ // Category 2: Idempotency
266
+ // =========================================================================
267
+
268
+ describe('Idempotency', () => {
269
+ it('TEST-4: applying conventions twice produces identical result', async () => {
270
+ const pluginDir = await createMockPlugin(tempDir, 'test-plugin', {
271
+ gitignoreProject: ['.mnemex/'],
272
+ claudemdSection: 'test-plugin',
273
+ claudemdVersion: '1.0.0',
274
+ templateContent: '## Test Conventions\n\nContent.\n',
275
+ });
276
+
277
+ // First apply (using manual helper)
278
+ const result1 = await applyConventionsManually(pluginDir, projectDir);
279
+ expect(result1.gitignore.modified).toBe(true);
280
+ expect(result1.claudemd.action).toBe('inserted');
281
+
282
+ // Snapshot file contents after first apply
283
+ const gitignorePath = join(projectDir, '.gitignore');
284
+ const claudeMdPath = join(projectDir, 'CLAUDE.md');
285
+ const gitignoreAfterFirst = await readFile(gitignorePath, 'utf-8');
286
+ const claudeMdAfterFirst = await readFile(claudeMdPath, 'utf-8');
287
+
288
+ // Second apply
289
+ const result2 = await applyConventionsManually(pluginDir, projectDir);
290
+ expect(result2.gitignore.modified).toBe(false);
291
+ expect(result2.claudemd.action).toBe('unchanged');
292
+
293
+ // File contents must be identical
294
+ const gitignoreAfterSecond = await readFile(gitignorePath, 'utf-8');
295
+ const claudeMdAfterSecond = await readFile(claudeMdPath, 'utf-8');
296
+ expect(gitignoreAfterSecond).toBe(gitignoreAfterFirst);
297
+ expect(claudeMdAfterSecond).toBe(claudeMdAfterFirst);
298
+ });
299
+
300
+ it('TEST-5: ensureGitignoreEntries is idempotent', async () => {
301
+ const gitignorePath = join(projectDir, '.gitignore');
302
+
303
+ // First call
304
+ const result1 = await ensureGitignoreEntries(gitignorePath, ['.mnemex/', '.claudemem/']);
305
+ expect(result1.modified).toBe(true);
306
+ expect(result1.added.length).toBe(2);
307
+
308
+ const contentAfterFirst = await readFile(gitignorePath, 'utf-8');
309
+
310
+ // Second call with same entries
311
+ const result2 = await ensureGitignoreEntries(gitignorePath, ['.mnemex/', '.claudemem/']);
312
+ expect(result2.modified).toBe(false);
313
+ expect(result2.added.length).toBe(0);
314
+
315
+ const contentAfterSecond = await readFile(gitignorePath, 'utf-8');
316
+ expect(contentAfterSecond).toBe(contentAfterFirst);
317
+ });
318
+
319
+ it('TEST-6: injectClaudeMdSection with same version is idempotent', async () => {
320
+ const claudeMdPath = join(projectDir, 'CLAUDE.md');
321
+ const content = '## Test Section\n\nSome content.\n';
322
+
323
+ // First inject
324
+ const result1 = await injectClaudeMdSection(claudeMdPath, 'test-plugin', '1.0.0', content);
325
+ expect(result1.action).toBe('inserted');
326
+
327
+ // Second inject with same version and content
328
+ const result2 = await injectClaudeMdSection(claudeMdPath, 'test-plugin', '1.0.0', content);
329
+ expect(result2.action).toBe('unchanged');
330
+ });
331
+ });
332
+
333
+ // =========================================================================
334
+ // Category 3: Version Upgrade
335
+ // =========================================================================
336
+
337
+ describe('Version Upgrade', () => {
338
+ it('TEST-7: applying v2.0.0 over v1.0.0 updates CLAUDE.md content', async () => {
339
+ const claudeMdPath = join(projectDir, 'CLAUDE.md');
340
+ const v1Content = '## V1 Content\n\nOriginal version.\n';
341
+ const v2Content = '## V2 Content\n\nUpdated version with new features.\n';
342
+
343
+ // Inject v1
344
+ const result1 = await injectClaudeMdSection(claudeMdPath, 'test-plugin', '1.0.0', v1Content);
345
+ expect(result1.action).toBe('inserted');
346
+
347
+ // Inject v2
348
+ const result2 = await injectClaudeMdSection(claudeMdPath, 'test-plugin', '2.0.0', v2Content);
349
+ expect(result2.action).toBe('updated');
350
+ expect(result2.previousVersion).toBe('1.0.0');
351
+ });
352
+
353
+ it('TEST-8: version marker is updated in BEGIN tag after upgrade', async () => {
354
+ const claudeMdPath = join(projectDir, 'CLAUDE.md');
355
+ const v1Content = '## V1 Content\n\nOriginal.\n';
356
+ const v2Content = '## V2 Content\n\nNew stuff.\n';
357
+
358
+ await injectClaudeMdSection(claudeMdPath, 'test-plugin', '1.0.0', v1Content);
359
+ await injectClaudeMdSection(claudeMdPath, 'test-plugin', '2.0.0', v2Content);
360
+
361
+ const fileContent = await readFile(claudeMdPath, 'utf-8');
362
+
363
+ // Should have v2 marker, not v1
364
+ expect(fileContent).toContain('<!-- BEGIN magus:test-plugin v2.0.0 -->');
365
+ expect(fileContent).not.toContain('<!-- BEGIN magus:test-plugin v1.0.0 -->');
366
+
367
+ // Should have v2 content, not v1
368
+ expect(fileContent).toContain('V2 Content');
369
+ expect(fileContent).toContain('New stuff.');
370
+ expect(fileContent).not.toContain('V1 Content');
371
+ expect(fileContent).not.toContain('Original.');
372
+ });
373
+ });
374
+
375
+ // =========================================================================
376
+ // Category 4: Multiple Plugins
377
+ // =========================================================================
378
+
379
+ describe('Multiple Plugins', () => {
380
+ it('TEST-9: two plugins can coexist in CLAUDE.md', async () => {
381
+ const claudeMdPath = join(projectDir, 'CLAUDE.md');
382
+
383
+ const contentA = '## Plugin A Conventions\n\nPlugin A content.\n';
384
+ const contentB = '## Plugin B Conventions\n\nPlugin B content.\n';
385
+
386
+ await injectClaudeMdSection(claudeMdPath, 'plugin-a', '1.0.0', contentA);
387
+ await injectClaudeMdSection(claudeMdPath, 'plugin-b', '1.0.0', contentB);
388
+
389
+ const fileContent = await readFile(claudeMdPath, 'utf-8');
390
+
391
+ // Both sections present
392
+ expect(fileContent).toContain('<!-- BEGIN magus:plugin-a v1.0.0 -->');
393
+ expect(fileContent).toContain('<!-- END magus:plugin-a -->');
394
+ expect(fileContent).toContain('Plugin A content.');
395
+
396
+ expect(fileContent).toContain('<!-- BEGIN magus:plugin-b v1.0.0 -->');
397
+ expect(fileContent).toContain('<!-- END magus:plugin-b -->');
398
+ expect(fileContent).toContain('Plugin B content.');
399
+
400
+ // List should show both
401
+ const sections = await listClaudeMdSections(claudeMdPath);
402
+ expect(sections.length).toBe(2);
403
+ const sectionNames = sections.map((s) => s.section);
404
+ expect(sectionNames).toContain('plugin-a');
405
+ expect(sectionNames).toContain('plugin-b');
406
+ });
407
+
408
+ it('TEST-10: removing one plugin leaves the other intact', async () => {
409
+ const claudeMdPath = join(projectDir, 'CLAUDE.md');
410
+
411
+ const contentA = '## Plugin A Conventions\n\nPlugin A content.\n';
412
+ const contentB = '## Plugin B Conventions\n\nPlugin B content.\n';
413
+
414
+ await injectClaudeMdSection(claudeMdPath, 'plugin-a', '1.0.0', contentA);
415
+ await injectClaudeMdSection(claudeMdPath, 'plugin-b', '1.0.0', contentB);
416
+
417
+ // Remove plugin-a
418
+ const removeResult = await removeClaudeMdSection(claudeMdPath, 'plugin-a');
419
+ expect(removeResult.removed).toBe(true);
420
+
421
+ const fileContent = await readFile(claudeMdPath, 'utf-8');
422
+
423
+ // Plugin A should be gone
424
+ expect(fileContent).not.toContain('<!-- BEGIN magus:plugin-a');
425
+ expect(fileContent).not.toContain('Plugin A content.');
426
+
427
+ // Plugin B should still be there
428
+ expect(fileContent).toContain('<!-- BEGIN magus:plugin-b v1.0.0 -->');
429
+ expect(fileContent).toContain('<!-- END magus:plugin-b -->');
430
+ expect(fileContent).toContain('Plugin B content.');
431
+ });
432
+
433
+ it('TEST-11: two plugins with different gitignore entries both present', async () => {
434
+ const gitignorePath = join(projectDir, '.gitignore');
435
+
436
+ await ensureGitignoreEntries(gitignorePath, ['.mnemex/'], '# Plugin A');
437
+ await ensureGitignoreEntries(gitignorePath, ['.claudemem/'], '# Plugin B');
438
+
439
+ const content = await readFile(gitignorePath, 'utf-8');
440
+ expect(content).toContain('.mnemex/');
441
+ expect(content).toContain('.claudemem/');
442
+
443
+ // No duplicates -- adding same entries again should not duplicate
444
+ await ensureGitignoreEntries(gitignorePath, ['.mnemex/']);
445
+ const contentAfter = await readFile(gitignorePath, 'utf-8');
446
+ const mnemexOccurrences = contentAfter.split('.mnemex/').length - 1;
447
+ expect(mnemexOccurrences).toBe(1);
448
+ });
449
+ });
450
+
451
+ // =========================================================================
452
+ // Category 5: Doctor Detection
453
+ // =========================================================================
454
+
455
+ describe('Doctor Detection', () => {
456
+ // Note: checkGitignoreEntries returns { present: string[], missing: string[] }
457
+ // instead of string[] as documented in the architecture. This is an
458
+ // IMPLEMENTATION_ISSUE (API deviation), but the richer return type is a
459
+ // superset. Tests use .missing property to access the missing entries.
460
+
461
+ it('TEST-12: checkGitignoreEntries detects missing entries', async () => {
462
+ const gitignorePath = join(projectDir, '.gitignore');
463
+ // Create gitignore with only one entry
464
+ await writeFile(gitignorePath, '.mnemex/\n', 'utf-8');
465
+
466
+ const result = await checkGitignoreEntries(gitignorePath, [
467
+ '.mnemex/',
468
+ '.claudemem/',
469
+ '.claude/sessions/',
470
+ ]);
471
+
472
+ // API returns { present, missing } object instead of string[] per contract
473
+ const resultObj = result as unknown as { present: string[]; missing: string[] };
474
+
475
+ expect(resultObj.missing).toContain('.claudemem/');
476
+ expect(resultObj.missing).toContain('.claude/sessions/');
477
+ expect(resultObj.missing).not.toContain('.mnemex/');
478
+ expect(resultObj.missing.length).toBe(2);
479
+
480
+ // Also verify present entries
481
+ expect(resultObj.present).toContain('.mnemex/');
482
+ expect(resultObj.present.length).toBe(1);
483
+ });
484
+
485
+ it('TEST-13: missing CLAUDE.md section is detectable via listClaudeMdSections', async () => {
486
+ const claudeMdPath = join(projectDir, 'CLAUDE.md');
487
+ // Create CLAUDE.md with existing content but no managed sections
488
+ await writeFile(claudeMdPath, '# Project CLAUDE.md\n\nSome existing content.\n', 'utf-8');
489
+
490
+ const sections = await listClaudeMdSections(claudeMdPath);
491
+ expect(sections.length).toBe(0);
492
+ });
493
+
494
+ it('TEST-14: ensureGitignoreEntries fixes missing entries detected by check', async () => {
495
+ const gitignorePath = join(projectDir, '.gitignore');
496
+ await writeFile(gitignorePath, '.mnemex/\n', 'utf-8');
497
+
498
+ // Detect missing using the actual return type
499
+ const checkResult = await checkGitignoreEntries(gitignorePath, [
500
+ '.mnemex/',
501
+ '.claudemem/',
502
+ '.claude/sessions/',
503
+ ]);
504
+ const missing = (checkResult as unknown as { missing: string[] }).missing;
505
+ expect(missing.length).toBe(2);
506
+
507
+ // Fix by adding missing entries
508
+ const fixResult = await ensureGitignoreEntries(gitignorePath, missing);
509
+ expect(fixResult.modified).toBe(true);
510
+ expect(fixResult.added.length).toBe(2);
511
+
512
+ // Re-check: should now all be present
513
+ const recheck = await checkGitignoreEntries(gitignorePath, [
514
+ '.mnemex/',
515
+ '.claudemem/',
516
+ '.claude/sessions/',
517
+ ]);
518
+ const stillMissing = (recheck as unknown as { missing: string[] }).missing;
519
+ expect(stillMissing.length).toBe(0);
520
+ });
521
+ });
522
+
523
+ // =========================================================================
524
+ // Category 6: Malformed CLAUDE.md Handling
525
+ // =========================================================================
526
+
527
+ describe('Malformed CLAUDE.md Handling', () => {
528
+ it('TEST-15: inject refuses when CLAUDE.md has missing END marker', async () => {
529
+ const claudeMdPath = join(projectDir, 'CLAUDE.md');
530
+ // Write a malformed file: BEGIN without END
531
+ const malformedContent = [
532
+ '# Project Notes',
533
+ '',
534
+ '<!-- BEGIN magus:broken-plugin v1.0.0 -->',
535
+ '## Broken Plugin',
536
+ '',
537
+ 'This section has no END marker.',
538
+ '',
539
+ '# More content below',
540
+ '',
541
+ ].join('\n');
542
+ await writeFile(claudeMdPath, malformedContent, 'utf-8');
543
+
544
+ // Attempting to inject a NEW section should be refused
545
+ const result = await injectClaudeMdSection(
546
+ claudeMdPath,
547
+ 'new-plugin',
548
+ '1.0.0',
549
+ '## New Plugin\n\nContent.\n',
550
+ );
551
+ expect(result.action).toBe('error');
552
+ expect(result.error).toBeDefined();
553
+ expect(typeof result.error).toBe('string');
554
+ });
555
+
556
+ it('TEST-16: parse detects duplicate sections', async () => {
557
+ const claudeMdPath = join(projectDir, 'CLAUDE.md');
558
+ const duplicateContent = [
559
+ '# Project',
560
+ '',
561
+ '<!-- BEGIN magus:test-plugin v1.0.0 -->',
562
+ '## First instance',
563
+ '<!-- END magus:test-plugin -->',
564
+ '',
565
+ '<!-- BEGIN magus:test-plugin v1.0.0 -->',
566
+ '## Second instance (duplicate!)',
567
+ '<!-- END magus:test-plugin -->',
568
+ '',
569
+ ].join('\n');
570
+ await writeFile(claudeMdPath, duplicateContent, 'utf-8');
571
+
572
+ const parseResult: ParseResult = await parseClaudeMdSections(claudeMdPath);
573
+ expect(parseResult.errors.length).toBeGreaterThan(0);
574
+
575
+ const duplicateError = parseResult.errors.find(
576
+ (e) => e.type === 'duplicate_section',
577
+ );
578
+ expect(duplicateError).toBeDefined();
579
+ if (duplicateError) {
580
+ expect(duplicateError.section).toBe('test-plugin');
581
+ }
582
+ });
583
+
584
+ it('TEST-17: parse ignores markers inside code blocks', async () => {
585
+ const claudeMdPath = join(projectDir, 'CLAUDE.md');
586
+ const contentWithCodeBlock = [
587
+ '# Documentation',
588
+ '',
589
+ 'Here is an example of markers:',
590
+ '',
591
+ '```markdown',
592
+ '<!-- BEGIN magus:example v1.0.0 -->',
593
+ '## Example section',
594
+ '<!-- END magus:example -->',
595
+ '```',
596
+ '',
597
+ 'The above markers are inside a code block and should be ignored.',
598
+ '',
599
+ ].join('\n');
600
+ await writeFile(claudeMdPath, contentWithCodeBlock, 'utf-8');
601
+
602
+ const parseResult = await parseClaudeMdSections(claudeMdPath);
603
+
604
+ // Markers inside code blocks should NOT be treated as real sections
605
+ const realSections = parseResult.sections.filter(
606
+ (s) => !s.insideCodeBlock,
607
+ );
608
+ expect(realSections.length).toBe(0);
609
+
610
+ // If the parser reports code-block markers, check the error type
611
+ if (parseResult.errors.length > 0) {
612
+ const codeBlockErrors = parseResult.errors.filter(
613
+ (e) => e.type === 'marker_in_code_block',
614
+ );
615
+ // It's valid for the parser to either:
616
+ // 1. Report marker_in_code_block errors, OR
617
+ // 2. Simply ignore them (no sections, no errors)
618
+ // Both behaviors comply with the architecture spec.
619
+ if (codeBlockErrors.length > 0) {
620
+ expect(codeBlockErrors[0].section).toBe('example');
621
+ }
622
+ }
623
+ });
624
+ });
625
+
626
+ // =========================================================================
627
+ // Category 7: Path Classification (Gitignore Entries)
628
+ // =========================================================================
629
+
630
+ describe('Path Classification', () => {
631
+ it('TEST-18: all standard private paths can be added to gitignore', async () => {
632
+ const gitignorePath = join(projectDir, '.gitignore');
633
+ const privatePaths = [
634
+ '.mnemex/',
635
+ '.claudemem/',
636
+ '.claude/sessions/',
637
+ '.claude/worktrees/',
638
+ ];
639
+
640
+ const result = await ensureGitignoreEntries(
641
+ gitignorePath,
642
+ privatePaths,
643
+ '# Claude Code ephemeral/machine-specific data',
644
+ );
645
+
646
+ expect(result.modified).toBe(true);
647
+ expect(result.added.length).toBe(4);
648
+
649
+ const content = await readFile(gitignorePath, 'utf-8');
650
+ for (const path of privatePaths) {
651
+ expect(content).toContain(path);
652
+ }
653
+ });
654
+
655
+ it('TEST-19: checkGitignoreEntries reports correct missing set', async () => {
656
+ const gitignorePath = join(projectDir, '.gitignore');
657
+ // Only .mnemex/ exists
658
+ await writeFile(gitignorePath, '.mnemex/\n', 'utf-8');
659
+
660
+ const allPaths = [
661
+ '.mnemex/',
662
+ '.claudemem/',
663
+ '.claude/sessions/',
664
+ '.claude/worktrees/',
665
+ ];
666
+
667
+ const result = await checkGitignoreEntries(gitignorePath, allPaths);
668
+ // API returns { present, missing } instead of string[] (see IMPLEMENTATION_ISSUE note)
669
+ const resultObj = result as unknown as { present: string[]; missing: string[] };
670
+
671
+ expect(resultObj.missing.length).toBe(3);
672
+ expect(resultObj.missing).toContain('.claudemem/');
673
+ expect(resultObj.missing).toContain('.claude/sessions/');
674
+ expect(resultObj.missing).toContain('.claude/worktrees/');
675
+ expect(resultObj.missing).not.toContain('.mnemex/');
676
+ });
677
+
678
+ it('TEST-20: gitignore entries survive plugin uninstall', async () => {
679
+ const pluginDir = await createMockPlugin(tempDir, 'test-plugin', {
680
+ gitignoreProject: ['.mnemex/', '.claude/sessions/'],
681
+ claudemdSection: 'test-plugin',
682
+ claudemdVersion: '1.0.0',
683
+ templateContent: '## Test\n\nContent.\n',
684
+ });
685
+
686
+ // Apply conventions manually (since applyConventions has a bug)
687
+ await applyConventionsManually(pluginDir, projectDir);
688
+
689
+ const gitignorePath = join(projectDir, '.gitignore');
690
+ const gitignoreBefore = await readFile(gitignorePath, 'utf-8');
691
+ expect(gitignoreBefore).toContain('.mnemex/');
692
+ expect(gitignoreBefore).toContain('.claude/sessions/');
693
+
694
+ // Remove conventions via public API
695
+ await removeConventions('test-plugin', projectDir);
696
+
697
+ // Gitignore entries MUST still be there (by design)
698
+ const gitignoreAfter = await readFile(gitignorePath, 'utf-8');
699
+ expect(gitignoreAfter).toContain('.mnemex/');
700
+ expect(gitignoreAfter).toContain('.claude/sessions/');
701
+ });
702
+ });
703
+
704
+ // =========================================================================
705
+ // IMPLEMENTATION_ISSUE: applyConventions() regression tests
706
+ // These tests SHOULD pass when the bug in applyConventions() is fixed.
707
+ // They currently fail because applyConventions() silently skips all work.
708
+ // =========================================================================
709
+
710
+ describe('applyConventions orchestration (IMPLEMENTATION_ISSUE)', () => {
711
+ it('TEST-1-A: applyConventions should apply gitignore entries', async () => {
712
+ const pluginDir = await createMockPlugin(tempDir, 'apply-test', {
713
+ gitignoreProject: ['.mnemex/', '.claude/sessions/'],
714
+ claudemdSection: 'apply-test',
715
+ claudemdVersion: '1.0.0',
716
+ templateContent: '## Test Plugin\n\nConventions.\n',
717
+ });
718
+
719
+ const result = await applyConventions(pluginDir, projectDir);
720
+ expect(result.gitignore.modified).toBe(true);
721
+ expect(result.gitignore.added).toContain('.mnemex/');
722
+ });
723
+
724
+ it('TEST-2-A: applyConventions should inject CLAUDE.md section', async () => {
725
+ const pluginDir = await createMockPlugin(tempDir, 'apply-test', {
726
+ claudemdSection: 'apply-test',
727
+ claudemdVersion: '1.0.0',
728
+ templateContent: '## Test Plugin\n\nConventions.\n',
729
+ });
730
+
731
+ const result = await applyConventions(pluginDir, projectDir);
732
+ expect(result.claudemd.action).toBe('inserted');
733
+ });
734
+
735
+ it('TEST-3-A: removeConventions should remove CLAUDE.md section', async () => {
736
+ // First create a CLAUDE.md with a section using the working low-level API
737
+ const claudeMdPath = join(projectDir, 'CLAUDE.md');
738
+ await injectClaudeMdSection(claudeMdPath, 'test-plugin', '1.0.0', '## Test\n\nContent.\n');
739
+
740
+ // removeConventions should find and remove the section
741
+ const result = await removeConventions('test-plugin', projectDir);
742
+ expect(result.claudemd.removed).toBe(true);
743
+ });
744
+ });
745
+ });