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