claudeup 4.19.0 → 4.23.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 (182) 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-badge.test.ts +91 -0
  18. package/src/__tests__/marketplace-refresh.test.ts +267 -0
  19. package/src/__tests__/plugin-requires.test.ts +133 -0
  20. package/src/__tests__/plugin-setup.test.ts +7 -0
  21. package/src/__tests__/profile-command.test.ts +116 -0
  22. package/src/__tests__/profile-materializer.test.ts +82 -0
  23. package/src/__tests__/profile-sync.test.ts +136 -0
  24. package/src/__tests__/registry-version-resolution.test.ts +74 -0
  25. package/src/__tests__/resolve-executable.test.ts +42 -0
  26. package/src/__tests__/resolver.test.ts +218 -0
  27. package/src/__tests__/symlink-manager.test.ts +99 -0
  28. package/src/__tests__/toolchain.test.ts +56 -0
  29. package/src/cli/claude.ts +21 -0
  30. package/src/cli/doctor.ts +132 -0
  31. package/src/cli/install.ts +360 -0
  32. package/src/cli/profile.ts +166 -0
  33. package/src/cli/router.ts +107 -0
  34. package/src/cli/update.ts +85 -0
  35. package/src/data/cli-tools.ts +7 -7
  36. package/src/data/gitignore-defaults.ts +4 -0
  37. package/src/data/marketplaces.ts +12 -0
  38. package/src/data/predefined-profiles.ts +3 -4
  39. package/src/main.tsx +11 -154
  40. package/src/prerunner/index.ts +62 -27
  41. package/src/services/claude-cli.ts +19 -22
  42. package/src/services/claude-settings.ts +99 -26
  43. package/src/services/conventions-manager.ts +865 -0
  44. package/src/services/doctor-bins.ts +49 -0
  45. package/src/services/doctor.ts +509 -0
  46. package/src/services/install-plan.ts +107 -0
  47. package/src/services/manifest.ts +166 -0
  48. package/src/services/marketplace-refresh.ts +173 -0
  49. package/src/services/plugin-manager.ts +7 -1
  50. package/src/services/plugin-requires.ts +215 -0
  51. package/src/services/plugin-version-check.ts +9 -4
  52. package/src/services/profile-materializer.ts +61 -0
  53. package/src/services/profile-sync.ts +150 -0
  54. package/src/services/resolver.ts +293 -0
  55. package/src/services/symlink-manager.ts +146 -0
  56. package/src/services/toolchain.ts +97 -0
  57. package/src/services/version-check.ts +10 -11
  58. package/src/types/index.ts +138 -31
  59. package/src/ui/App.tsx +0 -4
  60. package/src/ui/adapters/pluginsAdapter.ts +13 -0
  61. package/src/ui/screens/CliToolsScreen.tsx +11 -3
  62. package/src/ui/screens/index.ts +0 -1
  63. package/src/ui/state/reducer.ts +0 -101
  64. package/src/ui/state/types.ts +0 -35
  65. package/src/utils/command-utils.ts +23 -0
  66. package/src/utils/file-locking.ts +144 -0
  67. package/src/data/alias-flags.js +0 -205
  68. package/src/data/cli-tools.js +0 -123
  69. package/src/data/gitignore-defaults.js +0 -24
  70. package/src/data/gitignore-reasons.js +0 -97
  71. package/src/data/gitignore-templates.js +0 -21
  72. package/src/data/marketplaces.js +0 -138
  73. package/src/data/mcp-servers.js +0 -509
  74. package/src/data/predefined-profiles.js +0 -248
  75. package/src/data/settings-catalog.js +0 -625
  76. package/src/data/skill-repos.js +0 -160
  77. package/src/data/statuslines.js +0 -159
  78. package/src/data/statuslines.ts +0 -188
  79. package/src/index.js +0 -4
  80. package/src/index.ts +0 -5
  81. package/src/main.js +0 -170
  82. package/src/prerunner/index.js +0 -252
  83. package/src/services/alias-settings.js +0 -51
  84. package/src/services/alias-shell-writer.js +0 -1018
  85. package/src/services/alias-store.js +0 -129
  86. package/src/services/claude-cli.js +0 -189
  87. package/src/services/claude-runner.js +0 -28
  88. package/src/services/claude-settings.js +0 -1223
  89. package/src/services/gitignore-detector.js +0 -155
  90. package/src/services/gitignore-fixer.js +0 -231
  91. package/src/services/gitignore-prerun.js +0 -46
  92. package/src/services/gitignore-resolver.js +0 -143
  93. package/src/services/gitignore-service.js +0 -117
  94. package/src/services/local-marketplace.js +0 -339
  95. package/src/services/marketplace-fetcher.js +0 -96
  96. package/src/services/marketplace-sync.js +0 -94
  97. package/src/services/mcp-registry.js +0 -87
  98. package/src/services/plugin-manager.js +0 -473
  99. package/src/services/plugin-mcp-config.js +0 -177
  100. package/src/services/plugin-setup.js +0 -499
  101. package/src/services/plugin-version-check.js +0 -262
  102. package/src/services/profiles.js +0 -161
  103. package/src/services/settings-manager.js +0 -243
  104. package/src/services/skills-manager.js +0 -393
  105. package/src/services/skillsmp-client.js +0 -87
  106. package/src/services/update-cache.js +0 -52
  107. package/src/services/version-check.js +0 -99
  108. package/src/types/gitignore.js +0 -6
  109. package/src/types/index.js +0 -1
  110. package/src/ui/App.js +0 -355
  111. package/src/ui/adapters/pluginsAdapter.js +0 -139
  112. package/src/ui/adapters/settingsAdapter.js +0 -111
  113. package/src/ui/adapters/skillsAdapter.js +0 -154
  114. package/src/ui/components/CategoryHeader.js +0 -9
  115. package/src/ui/components/EmptyFilterState.js +0 -4
  116. package/src/ui/components/FlagDetailEditor.js +0 -0
  117. package/src/ui/components/ScopeIndicator.js +0 -30
  118. package/src/ui/components/ScrollableList.js +0 -36
  119. package/src/ui/components/SearchInput.js +0 -19
  120. package/src/ui/components/StyledText.js +0 -39
  121. package/src/ui/components/TabBar.js +0 -19
  122. package/src/ui/components/layout/FooterHints.js +0 -29
  123. package/src/ui/components/layout/Panel.js +0 -6
  124. package/src/ui/components/layout/ProgressBar.js +0 -14
  125. package/src/ui/components/layout/ScopeTabs.js +0 -6
  126. package/src/ui/components/layout/ScreenLayout.js +0 -16
  127. package/src/ui/components/layout/index.js +0 -5
  128. package/src/ui/components/modals/ConfirmModal.js +0 -14
  129. package/src/ui/components/modals/InputModal.js +0 -5
  130. package/src/ui/components/modals/LoadingModal.js +0 -17
  131. package/src/ui/components/modals/MessageModal.js +0 -16
  132. package/src/ui/components/modals/ModalContainer.js +0 -137
  133. package/src/ui/components/modals/SelectModal.js +0 -18
  134. package/src/ui/components/modals/VersionMismatchModal.js +0 -36
  135. package/src/ui/components/modals/index.js +0 -6
  136. package/src/ui/components/primitives/ActionHints.js +0 -13
  137. package/src/ui/components/primitives/DetailSection.js +0 -7
  138. package/src/ui/components/primitives/KeyValueLine.js +0 -8
  139. package/src/ui/components/primitives/ListCategoryRow.js +0 -8
  140. package/src/ui/components/primitives/MetaText.js +0 -8
  141. package/src/ui/components/primitives/ScopeDetail.js +0 -32
  142. package/src/ui/components/primitives/ScopeSquares.js +0 -11
  143. package/src/ui/components/primitives/SelectableRow.js +0 -5
  144. package/src/ui/components/primitives/index.js +0 -8
  145. package/src/ui/hooks/index.js +0 -4
  146. package/src/ui/hooks/useAsyncData.js +0 -77
  147. package/src/ui/hooks/useGitignoreModal.js +0 -74
  148. package/src/ui/hooks/useKeyboard.js +0 -13
  149. package/src/ui/hooks/useKeyboardHandler.js +0 -39
  150. package/src/ui/hooks/useMismatchModal.js +0 -77
  151. package/src/ui/registry.js +0 -1
  152. package/src/ui/renderers/cliToolRenderers.js +0 -54
  153. package/src/ui/renderers/gitignoreRenderers.js +0 -46
  154. package/src/ui/renderers/mcpRenderers.js +0 -26
  155. package/src/ui/renderers/pluginRenderers.js +0 -124
  156. package/src/ui/renderers/profileRenderers.js +0 -177
  157. package/src/ui/renderers/settingsRenderers.js +0 -73
  158. package/src/ui/renderers/skillRenderers.js +0 -138
  159. package/src/ui/screens/AliasScreen.js +0 -1111
  160. package/src/ui/screens/CliToolsScreen.js +0 -338
  161. package/src/ui/screens/EnvVarsScreen.js +0 -152
  162. package/src/ui/screens/GitignoreScreen.js +0 -328
  163. package/src/ui/screens/McpRegistryScreen.js +0 -238
  164. package/src/ui/screens/McpScreen.js +0 -176
  165. package/src/ui/screens/ModelSelectorScreen.js +0 -296
  166. package/src/ui/screens/ModelSelectorScreen.tsx +0 -444
  167. package/src/ui/screens/PluginsScreen.js +0 -769
  168. package/src/ui/screens/ProfilesScreen.js +0 -298
  169. package/src/ui/screens/SkillsScreen.js +0 -549
  170. package/src/ui/screens/StatusLineScreen.js +0 -206
  171. package/src/ui/screens/StatusLineScreen.tsx +0 -416
  172. package/src/ui/screens/index.js +0 -10
  173. package/src/ui/state/AnimationContext.js +0 -34
  174. package/src/ui/state/AppContext.js +0 -162
  175. package/src/ui/state/DimensionsContext.js +0 -71
  176. package/src/ui/state/reducer.js +0 -547
  177. package/src/ui/state/types.js +0 -1
  178. package/src/ui/theme.js +0 -47
  179. package/src/utils/clipboard.js +0 -56
  180. package/src/utils/command-utils.js +0 -19
  181. package/src/utils/fuzzy-search.js +0 -101
  182. 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
+ }