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.
Files changed (180) hide show
  1. package/bin/claudeup.js +43 -19
  2. package/package.json +14 -19
  3. package/scripts/build-binaries.ts +77 -0
  4. package/scripts/check-optional-deps.ts +49 -0
  5. package/scripts/test-isolated.ts +43 -0
  6. package/src/__tests__/cli-router.test.ts +65 -0
  7. package/src/__tests__/conventions-integration.test.ts +745 -0
  8. package/src/__tests__/conventions-manager.test.ts +1172 -0
  9. package/src/__tests__/doctor-bins.test.ts +35 -0
  10. package/src/__tests__/doctor.test.ts +450 -0
  11. package/src/__tests__/dual-write-prevention.test.ts +14 -4
  12. package/src/__tests__/file-locking.test.ts +208 -0
  13. package/src/__tests__/gitignore-prerun.test.ts +1 -0
  14. package/src/__tests__/install-command.test.ts +81 -0
  15. package/src/__tests__/install-plan.test.ts +98 -0
  16. package/src/__tests__/manifest.test.ts +136 -0
  17. package/src/__tests__/marketplace-refresh.test.ts +267 -0
  18. package/src/__tests__/plugin-requires.test.ts +133 -0
  19. package/src/__tests__/plugin-setup.test.ts +7 -0
  20. package/src/__tests__/profile-command.test.ts +116 -0
  21. package/src/__tests__/profile-materializer.test.ts +82 -0
  22. package/src/__tests__/profile-sync.test.ts +136 -0
  23. package/src/__tests__/registry-version-resolution.test.ts +74 -0
  24. package/src/__tests__/resolve-executable.test.ts +42 -0
  25. package/src/__tests__/resolver.test.ts +218 -0
  26. package/src/__tests__/symlink-manager.test.ts +99 -0
  27. package/src/__tests__/toolchain.test.ts +56 -0
  28. package/src/cli/claude.ts +21 -0
  29. package/src/cli/doctor.ts +132 -0
  30. package/src/cli/install.ts +360 -0
  31. package/src/cli/profile.ts +166 -0
  32. package/src/cli/router.ts +107 -0
  33. package/src/cli/update.ts +85 -0
  34. package/src/data/cli-tools.ts +7 -7
  35. package/src/data/gitignore-defaults.ts +4 -0
  36. package/src/data/marketplaces.ts +12 -0
  37. package/src/data/predefined-profiles.ts +3 -4
  38. package/src/main.tsx +11 -154
  39. package/src/prerunner/index.ts +62 -27
  40. package/src/services/claude-cli.ts +19 -22
  41. package/src/services/claude-settings.ts +99 -26
  42. package/src/services/conventions-manager.ts +865 -0
  43. package/src/services/doctor-bins.ts +49 -0
  44. package/src/services/doctor.ts +509 -0
  45. package/src/services/install-plan.ts +107 -0
  46. package/src/services/manifest.ts +166 -0
  47. package/src/services/marketplace-refresh.ts +173 -0
  48. package/src/services/plugin-manager.ts +7 -1
  49. package/src/services/plugin-requires.ts +215 -0
  50. package/src/services/plugin-version-check.ts +9 -4
  51. package/src/services/profile-materializer.ts +61 -0
  52. package/src/services/profile-sync.ts +150 -0
  53. package/src/services/resolver.ts +293 -0
  54. package/src/services/symlink-manager.ts +146 -0
  55. package/src/services/toolchain.ts +97 -0
  56. package/src/services/version-check.ts +10 -11
  57. package/src/types/index.ts +138 -31
  58. package/src/ui/App.tsx +0 -4
  59. package/src/ui/screens/CliToolsScreen.tsx +11 -3
  60. package/src/ui/screens/index.ts +0 -1
  61. package/src/ui/state/reducer.ts +0 -101
  62. package/src/ui/state/types.ts +0 -35
  63. package/src/utils/command-utils.ts +23 -0
  64. package/src/utils/file-locking.ts +144 -0
  65. package/src/data/alias-flags.js +0 -205
  66. package/src/data/cli-tools.js +0 -123
  67. package/src/data/gitignore-defaults.js +0 -24
  68. package/src/data/gitignore-reasons.js +0 -97
  69. package/src/data/gitignore-templates.js +0 -21
  70. package/src/data/marketplaces.js +0 -138
  71. package/src/data/mcp-servers.js +0 -509
  72. package/src/data/predefined-profiles.js +0 -248
  73. package/src/data/settings-catalog.js +0 -625
  74. package/src/data/skill-repos.js +0 -160
  75. package/src/data/statuslines.js +0 -159
  76. package/src/data/statuslines.ts +0 -188
  77. package/src/index.js +0 -4
  78. package/src/index.ts +0 -5
  79. package/src/main.js +0 -170
  80. package/src/prerunner/index.js +0 -252
  81. package/src/services/alias-settings.js +0 -51
  82. package/src/services/alias-shell-writer.js +0 -1018
  83. package/src/services/alias-store.js +0 -129
  84. package/src/services/claude-cli.js +0 -189
  85. package/src/services/claude-runner.js +0 -28
  86. package/src/services/claude-settings.js +0 -1223
  87. package/src/services/gitignore-detector.js +0 -155
  88. package/src/services/gitignore-fixer.js +0 -231
  89. package/src/services/gitignore-prerun.js +0 -46
  90. package/src/services/gitignore-resolver.js +0 -143
  91. package/src/services/gitignore-service.js +0 -117
  92. package/src/services/local-marketplace.js +0 -339
  93. package/src/services/marketplace-fetcher.js +0 -96
  94. package/src/services/marketplace-sync.js +0 -94
  95. package/src/services/mcp-registry.js +0 -87
  96. package/src/services/plugin-manager.js +0 -473
  97. package/src/services/plugin-mcp-config.js +0 -177
  98. package/src/services/plugin-setup.js +0 -499
  99. package/src/services/plugin-version-check.js +0 -262
  100. package/src/services/profiles.js +0 -161
  101. package/src/services/settings-manager.js +0 -243
  102. package/src/services/skills-manager.js +0 -393
  103. package/src/services/skillsmp-client.js +0 -87
  104. package/src/services/update-cache.js +0 -52
  105. package/src/services/version-check.js +0 -99
  106. package/src/types/gitignore.js +0 -6
  107. package/src/types/index.js +0 -1
  108. package/src/ui/App.js +0 -355
  109. package/src/ui/adapters/pluginsAdapter.js +0 -139
  110. package/src/ui/adapters/settingsAdapter.js +0 -111
  111. package/src/ui/adapters/skillsAdapter.js +0 -154
  112. package/src/ui/components/CategoryHeader.js +0 -9
  113. package/src/ui/components/EmptyFilterState.js +0 -4
  114. package/src/ui/components/FlagDetailEditor.js +0 -0
  115. package/src/ui/components/ScopeIndicator.js +0 -30
  116. package/src/ui/components/ScrollableList.js +0 -36
  117. package/src/ui/components/SearchInput.js +0 -19
  118. package/src/ui/components/StyledText.js +0 -39
  119. package/src/ui/components/TabBar.js +0 -19
  120. package/src/ui/components/layout/FooterHints.js +0 -29
  121. package/src/ui/components/layout/Panel.js +0 -6
  122. package/src/ui/components/layout/ProgressBar.js +0 -14
  123. package/src/ui/components/layout/ScopeTabs.js +0 -6
  124. package/src/ui/components/layout/ScreenLayout.js +0 -16
  125. package/src/ui/components/layout/index.js +0 -5
  126. package/src/ui/components/modals/ConfirmModal.js +0 -14
  127. package/src/ui/components/modals/InputModal.js +0 -5
  128. package/src/ui/components/modals/LoadingModal.js +0 -17
  129. package/src/ui/components/modals/MessageModal.js +0 -16
  130. package/src/ui/components/modals/ModalContainer.js +0 -137
  131. package/src/ui/components/modals/SelectModal.js +0 -18
  132. package/src/ui/components/modals/VersionMismatchModal.js +0 -36
  133. package/src/ui/components/modals/index.js +0 -6
  134. package/src/ui/components/primitives/ActionHints.js +0 -13
  135. package/src/ui/components/primitives/DetailSection.js +0 -7
  136. package/src/ui/components/primitives/KeyValueLine.js +0 -8
  137. package/src/ui/components/primitives/ListCategoryRow.js +0 -8
  138. package/src/ui/components/primitives/MetaText.js +0 -8
  139. package/src/ui/components/primitives/ScopeDetail.js +0 -32
  140. package/src/ui/components/primitives/ScopeSquares.js +0 -11
  141. package/src/ui/components/primitives/SelectableRow.js +0 -5
  142. package/src/ui/components/primitives/index.js +0 -8
  143. package/src/ui/hooks/index.js +0 -4
  144. package/src/ui/hooks/useAsyncData.js +0 -77
  145. package/src/ui/hooks/useGitignoreModal.js +0 -74
  146. package/src/ui/hooks/useKeyboard.js +0 -13
  147. package/src/ui/hooks/useKeyboardHandler.js +0 -39
  148. package/src/ui/hooks/useMismatchModal.js +0 -77
  149. package/src/ui/registry.js +0 -1
  150. package/src/ui/renderers/cliToolRenderers.js +0 -54
  151. package/src/ui/renderers/gitignoreRenderers.js +0 -46
  152. package/src/ui/renderers/mcpRenderers.js +0 -26
  153. package/src/ui/renderers/pluginRenderers.js +0 -124
  154. package/src/ui/renderers/profileRenderers.js +0 -177
  155. package/src/ui/renderers/settingsRenderers.js +0 -73
  156. package/src/ui/renderers/skillRenderers.js +0 -138
  157. package/src/ui/screens/AliasScreen.js +0 -1111
  158. package/src/ui/screens/CliToolsScreen.js +0 -338
  159. package/src/ui/screens/EnvVarsScreen.js +0 -152
  160. package/src/ui/screens/GitignoreScreen.js +0 -328
  161. package/src/ui/screens/McpRegistryScreen.js +0 -238
  162. package/src/ui/screens/McpScreen.js +0 -176
  163. package/src/ui/screens/ModelSelectorScreen.js +0 -296
  164. package/src/ui/screens/ModelSelectorScreen.tsx +0 -444
  165. package/src/ui/screens/PluginsScreen.js +0 -769
  166. package/src/ui/screens/ProfilesScreen.js +0 -298
  167. package/src/ui/screens/SkillsScreen.js +0 -549
  168. package/src/ui/screens/StatusLineScreen.js +0 -206
  169. package/src/ui/screens/StatusLineScreen.tsx +0 -416
  170. package/src/ui/screens/index.js +0 -10
  171. package/src/ui/state/AnimationContext.js +0 -34
  172. package/src/ui/state/AppContext.js +0 -162
  173. package/src/ui/state/DimensionsContext.js +0 -71
  174. package/src/ui/state/reducer.js +0 -547
  175. package/src/ui/state/types.js +0 -1
  176. package/src/ui/theme.js +0 -47
  177. package/src/utils/clipboard.js +0 -56
  178. package/src/utils/command-utils.js +0 -19
  179. package/src/utils/fuzzy-search.js +0 -101
  180. package/src/utils/string-utils.js +0 -62
