claudeup 6.2.1 → 6.3.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 (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 +38 -13
  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 +183 -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 +1 -2
  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
@@ -12,90 +12,97 @@
12
12
  * - plugin-manager.js (resolve plugin source paths)
13
13
  */
14
14
 
15
- import { existsSync } from 'node:fs';
16
- import { join } from 'node:path';
17
- import { readInstalledPluginsRegistry } from './claude-settings.js';
18
- import { getLocalMarketplacesInfo } from './plugin-manager.js';
15
+ import { existsSync } from "node:fs";
16
+ import { join } from "node:path";
17
+ import type { PluginConventions } from "../types/index.js";
18
+ import { parsePluginId } from "../utils/string-utils.js";
19
+ import { readInstalledPluginsRegistry } from "./claude-settings.js";
19
20
  import {
20
- checkGitignoreEntries,
21
- ensureGitignoreEntries,
22
- parseClaudeMdSections,
23
- resolvePluginConventions,
24
- readConventionTemplate,
25
- injectClaudeMdSection,
26
- removeClaudeMdSection,
27
- } from './conventions-manager.js';
28
- import { parsePluginId } from '../utils/string-utils.js';
29
- import type { PluginConventions } from '../types/index.js';
21
+ checkGitignoreEntries,
22
+ ensureGitignoreEntries,
23
+ injectClaudeMdSection,
24
+ parseClaudeMdSections,
25
+ readConventionTemplate,
26
+ removeClaudeMdSection,
27
+ resolvePluginConventions,
28
+ } from "./conventions-manager.js";
29
+ import { getLocalMarketplacesInfo } from "./plugin-manager.js";
30
30
 
31
31
  // ============================================================
32
32
  // TYPES
33
33
  // ============================================================
34
34
 
35
- export type CheckStatus = 'pass' | 'warn' | 'fail';
35
+ export type CheckStatus = "pass" | "warn" | "fail";
36
36
 
37
37
  export interface DoctorCheck {
38
- name: string;
39
- status: CheckStatus;
40
- message: string;
41
- fixable: boolean;
42
- /** Metadata used by fixDoctorIssues to know what to fix */
43
- _fixData?: unknown;
38
+ name: string;
39
+ status: CheckStatus;
40
+ message: string;
41
+ fixable: boolean;
42
+ /** Metadata used by fixDoctorIssues to know what to fix */
43
+ _fixData?: unknown;
44
44
  }
45
45
 
46
46
  export interface ProjectDoctorResult {
47
- projectPath: string;
48
- checks: DoctorCheck[];
49
- plugins: string[];
47
+ projectPath: string;
48
+ checks: DoctorCheck[];
49
+ plugins: string[];
50
50
  }
51
51
 
52
52
  export interface DoctorResult {
53
- projects: ProjectDoctorResult[];
54
- summary: {
55
- totalProjects: number;
56
- totalChecks: number;
57
- passed: number;
58
- warnings: number;
59
- failures: number;
60
- fixable: number;
61
- };
53
+ projects: ProjectDoctorResult[];
54
+ summary: {
55
+ totalProjects: number;
56
+ totalChecks: number;
57
+ passed: number;
58
+ warnings: number;
59
+ failures: number;
60
+ fixable: number;
61
+ };
62
62
  }
63
63
 
64
- export type DoctorLogger = (level: 'info' | 'warn' | 'error', message: string) => void;
64
+ export type DoctorLogger = (
65
+ level: "info" | "warn" | "error",
66
+ message: string,
67
+ ) => void;
65
68
 
66
69
  // ============================================================
67
70
  // INTERNAL HELPERS
68
71
  // ============================================================
69
72
 
70
- import { homedir } from 'node:os';
73
+ import { homedir } from "node:os";
71
74
 
72
75
  /** Path constants matching plugin-manager.ts */
73
- const MARKETPLACES_DIR = join(homedir(), '.claude', 'plugins', 'marketplaces');
76
+ const MARKETPLACES_DIR = join(homedir(), ".claude", "plugins", "marketplaces");
74
77
 
75
78
  /**
76
79
  * Resolve a plugin ID to its filesystem source path.
77
80
  * Mirrors the private resolvePluginSourcePath in plugin-manager.ts.
78
81
  */
