claudeup 4.19.0 → 4.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/bin/claudeup.js +43 -19
  2. package/package.json +14 -19
  3. package/scripts/build-binaries.ts +77 -0
  4. package/scripts/check-optional-deps.ts +49 -0
  5. package/scripts/test-isolated.ts +43 -0
  6. package/src/__tests__/cli-router.test.ts +65 -0
  7. package/src/__tests__/conventions-integration.test.ts +745 -0
  8. package/src/__tests__/conventions-manager.test.ts +1172 -0
  9. package/src/__tests__/doctor-bins.test.ts +35 -0
  10. package/src/__tests__/doctor.test.ts +450 -0
  11. package/src/__tests__/dual-write-prevention.test.ts +14 -4
  12. package/src/__tests__/file-locking.test.ts +208 -0
  13. package/src/__tests__/gitignore-prerun.test.ts +1 -0
  14. package/src/__tests__/install-command.test.ts +81 -0
  15. package/src/__tests__/install-plan.test.ts +98 -0
  16. package/src/__tests__/manifest.test.ts +136 -0
  17. package/src/__tests__/marketplace-refresh.test.ts +267 -0
  18. package/src/__tests__/plugin-requires.test.ts +133 -0
  19. package/src/__tests__/plugin-setup.test.ts +7 -0
  20. package/src/__tests__/profile-command.test.ts +116 -0
  21. package/src/__tests__/profile-materializer.test.ts +82 -0
  22. package/src/__tests__/profile-sync.test.ts +136 -0
  23. package/src/__tests__/registry-version-resolution.test.ts +74 -0
  24. package/src/__tests__/resolve-executable.test.ts +42 -0
  25. package/src/__tests__/resolver.test.ts +218 -0
  26. package/src/__tests__/symlink-manager.test.ts +99 -0
  27. package/src/__tests__/toolchain.test.ts +56 -0
  28. package/src/cli/claude.ts +21 -0
  29. package/src/cli/doctor.ts +132 -0
  30. package/src/cli/install.ts +360 -0
  31. package/src/cli/profile.ts +166 -0
  32. package/src/cli/router.ts +107 -0
  33. package/src/cli/update.ts +85 -0
  34. package/src/data/cli-tools.ts +7 -7
  35. package/src/data/gitignore-defaults.ts +4 -0
  36. package/src/data/marketplaces.ts +12 -0
  37. package/src/data/predefined-profiles.ts +3 -4
  38. package/src/main.tsx +11 -154
  39. package/src/prerunner/index.ts +62 -27
  40. package/src/services/claude-cli.ts +19 -22
  41. package/src/services/claude-settings.ts +99 -26
  42. package/src/services/conventions-manager.ts +865 -0
  43. package/src/services/doctor-bins.ts +49 -0
  44. package/src/services/doctor.ts +509 -0
  45. package/src/services/install-plan.ts +107 -0
  46. package/src/services/manifest.ts +166 -0
  47. package/src/services/marketplace-refresh.ts +173 -0
  48. package/src/services/plugin-manager.ts +7 -1
  49. package/src/services/plugin-requires.ts +215 -0
  50. package/src/services/plugin-version-check.ts +9 -4
  51. package/src/services/profile-materializer.ts +61 -0
  52. package/src/services/profile-sync.ts +150 -0
  53. package/src/services/resolver.ts +293 -0
  54. package/src/services/symlink-manager.ts +146 -0
  55. package/src/services/toolchain.ts +97 -0
  56. package/src/services/version-check.ts +10 -11
  57. package/src/types/index.ts +138 -31
  58. package/src/ui/App.tsx +0 -4
  59. package/src/ui/screens/CliToolsScreen.tsx +11 -3
  60. package/src/ui/screens/index.ts +0 -1
  61. package/src/ui/state/reducer.ts +0 -101
  62. package/src/ui/state/types.ts +0 -35
  63. package/src/utils/command-utils.ts +23 -0
  64. package/src/utils/file-locking.ts +144 -0
  65. package/src/data/alias-flags.js +0 -205
  66. package/src/data/cli-tools.js +0 -123
  67. package/src/data/gitignore-defaults.js +0 -24
  68. package/src/data/gitignore-reasons.js +0 -97
  69. package/src/data/gitignore-templates.js +0 -21
  70. package/src/data/marketplaces.js +0 -138
  71. package/src/data/mcp-servers.js +0 -509
  72. package/src/data/predefined-profiles.js +0 -248
  73. package/src/data/settings-catalog.js +0 -625
  74. package/src/data/skill-repos.js +0 -160
  75. package/src/data/statuslines.js +0 -159
  76. package/src/data/statuslines.ts +0 -188
  77. package/src/index.js +0 -4
  78. package/src/index.ts +0 -5
  79. package/src/main.js +0 -170
  80. package/src/prerunner/index.js +0 -252
  81. package/src/services/alias-settings.js +0 -51
  82. package/src/services/alias-shell-writer.js +0 -1018
  83. package/src/services/alias-store.js +0 -129
  84. package/src/services/claude-cli.js +0 -189
  85. package/src/services/claude-runner.js +0 -28
  86. package/src/services/claude-settings.js +0 -1223
  87. package/src/services/gitignore-detector.js +0 -155
  88. package/src/services/gitignore-fixer.js +0 -231
  89. package/src/services/gitignore-prerun.js +0 -46
  90. package/src/services/gitignore-resolver.js +0 -143
  91. package/src/services/gitignore-service.js +0 -117
  92. package/src/services/local-marketplace.js +0 -339
  93. package/src/services/marketplace-fetcher.js +0 -96
  94. package/src/services/marketplace-sync.js +0 -94
  95. package/src/services/mcp-registry.js +0 -87
  96. package/src/services/plugin-manager.js +0 -473
  97. package/src/services/plugin-mcp-config.js +0 -177
  98. package/src/services/plugin-setup.js +0 -499
  99. package/src/services/plugin-version-check.js +0 -262
  100. package/src/services/profiles.js +0 -161
  101. package/src/services/settings-manager.js +0 -243
  102. package/src/services/skills-manager.js +0 -393
  103. package/src/services/skillsmp-client.js +0 -87
  104. package/src/services/update-cache.js +0 -52
  105. package/src/services/version-check.js +0 -99
  106. package/src/types/gitignore.js +0 -6
  107. package/src/types/index.js +0 -1
  108. package/src/ui/App.js +0 -355
  109. package/src/ui/adapters/pluginsAdapter.js +0 -139
  110. package/src/ui/adapters/settingsAdapter.js +0 -111
  111. package/src/ui/adapters/skillsAdapter.js +0 -154
  112. package/src/ui/components/CategoryHeader.js +0 -9
  113. package/src/ui/components/EmptyFilterState.js +0 -4
  114. package/src/ui/components/FlagDetailEditor.js +0 -0
  115. package/src/ui/components/ScopeIndicator.js +0 -30
  116. package/src/ui/components/ScrollableList.js +0 -36
  117. package/src/ui/components/SearchInput.js +0 -19
  118. package/src/ui/components/StyledText.js +0 -39
  119. package/src/ui/components/TabBar.js +0 -19
  120. package/src/ui/components/layout/FooterHints.js +0 -29
  121. package/src/ui/components/layout/Panel.js +0 -6
  122. package/src/ui/components/layout/ProgressBar.js +0 -14
  123. package/src/ui/components/layout/ScopeTabs.js +0 -6
  124. package/src/ui/components/layout/ScreenLayout.js +0 -16
  125. package/src/ui/components/layout/index.js +0 -5
  126. package/src/ui/components/modals/ConfirmModal.js +0 -14
  127. package/src/ui/components/modals/InputModal.js +0 -5
  128. package/src/ui/components/modals/LoadingModal.js +0 -17
  129. package/src/ui/components/modals/MessageModal.js +0 -16
  130. package/src/ui/components/modals/ModalContainer.js +0 -137
  131. package/src/ui/components/modals/SelectModal.js +0 -18
  132. package/src/ui/components/modals/VersionMismatchModal.js +0 -36
  133. package/src/ui/components/modals/index.js +0 -6
  134. package/src/ui/components/primitives/ActionHints.js +0 -13
  135. package/src/ui/components/primitives/DetailSection.js +0 -7
  136. package/src/ui/components/primitives/KeyValueLine.js +0 -8
  137. package/src/ui/components/primitives/ListCategoryRow.js +0 -8
  138. package/src/ui/components/primitives/MetaText.js +0 -8
  139. package/src/ui/components/primitives/ScopeDetail.js +0 -32
  140. package/src/ui/components/primitives/ScopeSquares.js +0 -11
  141. package/src/ui/components/primitives/SelectableRow.js +0 -5
  142. package/src/ui/components/primitives/index.js +0 -8
  143. package/src/ui/hooks/index.js +0 -4
  144. package/src/ui/hooks/useAsyncData.js +0 -77
  145. package/src/ui/hooks/useGitignoreModal.js +0 -74
  146. package/src/ui/hooks/useKeyboard.js +0 -13
  147. package/src/ui/hooks/useKeyboardHandler.js +0 -39
  148. package/src/ui/hooks/useMismatchModal.js +0 -77
  149. package/src/ui/registry.js +0 -1
  150. package/src/ui/renderers/cliToolRenderers.js +0 -54
  151. package/src/ui/renderers/gitignoreRenderers.js +0 -46
  152. package/src/ui/renderers/mcpRenderers.js +0 -26
  153. package/src/ui/renderers/pluginRenderers.js +0 -124
  154. package/src/ui/renderers/profileRenderers.js +0 -177
  155. package/src/ui/renderers/settingsRenderers.js +0 -73
  156. package/src/ui/renderers/skillRenderers.js +0 -138
  157. package/src/ui/screens/AliasScreen.js +0 -1111
  158. package/src/ui/screens/CliToolsScreen.js +0 -338
  159. package/src/ui/screens/EnvVarsScreen.js +0 -152
  160. package/src/ui/screens/GitignoreScreen.js +0 -328
  161. package/src/ui/screens/McpRegistryScreen.js +0 -238
  162. package/src/ui/screens/McpScreen.js +0 -176
  163. package/src/ui/screens/ModelSelectorScreen.js +0 -296
  164. package/src/ui/screens/ModelSelectorScreen.tsx +0 -444
  165. package/src/ui/screens/PluginsScreen.js +0 -769
  166. package/src/ui/screens/ProfilesScreen.js +0 -298
  167. package/src/ui/screens/SkillsScreen.js +0 -549
  168. package/src/ui/screens/StatusLineScreen.js +0 -206
  169. package/src/ui/screens/StatusLineScreen.tsx +0 -416
  170. package/src/ui/screens/index.js +0 -10
  171. package/src/ui/state/AnimationContext.js +0 -34
  172. package/src/ui/state/AppContext.js +0 -162
  173. package/src/ui/state/DimensionsContext.js +0 -71
  174. package/src/ui/state/reducer.js +0 -547
  175. package/src/ui/state/types.js +0 -1
  176. package/src/ui/theme.js +0 -47
  177. package/src/utils/clipboard.js +0 -56
  178. package/src/utils/command-utils.js +0 -19
  179. package/src/utils/fuzzy-search.js +0 -101
  180. package/src/utils/string-utils.js +0 -62
