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,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
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `profile sync` — promote edits made to the active profile dir back into the
|
|
3
|
+
* committed manifest.
|
|
4
|
+
*
|
|
5
|
+
* `.claude/_profiles/<name>/` is generated build output, so an edit to the
|
|
6
|
+
* symlinked settings.json / mcp.json would be lost on the next `install`. Sync
|
|
7
|
+
* reverse-materializes those files into `.claude/profiles.json` so the change
|
|
8
|
+
* becomes a reviewable manifest diff instead of silent, throwaway drift.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from "fs-extra";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import type {
|
|
14
|
+
McpServerConfig,
|
|
15
|
+
ProfileManifest,
|
|
16
|
+
ProfileManifestEntry,
|
|
17
|
+
} from "../types/index.js";
|
|
18
|
+
import { readManifest, writeManifest } from "./manifest.js";
|
|
19
|
+
import { profileDir } from "./symlink-manager.js";
|
|
20
|
+
|
|
21
|
+
export interface SyncChange {
|
|
22
|
+
kind: "plugin-added" | "plugin-removed" | "settings-changed" | "mcp-changed";
|
|
23
|
+
detail: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Merge a materialized profile's settings.json + mcp.json back into a manifest
|
|
28
|
+
* entry. Pure: returns the updated entry and the list of changes.
|
|
29
|
+
*
|
|
30
|
+
* - enabledPlugins present but not in the entry -> added as "latest".
|
|
31
|
+
* - entry plugins that are no longer enabled -> removed.
|
|
32
|
+
* - an existing version PIN is preserved (enabled != downgrade-to-latest).
|
|
33
|
+
* - non-enabledPlugins settings keys replace the entry's settings.
|
|
34
|
+
* - mcpServers replace the entry's mcpServers when they differ.
|
|
35
|
+
*/
|
|
36
|
+
export function computeSync(
|
|
37
|
+
entry: ProfileManifestEntry,
|
|
38
|
+
materializedSettings: Record<string, unknown>,
|
|
39
|
+
materializedMcp: { mcpServers?: Record<string, McpServerConfig> },
|
|
40
|
+
now: string = new Date().toISOString(),
|
|
41
|
+
): { entry: ProfileManifestEntry; changes: SyncChange[] } {
|
|
42
|
+
const changes: SyncChange[] = [];
|
|
43
|
+
const next: ProfileManifestEntry = { ...entry };
|
|
44
|
+
|
|
45
|
+
const enabled = (materializedSettings.enabledPlugins ?? {}) as Record<
|
|
46
|
+
string,
|
|
47
|
+
boolean
|
|
48
|
+
>;
|
|
49
|
+
const currentPlugins = { ...(entry.plugins ?? {}) };
|
|
50
|
+
|
|
51
|
+
// Additions: enabled plugins the manifest doesn't list.
|
|
52
|
+
for (const [id, on] of Object.entries(enabled)) {
|
|
53
|
+
if (on && !(id in currentPlugins)) {
|
|
54
|
+
currentPlugins[id] = "latest";
|
|
55
|
+
changes.push({ kind: "plugin-added", detail: id });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// Removals: manifest plugins no longer enabled.
|
|
59
|
+
for (const id of Object.keys(entry.plugins ?? {})) {
|
|
60
|
+
if (!enabled[id]) {
|
|
61
|
+
delete currentPlugins[id];
|
|
62
|
+
changes.push({ kind: "plugin-removed", detail: id });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
next.plugins = currentPlugins;
|
|
66
|
+
|
|
67
|
+
// Settings = everything in the materialized file except enabledPlugins.
|
|
68
|
+
const { enabledPlugins: _drop, ...settings } = materializedSettings;
|
|
69
|
+
if (JSON.stringify(settings) !== JSON.stringify(entry.settings ?? {})) {
|
|
70
|
+
if (Object.keys(settings).length > 0) next.settings = settings;
|
|
71
|
+
else delete next.settings;
|
|
72
|
+
changes.push({ kind: "settings-changed", detail: "settings updated" });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// MCP servers.
|
|
76
|
+
const mcp = materializedMcp.mcpServers ?? {};
|
|
77
|
+
if (JSON.stringify(mcp) !== JSON.stringify(entry.mcpServers ?? {})) {
|
|
78
|
+
if (Object.keys(mcp).length > 0) next.mcpServers = mcp;
|
|
79
|
+
else delete next.mcpServers;
|
|
80
|
+
changes.push({ kind: "mcp-changed", detail: "mcp servers updated" });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Refresh the timestamp when anything changed. `now` is injectable so
|
|
84
|
+
// callers/tests control it (and computeSync stays pure).
|
|
85
|
+
if (changes.length > 0) next.updatedAt = now;
|
|
86
|
+
|
|
87
|
+
return { entry: next, changes };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Detect drift between a materialized profile and the manifest WITHOUT writing.
|
|
92
|
+
* Returns the changes `sync` would promote (empty = in sync). Returns [] if the
|
|
93
|
+
* profile isn't materialized (nothing to lose).
|
|
94
|
+
*/
|
|
95
|
+
export async function detectProfileDrift(
|
|
96
|
+
name: string,
|
|
97
|
+
projectPath?: string,
|
|
98
|
+
): Promise<SyncChange[]> {
|
|
99
|
+
const manifest = await readManifest(projectPath);
|
|
100
|
+
const entry = manifest.profiles[name];
|
|
101
|
+
if (!entry) return [];
|
|
102
|
+
|
|
103
|
+
const dir = profileDir(name, projectPath);
|
|
104
|
+
const settingsPath = path.join(dir, "settings.json");
|
|
105
|
+
if (!(await fs.pathExists(settingsPath))) return [];
|
|
106
|
+
|
|
107
|
+
const mcpPath = path.join(dir, "mcp.json");
|
|
108
|
+
const materializedSettings = await fs.readJson(settingsPath);
|
|
109
|
+
const materializedMcp = (await fs.pathExists(mcpPath))
|
|
110
|
+
? await fs.readJson(mcpPath)
|
|
111
|
+
: { mcpServers: {} };
|
|
112
|
+
|
|
113
|
+
return computeSync(entry, materializedSettings, materializedMcp).changes;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Read the materialized active profile, merge it into the manifest, and write
|
|
118
|
+
* the manifest back if anything changed. Returns the applied changes.
|
|
119
|
+
*/
|
|
120
|
+
export async function syncProfile(
|
|
121
|
+
name: string,
|
|
122
|
+
projectPath?: string,
|
|
123
|
+
): Promise<{ changes: SyncChange[]; wrote: boolean }> {
|
|
124
|
+
const manifest: ProfileManifest = await readManifest(projectPath);
|
|
125
|
+
const entry = manifest.profiles[name];
|
|
126
|
+
if (!entry) throw new Error(`Profile "${name}" not found in manifest`);
|
|
127
|
+
|
|
128
|
+
const dir = profileDir(name, projectPath);
|
|
129
|
+
const settingsPath = path.join(dir, "settings.json");
|
|
130
|
+
const mcpPath = path.join(dir, "mcp.json");
|
|
131
|
+
if (!(await fs.pathExists(settingsPath))) {
|
|
132
|
+
throw new Error(`Profile "${name}" isn't materialized (${settingsPath} missing).`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const materializedSettings = await fs.readJson(settingsPath);
|
|
136
|
+
const materializedMcp = (await fs.pathExists(mcpPath))
|
|
137
|
+
? await fs.readJson(mcpPath)
|
|
138
|
+
: { mcpServers: {} };
|
|
139
|
+
|
|
140
|
+
const { entry: nextEntry, changes } = computeSync(
|
|
141
|
+
entry,
|
|
142
|
+
materializedSettings,
|
|
143
|
+
materializedMcp,
|
|
144
|
+
);
|
|
145
|
+
if (changes.length === 0) return { changes: [], wrote: false };
|
|
146
|
+
|
|
147
|
+
manifest.profiles[name] = nextEntry;
|
|
148
|
+
await writeManifest(manifest, projectPath);
|
|
149
|
+
return { changes, wrote: true };
|
|
150
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Profile resolver — compute the install closure for a team profile.
|
|
3
|
+
*
|
|
4
|
+
* Turns a manifest entry into a fully-resolved {@link ResolvedClosure}: the
|
|
5
|
+
* marketplaces, plugins, MCP servers, binary deps, skills, settings, and env a
|
|
6
|
+
* machine must have to match the profile. Binary deps are the union of the
|
|
7
|
+
* profile's own `cliTools` and every enabled plugin's declared `requires.bin`,
|
|
8
|
+
* deduped by executable name with the project's pin winning.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import semver from "semver";
|
|
12
|
+
import type {
|
|
13
|
+
BinRequirement,
|
|
14
|
+
ProfileManifest,
|
|
15
|
+
ProfileManifestEntry,
|
|
16
|
+
ResolvedBin,
|
|
17
|
+
ResolvedClosure,
|
|
18
|
+
} from "../types/index.js";
|
|
19
|
+
import { cliTools } from "../data/cli-tools.js";
|
|
20
|
+
import { PREDEFINED_PROFILES } from "../data/predefined-profiles.js";
|
|
21
|
+
import { resolvePluginBinRequirements } from "./plugin-requires.js";
|
|
22
|
+
|
|
23
|
+
export interface ResolveOptions {
|
|
24
|
+
/**
|
|
25
|
+
* Resolve a plugin id to its declared binary requirements. Injectable for
|
|
26
|
+
* tests; defaults to reading plugin.json from the marketplace cache.
|
|
27
|
+
*/
|
|
28
|
+
binResolver?: (pluginId: string) => Promise<BinRequirement[]>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Normalize a manifest plugins map to pluginId -> version ("latest" for true). */
|
|
32
|
+
function normalizePlugins(
|
|
33
|
+
plugins: Record<string, string | boolean> | undefined,
|
|
34
|
+
): Record<string, string> {
|
|
35
|
+
const out: Record<string, string> = {};
|
|
36
|
+
for (const [id, ver] of Object.entries(plugins ?? {})) {
|
|
37
|
+
if (ver === false) continue; // explicitly disabled
|
|
38
|
+
out[id] = ver === true || ver === "" ? "latest" : String(ver);
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Merge a predefined profile (referenced via `extends`) UNDER an entry: the
|
|
45
|
+
* entry's own plugins/settings take precedence. Predefined skills are bare
|
|
46
|
+
* display names without repo/path, so they are NOT installable and are skipped.
|
|
47
|
+
*/
|
|
48
|
+
export function resolveExtends(
|
|
49
|
+
entry: ProfileManifestEntry,
|
|
50
|
+
): ProfileManifestEntry {
|
|
51
|
+
if (!entry.extends) return entry;
|
|
52
|
+
const base = PREDEFINED_PROFILES.find((p) => p.id === entry.extends);
|
|
53
|
+
if (!base) return entry;
|
|
54
|
+
|
|
55
|
+
const inheritedPlugins: Record<string, string | boolean> = {};
|
|
56
|
+
for (const name of base.magusPlugins) {
|
|
57
|
+
// Entries may pin a marketplace explicitly (e.g. "seo@magus-marketing")
|
|
58
|
+
// for plugins that ship on a channel other than magus. Bare names
|
|
59
|
+
// still default to @magus.
|
|
60
|
+
inheritedPlugins[name.includes("@") ? name : `${name}@magus`] = "latest";
|
|
61
|
+
}
|
|
62
|
+
for (const name of base.anthropicPlugins) {
|
|
63
|
+
inheritedPlugins[`${name}@claude-plugins-official`] = "latest";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
...entry,
|
|
68
|
+
// Entry values win over inherited ones.
|
|
69
|
+
plugins: { ...inheritedPlugins, ...(entry.plugins ?? {}) },
|
|
70
|
+
settings: { ...base.settings, ...(entry.settings ?? {}) },
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Map a manifest cliTools entry (name -> version) to a BinRequirement. */
|
|
75
|
+
function cliToolToBin(name: string, version: string): BinRequirement {
|
|
76
|
+
const tool = cliTools.find((t) => t.name === name);
|
|
77
|
+
const pinned = version && version !== "latest" ? version : undefined;
|
|
78
|
+
if (tool) {
|
|
79
|
+
return {
|
|
80
|
+
name,
|
|
81
|
+
via: tool.packageManager,
|
|
82
|
+
package: tool.packageName,
|
|
83
|
+
check: tool.checkCommand,
|
|
84
|
+
version: pinned,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
// Unknown tool: default to bun (claudeup's toolchain) with name as package.
|
|
88
|
+
return {
|
|
89
|
+
name,
|
|
90
|
+
via: "bun",
|
|
91
|
+
package: name,
|
|
92
|
+
check: `${name} --version`,
|
|
93
|
+
version: pinned,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Dedupe binary requirements by executable name. Entries are processed in the
|
|
99
|
+
* order given, so callers put higher-precedence sources first (project pins
|
|
100
|
+
* before plugin-declared deps); the first entry's installer/version wins and
|
|
101
|
+
* later ones only contribute provenance.
|
|
102
|
+
*/
|
|
103
|
+
export function dedupeBins(
|
|
104
|
+
entries: Array<{ bin: BinRequirement; source: string }>,
|
|
105
|
+
): ResolvedBin[] {
|
|
106
|
+
const byName = new Map<string, ResolvedBin>();
|
|
107
|
+
for (const { bin, source } of entries) {
|
|
108
|
+
const existing = byName.get(bin.name);
|
|
109
|
+
if (existing) {
|
|
110
|
+
if (!existing.sources.includes(source)) existing.sources.push(source);
|
|
111
|
+
// A later entry only fills a version the winner left unset.
|
|
112
|
+
if (existing.version === undefined && bin.version !== undefined) {
|
|
113
|
+
existing.version = bin.version;
|
|
114
|
+
}
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
byName.set(bin.name, { ...bin, sources: [source] });
|
|
118
|
+
}
|
|
119
|
+
return [...byName.values()];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Collect the binary requirements for a resolved plugin set + profile cliTools. */
|
|
123
|
+
async function resolveBins(
|
|
124
|
+
entry: ProfileManifestEntry,
|
|
125
|
+
pluginIds: string[],
|
|
126
|
+
binResolver: (pluginId: string) => Promise<BinRequirement[]>,
|
|
127
|
+
): Promise<ResolvedBin[]> {
|
|
128
|
+
const collected: Array<{ bin: BinRequirement; source: string }> = [];
|
|
129
|
+
|
|
130
|
+
// Project pins first (highest precedence).
|
|
131
|
+
for (const [name, version] of Object.entries(entry.cliTools ?? {})) {
|
|
132
|
+
collected.push({
|
|
133
|
+
bin: cliToolToBin(name, version),
|
|
134
|
+
source: `profile:${entry.name}.cliTools`,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Then each plugin's declared binary deps.
|
|
139
|
+
for (const pluginId of pluginIds) {
|
|
140
|
+
const bins = await binResolver(pluginId);
|
|
141
|
+
for (const bin of bins) {
|
|
142
|
+
collected.push({ bin, source: `plugin:${pluginId}` });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return dedupeBins(collected);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Resolve a single profile (applying `extends`) into an install closure. */
|
|
150
|
+
export async function resolveProfile(
|
|
151
|
+
manifest: ProfileManifest,
|
|
152
|
+
profileId: string,
|
|
153
|
+
opts: ResolveOptions = {},
|
|
154
|
+
): Promise<ResolvedClosure> {
|
|
155
|
+
const raw = manifest.profiles[profileId];
|
|
156
|
+
if (!raw) throw new Error(`Profile "${profileId}" not found in manifest`);
|
|
157
|
+
const entry = resolveExtends(raw);
|
|
158
|
+
const binResolver = opts.binResolver ?? resolvePluginBinRequirements;
|
|
159
|
+
|
|
160
|
+
const plugins = normalizePlugins(entry.plugins);
|
|
161
|
+
const bins = await resolveBins(entry, Object.keys(plugins), binResolver);
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
marketplaces: { ...(entry.marketplaces ?? {}) },
|
|
165
|
+
plugins,
|
|
166
|
+
mcpServers: { ...(entry.mcpServers ?? {}) },
|
|
167
|
+
bins,
|
|
168
|
+
skills: [...(entry.skills ?? [])],
|
|
169
|
+
settings: { ...(entry.settings ?? {}) },
|
|
170
|
+
env: {
|
|
171
|
+
required: [...(entry.env?.required ?? [])],
|
|
172
|
+
optional: [...(entry.env?.optional ?? [])],
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Deterministically pick between two version specs for the same id. "latest"
|
|
179
|
+
* always yields to an explicit pin; two DIFFERENT explicit pins are a conflict,
|
|
180
|
+
* resolved to the higher semver (order-independent) and reported.
|
|
181
|
+
*/
|
|
182
|
+
function pickVersion(
|
|
183
|
+
id: string,
|
|
184
|
+
a: string,
|
|
185
|
+
b: string,
|
|
186
|
+
): { chosen: string; conflict?: string } {
|
|
187
|
+
if (a === b) return { chosen: a };
|
|
188
|
+
if (a === "latest") return { chosen: b };
|
|
189
|
+
if (b === "latest") return { chosen: a };
|
|
190
|
+
const higher =
|
|
191
|
+
semver.valid(a) && semver.valid(b)
|
|
192
|
+
? semver.gt(a, b)
|
|
193
|
+
? a
|
|
194
|
+
: b
|
|
195
|
+
: a > b
|
|
196
|
+
? a
|
|
197
|
+
: b; // lexical fallback for non-semver pins
|
|
198
|
+
return {
|
|
199
|
+
chosen: higher,
|
|
200
|
+
conflict: `${id}: profiles pin ${a} and ${b}; using ${higher}`,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Merge closure `b` into `a` (union). Version conflicts (plugins and bins) are
|
|
206
|
+
* resolved deterministically to the higher pin — NOT by profile order — and
|
|
207
|
+
* collected into `conflicts` so `install`/`profile show` can surface them.
|
|
208
|
+
*/
|
|
209
|
+
function mergeClosures(a: ResolvedClosure, b: ResolvedClosure): ResolvedClosure {
|
|
210
|
+
const conflicts = [...(a.conflicts ?? []), ...(b.conflicts ?? [])];
|
|
211
|
+
|
|
212
|
+
// Plugins: deterministic version pick per id.
|
|
213
|
+
const plugins: Record<string, string> = { ...a.plugins };
|
|
214
|
+
for (const [id, ver] of Object.entries(b.plugins)) {
|
|
215
|
+
if (plugins[id] === undefined) {
|
|
216
|
+
plugins[id] = ver;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
const { chosen, conflict } = pickVersion(id, plugins[id]!, ver);
|
|
220
|
+
plugins[id] = chosen;
|
|
221
|
+
if (conflict) conflicts.push(conflict);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Bins: union by name, deterministic version pick; keep the winning entry's
|
|
225
|
+
// installer/fields and accumulate provenance from both.
|
|
226
|
+
const binByName = new Map<string, ResolvedBin>();
|
|
227
|
+
for (const bin of [...a.bins, ...b.bins]) {
|
|
228
|
+
const existing = binByName.get(bin.name);
|
|
229
|
+
if (!existing) {
|
|
230
|
+
binByName.set(bin.name, { ...bin, sources: [...bin.sources] });
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
const mergedSources = [...new Set([...existing.sources, ...bin.sources])];
|
|
234
|
+
const va = existing.version;
|
|
235
|
+
const vb = bin.version;
|
|
236
|
+
if (va === undefined && vb === undefined) {
|
|
237
|
+
existing.sources = mergedSources;
|
|
238
|
+
} else if (va === undefined) {
|
|
239
|
+
binByName.set(bin.name, { ...bin, sources: mergedSources });
|
|
240
|
+
} else if (vb === undefined) {
|
|
241
|
+
existing.sources = mergedSources;
|
|
242
|
+
} else {
|
|
243
|
+
const { chosen, conflict } = pickVersion(bin.name, va, vb);
|
|
244
|
+
const winner = chosen === va ? existing : bin;
|
|
245
|
+
binByName.set(bin.name, { ...winner, sources: mergedSources });
|
|
246
|
+
if (conflict) conflicts.push(conflict);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Skills: union by repo+path.
|
|
251
|
+
const skillKey = (s: { repo: string; path: string }) => `${s.repo}#${s.path}`;
|
|
252
|
+
const skillMap = new Map(b.skills.map((s) => [skillKey(s), s]));
|
|
253
|
+
for (const s of a.skills) skillMap.set(skillKey(s), s);
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
marketplaces: { ...b.marketplaces, ...a.marketplaces },
|
|
257
|
+
plugins,
|
|
258
|
+
mcpServers: { ...b.mcpServers, ...a.mcpServers },
|
|
259
|
+
bins: [...binByName.values()],
|
|
260
|
+
skills: [...skillMap.values()],
|
|
261
|
+
settings: { ...b.settings, ...a.settings },
|
|
262
|
+
env: {
|
|
263
|
+
required: [...new Set([...a.env.required, ...b.env.required])],
|
|
264
|
+
optional: [...new Set([...a.env.optional, ...b.env.optional])],
|
|
265
|
+
},
|
|
266
|
+
...(conflicts.length > 0 ? { conflicts: [...new Set(conflicts)] } : {}),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Resolve the union of ALL profiles in a manifest — the closure `claudeup
|
|
272
|
+
* install` (no argument) materializes so every profile's deps are present.
|
|
273
|
+
*/
|
|
274
|
+
export async function resolveAllProfiles(
|
|
275
|
+
manifest: ProfileManifest,
|
|
276
|
+
opts: ResolveOptions = {},
|
|
277
|
+
): Promise<ResolvedClosure> {
|
|
278
|
+
const ids = Object.keys(manifest.profiles);
|
|
279
|
+
let union: ResolvedClosure = {
|
|
280
|
+
marketplaces: {},
|
|
281
|
+
plugins: {},
|
|
282
|
+
mcpServers: {},
|
|
283
|
+
bins: [],
|
|
284
|
+
skills: [],
|
|
285
|
+
settings: {},
|
|
286
|
+
env: { required: [], optional: [] },
|
|
287
|
+
};
|
|
288
|
+
for (const id of ids) {
|
|
289
|
+
const closure = await resolveProfile(manifest, id, opts);
|
|
290
|
+
union = mergeClosures(union, closure);
|
|
291
|
+
}
|
|
292
|
+
return union;
|
|
293
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Profile symlink manager.
|
|
3
|
+
*
|
|
4
|
+
* Materialized profiles live at `.claude/_profiles/<name>/` (generated,
|
|
5
|
+
* gitignored). The active profile is made "live" by symlinking the shared
|
|
6
|
+
* config files into place:
|
|
7
|
+
*
|
|
8
|
+
* .claude/settings.json -> .claude/_profiles/<name>/settings.json
|
|
9
|
+
* .claude/skills -> .claude/_profiles/<name>/skills
|
|
10
|
+
* .mcp.json -> .claude/_profiles/<name>/mcp.json
|
|
11
|
+
*
|
|
12
|
+
* Switching profiles just repoints these links — no network, no reinstall.
|
|
13
|
+
* settings.local.json is NEVER linked: it holds personal credentials and must
|
|
14
|
+
* survive switches untouched.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import fs from "fs-extra";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Paths that must be gitignored once a project uses profiles: the generated
|
|
22
|
+
* build output plus the active-profile symlinks. Only .claude/profiles.json is
|
|
23
|
+
* the committed source of truth. settings.local.json is intentionally NOT here
|
|
24
|
+
* (it's already gitignored elsewhere and holds personal credentials).
|
|
25
|
+
*/
|
|
26
|
+
export const PROFILE_GITIGNORE_ENTRIES = [
|
|
27
|
+
".claude/_profiles/",
|
|
28
|
+
".claude/settings.json",
|
|
29
|
+
".claude/skills",
|
|
30
|
+
".mcp.json",
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
/** The generated, gitignored directory holding materialized profiles. */
|
|
34
|
+
export function profilesRoot(projectPath?: string): string {
|
|
35
|
+
const base = projectPath ?? process.cwd();
|
|
36
|
+
return path.join(base, ".claude", "_profiles");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The materialized directory for one profile. */
|
|
40
|
+
export function profileDir(name: string, projectPath?: string): string {
|
|
41
|
+
return path.join(profilesRoot(projectPath), name);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The set of links an active profile owns, as [linkPath, targetPath] pairs. */
|
|
45
|
+
function linkTargets(
|
|
46
|
+
name: string,
|
|
47
|
+
projectPath?: string,
|
|
48
|
+
): Array<{ link: string; target: string }> {
|
|
49
|
+
const base = projectPath ?? process.cwd();
|
|
50
|
+
const dir = profileDir(name, projectPath);
|
|
51
|
+
return [
|
|
52
|
+
{
|
|
53
|
+
link: path.join(base, ".claude", "settings.json"),
|
|
54
|
+
target: path.join(dir, "settings.json"),
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
link: path.join(base, ".claude", "skills"),
|
|
58
|
+
target: path.join(dir, "skills"),
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
link: path.join(base, ".mcp.json"),
|
|
62
|
+
target: path.join(dir, "mcp.json"),
|
|
63
|
+
},
|
|
64
|
+
];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Point `link` at `target` with a relative symlink. Any existing file, dir, or
|
|
69
|
+
* link at `link` is removed first. The target need not exist yet (dangling
|
|
70
|
+
* links are tolerated so a profile can be activated before every artifact is
|
|
71
|
+
* materialized), but its parent is created.
|
|
72
|
+
*/
|
|
73
|
+
async function relink(link: string, target: string): Promise<void> {
|
|
74
|
+
await fs.ensureDir(path.dirname(link));
|
|
75
|
+
await fs.remove(link);
|
|
76
|
+
const rel = path.relative(path.dirname(link), target);
|
|
77
|
+
await fs.symlink(rel, link);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Activate a profile: repoint every shared-config symlink into its
|
|
82
|
+
* materialized directory. Only links whose target exists in the profile dir
|
|
83
|
+
* are created (e.g. a profile with no skills/ dir gets no skills symlink).
|
|
84
|
+
*/
|
|
85
|
+
export async function activateProfile(
|
|
86
|
+
name: string,
|
|
87
|
+
projectPath?: string,
|
|
88
|
+
): Promise<void> {
|
|
89
|
+
const dir = profileDir(name, projectPath);
|
|
90
|
+
if (!(await fs.pathExists(dir))) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`Profile "${name}" is not materialized (${dir} missing). Run \`claudeup install ${name}\`.`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
for (const { link, target } of linkTargets(name, projectPath)) {
|
|
96
|
+
if (await fs.pathExists(target)) {
|
|
97
|
+
await relink(link, target);
|
|
98
|
+
} else {
|
|
99
|
+
// Profile doesn't provide this artifact — remove a stale link only if
|
|
100
|
+
// it currently points into a profile dir (don't clobber a real file).
|
|
101
|
+
if (await isProfileLink(link, projectPath)) {
|
|
102
|
+
await fs.remove(link);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Is `link` a symlink pointing into `.claude/_profiles/`? */
|
|
109
|
+
async function isProfileLink(
|
|
110
|
+
link: string,
|
|
111
|
+
projectPath?: string,
|
|
112
|
+
): Promise<boolean> {
|
|
113
|
+
try {
|
|
114
|
+
const stat = await fs.lstat(link);
|
|
115
|
+
if (!stat.isSymbolicLink()) return false;
|
|
116
|
+
const resolved = path.resolve(
|
|
117
|
+
path.dirname(link),
|
|
118
|
+
await fs.readlink(link),
|
|
119
|
+
);
|
|
120
|
+
return resolved.startsWith(profilesRoot(projectPath) + path.sep);
|
|
121
|
+
} catch {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The name of the currently active profile, derived from where
|
|
128
|
+
* .claude/settings.json points. Returns null if it isn't a profile symlink.
|
|
129
|
+
*/
|
|
130
|
+
export async function activeProfile(
|
|
131
|
+
projectPath?: string,
|
|
132
|
+
): Promise<string | null> {
|
|
133
|
+
const base = projectPath ?? process.cwd();
|
|
134
|
+
const link = path.join(base, ".claude", "settings.json");
|
|
135
|
+
try {
|
|
136
|
+
const stat = await fs.lstat(link);
|
|
137
|
+
if (!stat.isSymbolicLink()) return null;
|
|
138
|
+
const resolved = path.resolve(path.dirname(link), await fs.readlink(link));
|
|
139
|
+
const root = profilesRoot(projectPath) + path.sep;
|
|
140
|
+
if (!resolved.startsWith(root)) return null;
|
|
141
|
+
// resolved = <root>/<name>/settings.json → take the first segment.
|
|
142
|
+
return resolved.slice(root.length).split(path.sep)[0] ?? null;
|
|
143
|
+
} catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|