79
82
  async function resolvePluginPath(pluginId: string): Promise<string | null> {
80
- const parsed = parsePluginId(pluginId);
81
- if (!parsed) return null;
83
+ const parsed = parsePluginId(pluginId);
84
+ if (!parsed) return null;
82
85
 
83
- const { pluginName, marketplace: mpName } = parsed;
84
- const localMarketplaces = await getLocalMarketplacesInfo();
85
- const localMp = localMarketplaces.get(mpName);
86
- if (!localMp) return null;
86
+ const { pluginName, marketplace: mpName } = parsed;
87
+ const localMarketplaces = await getLocalMarketplacesInfo();
88
+ const localMp = localMarketplaces.get(mpName);
89
+ if (!localMp) return null;
87
90
 
88
- const localPlugin = localMp.plugins.find((p) => p.name === pluginName);
89
- if (!localPlugin?.source || typeof localPlugin.source !== 'string') return null;
91
+ const localPlugin = localMp.plugins.find((p) => p.name === pluginName);
92
+ if (!localPlugin?.source || typeof localPlugin.source !== "string")
93
+ return null;
90
94
 
91
- const marketplacePath = join(MARKETPLACES_DIR, mpName);
92
- const pluginPath = join(marketplacePath, localPlugin.source.replace('./', ''));
95
+ const marketplacePath = join(MARKETPLACES_DIR, mpName);
96
+ const pluginPath = join(
97
+ marketplacePath,
98
+ localPlugin.source.replace("./", ""),
99
+ );
93
100
 
94
- if (existsSync(pluginPath)) {
95
- return pluginPath;
96
- }
101
+ if (existsSync(pluginPath)) {
102
+ return pluginPath;
103
+ }
97
104
 
98
- return null;
105
+ return null;
99
106
  }
100
107
 
101
108
  /**
@@ -103,37 +110,37 @@ async function resolvePluginPath(pluginId: string): Promise<string | null> {
103
110
  * along with the plugin IDs installed in each project.
104
111
  */
105
112
  async function collectProjects(): Promise<Map<string, string[]>> {
106
- const registry = await readInstalledPluginsRegistry();
107
- const projectMap = new Map<string, string[]>();
108
-
109
- for (const [pluginId, entries] of Object.entries(registry.plugins)) {
110
- for (const entry of entries) {
111
- if (entry.projectPath) {
112
- if (!projectMap.has(entry.projectPath)) {
113
- projectMap.set(entry.projectPath, []);
114
- }
115
- projectMap.get(entry.projectPath)!.push(pluginId);
116
- }
117
- }
118
- }
119
-
120
- return projectMap;
113
+ const registry = await readInstalledPluginsRegistry();
114
+ const projectMap = new Map<string, string[]>();
115
+
116
+ for (const [pluginId, entries] of Object.entries(registry.plugins)) {
117
+ for (const entry of entries) {
118
+ if (entry.projectPath) {
119
+ const ids = projectMap.get(entry.projectPath) ?? [];
120
+ ids.push(pluginId);
121
+ projectMap.set(entry.projectPath, ids);
122
+ }
123
+ }
124
+ }
125
+
126
+ return projectMap;
121
127
  }
122
128
 
123
129
  /**
124
130
  * Resolve conventions for a plugin, returning both the conventions object
125
131
  * and the plugin source path.
126
132
  */
