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