claudeup 4.18.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.
- package/bin/claudeup.js +43 -19
- package/package.json +14 -19
- package/scripts/build-binaries.ts +77 -0
- package/scripts/check-optional-deps.ts +49 -0
- package/scripts/test-isolated.ts +43 -0
- package/src/__tests__/alias-adopt.test.ts +364 -0
- package/src/__tests__/alias-parser.test.ts +92 -0
- package/src/__tests__/alias-shell-writer.test.ts +7 -0
- package/src/__tests__/alias-store.test.ts +77 -0
- package/src/__tests__/cli-router.test.ts +65 -0
- package/src/__tests__/conventions-integration.test.ts +745 -0
- package/src/__tests__/conventions-manager.test.ts +1172 -0
- package/src/__tests__/doctor-bins.test.ts +35 -0
- package/src/__tests__/doctor.test.ts +450 -0
- package/src/__tests__/dual-write-prevention.test.ts +14 -4
- package/src/__tests__/file-locking.test.ts +208 -0
- package/src/__tests__/gitignore-prerun.test.ts +1 -0
- package/src/__tests__/install-command.test.ts +81 -0
- package/src/__tests__/install-plan.test.ts +98 -0
- package/src/__tests__/manifest.test.ts +136 -0
- package/src/__tests__/marketplace-refresh.test.ts +267 -0
- package/src/__tests__/plugin-requires.test.ts +133 -0
- package/src/__tests__/plugin-setup.test.ts +118 -0
- package/src/__tests__/profile-command.test.ts +116 -0
- package/src/__tests__/profile-materializer.test.ts +82 -0
- package/src/__tests__/profile-sync.test.ts +136 -0
- package/src/__tests__/registry-version-resolution.test.ts +74 -0
- package/src/__tests__/resolve-executable.test.ts +42 -0
- package/src/__tests__/resolver.test.ts +218 -0
- package/src/__tests__/symlink-manager.test.ts +99 -0
- package/src/__tests__/toolchain.test.ts +56 -0
- package/src/cli/claude.ts +21 -0
- package/src/cli/doctor.ts +132 -0
- package/src/cli/install.ts +360 -0
- package/src/cli/profile.ts +166 -0
- package/src/cli/router.ts +107 -0
- package/src/cli/update.ts +85 -0
- package/src/data/alias-flags.ts +11 -1
- package/src/data/cli-tools.ts +7 -7
- package/src/data/gitignore-defaults.ts +4 -0
- package/src/data/marketplaces.ts +12 -0
- package/src/data/predefined-profiles.ts +3 -4
- package/src/main.tsx +11 -154
- package/src/prerunner/index.ts +62 -27
- package/src/services/alias-shell-writer.ts +382 -8
- package/src/services/alias-store.ts +60 -0
- package/src/services/claude-cli.ts +19 -22
- package/src/services/claude-settings.ts +99 -26
- package/src/services/conventions-manager.ts +865 -0
- package/src/services/doctor-bins.ts +49 -0
- package/src/services/doctor.ts +509 -0
- package/src/services/install-plan.ts +107 -0
- package/src/services/manifest.ts +166 -0
- package/src/services/marketplace-refresh.ts +173 -0
- package/src/services/plugin-manager.ts +7 -1
- package/src/services/plugin-requires.ts +215 -0
- package/src/services/plugin-setup.ts +61 -4
- package/src/services/plugin-version-check.ts +9 -4
- package/src/services/profile-materializer.ts +61 -0
- package/src/services/profile-sync.ts +150 -0
- package/src/services/resolver.ts +293 -0
- package/src/services/symlink-manager.ts +146 -0
- package/src/services/toolchain.ts +97 -0
- package/src/services/version-check.ts +10 -11
- package/src/types/index.ts +138 -31
- package/src/ui/App.tsx +16 -11
- package/src/ui/components/FlagDetailEditor.tsx +0 -0
- package/src/ui/components/modals/ConfirmModal.tsx +1 -1
- package/src/ui/screens/AliasScreen.tsx +491 -359
- package/src/ui/screens/CliToolsScreen.tsx +11 -3
- package/src/ui/screens/PluginsScreen.tsx +3 -1
- package/src/ui/screens/index.ts +0 -1
- package/src/ui/state/reducer.ts +6 -102
- package/src/ui/state/types.ts +8 -35
- package/src/utils/command-utils.ts +23 -0
- package/src/utils/file-locking.ts +144 -0
- package/src/data/alias-flags.js +0 -196
- package/src/data/cli-tools.js +0 -123
- package/src/data/gitignore-defaults.js +0 -24
- package/src/data/gitignore-reasons.js +0 -97
- package/src/data/gitignore-templates.js +0 -21
- package/src/data/marketplaces.js +0 -138
- package/src/data/mcp-servers.js +0 -509
- package/src/data/predefined-profiles.js +0 -248
- package/src/data/settings-catalog.js +0 -625
- package/src/data/skill-repos.js +0 -160
- package/src/data/statuslines.js +0 -159
- package/src/data/statuslines.ts +0 -188
- package/src/index.js +0 -4
- package/src/index.ts +0 -5
- package/src/main.js +0 -170
- package/src/prerunner/index.js +0 -252
- package/src/services/alias-settings.js +0 -51
- package/src/services/alias-shell-writer.js +0 -764
- package/src/services/alias-store.js +0 -77
- package/src/services/claude-cli.js +0 -189
- package/src/services/claude-runner.js +0 -28
- package/src/services/claude-settings.js +0 -1223
- package/src/services/gitignore-detector.js +0 -155
- package/src/services/gitignore-fixer.js +0 -231
- package/src/services/gitignore-prerun.js +0 -46
- package/src/services/gitignore-resolver.js +0 -143
- package/src/services/gitignore-service.js +0 -117
- package/src/services/local-marketplace.js +0 -339
- package/src/services/marketplace-fetcher.js +0 -96
- package/src/services/marketplace-sync.js +0 -94
- package/src/services/mcp-registry.js +0 -87
- package/src/services/plugin-manager.js +0 -473
- package/src/services/plugin-mcp-config.js +0 -177
- package/src/services/plugin-setup.js +0 -444
- package/src/services/plugin-version-check.js +0 -262
- package/src/services/profiles.js +0 -161
- package/src/services/settings-manager.js +0 -243
- package/src/services/skills-manager.js +0 -393
- package/src/services/skillsmp-client.js +0 -87
- package/src/services/update-cache.js +0 -52
- package/src/services/version-check.js +0 -99
- package/src/types/gitignore.js +0 -6
- package/src/types/index.js +0 -1
- package/src/ui/App.js +0 -346
- package/src/ui/adapters/pluginsAdapter.js +0 -139
- package/src/ui/adapters/settingsAdapter.js +0 -111
- package/src/ui/adapters/skillsAdapter.js +0 -154
- package/src/ui/components/CategoryHeader.js +0 -9
- package/src/ui/components/EmptyFilterState.js +0 -4
- package/src/ui/components/ScopeIndicator.js +0 -30
- package/src/ui/components/ScrollableList.js +0 -36
- package/src/ui/components/SearchInput.js +0 -19
- package/src/ui/components/StyledText.js +0 -39
- package/src/ui/components/TabBar.js +0 -19
- package/src/ui/components/layout/FooterHints.js +0 -29
- package/src/ui/components/layout/Panel.js +0 -6
- package/src/ui/components/layout/ProgressBar.js +0 -14
- package/src/ui/components/layout/ScopeTabs.js +0 -6
- package/src/ui/components/layout/ScreenLayout.js +0 -16
- package/src/ui/components/layout/index.js +0 -5
- package/src/ui/components/modals/ConfirmModal.js +0 -14
- package/src/ui/components/modals/InputModal.js +0 -5
- package/src/ui/components/modals/LoadingModal.js +0 -17
- package/src/ui/components/modals/MessageModal.js +0 -16
- package/src/ui/components/modals/ModalContainer.js +0 -137
- package/src/ui/components/modals/SelectModal.js +0 -18
- package/src/ui/components/modals/VersionMismatchModal.js +0 -36
- package/src/ui/components/modals/index.js +0 -6
- package/src/ui/components/primitives/ActionHints.js +0 -13
- package/src/ui/components/primitives/DetailSection.js +0 -7
- package/src/ui/components/primitives/KeyValueLine.js +0 -8
- package/src/ui/components/primitives/ListCategoryRow.js +0 -8
- package/src/ui/components/primitives/MetaText.js +0 -8
- package/src/ui/components/primitives/ScopeDetail.js +0 -32
- package/src/ui/components/primitives/ScopeSquares.js +0 -11
- package/src/ui/components/primitives/SelectableRow.js +0 -5
- package/src/ui/components/primitives/index.js +0 -8
- package/src/ui/hooks/index.js +0 -4
- package/src/ui/hooks/useAsyncData.js +0 -77
- package/src/ui/hooks/useGitignoreModal.js +0 -74
- package/src/ui/hooks/useKeyboard.js +0 -13
- package/src/ui/hooks/useKeyboardHandler.js +0 -39
- package/src/ui/hooks/useMismatchModal.js +0 -77
- package/src/ui/registry.js +0 -1
- package/src/ui/renderers/cliToolRenderers.js +0 -54
- package/src/ui/renderers/gitignoreRenderers.js +0 -46
- package/src/ui/renderers/mcpRenderers.js +0 -26
- package/src/ui/renderers/pluginRenderers.js +0 -124
- package/src/ui/renderers/profileRenderers.js +0 -177
- package/src/ui/renderers/settingsRenderers.js +0 -73
- package/src/ui/renderers/skillRenderers.js +0 -138
- package/src/ui/screens/AliasScreen.js +0 -1008
- package/src/ui/screens/CliToolsScreen.js +0 -338
- package/src/ui/screens/EnvVarsScreen.js +0 -152
- package/src/ui/screens/GitignoreScreen.js +0 -328
- package/src/ui/screens/McpRegistryScreen.js +0 -238
- package/src/ui/screens/McpScreen.js +0 -176
- package/src/ui/screens/ModelSelectorScreen.js +0 -296
- package/src/ui/screens/ModelSelectorScreen.tsx +0 -444
- package/src/ui/screens/PluginsScreen.js +0 -766
- package/src/ui/screens/ProfilesScreen.js +0 -298
- package/src/ui/screens/SkillsScreen.js +0 -549
- package/src/ui/screens/StatusLineScreen.js +0 -206
- package/src/ui/screens/StatusLineScreen.tsx +0 -416
- package/src/ui/screens/index.js +0 -10
- package/src/ui/state/AnimationContext.js +0 -34
- package/src/ui/state/AppContext.js +0 -162
- package/src/ui/state/DimensionsContext.js +0 -71
- package/src/ui/state/reducer.js +0 -543
- package/src/ui/state/types.js +0 -1
- package/src/ui/theme.js +0 -47
- package/src/utils/clipboard.js +0 -56
- package/src/utils/command-utils.js +0 -19
- package/src/utils/fuzzy-search.js +0 -101
- 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
|
+
}
|