@@ -0,0 +1,35 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ checkBinaries,
4
+ missingBinaries,
5
+ } from "../services/doctor-bins.js";
6
+ import type { ResolvedBin } from "../types/index.js";
7
+
8
+ const bins: ResolvedBin[] = [
9
+ { name: "tmux", via: "brew", formula: "tmux", sources: ["plugin:terminal@magus"] },
10
+ { name: "claudish", via: "bun", package: "claudish", version: "1.2.0", sources: ["profile:x.cliTools"] },
11
+ ];
12
+
13
+ describe("checkBinaries", () => {
14
+ test("reports presence and an install command per bin", async () => {
15
+ const present = new Set(["claudish"]);
16
+ const results = await checkBinaries(bins, async (n) => present.has(n));
17
+
18
+ const tmux = results.find((r) => r.name === "tmux")!;
19
+ expect(tmux.present).toBe(false);
20
+ expect(tmux.installCommand).toBe("brew install tmux");
21
+ expect(tmux.sources).toEqual(["plugin:terminal@magus"]);
22
+
23
+ const claudish = results.find((r) => r.name === "claudish")!;
24
+ expect(claudish.present).toBe(true);
25
+ expect(claudish.installCommand).toBe("bun install -g claudish@1.2.0");
26
+ });
27
+ });
28
+
29
+ describe("missingBinaries", () => {
30
+ test("returns only the absent ones", async () => {
31
+ const results = await checkBinaries(bins, async (n) => n === "claudish");
32
+ const missing = missingBinaries(results);
33
+ expect(missing.map((m) => m.name)).toEqual(["tmux"]);
34
+ });
35
+ });
@@ -0,0 +1,450 @@
1
+ /**
2
+ * Unit tests for doctor service
3
+ *
4
+ * Tests cover:
5
+ * - checkProject: project accessible, gitignore entries, claudemd sections, malformed markers
6
+ * - runDoctor: empty registry, registered projects, summary counts
7
+ * - fixDoctorIssues: add gitignore entries, inject claudemd sections, repair markers
8
+ *
9
+ * Strategy: checkProject and fixDoctorIssues are tested against real temp directories
10
+ * with real plugin.json files. runDoctor is tested by mocking the registry.
11
+ */
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';
20
+
21
+ // ============================================================
22
+ // HELPERS
23
+ // ============================================================
24
+
25
+ /**
26
+ * Create a minimal plugin directory with plugin.json and optional template.
27
+ */
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,
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;
59
+ }
60
+
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
+ });
450
+ });
@@ -15,6 +15,11 @@ import { describe, it, expect, mock, beforeEach, afterEach } from "bun:test";
15
15
  import * as fs from "node:fs";
