claudeup 6.2.1 → 6.3.2

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 (150) hide show
  1. package/README.md +22 -0
  2. package/bin/claudeup.js +6 -1
  3. package/package.json +6 -6
  4. package/src/__tests__/alias-adopt.test.ts +331 -329
  5. package/src/__tests__/alias-parser.test.ts +384 -376
  6. package/src/__tests__/alias-shell-writer.test.ts +638 -625
  7. package/src/__tests__/alias-store.test.ts +143 -143
  8. package/src/__tests__/catalog-cache-store.test.ts +265 -265
  9. package/src/__tests__/catalog-notice.test.ts +123 -123
  10. package/src/__tests__/cli-apply-seams.test.ts +12 -8
  11. package/src/__tests__/cli-router.test.ts +132 -3
  12. package/src/__tests__/cli-update-view.test.ts +27 -8
  13. package/src/__tests__/community-staleness.test.ts +0 -2
  14. package/src/__tests__/component-badge.test.ts +4 -4
  15. package/src/__tests__/content-drift.test.ts +5 -1
  16. package/src/__tests__/conventions-integration.test.ts +774 -689
  17. package/src/__tests__/conventions-manager.test.ts +1216 -1152
  18. package/src/__tests__/doctor-bins.test.ts +14 -6
  19. package/src/__tests__/doctor.test.ts +438 -425
  20. package/src/__tests__/dual-write-prevention.test.ts +277 -282
  21. package/src/__tests__/enabled-not-installed.test.ts +71 -63
  22. package/src/__tests__/file-locking.test.ts +196 -196
  23. package/src/__tests__/gap-fill-versions.test.ts +318 -311
  24. package/src/__tests__/github-budget.test.ts +170 -164
  25. package/src/__tests__/gitignore-detector.test.ts +160 -152
  26. package/src/__tests__/gitignore-fixer.test.ts +285 -247
  27. package/src/__tests__/gitignore-prerun.test.ts +57 -57
  28. package/src/__tests__/gitignore-resolver.test.ts +173 -146
  29. package/src/__tests__/gitignore-service.test.ts +121 -119
  30. package/src/__tests__/install-command.test.ts +3 -3
  31. package/src/__tests__/install-plan.test.ts +13 -7
  32. package/src/__tests__/manifest.test.ts +16 -5
  33. package/src/__tests__/markdown-renderer.test.ts +0 -1
  34. package/src/__tests__/marketplace-badge.test.ts +111 -90
  35. package/src/__tests__/marketplace-version.test.ts +64 -61
  36. package/src/__tests__/plugin-manager-fallback.test.ts +249 -239
  37. package/src/__tests__/plugin-requires.test.ts +18 -4
  38. package/src/__tests__/plugin-setup.test.ts +12 -12
  39. package/src/__tests__/profile-command.test.ts +1 -7
  40. package/src/__tests__/profile-materializer.test.ts +31 -8
  41. package/src/__tests__/profile-sync.test.ts +23 -5
  42. package/src/__tests__/registry-version-resolution.test.ts +61 -47
  43. package/src/__tests__/resolve-executable.test.ts +1 -1
  44. package/src/__tests__/resolver.test.ts +40 -15
  45. package/src/__tests__/scope-action.test.ts +52 -50
  46. package/src/__tests__/scope-squares.test.tsx +4 -5
  47. package/src/__tests__/skill-install-files.test.ts +111 -103
  48. package/src/__tests__/symlink-manager.test.ts +27 -10
  49. package/src/__tests__/theme-env.test.ts +281 -0
  50. package/src/__tests__/theme-resolve.test.ts +380 -0
  51. package/src/__tests__/update-apply.test.ts +1 -3
  52. package/src/__tests__/useGitignoreModal.test.ts +69 -61
  53. package/src/__tests__/version-snapshot.test.ts +181 -165
  54. package/src/cli/ansi.ts +23 -15
  55. package/src/cli/doctor.ts +10 -9
  56. package/src/cli/install.ts +3 -3
  57. package/src/cli/profile.ts +5 -4
  58. package/src/cli/prompt.ts +0 -1
  59. package/src/cli/router.ts +69 -2
  60. package/src/cli/update-view.ts +11 -6
  61. package/src/cli/update.ts +9 -4
  62. package/src/cli/upgrade.ts +6 -4
  63. package/src/data/alias-flags.ts +260 -260
  64. package/src/data/gitignore-defaults.ts +20 -20
  65. package/src/data/gitignore-reasons.ts +86 -92
  66. package/src/data/gitignore-templates.ts +14 -14
  67. package/src/data/predefined-profiles.ts +8 -9
  68. package/src/data/skill-repos.ts +43 -35
  69. package/src/main.tsx +29 -6
  70. package/src/opentui.d.ts +1 -3
  71. package/src/prerunner/index.ts +7 -8
  72. package/src/services/alias-settings.ts +20 -23
  73. package/src/services/alias-shell-writer.ts +800 -803
  74. package/src/services/alias-store.ts +77 -77
  75. package/src/services/claude-settings.ts +27 -18
  76. package/src/services/content-drift.ts +13 -3
  77. package/src/services/conventions-manager.ts +692 -617
  78. package/src/services/doctor.ts +464 -403
  79. package/src/services/gitignore-detector.ts +113 -103
  80. package/src/services/gitignore-fixer.ts +204 -189
  81. package/src/services/gitignore-prerun.ts +26 -26
  82. package/src/services/gitignore-resolver.ts +162 -144
  83. package/src/services/gitignore-service.ts +120 -117
  84. package/src/services/install-plan.ts +4 -4
  85. package/src/services/manifest.ts +1 -1
  86. package/src/services/marketplace-catalog-git.ts +5 -2
  87. package/src/services/marketplace-fetcher.ts +241 -235
  88. package/src/services/plugin-manager.ts +17 -20
  89. package/src/services/plugin-mcp-config.ts +2 -2
  90. package/src/services/plugin-requires.ts +9 -6
  91. package/src/services/plugin-setup.ts +7 -9
  92. package/src/services/plugin-version-check.ts +4 -4
  93. package/src/services/profile-materializer.ts +9 -5
  94. package/src/services/profile-sync.ts +12 -4
  95. package/src/services/profiles.ts +3 -3
  96. package/src/services/resolver.ts +10 -6
  97. package/src/services/settings-manager.ts +82 -46
  98. package/src/services/skills-manager.ts +11 -9
  99. package/src/services/symlink-manager.ts +2 -5
  100. package/src/services/toolchain.ts +3 -3
  101. package/src/services/update-cache.ts +1 -1
  102. package/src/services/update-engine.ts +4 -4
  103. package/src/services/update-plan.ts +9 -11
  104. package/src/services/version-snapshot.ts +88 -88
  105. package/src/types/gitignore.ts +28 -28
  106. package/src/ui/App.tsx +36 -36
  107. package/src/ui/adapters/settingsAdapter.ts +2 -2
  108. package/src/ui/components/CategoryHeader.tsx +1 -1
  109. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  110. package/src/ui/components/ScopeIndicator.tsx +3 -1
  111. package/src/ui/components/ScrollableList.tsx +2 -1
  112. package/src/ui/components/StyledText.tsx +1 -1
  113. package/src/ui/components/layout/FooterHints.tsx +5 -3
  114. package/src/ui/components/layout/Panel.tsx +2 -2
  115. package/src/ui/components/layout/ScreenLayout.tsx +5 -4
  116. package/src/ui/components/modals/InputModal.tsx +7 -1
  117. package/src/ui/components/modals/ModalContainer.tsx +3 -3
  118. package/src/ui/components/modals/SelectModal.tsx +1 -1
  119. package/src/ui/components/primitives/DetailSection.tsx +1 -1
  120. package/src/ui/components/primitives/KeyValueLine.tsx +1 -1
  121. package/src/ui/components/primitives/ScopeSquares.tsx +2 -2
  122. package/src/ui/hooks/index.ts +0 -1
  123. package/src/ui/hooks/useGitignoreModal.ts +1 -5
  124. package/src/ui/hooks/useMismatchModal.ts +4 -10
  125. package/src/ui/renderers/cliToolRenderers.tsx +2 -2
  126. package/src/ui/renderers/gitignoreRenderers.tsx +2 -2
  127. package/src/ui/renderers/mcpRenderers.tsx +1 -1
  128. package/src/ui/renderers/pluginRenderers.tsx +3 -9
  129. package/src/ui/renderers/profileRenderers.tsx +9 -8
  130. package/src/ui/renderers/settingsRenderers.tsx +4 -4
  131. package/src/ui/renderers/skillRenderers.tsx +14 -14
  132. package/src/ui/screens/AliasScreen.tsx +31 -34
  133. package/src/ui/screens/CliToolsScreen.tsx +14 -13
  134. package/src/ui/screens/EnvVarsScreen.tsx +8 -8
  135. package/src/ui/screens/GitignoreScreen.tsx +15 -14
  136. package/src/ui/screens/McpRegistryScreen.tsx +10 -7
  137. package/src/ui/screens/McpScreen.tsx +10 -10
  138. package/src/ui/screens/PluginsScreen.tsx +17 -21
  139. package/src/ui/screens/ProfilesScreen.tsx +25 -24
  140. package/src/ui/screens/SkillsScreen.tsx +16 -14
  141. package/src/ui/state/AppContext.tsx +4 -8
  142. package/src/ui/state/DimensionsContext.tsx +3 -2
  143. package/src/ui/theme-mode.ts +20 -38
  144. package/src/ui/theme-resolve.ts +148 -0
  145. package/src/utils/clipboard.ts +5 -5
  146. package/src/utils/command-utils.ts +2 -2
  147. package/src/utils/config-dir.ts +1 -3
  148. package/src/utils/file-locking.ts +88 -86
  149. package/src/ui/hooks/useAsyncData.ts +0 -127
  150. package/src/ui/state/AnimationContext.tsx +0 -76
