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,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
+ });