16
16
  import * as path from "node:path";
17
17
  import * as url from "node:url";
18
+ // Real modules captured before the mock.module() overrides below, so the mocks
19
+ // can spread the full export surface instead of stripping it.
20
+ import * as actualClaudeSettings from "../services/claude-settings.js";
21
+ import * as actualClaudeCli from "../services/claude-cli.js";
22
+ import * as actualPluginManager from "../services/plugin-manager.js";
18
23
 
19
24
  // ---------------------------------------------------------------------------
20
25
  // PART 1: PluginsScreen source-level policy tests
@@ -93,11 +98,14 @@ let mockUpdatePlugin: ReturnType<typeof mock>;
93
98
  let mockIsClaudeAvailable: ReturnType<typeof mock>;
94
99
  let mockGetAvailablePlugins: ReturnType<typeof mock>;
95
100
 
96
- // We must register mock.module before importing the module under test.
97
- // Bun hoists mock.module() calls, so the mocks are active when the
98
- // module is first loaded.
99
-
101
+ // Spread the REAL modules and override only the functions under test, so this
102
+ // file's mock.module doesn't strip other exports the module graph needs (which
103
+ // breaks both this file in isolation and — because bun's mock.module is global
104
+ // and not restored across files — every later test file). The `import * as`
105
+ // bindings are evaluated before these mock.module() calls run, so they capture
106
+ // the real modules.
100
107
  mock.module("../services/claude-settings.js", () => ({
108
+ ...actualClaudeSettings,
101
109
  recoverMarketplaceSettings: mock(() =>
102
110
  Promise.resolve({ enabledAutoUpdate: [], removed: [], reregistered: [] }),
103
111
  ),
@@ -120,6 +128,7 @@ mock.module("../services/claude-settings.js", () => ({
120
128
  }));
121
129
 
122
130
  mock.module("../services/claude-cli.js", () => ({
131
+ ...actualClaudeCli,
123
132
  updatePlugin: (...args: unknown[]) => mockUpdatePlugin(...args),
124
133
  isClaudeAvailable: (...args: unknown[]) => mockIsClaudeAvailable(...args),
125
134
  addMarketplace: mock(() => Promise.resolve()),
@@ -127,6 +136,7 @@ mock.module("../services/claude-cli.js", () => ({
127
136
  }));
128
137
 
129
138
  mock.module("../services/plugin-manager.js", () => ({
139
+ ...actualPluginManager,
130
140
  getAvailablePlugins: (...args: unknown[]) =>
131
141
  mockGetAvailablePlugins(...args),
132
142
  clearMarketplaceCache: mock(() => undefined),