@@ -10,13 +10,13 @@
10
10
  * with real plugin.json files. runDoctor is tested by mocking the registry.
11
11
  */
12
12
 
13
- import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
14
- import { mkdtemp, rm, writeFile, readFile, mkdir } from 'node:fs/promises';
15
- import { existsSync } from 'node:fs';
16
- import { tmpdir } from 'node:os';
17
- import { join } from 'node:path';
18
- import { checkProject, fixDoctorIssues } from '../services/doctor.js';
19
- import type { DoctorResult } from '../services/doctor.js';
13
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
14
+ import { existsSync } from "node:fs";
15
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+ import { checkProject, fixDoctorIssues } from "../services/doctor.js";
19
+ import type { DoctorResult } from "../services/doctor.js";
20
20
 
21
21
  // ============================================================
22
22
  // HELPERS
@@ -26,425 +26,438 @@ import type { DoctorResult } from '../services/doctor.js';
26
26
  * Create a minimal plugin directory with plugin.json and optional template.
27
27
  */
28
28
  async function createPluginDir(
29
- baseDir: string,
30
- pluginName: string,
31
- conventions?: {
32
- gitignore?: { project?: string[] };
33
- claudemd?: { section: string; template: string; version: string };
34
- },
35
- templateContent?: string,
29
+ baseDir: string,
30
+ pluginName: string,
31
+ conventions?: {
32
+ gitignore?: { project?: string[] };
33
+ claudemd?: { section: string; template: string; version: string };
34
+ },
35
+ templateContent?: string,
36
36
  ): Promise<string> {
37
- const pluginDir = join(baseDir, pluginName);
38
- await mkdir(pluginDir, { recursive: true });
39
-
40
- const manifest: Record<string, unknown> = {
41
- name: pluginName,
42
- version: '1.0.0',
43
- description: `Test plugin ${pluginName}`,
44
- };
45
-
46
- if (conventions) {
47
- manifest.conventions = conventions;
48
- }
49
-
50
- await writeFile(join(pluginDir, 'plugin.json'), JSON.stringify(manifest, null, 2));
51
-
52
- if (templateContent && conventions?.claudemd?.template) {
53
- const templateDir = join(pluginDir, 'templates');
54
- await mkdir(templateDir, { recursive: true });
55
- await writeFile(join(templateDir, 'claude-md-conventions.md'), templateContent);
56
- }
57
-
58
- return pluginDir;
37
+ const pluginDir = join(baseDir, pluginName);
38
+ await mkdir(pluginDir, { recursive: true });
39
+
40
+ const manifest: Record<string, unknown> = {
41
+ name: pluginName,
42
+ version: "1.0.0",
43
+ description: `Test plugin ${pluginName}`,
44
+ };
45
+
46
+ if (conventions) {
47
+ manifest.conventions = conventions;
48
+ }
49
+
50
+ await writeFile(
51
+ join(pluginDir, "plugin.json"),
52
+ JSON.stringify(manifest, null, 2),
53
+ );
54
+
55
+ if (templateContent && conventions?.claudemd?.template) {
56
+ const templateDir = join(pluginDir, "templates");
57
+ await mkdir(templateDir, { recursive: true });
58
+ await writeFile(
59
+ join(templateDir, "claude-md-conventions.md"),
60
+ templateContent,
61
+ );
62
+ }
63
+
64
+ return pluginDir;
59
65
  }