@@ -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
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Toolchain detection + bootstrap for the package managers a profile's binary
3
+ * deps need (bun, brew, npm, pip, go).
4
+ *
5
+ * `claudeup install` requires whatever installers the resolved closure uses. In
6
+ * source form bun is already guaranteed (bin/claudeup.js hard-exits without it),
7
+ * but once claudeup ships as a compiled binary an end user may lack bun — so bun
8
+ * is treated as a bootstrappable toolchain exactly like brew.
9
+ */
10
+
11
+ import type { BinInstaller, ResolvedBin } from "../types/index.js";
12
+ import { which } from "../utils/command-utils.js";
13
+
14
+ /** The executable that proves a toolchain is installed. */
15
+ const TOOLCHAIN_PROBE: Record<BinInstaller, string> = {
16
+ bun: "bun",
17
+ brew: "brew",
18
+ npm: "npm",
19
+ pip: "pip",
20
+ go: "go",
21
+ };
22
+
23
+ /** Official one-line bootstrap for the toolchains claudeup will install. */
24
+ export const TOOLCHAIN_BOOTSTRAP: Partial<Record<BinInstaller, string>> = {
25
+ bun: "curl -fsSL https://bun.sh/install | bash",
26
+ brew: '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"',
27
+ };
28
+
29
+ export interface ToolchainStatus {
30
+ name: BinInstaller;
31
+ present: boolean;
32
+ /** Binary names in the closure that need this toolchain. */
33
+ requiredBy: string[];
34
+ /** Bootstrap command, when claudeup can install this toolchain itself. */
35
+ bootstrap?: string;
36
+ }
37
+
38
+ /** Is a given toolchain available on PATH? */
39
+ export async function isToolchainPresent(name: BinInstaller): Promise<boolean> {
40
+ return (await which(TOOLCHAIN_PROBE[name])) !== null;
41
+ }
42
+
43
+ /**
44
+ * Determine which toolchains a resolved closure's binary deps require, and
45
+ * whether each is present. Only toolchains actually needed are returned.
46
+ */
47
+ export async function detectToolchains(
48
+ bins: ResolvedBin[],
49
+ ): Promise<ToolchainStatus[]> {
50
+ const requiredBy = new Map<BinInstaller, string[]>();
51
+ for (const bin of bins) {
52
+ const list = requiredBy.get(bin.via) ?? [];
53
+ list.push(bin.name);
54
+ requiredBy.set(bin.via, list);
55
+ }
56
+
57
+ const statuses: ToolchainStatus[] = [];
58
+ for (const [name, names] of requiredBy) {
59
+ statuses.push({
60
+ name,
61
+ present: await isToolchainPresent(name),
62
+ requiredBy: names,
63
+ bootstrap: TOOLCHAIN_BOOTSTRAP[name],
64
+ });
65
+ }
66
+ // Stable order: bun, brew, npm, pip, go.
67
+ const order: BinInstaller[] = ["bun", "brew", "npm", "pip", "go"];
68
+ statuses.sort((a, b) => order.indexOf(a.name) - order.indexOf(b.name));
69
+ return statuses;
70
+ }
71
+
72
+ /** The install command for a single binary requirement. */
73
+ export function binInstallCommand(bin: {
74
+ name: string;
75
+ via: BinInstaller;
76
+ package?: string;
77
+ formula?: string;
78
+ module?: string;
79
+ version?: string;
80
+ }): string {
81
+ const pkg = bin.package ?? bin.name;
82
+ const at = bin.version ? `@${bin.version}` : "";
83
+ switch (bin.via) {
84
+ case "bun":
85
+ return `bun install -g ${pkg}${at}`;
86
+ case "npm":
87
+ return `npm install -g ${pkg}${at}`;
88
+ case "pip":
89
+ return bin.version
90
+ ? `pip install ${pkg}==${bin.version}`
91
+ : `pip install ${pkg}`;
92
+ case "brew":
93
+ return `brew install ${bin.formula ?? bin.name}`;
94
+ case "go":
95
+ return `go install ${bin.module ?? bin.name}@${bin.version ?? "latest"}`;
96
+ }
97
+ }