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.
- 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__/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 +7 -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/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/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-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 +0 -4
- package/src/ui/screens/CliToolsScreen.tsx +11 -3
- package/src/ui/screens/index.ts +0 -1
- package/src/ui/state/reducer.ts +0 -101
- package/src/ui/state/types.ts +0 -35
- package/src/utils/command-utils.ts +23 -0
- package/src/utils/file-locking.ts +144 -0
- package/src/data/alias-flags.js +0 -205
- 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 -1018
- package/src/services/alias-store.js +0 -129
- 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 -499
- 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 -355
- 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/FlagDetailEditor.js +0 -0
- 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 -1111
- 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 -769
- 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 -547
- 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,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Team profile manifest (.claude/profiles.json) — read, migrate, validate.
|
|
3
|
+
*
|
|
4
|
+
* This is the v2 view over the same project-scoped profiles.json that the TUI's
|
|
5
|
+
* v1 saved-profiles code (services/profiles.ts) reads. v1 files migrate forward
|
|
6
|
+
* transparently: a v1 profile (`{name, plugins: Record<string,boolean>}`) is a
|
|
7
|
+
* structural subset of a v2 manifest entry, so migration is a version bump.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from "fs-extra";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import type { ProfileManifest, ProfileManifestEntry } from "../types/index.js";
|
|
13
|
+
|
|
14
|
+
const PROFILES_FILE = "profiles.json";
|
|
15
|
+
|
|
16
|
+
/** Project-scoped manifest path: <projectPath|cwd>/.claude/profiles.json */
|
|
17
|
+
export function getManifestPath(projectPath?: string): string {
|
|
18
|
+
const base = projectPath ?? process.cwd();
|
|
19
|
+
return path.join(base, ".claude", PROFILES_FILE);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** An empty, valid v2 manifest. */
|
|
23
|
+
export function emptyManifest(): ProfileManifest {
|
|
24
|
+
return { version: 2, profiles: {} };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Normalize any parsed profiles.json (v1 or v2) into a v2 manifest.
|
|
29
|
+
* Unknown/missing shapes degrade to an empty manifest rather than throwing —
|
|
30
|
+
* validation is the caller's job via {@link validateManifest}.
|
|
31
|
+
*/
|
|
32
|
+
export function migrateToV2(raw: unknown): ProfileManifest {
|
|
33
|
+
if (!raw || typeof raw !== "object") return emptyManifest();
|
|
34
|
+
const obj = raw as Record<string, unknown>;
|
|
35
|
+
|
|
36
|
+
const profilesRaw =
|
|
37
|
+
obj.profiles && typeof obj.profiles === "object"
|
|
38
|
+
? (obj.profiles as Record<string, unknown>)
|
|
39
|
+
: {};
|
|
40
|
+
|
|
41
|
+
const profiles: Record<string, ProfileManifestEntry> = {};
|
|
42
|
+
for (const [id, entry] of Object.entries(profilesRaw)) {
|
|
43
|
+
if (entry && typeof entry === "object") {
|
|
44
|
+
// v1 and v2 entries are both accepted as-is; v1's plugins map
|
|
45
|
+
// (Record<string, boolean>) is valid under v2's widened type.
|
|
46
|
+
profiles[id] = entry as ProfileManifestEntry;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const manifest: ProfileManifest = { version: 2, profiles };
|
|
51
|
+
// Only carry strictVersions when enabled, so a committed manifest that
|
|
52
|
+
// doesn't opt in stays minimal (no spurious `"strictVersions": false`).
|
|
53
|
+
if (obj.strictVersions === true) manifest.strictVersions = true;
|
|
54
|
+
return manifest;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Read and migrate the project manifest. Returns an empty v2 manifest if the
|
|
59
|
+
* file is absent or unreadable.
|
|
60
|
+
*/
|
|
61
|
+
export async function readManifest(
|
|
62
|
+
projectPath?: string,
|
|
63
|
+
): Promise<ProfileManifest> {
|
|
64
|
+
const filePath = getManifestPath(projectPath);
|
|
65
|
+
try {
|
|
66
|
+
if (await fs.pathExists(filePath)) {
|
|
67
|
+
return migrateToV2(await fs.readJson(filePath));
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
// Corrupt/unreadable — fall through to empty.
|
|
71
|
+
}
|
|
72
|
+
return emptyManifest();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Write the manifest back to disk (used by `profile sync`, tests). */
|
|
76
|
+
export async function writeManifest(
|
|
77
|
+
manifest: ProfileManifest,
|
|
78
|
+
projectPath?: string,
|
|
79
|
+
): Promise<void> {
|
|
80
|
+
const filePath = getManifestPath(projectPath);
|
|
81
|
+
await fs.ensureDir(path.dirname(filePath));
|
|
82
|
+
await fs.writeJson(filePath, manifest, { spaces: 2 });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface ManifestValidationError {
|
|
86
|
+
/** Dotted path to the offending field, e.g. "profiles.frontend.plugins". */
|
|
87
|
+
path: string;
|
|
88
|
+
message: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Validate a manifest structurally. Returns an empty array when valid.
|
|
93
|
+
* Strict enough to back `install --check`; does not touch the network or FS.
|
|
94
|
+
*/
|
|
95
|
+
export function validateManifest(
|
|
96
|
+
manifest: ProfileManifest,
|
|
97
|
+
): ManifestValidationError[] {
|
|
98
|
+
const errors: ManifestValidationError[] = [];
|
|
99
|
+
|
|
100
|
+
if (manifest.version !== 2) {
|
|
101
|
+
errors.push({
|
|
102
|
+
path: "version",
|
|
103
|
+
message: `expected version 2, got ${JSON.stringify(manifest.version)}`,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const ids = Object.keys(manifest.profiles ?? {});
|
|
108
|
+
if (ids.length === 0) {
|
|
109
|
+
errors.push({ path: "profiles", message: "no profiles defined" });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
for (const [id, entry] of Object.entries(manifest.profiles ?? {})) {
|
|
113
|
+
const base = `profiles.${id}`;
|
|
114
|
+
if (!entry || typeof entry !== "object") {
|
|
115
|
+
errors.push({ path: base, message: "profile must be an object" });
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (!entry.name || typeof entry.name !== "string") {
|
|
119
|
+
errors.push({ path: `${base}.name`, message: "missing name" });
|
|
120
|
+
}
|
|
121
|
+
if (entry.plugins !== undefined) {
|
|
122
|
+
for (const [pluginId, ver] of Object.entries(entry.plugins)) {
|
|
123
|
+
if (typeof ver !== "string" && typeof ver !== "boolean") {
|
|
124
|
+
errors.push({
|
|
125
|
+
path: `${base}.plugins.${pluginId}`,
|
|
126
|
+
message: "version must be a string or boolean",
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
if (!pluginId.includes("@")) {
|
|
130
|
+
errors.push({
|
|
131
|
+
path: `${base}.plugins.${pluginId}`,
|
|
132
|
+
message: "plugin id must be in the form name@marketplace",
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (entry.marketplaces !== undefined) {
|
|
138
|
+
for (const [mpName, ref] of Object.entries(entry.marketplaces)) {
|
|
139
|
+
if (ref.source === "github" && !ref.repo) {
|
|
140
|
+
errors.push({
|
|
141
|
+
path: `${base}.marketplaces.${mpName}`,
|
|
142
|
+
message: "github marketplace requires `repo`",
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
if (ref.source === "directory" && !ref.path) {
|
|
146
|
+
errors.push({
|
|
147
|
+
path: `${base}.marketplaces.${mpName}`,
|
|
148
|
+
message: "directory marketplace requires `path`",
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (entry.skills !== undefined) {
|
|
154
|
+
entry.skills.forEach((s, i) => {
|
|
155
|
+
if (!s.name || !s.repo || !s.path) {
|
|
156
|
+
errors.push({
|
|
157
|
+
path: `${base}.skills[${i}]`,
|
|
158
|
+
message: "skill requires name, repo, and path",
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return errors;
|
|
166
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keep on-disk marketplace clones current — safely.
|
|
3
|
+
*
|
|
4
|
+
* The plugin *list* claudeup shows is fetched fresh from GitHub, but a plugin's
|
|
5
|
+
* `setup` block and Claude Code's own installer read the on-disk marketplace
|
|
6
|
+
* clone (~/.claude/plugins/marketplaces/<name>). Nothing else keeps that clone
|
|
7
|
+
* current — recoverMarketplaceSettings only pulls entries it had to re-register —
|
|
8
|
+
* so a healthy marketplace's clone goes stale and its plugin metadata (e.g. a
|
|
9
|
+
* pinned dependency version in a plugin's setup) is never seen, even while the
|
|
10
|
+
* displayed version looks up to date.
|
|
11
|
+
*
|
|
12
|
+
* IMPORTANT: this refreshes the clone with a NON-DESTRUCTIVE `git pull --ff-only`,
|
|
13
|
+
* never `claude plugin marketplace update`. The latter (cacheMarketplaceFromGit)
|
|
14
|
+
* deletes the clone directory before re-cloning, so a failed clone leaves the
|
|
15
|
+
* marketplace permanently deleted — a documented hazard this codebase deliberately
|
|
16
|
+
* avoids (see marketplace-sync.ts and ai-docs/plugin-marketplace-bug-investigation.md).
|
|
17
|
+
* A failed `git pull --ff-only` leaves the clone exactly as it was: stale, but intact.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { spawn } from "node:child_process";
|
|
21
|
+
import fs from "node:fs";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
import {
|
|
24
|
+
getConfiguredMarketplaces,
|
|
25
|
+
getGlobalClaudeDir,
|
|
26
|
+
getMarketplaceAutoUpdate,
|
|
27
|
+
} from "./claude-settings.js";
|
|
28
|
+
|
|
29
|
+
/** Per-git-invocation timeout. A hung pull must never block launching Claude. */
|
|
30
|
+
const GIT_TIMEOUT_MS = 30_000;
|
|
31
|
+
|
|
32
|
+
export interface RefreshResult {
|
|
33
|
+
/** Clones fast-forwarded to a newer commit (or already current). */
|
|
34
|
+
refreshed: string[];
|
|
35
|
+
/** Pull errored (network, diverged, timeout) — clone left intact and stale. */
|
|
36
|
+
failed: string[];
|
|
37
|
+
/** Nothing to do: no clone on disk, not a git repo, dirty tree, or opted out. */
|
|
38
|
+
skipped: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function cloneDir(name: string): string {
|
|
42
|
+
return path.join(getGlobalClaudeDir(), "plugins", "marketplaces", name);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Run git in `cwd`, resolving stdout on exit 0. Killed after `timeoutMs`. */
|
|
46
|
+
function runGit(
|
|
47
|
+
cwd: string,
|
|
48
|
+
args: string[],
|
|
49
|
+
timeoutMs: number,
|
|
50
|
+
): Promise<string> {
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
const proc = spawn("git", args, {
|
|
53
|
+
cwd,
|
|
54
|
+
timeout: timeoutMs,
|
|
55
|
+
killSignal: "SIGKILL",
|
|
56
|
+
});
|
|
57
|
+
let stdout = "";
|
|
58
|
+
let stderr = "";
|
|
59
|
+
proc.stdout?.on("data", (b) => {
|
|
60
|
+
stdout += b.toString();
|
|
61
|
+
});
|
|
62
|
+
proc.stderr?.on("data", (b) => {
|
|
63
|
+
stderr += b.toString();
|
|
64
|
+
});
|
|
65
|
+
proc.on("error", reject);
|
|
66
|
+
proc.on("close", (code, signal) => {
|
|
67
|
+
if (code === 0) {
|
|
68
|
+
resolve(stdout);
|
|
69
|
+
} else {
|
|
70
|
+
reject(
|
|
71
|
+
new Error(
|
|
72
|
+
`git ${args.join(" ")} ${
|
|
73
|
+
signal ? `killed (${signal})` : `exited ${code}`
|
|
74
|
+
}: ${stderr.trim()}`,
|
|
75
|
+
),
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** True if the clone has uncommitted changes we must not clobber. */
|
|
83
|
+
async function isDirty(dir: string): Promise<boolean> {
|
|
84
|
+
const out = await runGit(dir, ["status", "--porcelain"], GIT_TIMEOUT_MS);
|
|
85
|
+
return out.trim().length > 0;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Outcome of fast-forwarding one clone:
|
|
90
|
+
* - `absent` no clone on disk (caller may create it via `marketplace add`)
|
|
91
|
+
* - `skipped` dirty tree — left untouched so local edits survive
|
|
92
|
+
* - `refreshed` fast-forwarded, or already current
|
|
93
|
+
* - `failed` pull errored (network, diverged, timeout) — clone left intact
|
|
94
|
+
*/
|
|
95
|
+
export type CloneRefresh = "absent" | "skipped" | "refreshed" | "failed";
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Non-destructively fast-forward ONE marketplace clone. Never throws, never
|
|
99
|
+
* deletes: the worst case is `failed` (stale clone left intact). Shared by the
|
|
100
|
+
* batch refresh below and by claude-cli's "plugin not found" recovery, so both
|
|
101
|
+
* paths get the same safe semantics instead of the old `marketplace update`.
|
|
102
|
+
*/
|
|
103
|
+
export async function fastForwardCloneIfPresent(
|
|
104
|
+
name: string,
|
|
105
|
+
): Promise<CloneRefresh> {
|
|
106
|
+
const dir = cloneDir(name);
|
|
107
|
+
// No clone on disk (claude never installed it) or not a git repo.
|
|
108
|
+
if (!fs.existsSync(path.join(dir, ".git"))) return "absent";
|
|
109
|
+
try {
|
|
110
|
+
// Never clobber a user's local edits to the clone.
|
|
111
|
+
if (await isDirty(dir)) return "skipped";
|
|
112
|
+
// --ff-only is the safety guarantee: it fetches and fast-forwards, and
|
|
113
|
+
// aborts without mutating the tree if it can't. It never deletes the dir,
|
|
114
|
+
// so a failure degrades to "stale clone", never "deleted marketplace".
|
|
115
|
+
await runGit(dir, ["pull", "--ff-only"], GIT_TIMEOUT_MS);
|
|
116
|
+
return "refreshed";
|
|
117
|
+
} catch {
|
|
118
|
+
return "failed";
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Non-destructively fast-forward the on-disk clone of every registered,
|
|
124
|
+
* auto-update-enabled, github-sourced marketplace.
|
|
125
|
+
*
|
|
126
|
+
* @param skip marketplaces already refreshed this run (e.g. re-registered ones),
|
|
127
|
+
* so they aren't pulled twice.
|
|
128
|
+
* @returns which marketplaces were refreshed, which failed (clone left intact),
|
|
129
|
+
* and which were skipped. Failures and skips are collected rather than thrown —
|
|
130
|
+
* one unreachable marketplace must not block launching Claude. Pulls run
|
|
131
|
+
* concurrently, so a slow one doesn't serialize the rest onto the launch path.
|
|
132
|
+
*/
|
|
133
|
+
export async function refreshRegisteredMarketplaces(
|
|
134
|
+
skip: Iterable<string> = [],
|
|
135
|
+
): Promise<RefreshResult> {
|
|
136
|
+
const refreshed: string[] = [];
|
|
137
|
+
const failed: string[] = [];
|
|
138
|
+
const skipped: string[] = [];
|
|
139
|
+
|
|
140
|
+
const configured = await getConfiguredMarketplaces();
|
|
141
|
+
const skipSet = new Set(skip);
|
|
142
|
+
|
|
143
|
+
// Select eligible marketplaces first so the pulls can run concurrently.
|
|
144
|
+
const eligible: string[] = [];
|
|
145
|
+
for (const [name, cfg] of Object.entries(configured)) {
|
|
146
|
+
if (skipSet.has(name)) continue;
|
|
147
|
+
if (cfg.source?.source !== "github" || !cfg.source?.repo) {
|
|
148
|
+
skipped.push(name);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if ((await getMarketplaceAutoUpdate(name)) === false) {
|
|
152
|
+
skipped.push(name);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
eligible.push(name);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Fast-forward every eligible clone in parallel so a slow one doesn't
|
|
159
|
+
// serialize the rest onto the launch path. fastForwardCloneIfPresent never
|
|
160
|
+
// throws, so the batch always resolves.
|
|
161
|
+
const statuses = await Promise.all(
|
|
162
|
+
eligible.map((name) => fastForwardCloneIfPresent(name)),
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
statuses.forEach((status, i) => {
|
|
166
|
+
const name = eligible[i];
|
|
167
|
+
if (status === "refreshed") refreshed.push(name);
|
|
168
|
+
else if (status === "failed") failed.push(name);
|
|
169
|
+
else skipped.push(name); // absent | skipped
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
return { refreshed, failed, skipped };
|
|
173
|
+
}
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
getGlobalInstalledPluginVersions,
|
|
12
12
|
getLocalEnabledPlugins,
|
|
13
13
|
getLocalInstalledPluginVersions,
|
|
14
|
+
getProjectInstalledPluginVersions,
|
|
14
15
|
updateInstalledPluginsRegistry,
|
|
15
16
|
removeFromInstalledPluginsRegistry,
|
|
16
17
|
} from "./claude-settings.js";
|
|
@@ -470,7 +471,12 @@ async function getInstalledPluginVersions(
|
|
|
470
471
|
projectPath?: string,
|
|
471
472
|
): Promise<Record<string, string>> {
|
|
472
473
|
const settings = await readSettings(projectPath);
|
|
473
|
-
|
|
474
|
+
// Settings alone under-report: Claude Code's own `plugin install` updates the
|
|
475
|
+
// registry without touching our settings key, so overlay the registry.
|
|
476
|
+
return getProjectInstalledPluginVersions(
|
|
477
|
+
settings.installedPluginVersions || {},
|
|
478
|
+
projectPath,
|
|
479
|
+
);
|
|
474
480
|
}
|
|
475
481
|
|
|
476
482
|
// Save installed plugin version to settings.json
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read plugin-declared binary dependencies from plugin.json `requires.bin`.
|
|
3
|
+
*
|
|
4
|
+
* A plugin declares the binaries it needs on PATH (e.g. terminal@magus needs
|
|
5
|
+
* `tmux` and `tmux-mcp`). `claudeup install` resolves these transitively so a
|
|
6
|
+
* teammate who enables a plugin also gets its binaries — closing the class of
|
|
7
|
+
* bug where a plugin's MCP server silently fails because its binary is missing.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from "fs-extra";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import type {
|
|
14
|
+
BinInstaller,
|
|
15
|
+
BinRequirement,
|
|
16
|
+
PluginRequires,
|
|
17
|
+
} from "../types/index.js";
|
|
18
|
+
import { parsePluginId } from "../utils/string-utils.js";
|
|
19
|
+
import { getLocalMarketplacesInfo } from "./plugin-manager.js";
|
|
20
|
+
|
|
21
|
+
const MARKETPLACES_DIR = (): string =>
|
|
22
|
+
path.join(os.homedir(), ".claude", "plugins", "marketplaces");
|
|
23
|
+
|
|
24
|
+
const VALID_INSTALLERS: readonly BinInstaller[] = [
|
|
25
|
+
"bun",
|
|
26
|
+
"brew",
|
|
27
|
+
"npm",
|
|
28
|
+
"pip",
|
|
29
|
+
"go",
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Normalize a raw `requires.bin` entry into a complete BinRequirement,
|
|
34
|
+
* filling in defaults. Returns null for entries that can't be made valid
|
|
35
|
+
* (missing name, or an unrecognized installer).
|
|
36
|
+
*/
|
|
37
|
+
function normalizeBin(raw: unknown): BinRequirement | null {
|
|
38
|
+
if (!raw || typeof raw !== "object") return null;
|
|
39
|
+
const o = raw as Record<string, unknown>;
|
|
40
|
+
const name = typeof o.name === "string" ? o.name.trim() : "";
|
|
41
|
+
if (!name) return null;
|
|
42
|
+
|
|
43
|
+
const via = o.via as BinInstaller;
|
|
44
|
+
if (!VALID_INSTALLERS.includes(via)) return null;
|
|
45
|
+
|
|
46
|
+
const bin: BinRequirement = {
|
|
47
|
+
name,
|
|
48
|
+
via,
|
|
49
|
+
check:
|
|
50
|
+
typeof o.check === "string" && o.check.trim()
|
|
51
|
+
? o.check.trim()
|
|
52
|
+
: `${name} --version`,
|
|
53
|
+
};
|
|
54
|
+
if (typeof o.package === "string") bin.package = o.package;
|
|
55
|
+
if (typeof o.formula === "string") bin.formula = o.formula;
|
|
56
|
+
if (typeof o.module === "string") bin.module = o.module;
|
|
57
|
+
if (typeof o.version === "string") bin.version = o.version;
|
|
58
|
+
return bin;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Split a package/module spec into its base and optional version. Handles
|
|
62
|
+
* scoped npm (@scope/pkg@1.2.0) and go modules (host/path@latest). */
|
|
63
|
+
function splitVersion(spec: string): { base: string; version?: string } {
|
|
64
|
+
const at = spec.lastIndexOf("@");
|
|
65
|
+
// at > 0 avoids treating a leading npm scope "@scope/..." as a version.
|
|
66
|
+
if (at > 0) return { base: spec.slice(0, at), version: spec.slice(at + 1) };
|
|
67
|
+
return { base: spec };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const basename = (p: string): string => p.split("/").pop() ?? p;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Map a plugin.json `setup` block (the established convention: browser-use uses
|
|
74
|
+
* `pip`, terminal uses `go` + `required`) to BinRequirements.
|
|
75
|
+
* setup: { bun|npm|pip: string[], go: string[], brew: string[], required: string[] }
|
|
76
|
+
*/
|
|
77
|
+
function parseSetup(setup: unknown): BinRequirement[] {
|
|
78
|
+
if (!setup || typeof setup !== "object") return [];
|
|
79
|
+
const s = setup as Record<string, unknown>;
|
|
80
|
+
const bins: BinRequirement[] = [];
|
|
81
|
+
|
|
82
|
+
const pkgManager = (
|
|
83
|
+
key: "bun" | "npm" | "pip",
|
|
84
|
+
): void => {
|
|
85
|
+
for (const spec of (s[key] as string[] | undefined) ?? []) {
|
|
86
|
+
if (typeof spec !== "string") continue;
|
|
87
|
+
const { base, version } = splitVersion(spec);
|
|
88
|
+
bins.push({
|
|
89
|
+
name: basename(base),
|
|
90
|
+
via: key,
|
|
91
|
+
package: base,
|
|
92
|
+
version,
|
|
93
|
+
check: `${basename(base)} --version`,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
pkgManager("bun");
|
|
98
|
+
pkgManager("npm");
|
|
99
|
+
pkgManager("pip");
|
|
100
|
+
|
|
101
|
+
for (const spec of (s.go as string[] | undefined) ?? []) {
|
|
102
|
+
if (typeof spec !== "string") continue;
|
|
103
|
+
const { base, version } = splitVersion(spec);
|
|
104
|
+
bins.push({
|
|
105
|
+
name: basename(base),
|
|
106
|
+
via: "go",
|
|
107
|
+
module: base,
|
|
108
|
+
version,
|
|
109
|
+
check: `${basename(base)} --version`,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
for (const spec of (s.brew as string[] | undefined) ?? []) {
|
|
113
|
+
if (typeof spec !== "string") continue;
|
|
114
|
+
bins.push({ name: spec, via: "brew", formula: spec, check: `${spec} --version` });
|
|
115
|
+
}
|
|
116
|
+
// `required` names a system tool that must be present; brew is the assumed
|
|
117
|
+
// installer (matches the "system tools via brew" decision).
|
|
118
|
+
for (const name of (s.required as string[] | undefined) ?? []) {
|
|
119
|
+
if (typeof name !== "string") continue;
|
|
120
|
+
bins.push({ name, via: "brew", formula: name, check: `${name} --version` });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return bins;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Parse a plugin.json's binary requirements from BOTH the established `setup`
|
|
128
|
+
* block and the richer explicit `requires.bin` form. On a name collision the
|
|
129
|
+
* explicit `requires.bin` entry wins (it can override installer/check/version).
|
|
130
|
+
*/
|
|
131
|
+
export function parsePluginRequires(pluginJson: unknown): PluginRequires {
|
|
132
|
+
if (!pluginJson || typeof pluginJson !== "object") return {};
|
|
133
|
+
const obj = pluginJson as Record<string, unknown>;
|
|
134
|
+
|
|
135
|
+
const fromSetup = parseSetup(obj.setup);
|
|
136
|
+
|
|
137
|
+
const requires = obj.requires;
|
|
138
|
+
const binRaw =
|
|
139
|
+
requires && typeof requires === "object"
|
|
140
|
+
? (requires as Record<string, unknown>).bin
|
|
141
|
+
: undefined;
|
|
142
|
+
const fromRequires = Array.isArray(binRaw)
|
|
143
|
+
? binRaw.map(normalizeBin).filter((b): b is BinRequirement => b !== null)
|
|
144
|
+
: [];
|
|
145
|
+
|
|
146
|
+
// Merge, requires.bin winning by name.
|
|
147
|
+
const byName = new Map<string, BinRequirement>();
|
|
148
|
+
for (const b of fromSetup) byName.set(b.name, b);
|
|
149
|
+
for (const b of fromRequires) byName.set(b.name, b);
|
|
150
|
+
|
|
151
|
+
const bin = [...byName.values()];
|
|
152
|
+
return bin.length > 0 ? { bin } : {};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Read `requires` from a plugin.json at a known plugin directory. */
|
|
156
|
+
export async function readPluginRequires(
|
|
157
|
+
pluginPath: string,
|
|
158
|
+
): Promise<PluginRequires> {
|
|
159
|
+
const manifestPath = path.join(pluginPath, "plugin.json");
|
|
160
|
+
try {
|
|
161
|
+
if (await fs.pathExists(manifestPath)) {
|
|
162
|
+
return parsePluginRequires(await fs.readJson(manifestPath));
|
|
163
|
+
}
|
|
164
|
+
// Some plugins nest the manifest under .claude-plugin/plugin.json.
|
|
165
|
+
const nested = path.join(pluginPath, ".claude-plugin", "plugin.json");
|
|
166
|
+
if (await fs.pathExists(nested)) {
|
|
167
|
+
return parsePluginRequires(await fs.readJson(nested));
|
|
168
|
+
}
|
|
169
|
+
} catch {
|
|
170
|
+
// Unreadable/malformed — treat as no requirements.
|
|
171
|
+
}
|
|
172
|
+
return {};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Resolve a plugin id (name@marketplace) to its on-disk source directory in
|
|
177
|
+
* the local marketplace cache. Returns null if the marketplace/plugin/source
|
|
178
|
+
* can't be located. Mirrors doctor.ts's resolvePluginPath.
|
|
179
|
+
*/
|
|
180
|
+
export async function resolvePluginPath(
|
|
181
|
+
pluginId: string,
|
|
182
|
+
): Promise<string | null> {
|
|
183
|
+
const parsed = parsePluginId(pluginId);
|
|
184
|
+
if (!parsed) return null;
|
|
185
|
+
const { pluginName, marketplace } = parsed;
|
|
186
|
+
|
|
187
|
+
const localMarketplaces = await getLocalMarketplacesInfo();
|
|
188
|
+
const localMp = localMarketplaces.get(marketplace);
|
|
189
|
+
if (!localMp) return null;
|
|
190
|
+
|
|
191
|
+
const localPlugin = localMp.plugins.find((p) => p.name === pluginName);
|
|
192
|
+
if (!localPlugin?.source || typeof localPlugin.source !== "string") {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const pluginPath = path.join(
|
|
197
|
+
MARKETPLACES_DIR(),
|
|
198
|
+
marketplace,
|
|
199
|
+
localPlugin.source.replace("./", ""),
|
|
200
|
+
);
|
|
201
|
+
return (await fs.pathExists(pluginPath)) ? pluginPath : null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Resolve the binary requirements a plugin declares. Returns an empty array
|
|
206
|
+
* when the plugin can't be located or declares none.
|
|
207
|
+
*/
|
|
208
|
+
export async function resolvePluginBinRequirements(
|
|
209
|
+
pluginId: string,
|
|
210
|
+
): Promise<BinRequirement[]> {
|
|
211
|
+
const pluginPath = await resolvePluginPath(pluginId);
|
|
212
|
+
if (!pluginPath) return [];
|
|
213
|
+
const requires = await readPluginRequires(pluginPath);
|
|
214
|
+
return requires.bin ?? [];
|
|
215
|
+
}
|
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Plugin version mismatch detection and fix.
|
|
3
3
|
*
|
|
4
|
-
* Claude Code's plugin loader
|
|
4
|
+
* HISTORICAL (pre-2.1.209): Claude Code's plugin loader took the FIRST entry
|
|
5
5
|
* from installed_plugins.json for each plugin ID:
|
|
6
6
|
*
|
|
7
7
|
* const installEntry = installedPluginsData.plugins[pluginId]?.[0]
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* so the first project to install a plugin determined the version loaded for
|
|
10
|
+
* ALL projects (bug #45997).
|
|
11
|
+
*
|
|
12
|
+
* FIXED in Claude Code 2.1.209: the loader is now project-aware — it filters
|
|
13
|
+
* entries to the current project/scope (same git root or user/managed scope)
|
|
14
|
+
* BEFORE selecting, so cross-project [0] contention no longer applies. This
|
|
15
|
+
* detection therefore mostly warns on older Claude Code; it is retained for
|
|
16
|
+
* those versions but should not be treated as a current-version defect.
|
|
12
17
|
*
|
|
13
18
|
* Bug report: https://github.com/anthropics/claude-code/issues/45997
|
|
14
19
|
*/
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Materialize a resolved profile into `.claude/_profiles/<name>/`.
|
|
3
|
+
*
|
|
4
|
+
* Writes the shared config the symlink manager later points at:
|
|
5
|
+
* _profiles/<name>/settings.json — enabledPlugins + the profile's settings
|
|
6
|
+
* _profiles/<name>/mcp.json — the profile's MCP servers
|
|
7
|
+
* _profiles/<name>/skills/ — created empty; skills install into it
|
|
8
|
+
* (through the activated symlink)
|
|
9
|
+
*
|
|
10
|
+
* This is pure output derived from a ResolvedClosure — it performs no installs
|
|
11
|
+
* and touches no global state, so it is fully testable with a temp dir.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import fs from "fs-extra";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import type { ResolvedClosure } from "../types/index.js";
|
|
17
|
+
import { profileDir } from "./symlink-manager.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Build the settings.json contents for a profile: every resolved plugin
|
|
21
|
+
* enabled, merged with the profile's declared settings. The profile's settings
|
|
22
|
+
* win over the derived enabledPlugins only if it explicitly sets that key
|
|
23
|
+
* (it normally won't).
|
|
24
|
+
*/
|
|
25
|
+
export function buildProfileSettings(
|
|
26
|
+
closure: ResolvedClosure,
|
|
27
|
+
): Record<string, unknown> {
|
|
28
|
+
const enabledPlugins: Record<string, boolean> = {};
|
|
29
|
+
for (const pluginId of Object.keys(closure.plugins)) {
|
|
30
|
+
enabledPlugins[pluginId] = true;
|
|
31
|
+
}
|
|
32
|
+
return { enabledPlugins, ...closure.settings };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Build the .mcp.json contents for a profile. */
|
|
36
|
+
export function buildProfileMcp(
|
|
37
|
+
closure: ResolvedClosure,
|
|
38
|
+
): { mcpServers: Record<string, unknown> } {
|
|
39
|
+
return { mcpServers: { ...closure.mcpServers } };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Write `_profiles/<name>/` from a resolved closure. Idempotent: overwrites
|
|
44
|
+
* settings.json / mcp.json and ensures skills/ exists. Returns the dir path.
|
|
45
|
+
*/
|
|
46
|
+
export async function materializeProfile(
|
|
47
|
+
name: string,
|
|
48
|
+
closure: ResolvedClosure,
|
|
49
|
+
projectPath?: string,
|
|
50
|
+
): Promise<string> {
|
|
51
|
+
const dir = profileDir(name, projectPath);
|
|
52
|
+
await fs.ensureDir(dir);
|
|
53
|
+
await fs.writeJson(path.join(dir, "settings.json"), buildProfileSettings(closure), {
|
|
54
|
+
spaces: 2,
|
|
55
|
+
});
|
|
56
|
+
await fs.writeJson(path.join(dir, "mcp.json"), buildProfileMcp(closure), {
|
|
57
|
+
spaces: 2,
|
|
58
|
+
});
|
|
59
|
+
await fs.ensureDir(path.join(dir, "skills"));
|
|
60
|
+
return dir;
|
|
61
|
+
}
|