60
66
 
61
- describe('doctor', () => {
62
- let testDir: string;
63
- let projectDir: string;
64
- let pluginsDir: string;
65
-
66
- beforeEach(async () => {
67
- testDir = await mkdtemp(join(tmpdir(), 'doctor-test-'));
68
- projectDir = join(testDir, 'project');
69
- pluginsDir = join(testDir, 'plugins');
70
- await mkdir(projectDir, { recursive: true });
71
- await mkdir(pluginsDir, { recursive: true });
72
- });
73
-
74
- afterEach(async () => {
75
- await rm(testDir, { recursive: true, force: true });
76
- });
77
-
78
- // ============================================================
79
- // checkProject
80
- // ============================================================
81
-
82
- describe('checkProject', () => {
83
- it('should report fail when project path does not exist', async () => {
84
- const result = await checkProject('/nonexistent/path/that/does/not/exist', ['some-plugin@magus']);
85
-
86
- expect(result.projectPath).toBe('/nonexistent/path/that/does/not/exist');
87
- expect(result.checks).toHaveLength(1);
88
- expect(result.checks[0].name).toBe('project-accessible');
89
- expect(result.checks[0].status).toBe('fail');
90
- expect(result.checks[0].fixable).toBe(false);
91
- });
92
-
93
- it('should return empty checks when plugins have no conventions', async () => {
94
- // checkProject with pluginIds that cannot be resolved will produce no convention checks
95
- // (since resolvePluginPath returns null for unknown IDs)
96
- const result = await checkProject(projectDir, ['nonexistent-plugin@nonexistent-marketplace']);
97
-
98
- // No convention checks should be generated since the plugin could not be resolved
99
- const conventionChecks = result.checks.filter(
100
- (c) => c.name !== 'project-accessible',
101
- );
102
- expect(conventionChecks).toHaveLength(0);
103
- });
104
- });
105
-
106
- // ============================================================
107
- // checkProject with real conventions (using checkProject directly with
108
- // pre-setup gitignore/CLAUDE.md, bypassing plugin resolution)
109
- // ============================================================
110
-
111
- describe('checkProject gitignore checks', () => {
112
- it('should pass when gitignore has all required entries', async () => {
113
- // Since checkProject resolves plugins via marketplace, we test the
114
- // underlying behavior by checking project files directly.
115
- // For unit testing, we verify checkProject handles the missing-plugin case gracefully.
116
- const gitignorePath = join(projectDir, '.gitignore');
117
- await writeFile(gitignorePath, 'node_modules/\n.mnemex/\n');
118
-
119
- // With no resolvable plugins, no gitignore checks are generated
120
- const result = await checkProject(projectDir, []);
121
- expect(result.checks).toHaveLength(0);
122
- });
123
- });
124
-
125
- describe('checkProject CLAUDE.md checks', () => {
126
- it('should detect malformed CLAUDE.md markers', async () => {
127
- const claudeMdPath = join(projectDir, 'CLAUDE.md');
128
- // Write CLAUDE.md with unclosed marker
129
- await writeFile(
130
- claudeMdPath,
131
- '# Project\n\n<!-- BEGIN magus:test-plugin v1.0.0 -->\nSome content\n',
132
- );
133
-
134
- // checkProject won't find the marker issue unless it resolves plugins.
135
- // But the parse is done on the file itself. Let's verify via parseClaudeMdSections directly.
136
- const { parseClaudeMdSections } = await import('../services/conventions-manager.js');
137
- const parseResult = await parseClaudeMdSections(claudeMdPath);
138
- expect(parseResult.errors).toHaveLength(1);
139
- expect(parseResult.errors[0].type).toBe('missing_end');
140
- });
141
- });
142
-
143
- // ============================================================
144
- // runDoctor with no registered projects
145
- // ============================================================
146
-
147
- describe('runDoctor', () => {
148
- it('should return empty results when no projects are registered', async () => {
149
- // Mock the registry to return empty
150
- const doctorModule = await import('../services/doctor.js');
151
-
152
- // We can't easily mock the internal collectProjects, but we can
153
- // test via the exported runDoctor if the registry is empty.
154
- // In practice, runDoctor reads the real registry, so we test the
155
- // output shape is correct.
156
- const { runDoctor } = doctorModule;
157
- const result = await runDoctor();
158
-
159
- // The result shape should be valid even if we have real projects
160
- expect(result).toHaveProperty('projects');
161
- expect(result).toHaveProperty('summary');
162
- expect(result.summary).toHaveProperty('totalProjects');
163
- expect(result.summary).toHaveProperty('totalChecks');
164
- expect(result.summary).toHaveProperty('passed');
165
- expect(result.summary).toHaveProperty('warnings');
166
- expect(result.summary).toHaveProperty('failures');
167
- expect(result.summary).toHaveProperty('fixable');
168
- expect(typeof result.summary.totalProjects).toBe('number');
169
- });
170
-
171
- it('should compute summary counts correctly', async () => {
172
- // Build a DoctorResult manually and verify summary computation
173
- // This tests the summary logic without needing real plugin resolution
174
- const { runDoctor } = await import('../services/doctor.js');
175
- const result = await runDoctor();
176
-
177
- // Verify summary math
178
- const { summary, projects } = result;
179
- let expectedChecks = 0;
180
- let expectedPassed = 0;
181
- let expectedWarnings = 0;
182
- let expectedFailures = 0;
183
- let expectedFixable = 0;
184
-
185
- for (const project of projects) {
186
- for (const check of project.checks) {
187
- expectedChecks++;
188
- if (check.status === 'pass') expectedPassed++;
189
- else if (check.status === 'warn') expectedWarnings++;
190
- else if (check.status === 'fail') expectedFailures++;
191
- if (check.fixable) expectedFixable++;
192
- }
193
- }
194
-
195
- expect(summary.totalChecks).toBe(expectedChecks);
196
- expect(summary.passed).toBe(expectedPassed);
197
- expect(summary.warnings).toBe(expectedWarnings);
198
- expect(summary.failures).toBe(expectedFailures);
199
- expect(summary.fixable).toBe(expectedFixable);
200
- expect(summary.totalProjects).toBe(projects.length);
201
- });
202
- });
203
-
204
- // ============================================================
205
- // fixDoctorIssues
206
- // ============================================================
207
-
208
- describe('fixDoctorIssues', () => {
209
- it('should add missing gitignore entries', async () => {
210
- const gitignorePath = join(projectDir, '.gitignore');
211
- await writeFile(gitignorePath, 'node_modules/\n');
212
-
213
- const doctorResult: DoctorResult = {
214
- projects: [
215
- {
216
- projectPath: projectDir,
217
- plugins: ['test-plugin@magus'],
218
- checks: [
219
- {
220
- name: 'gitignore-entries',
221
- status: 'fail',
222
- message: '.gitignore missing: .mnemex/',
223
- fixable: true,
224
- _fixData: {
225
- type: 'add-gitignore-entries',
226
- gitignorePath,
227
- entries: ['.mnemex/'],
228
- },
229
- },
230
- ],
231
- },
232
- ],
233
- summary: {
234
- totalProjects: 1,
235
- totalChecks: 1,
236
- passed: 0,
237
- warnings: 0,
238
- failures: 1,
239
- fixable: 1,
240
- },
241
- };
242
-
243
- const fixResult = await fixDoctorIssues(doctorResult);
244
-
245
- expect(fixResult.fixed).toBe(1);
246
- expect(fixResult.failed).toBe(0);
247
-
248
- const content = await readFile(gitignorePath, 'utf-8');
249
- expect(content).toContain('.mnemex/');
250
- expect(content).toContain('node_modules/');
251
- });
252
-
253
- it('should inject missing CLAUDE.md sections', async () => {
254
- const claudeMdPath = join(projectDir, 'CLAUDE.md');
255
- await writeFile(claudeMdPath, '# My Project\n\nSome existing content.\n');
256
-
257
- // Create a plugin dir with template
258
- const pluginDir = await createPluginDir(
259
- pluginsDir,
260
- 'test-plugin',
261
- {
262
- claudemd: {
263
- section: 'test-plugin',
264
- template: './templates/claude-md-conventions.md',
265
- version: '1.0.0',
266
- },
267
- },
268
- '## Test Plugin\n\nDo not commit .test-data/ to git.',
269
- );
270
-
271
- const doctorResult: DoctorResult = {
272
- projects: [
273
- {
274
- projectPath: projectDir,
275
- plugins: ['test-plugin@magus'],
276
- checks: [
277
- {
278
- name: 'claudemd-section-test-plugin',
279
- status: 'warn',
280
- message: 'CLAUDE.md missing test-plugin conventions',
281
- fixable: true,
282
- _fixData: {
283
- type: 'inject-section',
284
- claudeMdPath,
285
- pluginName: 'test-plugin',
286
- pluginPath: pluginDir,
287
- conventions: {
288
- claudemd: {
289
- section: 'test-plugin',
290
- template: './templates/claude-md-conventions.md',
291
- version: '1.0.0',
292
- },
293
- },
294
- },
295
- },
296
- ],
297
- },
298
- ],
299
- summary: {
300
- totalProjects: 1,
301
- totalChecks: 1,
302
- passed: 0,
303
- warnings: 1,
304
- failures: 0,
305
- fixable: 1,
306
- },
307
- };
308
-
309
- const fixResult = await fixDoctorIssues(doctorResult);
310
-
311
- expect(fixResult.fixed).toBe(1);
312
- expect(fixResult.failed).toBe(0);
313
-
314
- const content = await readFile(claudeMdPath, 'utf-8');
315
- expect(content).toContain('<!-- BEGIN magus:test-plugin v1.0.0 -->');
316
- expect(content).toContain('Do not commit .test-data/ to git.');
317
- expect(content).toContain('<!-- END magus:test-plugin -->');
318
- expect(content).toContain('# My Project');
319
- });
320
-
321
- it('should create gitignore when project has none', async () => {
322
- const gitignorePath = join(projectDir, '.gitignore');
323
- expect(existsSync(gitignorePath)).toBe(false);
324
-
325
- const doctorResult: DoctorResult = {
326
- projects: [
327
- {
328
- projectPath: projectDir,
329
- plugins: ['test-plugin@magus'],
330
- checks: [
331
- {
332
- name: 'gitignore-exists',
333
- status: 'warn',
334
- message: '.gitignore does not exist',
335
- fixable: true,
336
- _fixData: {
337
- type: 'create-gitignore',
338
- projectPath: projectDir,
339
- entries: ['.mnemex/', '.claudemem/'],
340
- },
341
- },
342
- ],
343
- },
344
- ],
345
- summary: {
346
- totalProjects: 1,
347
- totalChecks: 1,
348
- passed: 0,
349
- warnings: 1,
350
- failures: 0,
351
- fixable: 1,
352
- },
353
- };
354
-
355
- const fixResult = await fixDoctorIssues(doctorResult);
356
-
357
- expect(fixResult.fixed).toBe(1);
358
- expect(existsSync(gitignorePath)).toBe(true);
359
- const content = await readFile(gitignorePath, 'utf-8');
360
- expect(content).toContain('.mnemex/');
361
- expect(content).toContain('.claudemem/');
362
- });
363
-
364
- it('should skip unfixable issues', async () => {
365
- const doctorResult: DoctorResult = {
366
- projects: [
367
- {
368
- projectPath: '/nonexistent/path',
369
- plugins: ['some-plugin@magus'],
370
- checks: [
371
- {
372
- name: 'project-accessible',
373
- status: 'fail',
374
- message: 'Project path does not exist',
375
- fixable: false,
376
- },
377
- ],
378
- },
379
- ],
380
- summary: {
381
- totalProjects: 1,
382
- totalChecks: 1,
383
- passed: 0,
384
- warnings: 0,
385
- failures: 1,
386
- fixable: 0,
387
- },
388
- };
389
-
390
- const fixResult = await fixDoctorIssues(doctorResult);
391
-
392
- expect(fixResult.fixed).toBe(0);
393
- expect(fixResult.failed).toBe(0);
394
- });
395
-
396
- it('should report fix count correctly with mixed results', async () => {
397
- const gitignorePath = join(projectDir, '.gitignore');
398
- await writeFile(gitignorePath, 'node_modules/\n');
399
-
400
- const doctorResult: DoctorResult = {
401
- projects: [
402
- {
403
- projectPath: projectDir,
404
- plugins: ['test-plugin@magus'],
405
- checks: [
406
- {
407
- name: 'gitignore-entries',
408
- status: 'fail',
409
- message: '.gitignore missing: .mnemex/',
410
- fixable: true,
411
- _fixData: {
412
- type: 'add-gitignore-entries',
413
- gitignorePath,
414
- entries: ['.mnemex/'],
415
- },
416
- },
417
- {
418
- name: 'project-accessible',
419
- status: 'fail',
420
- message: 'Project path does not exist',
421
- fixable: false,
422
- },
423
- {
424
- name: 'claudemd-section-test-plugin',
425
- status: 'warn',
426
- message: 'CLAUDE.md missing test-plugin conventions',
427
- fixable: true,
428
- // Missing _fixData -- should count as failed
429
- },
430
- ],
431
- },
432
- ],
433
- summary: {
434
- totalProjects: 1,
435
- totalChecks: 3,
436
- passed: 0,
437
- warnings: 1,
438
- failures: 2,
439
- fixable: 2,
440
- },
441
- };
442
-
443
- const fixResult = await fixDoctorIssues(doctorResult);
444
-
445
- // 1 fixed (gitignore), 1 failed (missing _fixData), 1 skipped (not fixable)
446
- expect(fixResult.fixed).toBe(1);
447
- expect(fixResult.failed).toBe(1);
448
- });
449
- });
67
+ describe("doctor", () => {
68
+ let testDir: string;
69
+ let projectDir: string;
70
+ let pluginsDir: string;
71
+
72
+ beforeEach(async () => {
73
+ testDir = await mkdtemp(join(tmpdir(), "doctor-test-"));
74
+ projectDir = join(testDir, "project");
75
+ pluginsDir = join(testDir, "plugins");
76
+ await mkdir(projectDir, { recursive: true });
77
+ await mkdir(pluginsDir, { recursive: true });
78
+ });
79
+
80
+ afterEach(async () => {
81
+ await rm(testDir, { recursive: true, force: true });
82
+ });
83
+
84
+ // ============================================================
85
+ // checkProject
86
+ // ============================================================
87
+
88
+ describe("checkProject", () => {
89
+ it("should report fail when project path does not exist", async () => {
90
+ const result = await checkProject(
91
+ "/nonexistent/path/that/does/not/exist",
92
+ ["some-plugin@magus"],
93
+ );
94
+
95
+ expect(result.projectPath).toBe("/nonexistent/path/that/does/not/exist");
96
+ expect(result.checks).toHaveLength(1);
97
+ expect(result.checks[0].name).toBe("project-accessible");
98
+ expect(result.checks[0].status).toBe("fail");
99
+ expect(result.checks[0].fixable).toBe(false);
100
+ });
101
+
102
+ it("should return empty checks when plugins have no conventions", async () => {
103
+ // checkProject with pluginIds that cannot be resolved will produce no convention checks
104
+ // (since resolvePluginPath returns null for unknown IDs)
105
+ const result = await checkProject(projectDir, [
106
+ "nonexistent-plugin@nonexistent-marketplace",
107
+ ]);
108
+
109
+ // No convention checks should be generated since the plugin could not be resolved
110
+ const conventionChecks = result.checks.filter(
111
+ (c) => c.name !== "project-accessible",
112
+ );
113
+ expect(conventionChecks).toHaveLength(0);
114
+ });
115
+ });
116
+
117
+ // ============================================================
118
+ // checkProject with real conventions (using checkProject directly with
119
+ // pre-setup gitignore/CLAUDE.md, bypassing plugin resolution)
120
+ // ============================================================
121
+
122
+ describe("checkProject gitignore checks", () => {
123
+ it("should pass when gitignore has all required entries", async () => {
124
+ // Since checkProject resolves plugins via marketplace, we test the
125
+ // underlying behavior by checking project files directly.
126
+ // For unit testing, we verify checkProject handles the missing-plugin case gracefully.
127
+ const gitignorePath = join(projectDir, ".gitignore");
128
+ await writeFile(gitignorePath, "node_modules/\n.mnemex/\n");
129
+
130
+ // With no resolvable plugins, no gitignore checks are generated
131
+ const result = await checkProject(projectDir, []);
132
+ expect(result.checks).toHaveLength(0);
133
+ });
134
+ });
135
+
136
+ describe("checkProject CLAUDE.md checks", () => {
137
+ it("should detect malformed CLAUDE.md markers", async () => {
138
+ const claudeMdPath = join(projectDir, "CLAUDE.md");
139
+ // Write CLAUDE.md with unclosed marker
140
+ await writeFile(
141
+ claudeMdPath,
142
+ "# Project\n\n<!-- BEGIN magus:test-plugin v1.0.0 -->\nSome content\n",
143
+ );
144
+
145
+ // checkProject won't find the marker issue unless it resolves plugins.
146
+ // But the parse is done on the file itself. Let's verify via parseClaudeMdSections directly.
147
+ const { parseClaudeMdSections } = await import(
148
+ "../services/conventions-manager.js"
149
+ );
150
+ const parseResult = await parseClaudeMdSections(claudeMdPath);
151
+ expect(parseResult.errors).toHaveLength(1);
152
+ expect(parseResult.errors[0].type).toBe("missing_end");
153
+ });
154
+ });
155
+
156
+ // ============================================================
157
+ // runDoctor with no registered projects
158
+ // ============================================================
159
+
160
+ describe("runDoctor", () => {
161
+ it("should return empty results when no projects are registered", async () => {
162
+ // Mock the registry to return empty
163
+ const doctorModule = await import("../services/doctor.js");
164
+
165
+ // We can't easily mock the internal collectProjects, but we can
166
+ // test via the exported runDoctor if the registry is empty.
167
+ // In practice, runDoctor reads the real registry, so we test the
168
+ // output shape is correct.
169
+ const { runDoctor } = doctorModule;
170
+ const result = await runDoctor();
171
+
172
+ // The result shape should be valid even if we have real projects
173
+ expect(result).toHaveProperty("projects");
174
+ expect(result).toHaveProperty("summary");
175
+ expect(result.summary).toHaveProperty("totalProjects");
176
+ expect(result.summary).toHaveProperty("totalChecks");
177
+ expect(result.summary).toHaveProperty("passed");
178
+ expect(result.summary).toHaveProperty("warnings");
179
+ expect(result.summary).toHaveProperty("failures");
180
+ expect(result.summary).toHaveProperty("fixable");
181
+ expect(typeof result.summary.totalProjects).toBe("number");
182
+ });
183
+
184
+ it("should compute summary counts correctly", async () => {
185
+ // Build a DoctorResult manually and verify summary computation
186
+ // This tests the summary logic without needing real plugin resolution
187
+ const { runDoctor } = await import("../services/doctor.js");
188
+ const result = await runDoctor();
189
+
190
+ // Verify summary math
191
+ const { summary, projects } = result;
192
+ let expectedChecks = 0;
193
+ let expectedPassed = 0;
194
+ let expectedWarnings = 0;
195
+ let expectedFailures = 0;
196
+ let expectedFixable = 0;
197
+
198
+ for (const project of projects) {
199
+ for (const check of project.checks) {
200
+ expectedChecks++;
201
+ if (check.status === "pass") expectedPassed++;
202
+ else if (check.status === "warn") expectedWarnings++;
203
+ else if (check.status === "fail") expectedFailures++;
204
+ if (check.fixable) expectedFixable++;
205
+ }
206
+ }
207
+
208
+ expect(summary.totalChecks).toBe(expectedChecks);
209
+ expect(summary.passed).toBe(expectedPassed);
210
+ expect(summary.warnings).toBe(expectedWarnings);
211
+ expect(summary.failures).toBe(expectedFailures);
212
+ expect(summary.fixable).toBe(expectedFixable);
213
+ expect(summary.totalProjects).toBe(projects.length);
214
+ });
215
+ });
216
+
217
+ // ============================================================
218
+ // fixDoctorIssues
219
+ // ============================================================
220
+
221
+ describe("fixDoctorIssues", () => {
222
+ it("should add missing gitignore entries", async () => {
223
+ const gitignorePath = join(projectDir, ".gitignore");
224
+ await writeFile(gitignorePath, "node_modules/\n");
225
+
226
+ const doctorResult: DoctorResult = {
227
+ projects: [
228
+ {
229
+ projectPath: projectDir,
230
+ plugins: ["test-plugin@magus"],
231
+ checks: [
232
+ {
233
+ name: "gitignore-entries",
234
+ status: "fail",
235
+ message: ".gitignore missing: .mnemex/",
236
+ fixable: true,
237
+ _fixData: {
238
+ type: "add-gitignore-entries",
239
+ gitignorePath,
240
+ entries: [".mnemex/"],
241
+ },
242
+ },
243
+ ],
244
+ },
245
+ ],
246
+ summary: {
247
+ totalProjects: 1,
248
+ totalChecks: 1,
249
+ passed: 0,
250
+ warnings: 0,
251
+ failures: 1,
252
+ fixable: 1,
253
+ },
254
+ };
255
+
256
+ const fixResult = await fixDoctorIssues(doctorResult);
257
+
258
+ expect(fixResult.fixed).toBe(1);
259
+ expect(fixResult.failed).toBe(0);
260
+
261
+ const content = await readFile(gitignorePath, "utf-8");
262
+ expect(content).toContain(".mnemex/");
263
+ expect(content).toContain("node_modules/");
264
+ });
265
+
266
+ it("should inject missing CLAUDE.md sections", async () => {
267
+ const claudeMdPath = join(projectDir, "CLAUDE.md");
268
+ await writeFile(claudeMdPath, "# My Project\n\nSome existing content.\n");
269
+
270
+ // Create a plugin dir with template
271
+ const pluginDir = await createPluginDir(
272
+ pluginsDir,
273
+ "test-plugin",
274
+ {
275
+ claudemd: {
276
+ section: "test-plugin",
277
+ template: "./templates/claude-md-conventions.md",
278
+ version: "1.0.0",
279
+ },
280
+ },
281
+ "## Test Plugin\n\nDo not commit .test-data/ to git.",
282
+ );
283
+
284
+ const doctorResult: DoctorResult = {
285
+ projects: [
286
+ {
287
+ projectPath: projectDir,
288
+ plugins: ["test-plugin@magus"],
289
+ checks: [
290
+ {
291
+ name: "claudemd-section-test-plugin",
292
+ status: "warn",
293
+ message: "CLAUDE.md missing test-plugin conventions",
294
+ fixable: true,
295
+ _fixData: {
296
+ type: "inject-section",
297
+ claudeMdPath,
298
+ pluginName: "test-plugin",
299
+ pluginPath: pluginDir,
300
+ conventions: {
301
+ claudemd: {
302
+ section: "test-plugin",
303
+ template: "./templates/claude-md-conventions.md",
304
+ version: "1.0.0",
305
+ },
306
+ },
307
+ },
308
+ },
309
+ ],
310
+ },
311
+ ],
312
+ summary: {
313
+ totalProjects: 1,
314
+ totalChecks: 1,
315
+ passed: 0,
316
+ warnings: 1,
317
+ failures: 0,
318
+ fixable: 1,
319
+ },
320
+ };
321
+
322
+ const fixResult = await fixDoctorIssues(doctorResult);
323
+
324
+ expect(fixResult.fixed).toBe(1);
325
+ expect(fixResult.failed).toBe(0);
326
+
327
+ const content = await readFile(claudeMdPath, "utf-8");
328
+ expect(content).toContain("<!-- BEGIN magus:test-plugin v1.0.0 -->");
329
+ expect(content).toContain("Do not commit .test-data/ to git.");
330
+ expect(content).toContain("<!-- END magus:test-plugin -->");
331
+ expect(content).toContain("# My Project");
332
+ });
333
+
334
+ it("should create gitignore when project has none", async () => {
335
+ const gitignorePath = join(projectDir, ".gitignore");
336
+ expect(existsSync(gitignorePath)).toBe(false);
337
+
338
+ const doctorResult: DoctorResult = {
339
+ projects: [
340
+ {
341
+ projectPath: projectDir,
342
+ plugins: ["test-plugin@magus"],
343
+ checks: [
344
+ {
345
+ name: "gitignore-exists",
346
+ status: "warn",
347
+ message: ".gitignore does not exist",
348
+ fixable: true,
349
+ _fixData: {
350
+ type: "create-gitignore",
351
+ projectPath: projectDir,
352
+ entries: [".mnemex/", ".claudemem/"],
353
+ },
354
+ },
355
+ ],
356
+ },
357
+ ],
358
+ summary: {
359
+ totalProjects: 1,
360
+ totalChecks: 1,
361
+ passed: 0,
362
+ warnings: 1,
363
+ failures: 0,
364
+ fixable: 1,
365
+ },
366
+ };
367
+
368
+ const fixResult = await fixDoctorIssues(doctorResult);
369
+
370
+ expect(fixResult.fixed).toBe(1);
371
+ expect(existsSync(gitignorePath)).toBe(true);
372
+ const content = await readFile(gitignorePath, "utf-8");
373
+ expect(content).toContain(".mnemex/");
374
+ expect(content).toContain(".claudemem/");
375
+ });
376
+
377
+ it("should skip unfixable issues", async () => {
378
+ const doctorResult: DoctorResult = {
379
+ projects: [
380
+ {
381
+ projectPath: "/nonexistent/path",
382
+ plugins: ["some-plugin@magus"],
383
+ checks: [
384
+ {
385
+ name: "project-accessible",
386
+ status: "fail",
387
+ message: "Project path does not exist",
388
+ fixable: false,
389
+ },
390
+ ],
391
+ },
392
+ ],
393
+ summary: {
394
+ totalProjects: 1,
395
+ totalChecks: 1,
396
+ passed: 0,
397
+ warnings: 0,
398
+ failures: 1,
399
+ fixable: 0,
400
+ },
401
+ };
402
+
403
+ const fixResult = await fixDoctorIssues(doctorResult);
404
+
405
+ expect(fixResult.fixed).toBe(0);
406
+ expect(fixResult.failed).toBe(0);
407
+ });
408
+
409
+ it("should report fix count correctly with mixed results", async () => {
410
+ const gitignorePath = join(projectDir, ".gitignore");
411
+ await writeFile(gitignorePath, "node_modules/\n");
412
+
413
+ const doctorResult: DoctorResult = {
414
+ projects: [
415
+ {
416
+ projectPath: projectDir,
417
+ plugins: ["test-plugin@magus"],
418
+ checks: [
419
+ {
420
+ name: "gitignore-entries",
421
+ status: "fail",
422
+ message: ".gitignore missing: .mnemex/",
423
+ fixable: true,
424
+ _fixData: {
425
+ type: "add-gitignore-entries",
426
+ gitignorePath,
427
+ entries: [".mnemex/"],
428
+ },
429
+ },
430
+ {
431
+ name: "project-accessible",
432
+ status: "fail",
433
+ message: "Project path does not exist",
434
+ fixable: false,
435
+ },
436
+ {
437
+ name: "claudemd-section-test-plugin",
438
+ status: "warn",
439
+ message: "CLAUDE.md missing test-plugin conventions",
440
+ fixable: true,
441
+ // Missing _fixData -- should count as failed
442
+ },
443
+ ],
444
+ },
445
+ ],
446
+ summary: {
447
+ totalProjects: 1,
448
+ totalChecks: 3,
449
+ passed: 0,
450
+ warnings: 1,
451
+ failures: 2,
452
+ fixable: 2,
453
+ },
454
+ };
455
+
456
+ const fixResult = await fixDoctorIssues(doctorResult);
457
+
458
+ // 1 fixed (gitignore), 1 failed (missing _fixData), 1 skipped (not fixable)
459
+ expect(fixResult.fixed).toBe(1);
460
+ expect(fixResult.failed).toBe(1);
461
+ });
462
+ });
450
463
  });