127
- async function resolveConventionsForPlugin(
128
- pluginId: string,
129
- ): Promise<{ conventions: PluginConventions | null; pluginPath: string | null }> {
130
- const pluginPath = await resolvePluginPath(pluginId);
131
- if (!pluginPath) {
132
- return { conventions: null, pluginPath: null };
133
- }
134
-
135
- const conventions = await resolvePluginConventions(pluginPath);
136
- return { conventions, pluginPath };
133
+ async function resolveConventionsForPlugin(pluginId: string): Promise<{
134
+ conventions: PluginConventions | null;
135
+ pluginPath: string | null;
136
+ }> {
137
+ const pluginPath = await resolvePluginPath(pluginId);
138
+ if (!pluginPath) {
139
+ return { conventions: null, pluginPath: null };
140
+ }
141
+
142
+ const conventions = await resolvePluginConventions(pluginPath);
143
+ return { conventions, pluginPath };
137
144
  }
138
145
 
139
146
  // ============================================================
@@ -149,179 +156,205 @@ async function resolveConventionsForPlugin(
149
156
  * @returns Check results for this project
150
157
  */
151
158
  export async function checkProject(
152
- projectPath: string,
153
- pluginIds: string[],
154
- logger?: DoctorLogger,
159
+ projectPath: string,
160
+ pluginIds: string[],
161
+ logger?: DoctorLogger,
155
162
  ): Promise<ProjectDoctorResult> {
156
- const checks: DoctorCheck[] = [];
157
-
158
- // Check 6: Project path is accessible
159
- if (!existsSync(projectPath)) {
160
- checks.push({
161
- name: 'project-accessible',
162
- status: 'fail',
163
- message: 'Project path does not exist',
164
- fixable: false,
165
- });
166
- return { projectPath, checks, plugins: pluginIds };
167
- }
168
-
169
- // Aggregate all required gitignore entries and claudemd conventions across plugins
170
- const allGitignoreEntries: string[] = [];
171
- const pluginConventionsMap = new Map<
172
- string,
173
- { conventions: PluginConventions; pluginPath: string }
174
- >();
175
-
176
- for (const pluginId of pluginIds) {
177
- const { conventions, pluginPath } = await resolveConventionsForPlugin(pluginId);
178
- if (conventions && pluginPath) {
179
- const parsed = parsePluginId(pluginId);
180
- if (parsed) {
181
- pluginConventionsMap.set(parsed.pluginName, { conventions, pluginPath });
182
- }
183
-
184
- if (conventions.gitignore?.project) {
185
- allGitignoreEntries.push(...conventions.gitignore.project);
186
- }
187
- }
188
- }
189
-
190
- // Check 1: .gitignore exists
191
- const gitignorePath = join(projectPath, '.gitignore');
192
- if (!existsSync(gitignorePath)) {
193
- if (allGitignoreEntries.length > 0) {
194
- checks.push({
195
- name: 'gitignore-exists',
196
- status: 'warn',
197
- message: '.gitignore does not exist',
198
- fixable: true,
199
- _fixData: { type: 'create-gitignore', projectPath, entries: allGitignoreEntries },
200
- });
201
- }
202
- } else if (allGitignoreEntries.length > 0) {
203
- // Check 2: Required gitignore entries present
204
- const { missing } = await checkGitignoreEntries(gitignorePath, allGitignoreEntries);
205
- if (missing.length > 0) {
206
- checks.push({
207
- name: 'gitignore-entries',
208
- status: 'fail',
209
- message: `.gitignore missing: ${missing.join(', ')}`,
210
- fixable: true,
211
- _fixData: { type: 'add-gitignore-entries', gitignorePath, entries: missing },
212
- });
213
- } else {
214
- checks.push({
215
- name: 'gitignore-entries',
216
- status: 'pass',
217
- message: '.gitignore has all required entries',
218
- fixable: false,
219
- });
220
- }
221
- }
222
-
223
- // Check 5: CLAUDE.md markers well-formed (run before section checks)
224
- const claudeMdPath = join(projectPath, 'CLAUDE.md');
225
- let hasMarkerErrors = false;
226
-
227
- if (existsSync(claudeMdPath)) {
228
- const parseResult = await parseClaudeMdSections(claudeMdPath);
229
-
230
- if (parseResult.errors.length > 0) {
231
- hasMarkerErrors = true;
232
- const errorDesc = parseResult.errors
233
- .map((e) => {
234
- if (e.type === 'missing_end') return `missing END for "${e.section}"`;
235
- if (e.type === 'mismatched_end') return `mismatched END for "${e.section}" (expected "${(e as { expected: string }).expected}")`;
236
- if (e.type === 'duplicate_section') return `duplicate section "${e.section}"`;
237
- if (e.type === 'marker_in_code_block') return `marker for "${e.section}" inside code block`;
238
- return 'unknown error';
239
- })
240
- .join('; ');
241
-
242
- checks.push({
243
- name: 'claudemd-markers',
244
- status: 'fail',
245
- message: `CLAUDE.md has malformed markers: ${errorDesc}`,
246
- fixable: true,
247
- _fixData: {
248
- type: 'repair-markers',
249
- claudeMdPath,
250
- errors: parseResult.errors,
251
- pluginConventionsMap: Object.fromEntries(pluginConventionsMap),
252
- },
253
- });
254
- }
255
-
256
- // Check 3 & 4: CLAUDE.md convention sections present and current
257
- if (!hasMarkerErrors) {
258
- for (const [pluginName, { conventions, pluginPath }] of pluginConventionsMap) {
259
- if (!conventions.claudemd) continue;
260
-
261
- const existingSection = parseResult.sections.find((s) => s.section === pluginName);
262
-
263
- if (!existingSection) {
264
- checks.push({
265
- name: `claudemd-section-${pluginName}`,
266
- status: 'warn',
267
- message: `CLAUDE.md missing ${pluginName} conventions`,
268
- fixable: true,
269
- _fixData: {
270
- type: 'inject-section',
271
- claudeMdPath,
272
- pluginName,
273
- pluginPath,
274
- conventions,
275
- },
276
- });
277
- } else if (existingSection.version !== conventions.claudemd.version) {
278
- checks.push({
279
- name: `claudemd-section-${pluginName}`,
280
- status: 'warn',
281
- message: `CLAUDE.md ${pluginName} conventions outdated (v${existingSection.version} -> v${conventions.claudemd.version})`,
282
- fixable: true,
283
- _fixData: {
284
- type: 'update-section',
285
- claudeMdPath,
286
- pluginName,
287
- pluginPath,
288
- conventions,
289
- },
290
- });
291
- } else {
292
- checks.push({
293
- name: `claudemd-section-${pluginName}`,
294
- status: 'pass',
295
- message: `CLAUDE.md has ${pluginName} conventions (v${conventions.claudemd.version})`,
296
- fixable: false,
297
- });
298
- }
299
- }
300
- }
301
- } else {
302
- // CLAUDE.md doesn't exist -- warn for each plugin that needs a section
303
- for (const [pluginName, { conventions, pluginPath }] of pluginConventionsMap) {
304
- if (!conventions.claudemd) continue;
305
-
306
- checks.push({
307
- name: `claudemd-section-${pluginName}`,
308
- status: 'warn',
309
- message: `CLAUDE.md missing ${pluginName} conventions`,
310
- fixable: true,
311
- _fixData: {
312
- type: 'inject-section',
313
- claudeMdPath,
314
- pluginName,
315
- pluginPath,
316
- conventions,
317
- },
318
- });
319
- }
320
- }
321
-
322
- logger?.('info', `Checked ${projectPath}: ${checks.length} checks`);
323
-
324
- return { projectPath, checks, plugins: pluginIds };
163
+ const checks: DoctorCheck[] = [];
164
+
165
+ // Check 6: Project path is accessible
166
+ if (!existsSync(projectPath)) {
167
+ checks.push({
168
+ name: "project-accessible",
169
+ status: "fail",
170
+ message: "Project path does not exist",
171
+ fixable: false,
172
+ });
173
+ return { projectPath, checks, plugins: pluginIds };
174
+ }
175
+
176
+ // Aggregate all required gitignore entries and claudemd conventions across plugins
177
+ const allGitignoreEntries: string[] = [];
178
+ const pluginConventionsMap = new Map<
179
+ string,
180
+ { conventions: PluginConventions; pluginPath: string }
181
+ >();
182
+
183
+ for (const pluginId of pluginIds) {
184
+ const { conventions, pluginPath } =
185
+ await resolveConventionsForPlugin(pluginId);
186
+ if (conventions && pluginPath) {
187
+ const parsed = parsePluginId(pluginId);
188
+ if (parsed) {
189
+ pluginConventionsMap.set(parsed.pluginName, {
190
+ conventions,
191
+ pluginPath,
192
+ });
193
+ }
194
+
195
+ if (conventions.gitignore?.project) {
196
+ allGitignoreEntries.push(...conventions.gitignore.project);
197
+ }
198
+ }
199
+ }
200
+
201
+ // Check 1: .gitignore exists
202
+ const gitignorePath = join(projectPath, ".gitignore");
203
+ if (!existsSync(gitignorePath)) {
204
+ if (allGitignoreEntries.length > 0) {
205
+ checks.push({
206
+ name: "gitignore-exists",
207
+ status: "warn",
208
+ message: ".gitignore does not exist",
209
+ fixable: true,
210
+ _fixData: {
211
+ type: "create-gitignore",
212
+ projectPath,
213
+ entries: allGitignoreEntries,
214
+ },
215
+ });
216
+ }
217
+ } else if (allGitignoreEntries.length > 0) {
218
+ // Check 2: Required gitignore entries present
219
+ const { missing } = await checkGitignoreEntries(
220
+ gitignorePath,
221
+ allGitignoreEntries,
222
+ );
223
+ if (missing.length > 0) {
224
+ checks.push({
225
+ name: "gitignore-entries",
226
+ status: "fail",
227
+ message: `.gitignore missing: ${missing.join(", ")}`,
228
+ fixable: true,
229
+ _fixData: {
230
+ type: "add-gitignore-entries",
231
+ gitignorePath,
232
+ entries: missing,
233
+ },
234
+ });
235
+ } else {
236
+ checks.push({
237
+ name: "gitignore-entries",
238
+ status: "pass",
239
+ message: ".gitignore has all required entries",
240
+ fixable: false,
241
+ });
242
+ }
243
+ }
244
+
245
+ // Check 5: CLAUDE.md markers well-formed (run before section checks)
246
+ const claudeMdPath = join(projectPath, "CLAUDE.md");
247
+ let hasMarkerErrors = false;
248
+
249
+ if (existsSync(claudeMdPath)) {
250
+ const parseResult = await parseClaudeMdSections(claudeMdPath);
251
+
252
+ if (parseResult.errors.length > 0) {
253
+ hasMarkerErrors = true;
254
+ const errorDesc = parseResult.errors
255
+ .map((e) => {
256
+ if (e.type === "missing_end") return `missing END for "${e.section}"`;
257
+ if (e.type === "mismatched_end")
258
+ return `mismatched END for "${e.section}" (expected "${(e as { expected: string }).expected}")`;
259
+ if (e.type === "duplicate_section")
260
+ return `duplicate section "${e.section}"`;
261
+ if (e.type === "marker_in_code_block")
262
+ return `marker for "${e.section}" inside code block`;
263
+ return "unknown error";
264
+ })
265
+ .join("; ");
266
+
267
+ checks.push({
268
+ name: "claudemd-markers",
269
+ status: "fail",
270
+ message: `CLAUDE.md has malformed markers: ${errorDesc}`,
271
+ fixable: true,
272
+ _fixData: {
273
+ type: "repair-markers",
274
+ claudeMdPath,
275
+ errors: parseResult.errors,
276
+ pluginConventionsMap: Object.fromEntries(pluginConventionsMap),
277
+ },
278
+ });
279
+ }
280
+
281
+ // Check 3 & 4: CLAUDE.md convention sections present and current
282
+ if (!hasMarkerErrors) {
283
+ for (const [
284
+ pluginName,
285
+ { conventions, pluginPath },
286
+ ] of pluginConventionsMap) {
287
+ if (!conventions.claudemd) continue;
288
+
289
+ const existingSection = parseResult.sections.find(
290
+ (s) => s.section === pluginName,
291
+ );
292
+
293
+ if (!existingSection) {
294
+ checks.push({
295
+ name: `claudemd-section-${pluginName}`,
296
+ status: "warn",
297
+ message: `CLAUDE.md missing ${pluginName} conventions`,
298
+ fixable: true,
299
+ _fixData: {
300
+ type: "inject-section",
301
+ claudeMdPath,
302
+ pluginName,
303
+ pluginPath,
304
+ conventions,
305
+ },
306
+ });
307
+ } else if (existingSection.version !== conventions.claudemd.version) {
308
+ checks.push({
309
+ name: `claudemd-section-${pluginName}`,
310
+ status: "warn",
311
+ message: `CLAUDE.md ${pluginName} conventions outdated (v${existingSection.version} -> v${conventions.claudemd.version})`,
312
+ fixable: true,
313
+ _fixData: {
314
+ type: "update-section",
315
+ claudeMdPath,
316
+ pluginName,
317
+ pluginPath,
318
+ conventions,
319
+ },
320
+ });
321
+ } else {
322
+ checks.push({
323
+ name: `claudemd-section-${pluginName}`,
324
+ status: "pass",
325
+ message: `CLAUDE.md has ${pluginName} conventions (v${conventions.claudemd.version})`,
326
+ fixable: false,
327
+ });
328
+ }
329
+ }
330
+ }
331
+ } else {
332
+ // CLAUDE.md doesn't exist -- warn for each plugin that needs a section
333
+ for (const [
334
+ pluginName,
335
+ { conventions, pluginPath },
336
+ ] of pluginConventionsMap) {
337
+ if (!conventions.claudemd) continue;
338
+
339
+ checks.push({
340
+ name: `claudemd-section-${pluginName}`,
341
+ status: "warn",
342
+ message: `CLAUDE.md missing ${pluginName} conventions`,
343
+ fixable: true,
344
+ _fixData: {
345
+ type: "inject-section",
346
+ claudeMdPath,
347
+ pluginName,
348
+ pluginPath,
349
+ conventions,
350
+ },
351
+ });
352
+ }
353
+ }
354
+
355
+ logger?.("info", `Checked ${projectPath}: ${checks.length} checks`);
356
+
357
+ return { projectPath, checks, plugins: pluginIds };
325
358
  }
326
359
 
327
360
  // ============================================================
@@ -334,48 +367,46 @@ export async function checkProject(
334
367
  * @param logger - Optional logger
335
368
  * @returns Full doctor result with per-project checks and summary
336
369
  */
337
- export async function runDoctor(
338
- logger?: DoctorLogger,
339
- ): Promise<DoctorResult> {
340
- const projectMap = await collectProjects();
341
-
342
- logger?.('info', `Found ${projectMap.size} project(s) to check`);
343
-
344
- const projects: ProjectDoctorResult[] = [];
345
-
346
- for (const [projectPath, pluginIds] of projectMap) {
347
- const result = await checkProject(projectPath, pluginIds, logger);
348
- projects.push(result);
349
- }
350
-
351
- // Compute summary
352
- let totalChecks = 0;
353
- let passed = 0;
354
- let warnings = 0;
355
- let failures = 0;
356
- let fixable = 0;
357
-
358
- for (const project of projects) {
359
- for (const check of project.checks) {
360
- totalChecks++;
361
- if (check.status === 'pass') passed++;
362
- else if (check.status === 'warn') warnings++;
363
- else if (check.status === 'fail') failures++;
364
- if (check.fixable) fixable++;
365
- }
366
- }
367
-
368
- return {
369
- projects,
370
- summary: {
371
- totalProjects: projects.length,
372
- totalChecks,
373
- passed,
374
- warnings,
375
- failures,
376
- fixable,
377
- },
378
- };
370
+ export async function runDoctor(logger?: DoctorLogger): Promise<DoctorResult> {
371
+ const projectMap = await collectProjects();
372
+
373
+ logger?.("info", `Found ${projectMap.size} project(s) to check`);
374
+
375
+ const projects: ProjectDoctorResult[] = [];
376
+
377
+ for (const [projectPath, pluginIds] of projectMap) {
378
+ const result = await checkProject(projectPath, pluginIds, logger);
379
+ projects.push(result);
380
+ }
381
+
382
+ // Compute summary
383
+ let totalChecks = 0;
384
+ let passed = 0;
385
+ let warnings = 0;
386
+ let failures = 0;
387
+ let fixable = 0;
388
+
389
+ for (const project of projects) {
390
+ for (const check of project.checks) {
391
+ totalChecks++;
392
+ if (check.status === "pass") passed++;
393
+ else if (check.status === "warn") warnings++;
394
+ else if (check.status === "fail") failures++;
395
+ if (check.fixable) fixable++;
396
+ }
397
+ }
398
+
399
+ return {
400
+ projects,
401
+ summary: {
402
+ totalProjects: projects.length,
403
+ totalChecks,
404
+ passed,
405
+ warnings,
406
+ failures,
407
+ fixable,
408
+ },
409
+ };
379
410
  }
380
411
 
381
412
  // ============================================================
@@ -391,119 +422,149 @@ export async function runDoctor(
391
422
  * @returns Number of issues fixed and failed
392
423
  */
393
424
  export async function fixDoctorIssues(
394
- result: DoctorResult,
395
- logger?: DoctorLogger,
425
+ result: DoctorResult,
426
+ logger?: DoctorLogger,
396
427
  ): Promise<{ fixed: number; failed: number }> {
397
- let fixed = 0;
398
- let failed = 0;
399
-
400
- for (const project of result.projects) {
401
- for (const check of project.checks) {
402
- if (!check.fixable || check.status === 'pass') continue;
403
-
404
- const fixData = check._fixData as Record<string, unknown> | undefined;
405
- if (!fixData) {
406
- failed++;
407
- continue;
408
- }
409
-
410
- try {
411
- switch (fixData.type) {
412
- case 'create-gitignore':
413
- case 'add-gitignore-entries': {
414
- const gitignorePath =
415
- fixData.type === 'create-gitignore'
416
- ? join(fixData.projectPath as string, '.gitignore')
417
- : (fixData.gitignorePath as string);
418
- const entries = fixData.entries as string[];
419
-
420
- await ensureGitignoreEntries(gitignorePath, entries, '# Managed by magus plugin conventions');
421
- logger?.('info', `Fixed: added ${entries.join(', ')} to ${gitignorePath}`);
422
- fixed++;
423
- break;
424
- }
425
-
426
- case 'inject-section':
427
- case 'update-section': {
428
- const claudeMdPath = fixData.claudeMdPath as string;
429
- const pluginPath = fixData.pluginPath as string;
430
- const conventions = fixData.conventions as PluginConventions;
431
-
432
- if (!conventions.claudemd) {
433
- failed++;
434
- break;
435
- }
436
-
437
- const template = await readConventionTemplate(pluginPath, conventions.claudemd.template);
438
- const injectResult = await injectClaudeMdSection(
439
- claudeMdPath,
440
- conventions.claudemd.section,
441
- conventions.claudemd.version,
442
- template,
443
- );
444
-
445
- if (injectResult.action === 'error') {
446
- logger?.('error', `Failed to fix ${claudeMdPath}: ${injectResult.error}`);
447
- failed++;
448
- } else {
449
- logger?.('info', `Fixed: ${injectResult.action} ${conventions.claudemd.section} in ${claudeMdPath}`);
450
- fixed++;
451
- }
452
- break;
453
- }
454
-
455
- case 'repair-markers': {
456
- const claudeMdPath = fixData.claudeMdPath as string;
457
- const errors = fixData.errors as Array<{ type: string; section: string }>;
458
- const conventionsMap = fixData.pluginConventionsMap as Record<
459
- string,
460
- { conventions: PluginConventions; pluginPath: string }
461
- >;
462
-
463
- // Collect unique sections with errors
464
- const brokenSections = new Set(errors.map((e) => e.section));
465
-
466
- for (const section of brokenSections) {
467
- // Remove the broken section (force=true to bypass parse error guard)
468
- await removeClaudeMdSection(claudeMdPath, section, { force: true });
469
-
470
- // Re-inject if we have conventions for it
471
- const entry = conventionsMap[section];
472
- if (entry?.conventions?.claudemd) {
473
- try {
474
- const template = await readConventionTemplate(
475
- entry.pluginPath,
476
- entry.conventions.claudemd.template,
477
- );
478
- await injectClaudeMdSection(
479
- claudeMdPath,
480
- entry.conventions.claudemd.section,
481
- entry.conventions.claudemd.version,
482
- template,
483
- );
484
- } catch (reInjectErr) {
485
- const reInjectMsg = reInjectErr instanceof Error ? reInjectErr.message : String(reInjectErr);
486
- logger?.('warn', `Re-inject of section "${section}" failed after marker repair: ${reInjectMsg}`);
487
- }
488
- }
489
- }
490
-
491
- logger?.('info', `Fixed: repaired malformed markers in ${claudeMdPath}`);
492
- fixed++;
493
- break;
494
- }
495
-
496
- default:
497
- failed++;
498
- break;
499
- }
500
- } catch (err) {
501
- const message = err instanceof Error ? err.message : String(err);
502
- logger?.('error', `Fix failed: ${message}`);
503
- failed++;
504
- }
505
- }
506
- }
507
-
508
- return { fixed, failed };
428
+ let fixed = 0;
429
+ let failed = 0;
430
+
431
+ for (const project of result.projects) {
432
+ for (const check of project.checks) {
433
+ if (!check.fixable || check.status === "pass") continue;
434
+
435
+ const fixData = check._fixData as Record<string, unknown> | undefined;
436
+ if (!fixData) {
437
+ failed++;
438
+ continue;
439
+ }
440
+
441
+ try {
442
+ switch (fixData.type) {
443
+ case "create-gitignore":
444
+ case "add-gitignore-entries": {
445
+ const gitignorePath =
446
+ fixData.type === "create-gitignore"
447
+ ? join(fixData.projectPath as string, ".gitignore")
448
+ : (fixData.gitignorePath as string);
449
+ const entries = fixData.entries as string[];
450
+
451
+ await ensureGitignoreEntries(
452
+ gitignorePath,
453
+ entries,
454
+ "# Managed by magus plugin conventions",
455
+ );
456
+ logger?.(
457
+ "info",
458
+ `Fixed: added ${entries.join(", ")} to ${gitignorePath}`,
459
+ );
460
+ fixed++;
461
+ break;
462
+ }
463
+
464
+ case "inject-section":
465
+ case "update-section": {
466
+ const claudeMdPath = fixData.claudeMdPath as string;
467
+ const pluginPath = fixData.pluginPath as string;
468
+ const conventions = fixData.conventions as PluginConventions;
469
+
470
+ if (!conventions.claudemd) {
471
+ failed++;
472
+ break;
473
+ }
474
+
475
+ const template = await readConventionTemplate(
476
+ pluginPath,
477
+ conventions.claudemd.template,
478
+ );
479
+ const injectResult = await injectClaudeMdSection(
480
+ claudeMdPath,
481
+ conventions.claudemd.section,
482
+ conventions.claudemd.version,
483
+ template,
484
+ );
485
+
486
+ if (injectResult.action === "error") {
487
+ logger?.(
488
+ "error",
489
+ `Failed to fix ${claudeMdPath}: ${injectResult.error}`,
490
+ );
491
+ failed++;
492
+ } else {
493
+ logger?.(
494
+ "info",
495
+ `Fixed: ${injectResult.action} ${conventions.claudemd.section} in ${claudeMdPath}`,
496
+ );
497
+ fixed++;
498
+ }
499
+ break;
500
+ }
501
+
502
+ case "repair-markers": {
503
+ const claudeMdPath = fixData.claudeMdPath as string;
504
+ const errors = fixData.errors as Array<{
505
+ type: string;
506
+ section: string;
507
+ }>;
508
+ const conventionsMap = fixData.pluginConventionsMap as Record<
509
+ string,
510
+ { conventions: PluginConventions; pluginPath: string }
511
+ >;
512
+
513
+ // Collect unique sections with errors
514
+ const brokenSections = new Set(errors.map((e) => e.section));
515
+
516
+ for (const section of brokenSections) {
517
+ // Remove the broken section (force=true to bypass parse error guard)
518
+ await removeClaudeMdSection(claudeMdPath, section, {
519
+ force: true,
520
+ });
521
+
522
+ // Re-inject if we have conventions for it
523
+ const entry = conventionsMap[section];
524
+ if (entry?.conventions?.claudemd) {
525
+ try {
526
+ const template = await readConventionTemplate(
527
+ entry.pluginPath,
528
+ entry.conventions.claudemd.template,
529
+ );
530
+ await injectClaudeMdSection(
531
+ claudeMdPath,
532
+ entry.conventions.claudemd.section,
533
+ entry.conventions.claudemd.version,
534
+ template,
535
+ );
536
+ } catch (reInjectErr) {
537
+ const reInjectMsg =
538
+ reInjectErr instanceof Error
539
+ ? reInjectErr.message
540
+ : String(reInjectErr);
541
+ logger?.(
542
+ "warn",
543
+ `Re-inject of section "${section}" failed after marker repair: ${reInjectMsg}`,
544
+ );
545
+ }
546
+ }
547
+ }
548
+
549
+ logger?.(
550
+ "info",
551
+ `Fixed: repaired malformed markers in ${claudeMdPath}`,
552
+ );
553
+ fixed++;
554
+ break;
555
+ }
556
+
557
+ default:
558
+ failed++;
559
+ break;
560
+ }
561
+ } catch (err) {
562
+ const message = err instanceof Error ? err.message : String(err);
563
+ logger?.("error", `Fix failed: ${message}`);
564
+ failed++;
565
+ }
566
+ }
567
+ }
568
+
569
+ return { fixed, failed };
509
570
  }