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,49 @@
1
+ /**
2
+ * Binary-dependency check for `claudeup doctor`.
3
+ *
4
+ * Verifies that every binary a profile's plugins declare (plugin.json
5
+ * `requires.bin`) plus the profile's own cliTools is present on PATH. This is
6
+ * the diagnostic that would have caught the dead `tmux-mcp` — a plugin's MCP
7
+ * server silently failing because its binary was never installed.
8
+ */
9
+
10
+ import type { ResolvedBin } from "../types/index.js";
11
+ import { resolveExecutable } from "../utils/command-utils.js";
12
+ import { binInstallCommand } from "./toolchain.js";
13
+
14
+ export interface BinCheckResult {
15
+ name: string;
16
+ present: boolean;
17
+ /** The command that would install it, shown when missing. */
18
+ installCommand: string;
19
+ /** Where the requirement came from (profile cliTools / plugin ids). */
20
+ sources: string[];
21
+ }
22
+
23
+ /**
24
+ * Check each resolved binary for presence on PATH. `isPresent` is injectable
25
+ * for tests; it defaults to a real PATH lookup.
26
+ */
27
+ export async function checkBinaries(
28
+ bins: ResolvedBin[],
29
+ // Default resolves symlinks + checks executability, so a dangling link
30
+ // (the tmux-mcp / npx-cache failure mode) reads as MISSING, not present.
31
+ isPresent: (name: string) => Promise<boolean> = async (name) =>
32
+ (await resolveExecutable(name)) !== null,
33
+ ): Promise<BinCheckResult[]> {
34
+ const results: BinCheckResult[] = [];
35
+ for (const bin of bins) {
36
+ results.push({
37
+ name: bin.name,
38
+ present: await isPresent(bin.name),
39
+ installCommand: binInstallCommand(bin),
40
+ sources: bin.sources,
41
+ });
42
+ }
43
+ return results;
44
+ }
45
+
46
+ /** Just the missing ones — the actionable subset. */
47
+ export function missingBinaries(results: BinCheckResult[]): BinCheckResult[] {
48
+ return results.filter((r) => !r.present);
49
+ }
@@ -0,0 +1,509 @@
1
+ /**
2
+ * Doctor Service
3
+ *
4
+ * Cross-project audit of convention compliance.
5
+ * Reads installed_plugins.json to enumerate all projects, checks each for
6
+ * .gitignore completeness and CLAUDE.md convention blocks.
7
+ * Detects and reports malformed markers, duplicate sections, and missing END markers.
8
+ *
9
+ * Dependencies:
10
+ * - claude-settings.js (readInstalledPluginsRegistry)
11
+ * - conventions-manager.js (check/apply functions)
12
+ * - plugin-manager.js (resolve plugin source paths)
13
+ */
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';
19
+ 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';
30
+
31
+ // ============================================================
32
+ // TYPES
33
+ // ============================================================
34
+
35
+ export type CheckStatus = 'pass' | 'warn' | 'fail';
36
+
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;
44
+ }
45
+
46
+ export interface ProjectDoctorResult {
47
+ projectPath: string;
48
+ checks: DoctorCheck[];
49
+ plugins: string[];
50
+ }
51
+
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
+ };
62
+ }
63
+
64
+ export type DoctorLogger = (level: 'info' | 'warn' | 'error', message: string) => void;
65
+
66
+ // ============================================================
67
+ // INTERNAL HELPERS
68
+ // ============================================================
69
+
70
+ import { homedir } from 'node:os';
71
+
72
+ /** Path constants matching plugin-manager.ts */
73
+ const MARKETPLACES_DIR = join(homedir(), '.claude', 'plugins', 'marketplaces');
74
+
75
+ /**
76
+ * Resolve a plugin ID to its filesystem source path.
77
+ * Mirrors the private resolvePluginSourcePath in plugin-manager.ts.
78
+ */
79
+ async function resolvePluginPath(pluginId: string): Promise<string | null> {
80
+ const parsed = parsePluginId(pluginId);
81
+ if (!parsed) return null;
82
+
83
+ const { pluginName, marketplace: mpName } = parsed;
84
+ const localMarketplaces = await getLocalMarketplacesInfo();
85
+ const localMp = localMarketplaces.get(mpName);
86
+ if (!localMp) return null;
87
+
88
+ const localPlugin = localMp.plugins.find((p) => p.name === pluginName);
89
+ if (!localPlugin?.source || typeof localPlugin.source !== 'string') return null;
90
+
91
+ const marketplacePath = join(MARKETPLACES_DIR, mpName);
92
+ const pluginPath = join(marketplacePath, localPlugin.source.replace('./', ''));
93
+
94
+ if (existsSync(pluginPath)) {
95
+ return pluginPath;
96
+ }
97
+
98
+ return null;
99
+ }
100
+
101
+ /**
102
+ * Collect all unique project paths from the installed plugins registry,
103
+ * along with the plugin IDs installed in each project.
104
+ */
105
+ 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;
121
+ }
122
+
123
+ /**
124
+ * Resolve conventions for a plugin, returning both the conventions object
125
+ * and the plugin source path.
126
+ */
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 };
137
+ }
138
+
139
+ // ============================================================
140
+ // CHECK PROJECT
141
+ // ============================================================
142
+
143
+ /**
144
+ * Run doctor checks on a single project.
145
+ *
146
+ * @param projectPath - Absolute path to project
147
+ * @param pluginIds - Plugin IDs installed in this project
148
+ * @param logger - Optional logger
149
+ * @returns Check results for this project
150
+ */
151
+ export async function checkProject(
152
+ projectPath: string,
153
+ pluginIds: string[],
154
+ logger?: DoctorLogger,
155
+ ): 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 };
325
+ }
326
+
327
+ // ============================================================
328
+ // RUN DOCTOR
329
+ // ============================================================
330
+
331
+ /**
332
+ * Run doctor checks across all registered projects.
333
+ *
334
+ * @param logger - Optional logger
335
+ * @returns Full doctor result with per-project checks and summary
336
+ */
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
+ };
379
+ }
380
+
381
+ // ============================================================
382
+ // FIX DOCTOR ISSUES
383
+ // ============================================================
384
+
385
+ /**
386
+ * Fix all fixable issues found by doctor.
387
+ * For malformed CLAUDE.md markers: removes the broken section and re-injects cleanly.
388
+ *
389
+ * @param result - Doctor result from runDoctor()
390
+ * @param logger - Optional logger
391
+ * @returns Number of issues fixed and failed
392
+ */
393
+ export async function fixDoctorIssues(
394
+ result: DoctorResult,
395
+ logger?: DoctorLogger,
396
+ ): 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 };
509
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Pure planning helpers for `claudeup install`.
3
+ *
4
+ * The impure "what does this machine already have" probing lives in the command
5
+ * (cli/install.ts); everything here is a pure function of a ResolvedClosure and
6
+ * some captured machine state, so it can be unit-tested and reused by
7
+ * `install --check` (the CI drift gate).
8
+ */
9
+
10
+ import semver from "semver";
11
+ import type {
12
+ InstalledPluginsRegistry,
13
+ ResolvedClosure,
14
+ } from "../types/index.js";
15
+
16
+ export interface VersionDrift {
17
+ pluginId: string;
18
+ /** The version the manifest pins. */
19
+ pinned: string;
20
+ /** The highest installed version, or null if not installed. */
21
+ installed: string | null;
22
+ }
23
+
24
+ /** Highest valid semver in a list, or the raw first value if none parse. */
25
+ function maxVersion(versions: string[]): string | null {
26
+ const valid = versions.filter((v) => semver.valid(v));
27
+ if (valid.length > 0) return valid.sort(semver.rcompare)[0]!;
28
+ return versions[0] ?? null;
29
+ }
30
+
31
+ /**
32
+ * Compare the closure's pinned plugin versions against the installed registry.
33
+ * Only plugins with an explicit pin (not "latest") are checked. Returns the set
34
+ * of plugins whose installed version doesn't match the pin — the signal
35
+ * `install --check` fails on.
36
+ */
37
+ export function computeVersionDrift(
38
+ closure: ResolvedClosure,
39
+ registry: InstalledPluginsRegistry,
40
+ ): VersionDrift[] {
41
+ const drifts: VersionDrift[] = [];
42
+ for (const [pluginId, pinned] of Object.entries(closure.plugins)) {
43
+ if (pinned === "latest") continue;
44
+ const entries = registry.plugins?.[pluginId] ?? [];
45
+ const installed = entries.length
46
+ ? maxVersion(entries.map((e) => e.version))
47
+ : null;
48
+ if (installed !== pinned) {
49
+ drifts.push({ pluginId, pinned, installed });
50
+ }
51
+ }
52
+ return drifts;
53
+ }
54
+
55
+ export interface EnvStatus {
56
+ required: { name: string; present: boolean }[];
57
+ optional: { name: string; present: boolean }[];
58
+ /** Required vars that are unset — must be collected before the profile works. */
59
+ missingRequired: string[];
60
+ }
61
+
62
+ /** Which of the closure's declared env vars are present in `env` (process.env). */
63
+ export function computeEnvStatus(
64
+ closure: ResolvedClosure,
65
+ env: Record<string, string | undefined> = process.env,
66
+ ): EnvStatus {
67
+ const check = (name: string) => ({
68
+ name,
69
+ present: typeof env[name] === "string" && env[name] !== "",
70
+ });
71
+ const required = closure.env.required.map(check);
72
+ const optional = closure.env.optional.map(check);
73
+ return {
74
+ required,
75
+ optional,
76
+ missingRequired: required.filter((e) => !e.present).map((e) => e.name),
77
+ };
78
+ }
79
+
80
+ /**
81
+ * A one-line-per-item human summary of what a closure contains, for the
82
+ * pre-install plan display. Machine-state annotations (already installed, etc.)
83
+ * are layered on by the caller.
84
+ */
85
+ export function summarizeClosure(closure: ResolvedClosure): string[] {
86
+ const lines: string[] = [];
87
+ const mps = Object.keys(closure.marketplaces);
88
+ if (mps.length) lines.push(`marketplaces ${mps.join(", ")}`);
89
+ const plugins = Object.entries(closure.plugins).map(
90
+ ([id, v]) => (v === "latest" ? id : `${id}@${v}`),
91
+ );
92
+ if (plugins.length) lines.push(`plugins ${plugins.join(", ")}`);
93
+ const mcps = Object.keys(closure.mcpServers);
94
+ if (mcps.length) lines.push(`mcp servers ${mcps.join(", ")}`);
95
+ if (closure.bins.length) {
96
+ lines.push(
97
+ `cli tools ${closure.bins.map((b) => (b.version ? `${b.name}@${b.version}` : b.name)).join(", ")}`,
98
+ );
99
+ }
100
+ if (closure.skills.length) {
101
+ lines.push(`skills ${closure.skills.map((s) => s.name).join(", ")}`);
102
+ }
103
+ if (closure.env.required.length) {
104
+ lines.push(`env ${closure.env.required.join(", ")}`);
105
+ }
106
+ return lines;
107
+ }