claudeup 4.19.0 → 4.23.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 (182) 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-badge.test.ts +91 -0
  18. package/src/__tests__/marketplace-refresh.test.ts +267 -0
  19. package/src/__tests__/plugin-requires.test.ts +133 -0
  20. package/src/__tests__/plugin-setup.test.ts +7 -0
  21. package/src/__tests__/profile-command.test.ts +116 -0
  22. package/src/__tests__/profile-materializer.test.ts +82 -0
  23. package/src/__tests__/profile-sync.test.ts +136 -0
  24. package/src/__tests__/registry-version-resolution.test.ts +74 -0
  25. package/src/__tests__/resolve-executable.test.ts +42 -0
  26. package/src/__tests__/resolver.test.ts +218 -0
  27. package/src/__tests__/symlink-manager.test.ts +99 -0
  28. package/src/__tests__/toolchain.test.ts +56 -0
  29. package/src/cli/claude.ts +21 -0
  30. package/src/cli/doctor.ts +132 -0
  31. package/src/cli/install.ts +360 -0
  32. package/src/cli/profile.ts +166 -0
  33. package/src/cli/router.ts +107 -0
  34. package/src/cli/update.ts +85 -0
  35. package/src/data/cli-tools.ts +7 -7
  36. package/src/data/gitignore-defaults.ts +4 -0
  37. package/src/data/marketplaces.ts +12 -0
  38. package/src/data/predefined-profiles.ts +3 -4
  39. package/src/main.tsx +11 -154
  40. package/src/prerunner/index.ts +62 -27
  41. package/src/services/claude-cli.ts +19 -22
  42. package/src/services/claude-settings.ts +99 -26
  43. package/src/services/conventions-manager.ts +865 -0
  44. package/src/services/doctor-bins.ts +49 -0
  45. package/src/services/doctor.ts +509 -0
  46. package/src/services/install-plan.ts +107 -0
  47. package/src/services/manifest.ts +166 -0
  48. package/src/services/marketplace-refresh.ts +173 -0
  49. package/src/services/plugin-manager.ts +7 -1
  50. package/src/services/plugin-requires.ts +215 -0
  51. package/src/services/plugin-version-check.ts +9 -4
  52. package/src/services/profile-materializer.ts +61 -0
  53. package/src/services/profile-sync.ts +150 -0
  54. package/src/services/resolver.ts +293 -0
  55. package/src/services/symlink-manager.ts +146 -0
  56. package/src/services/toolchain.ts +97 -0
  57. package/src/services/version-check.ts +10 -11
  58. package/src/types/index.ts +138 -31
  59. package/src/ui/App.tsx +0 -4
  60. package/src/ui/adapters/pluginsAdapter.ts +13 -0
  61. package/src/ui/screens/CliToolsScreen.tsx +11 -3
  62. package/src/ui/screens/index.ts +0 -1
  63. package/src/ui/state/reducer.ts +0 -101
  64. package/src/ui/state/types.ts +0 -35
  65. package/src/utils/command-utils.ts +23 -0
  66. package/src/utils/file-locking.ts +144 -0
  67. package/src/data/alias-flags.js +0 -205
  68. package/src/data/cli-tools.js +0 -123
  69. package/src/data/gitignore-defaults.js +0 -24
  70. package/src/data/gitignore-reasons.js +0 -97
  71. package/src/data/gitignore-templates.js +0 -21
  72. package/src/data/marketplaces.js +0 -138
  73. package/src/data/mcp-servers.js +0 -509
  74. package/src/data/predefined-profiles.js +0 -248
  75. package/src/data/settings-catalog.js +0 -625
  76. package/src/data/skill-repos.js +0 -160
  77. package/src/data/statuslines.js +0 -159
  78. package/src/data/statuslines.ts +0 -188
  79. package/src/index.js +0 -4
  80. package/src/index.ts +0 -5
  81. package/src/main.js +0 -170
  82. package/src/prerunner/index.js +0 -252
  83. package/src/services/alias-settings.js +0 -51
  84. package/src/services/alias-shell-writer.js +0 -1018
  85. package/src/services/alias-store.js +0 -129
  86. package/src/services/claude-cli.js +0 -189
  87. package/src/services/claude-runner.js +0 -28
  88. package/src/services/claude-settings.js +0 -1223
  89. package/src/services/gitignore-detector.js +0 -155
  90. package/src/services/gitignore-fixer.js +0 -231
  91. package/src/services/gitignore-prerun.js +0 -46
  92. package/src/services/gitignore-resolver.js +0 -143
  93. package/src/services/gitignore-service.js +0 -117
  94. package/src/services/local-marketplace.js +0 -339
  95. package/src/services/marketplace-fetcher.js +0 -96
  96. package/src/services/marketplace-sync.js +0 -94
  97. package/src/services/mcp-registry.js +0 -87
  98. package/src/services/plugin-manager.js +0 -473
  99. package/src/services/plugin-mcp-config.js +0 -177
  100. package/src/services/plugin-setup.js +0 -499
  101. package/src/services/plugin-version-check.js +0 -262
  102. package/src/services/profiles.js +0 -161
  103. package/src/services/settings-manager.js +0 -243
  104. package/src/services/skills-manager.js +0 -393
  105. package/src/services/skillsmp-client.js +0 -87
  106. package/src/services/update-cache.js +0 -52
  107. package/src/services/version-check.js +0 -99
  108. package/src/types/gitignore.js +0 -6
  109. package/src/types/index.js +0 -1
  110. package/src/ui/App.js +0 -355
  111. package/src/ui/adapters/pluginsAdapter.js +0 -139
  112. package/src/ui/adapters/settingsAdapter.js +0 -111
  113. package/src/ui/adapters/skillsAdapter.js +0 -154
  114. package/src/ui/components/CategoryHeader.js +0 -9
  115. package/src/ui/components/EmptyFilterState.js +0 -4
  116. package/src/ui/components/FlagDetailEditor.js +0 -0
  117. package/src/ui/components/ScopeIndicator.js +0 -30
  118. package/src/ui/components/ScrollableList.js +0 -36
  119. package/src/ui/components/SearchInput.js +0 -19
  120. package/src/ui/components/StyledText.js +0 -39
  121. package/src/ui/components/TabBar.js +0 -19
  122. package/src/ui/components/layout/FooterHints.js +0 -29
  123. package/src/ui/components/layout/Panel.js +0 -6
  124. package/src/ui/components/layout/ProgressBar.js +0 -14
  125. package/src/ui/components/layout/ScopeTabs.js +0 -6
  126. package/src/ui/components/layout/ScreenLayout.js +0 -16
  127. package/src/ui/components/layout/index.js +0 -5
  128. package/src/ui/components/modals/ConfirmModal.js +0 -14
  129. package/src/ui/components/modals/InputModal.js +0 -5
  130. package/src/ui/components/modals/LoadingModal.js +0 -17
  131. package/src/ui/components/modals/MessageModal.js +0 -16
  132. package/src/ui/components/modals/ModalContainer.js +0 -137
  133. package/src/ui/components/modals/SelectModal.js +0 -18
  134. package/src/ui/components/modals/VersionMismatchModal.js +0 -36
  135. package/src/ui/components/modals/index.js +0 -6
  136. package/src/ui/components/primitives/ActionHints.js +0 -13
  137. package/src/ui/components/primitives/DetailSection.js +0 -7
  138. package/src/ui/components/primitives/KeyValueLine.js +0 -8
  139. package/src/ui/components/primitives/ListCategoryRow.js +0 -8
  140. package/src/ui/components/primitives/MetaText.js +0 -8
  141. package/src/ui/components/primitives/ScopeDetail.js +0 -32
  142. package/src/ui/components/primitives/ScopeSquares.js +0 -11
  143. package/src/ui/components/primitives/SelectableRow.js +0 -5
  144. package/src/ui/components/primitives/index.js +0 -8
  145. package/src/ui/hooks/index.js +0 -4
  146. package/src/ui/hooks/useAsyncData.js +0 -77
  147. package/src/ui/hooks/useGitignoreModal.js +0 -74
  148. package/src/ui/hooks/useKeyboard.js +0 -13
  149. package/src/ui/hooks/useKeyboardHandler.js +0 -39
  150. package/src/ui/hooks/useMismatchModal.js +0 -77
  151. package/src/ui/registry.js +0 -1
  152. package/src/ui/renderers/cliToolRenderers.js +0 -54
  153. package/src/ui/renderers/gitignoreRenderers.js +0 -46
  154. package/src/ui/renderers/mcpRenderers.js +0 -26
  155. package/src/ui/renderers/pluginRenderers.js +0 -124
  156. package/src/ui/renderers/profileRenderers.js +0 -177
  157. package/src/ui/renderers/settingsRenderers.js +0 -73
  158. package/src/ui/renderers/skillRenderers.js +0 -138
  159. package/src/ui/screens/AliasScreen.js +0 -1111
  160. package/src/ui/screens/CliToolsScreen.js +0 -338
  161. package/src/ui/screens/EnvVarsScreen.js +0 -152
  162. package/src/ui/screens/GitignoreScreen.js +0 -328
  163. package/src/ui/screens/McpRegistryScreen.js +0 -238
  164. package/src/ui/screens/McpScreen.js +0 -176
  165. package/src/ui/screens/ModelSelectorScreen.js +0 -296
  166. package/src/ui/screens/ModelSelectorScreen.tsx +0 -444
  167. package/src/ui/screens/PluginsScreen.js +0 -769
  168. package/src/ui/screens/ProfilesScreen.js +0 -298
  169. package/src/ui/screens/SkillsScreen.js +0 -549
  170. package/src/ui/screens/StatusLineScreen.js +0 -206
  171. package/src/ui/screens/StatusLineScreen.tsx +0 -416
  172. package/src/ui/screens/index.js +0 -10
  173. package/src/ui/state/AnimationContext.js +0 -34
  174. package/src/ui/state/AppContext.js +0 -162
  175. package/src/ui/state/DimensionsContext.js +0 -71
  176. package/src/ui/state/reducer.js +0 -547
  177. package/src/ui/state/types.js +0 -1
  178. package/src/ui/theme.js +0 -47
  179. package/src/utils/clipboard.js +0 -56
  180. package/src/utils/command-utils.js +0 -19
  181. package/src/utils/fuzzy-search.js +0 -101
  182. package/src/utils/string-utils.js +0 -62
@@ -0,0 +1,132 @@
1
+ /**
2
+ * `claudeup doctor [--fix]`
3
+ *
4
+ * Diagnoses (and with --fix, repairs) project health:
5
+ * 1. Binary dependencies — every requires.bin / cliTools binary on PATH.
6
+ * This is the check that would have caught a dead `tmux-mcp`.
7
+ * 2. Profile symlink integrity — the active profile's links resolve.
8
+ * 3. Convention compliance — .gitignore + CLAUDE.md blocks (ported engine).
9
+ */
10
+
11
+ import fs from "fs-extra";
12
+ import path from "node:path";
13
+ import { readManifest } from "../services/manifest.js";
14
+ import { resolveAllProfiles } from "../services/resolver.js";
15
+ import { checkBinaries, missingBinaries } from "../services/doctor-bins.js";
16
+ import {
17
+ activeProfile,
18
+ profileDir,
19
+ } from "../services/symlink-manager.js";
20
+ import { checkProject, fixDoctorIssues } from "../services/doctor.js";
21
+
22
+ export async function runDoctorCommand(args: string[]): Promise<number> {
23
+ const fix = args.includes("--fix");
24
+ const projectPath = process.cwd();
25
+ let problems = 0;
26
+
27
+ // 1. Binary dependencies.
28
+ const manifest = await readManifest(projectPath);
29
+ let pluginIds: string[] = [];
30
+ if (Object.keys(manifest.profiles).length > 0) {
31
+ const closure = await resolveAllProfiles(manifest);
32
+ pluginIds = Object.keys(closure.plugins);
33
+ if (closure.bins.length > 0) {
34
+ const results = await checkBinaries(closure.bins);
35
+ const missing = missingBinaries(results);
36
+ if (missing.length > 0) {
37
+ console.log("Binary dependencies:");
38
+ for (const m of missing) {
39
+ console.log(
40
+ ` ✗ ${m.name} — run: ${m.installCommand} (needed by ${m.sources.join(", ")})`,
41
+ );
42
+ problems++;
43
+ }
44
+ } else {
45
+ console.log(`✓ All ${closure.bins.length} binary dependencies present.`);
46
+ }
47
+ }
48
+ }
49
+
50
+ // 2. Profile symlink integrity.
51
+ const active = await activeProfile(projectPath);
52
+ if (active) {
53
+ const dangling = await danglingLinks(active, projectPath);
54
+ if (dangling.length > 0) {
55
+ console.log(`Profile "${active}" has broken symlinks:`);
56
+ for (const l of dangling) console.log(` ✗ ${l}`);
57
+ console.log(` Fix: claudeup install ${active}`);
58
+ problems += dangling.length;
59
+ } else {
60
+ console.log(`✓ Active profile "${active}" symlinks resolve.`);
61
+ }
62
+ }
63
+
64
+ // 3. Convention compliance — scoped to THIS project only (the ported engine
65
+ // can scan every registered project; that's noisy for a project command).
66
+ if (pluginIds.length > 0) {
67
+ try {
68
+ const project = await checkProject(projectPath, pluginIds);
69
+ const actionable = project.checks.filter((c) => c.status !== "pass");
70
+ if (actionable.length > 0) {
71
+ console.log("\nConventions:");
72
+ for (const check of actionable) {
73
+ console.log(` ${check.status === "fail" ? "✗" : "•"} ${check.message}`);
74
+ }
75
+ const fixable = actionable.filter((c) => c.fixable).length;
76
+ const failures = actionable.filter((c) => c.status === "fail").length;
77
+ if (fix && fixable > 0) {
78
+ const { fixed, failed } = await fixDoctorIssues({
79
+ projects: [project],
80
+ summary: {
81
+ totalProjects: 1,
82
+ totalChecks: project.checks.length,
83
+ passed: 0,
84
+ warnings: 0,
85
+ failures,
86
+ fixable,
87
+ },
88
+ });
89
+ console.log(`\nFixed ${fixed}, failed ${failed}.`);
90
+ } else if (fixable > 0) {
91
+ console.log(`\n${fixable} fixable — re-run with --fix.`);
92
+ problems += failures;
93
+ }
94
+ }
95
+ } catch {
96
+ // Convention doctor is best-effort; never fail the whole command on it.
97
+ }
98
+ }
99
+
100
+ if (problems === 0) console.log("\n✓ No problems found.");
101
+ return problems > 0 && !fix ? 1 : 0;
102
+ }
103
+
104
+ /** Which of the active profile's expected symlinks are missing or dangling. */
105
+ async function danglingLinks(
106
+ name: string,
107
+ projectPath: string,
108
+ ): Promise<string[]> {
109
+ const dir = profileDir(name, projectPath);
110
+ const checks: Array<{ link: string; target: string }> = [
111
+ {
112
+ link: path.join(projectPath, ".claude", "settings.json"),
113
+ target: path.join(dir, "settings.json"),
114
+ },
115
+ {
116
+ link: path.join(projectPath, ".mcp.json"),
117
+ target: path.join(dir, "mcp.json"),
118
+ },
119
+ ];
120
+ const broken: string[] = [];
121
+ for (const { link, target } of checks) {
122
+ try {
123
+ const stat = await fs.lstat(link);
124
+ if (stat.isSymbolicLink() && !(await fs.pathExists(target))) {
125
+ broken.push(path.relative(projectPath, link));
126
+ }
127
+ } catch {
128
+ // link absent — not "broken", the profile may not provide it.
129
+ }
130
+ }
131
+ return broken;
132
+ }
@@ -0,0 +1,360 @@
1
+ /**
2
+ * `claudeup install [profile] [--check] [--yes]`
3
+ *
4
+ * Materialize a committed team profile from .claude/profiles.json onto this
5
+ * machine: register marketplaces, install pinned plugins, install the binaries
6
+ * plugins declare (requires.bin) plus the profile's cliTools, install skills,
7
+ * collect required env, then activate the profile via symlinks.
8
+ *
9
+ * With no argument it installs the union of every profile's deps and
10
+ * materializes them all (so `profile switch` needs no reinstall), then
11
+ * activates one. `--check` is a read-only drift gate for CI. `--yes` skips
12
+ * the confirmation prompt.
13
+ */
14
+
15
+ import path from "node:path";
16
+ import { createInterface } from "node:readline/promises";
17
+ import type {
18
+ ProfileSkillRef,
19
+ ResolvedBin,
20
+ ResolvedClosure,
21
+ SkillInfo,
22
+ } from "../types/index.js";
23
+ import { readManifest, validateManifest } from "../services/manifest.js";
24
+ import { resolveAllProfiles, resolveProfile } from "../services/resolver.js";
25
+ import {
26
+ computeEnvStatus,
27
+ computeVersionDrift,
28
+ summarizeClosure,
29
+ } from "../services/install-plan.js";
30
+ import {
31
+ binInstallCommand,
32
+ detectToolchains,
33
+ TOOLCHAIN_BOOTSTRAP,
34
+ } from "../services/toolchain.js";
35
+ import { materializeProfile } from "../services/profile-materializer.js";
36
+ import {
37
+ activateProfile,
38
+ activeProfile,
39
+ PROFILE_GITIGNORE_ENTRIES,
40
+ } from "../services/symlink-manager.js";
41
+ import { ensureGitignoreEntries } from "../services/conventions-manager.js";
42
+ import { detectProfileDrift } from "../services/profile-sync.js";
43
+ import { resolveExecutable } from "../utils/command-utils.js";
44
+ import {
45
+ addMarketplace,
46
+ installPlugin,
47
+ isClaudeAvailable,
48
+ } from "../services/claude-cli.js";
49
+ import {
50
+ isMarketplaceRegistered,
51
+ readInstalledPluginsRegistry,
52
+ readLocalSettings,
53
+ writeLocalSettings,
54
+ } from "../services/claude-settings.js";
55
+ import { installSkill } from "../services/skills-manager.js";
56
+
57
+ interface InstallFlags {
58
+ check: boolean;
59
+ yes: boolean;
60
+ force: boolean;
61
+ profile?: string;
62
+ }
63
+
64
+ function parseArgs(args: string[]): InstallFlags {
65
+ return {
66
+ check: args.includes("--check"),
67
+ yes: args.includes("--yes") || args.includes("-y"),
68
+ force: args.includes("--force"),
69
+ profile: args.find((a) => !a.startsWith("-")),
70
+ };
71
+ }
72
+
73
+ async function confirm(question: string): Promise<boolean> {
74
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
75
+ try {
76
+ const answer = (await rl.question(`${question} [Y/n] `)).trim().toLowerCase();
77
+ return answer === "" || answer === "y" || answer === "yes";
78
+ } finally {
79
+ rl.close();
80
+ }
81
+ }
82
+
83
+ async function promptValue(question: string): Promise<string> {
84
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
85
+ try {
86
+ return (await rl.question(question)).trim();
87
+ } finally {
88
+ rl.close();
89
+ }
90
+ }
91
+
92
+ /** Run a shell command, streaming output; resolve true on exit 0. */
93
+ async function runShell(command: string): Promise<boolean> {
94
+ const { spawn } = await import("node:child_process");
95
+ return new Promise((resolve) => {
96
+ const child = spawn(command, { stdio: "inherit", shell: true });
97
+ child.on("exit", (code) => resolve(code === 0));
98
+ child.on("error", () => resolve(false));
99
+ });
100
+ }
101
+
102
+ // ── --check: read-only drift gate ───────────────────────────────────────────
103
+
104
+ async function runCheck(
105
+ closure: ResolvedClosure,
106
+ strict: boolean,
107
+ ): Promise<number> {
108
+ const registry = await readInstalledPluginsRegistry();
109
+ const drift = computeVersionDrift(closure, registry);
110
+ if (drift.length === 0) {
111
+ console.log("✓ No plugin version drift.");
112
+ return 0;
113
+ }
114
+ console.log("Plugin version drift:");
115
+ for (const d of drift) {
116
+ console.log(` ✗ ${d.pluginId} pinned ${d.pinned}, installed ${d.installed ?? "none"}`);
117
+ }
118
+ if (strict) {
119
+ console.error("\nstrictVersions is on — failing.");
120
+ return 1;
121
+ }
122
+ console.log("\n(strictVersions is off — reporting only.)");
123
+ return 0;
124
+ }
125
+
126
+ // ── install steps ───────────────────────────────────────────────────────────
127
+
128
+ async function ensureToolchains(closure: ResolvedClosure, yes: boolean): Promise<void> {
129
+ const toolchains = await detectToolchains(closure.bins);
130
+ for (const tc of toolchains) {
131
+ if (tc.present) continue;
132
+ const bootstrap = TOOLCHAIN_BOOTSTRAP[tc.name];
133
+ if (!bootstrap) {
134
+ console.warn(
135
+ `⚠ ${tc.name} is required by ${tc.requiredBy.join(", ")} but isn't installed and can't be auto-installed. Install it and re-run.`,
136
+ );
137
+ continue;
138
+ }
139
+ const ok = yes || (await confirm(`${tc.name} is missing (needed by ${tc.requiredBy.join(", ")}). Install it?`));
140
+ if (ok) {
141
+ console.log(`Installing ${tc.name}…`);
142
+ if (!(await runShell(bootstrap))) {
143
+ console.warn(`⚠ Failed to install ${tc.name}. Continuing.`);
144
+ }
145
+ }
146
+ }
147
+ }
148
+
149
+ async function registerMarketplaces(closure: ResolvedClosure): Promise<void> {
150
+ for (const [name, ref] of Object.entries(closure.marketplaces)) {
151
+ try {
152
+ if (await isMarketplaceRegistered(name)) continue;
153
+ const source = ref.source === "github" ? ref.repo : ref.path;
154
+ if (!source) continue;
155
+ console.log(`+ marketplace ${name} (${source})`);
156
+ await addMarketplace(source);
157
+ } catch (e) {
158
+ console.warn(`⚠ marketplace ${name}: ${(e as Error).message}`);
159
+ }
160
+ }
161
+ }
162
+
163
+ async function installPlugins(closure: ResolvedClosure): Promise<void> {
164
+ for (const pluginId of Object.keys(closure.plugins)) {
165
+ try {
166
+ console.log(`+ plugin ${pluginId}`);
167
+ await installPlugin(pluginId, "user");
168
+ } catch (e) {
169
+ console.warn(`⚠ plugin ${pluginId}: ${(e as Error).message}`);
170
+ }
171
+ }
172
+ }
173
+
174
+ async function installBins(bins: ResolvedBin[]): Promise<void> {
175
+ for (const bin of bins) {
176
+ if (await resolveExecutable(bin.name)) {
177
+ continue; // already installed and runnable (not a dangling link)
178
+ }
179
+ const cmd = binInstallCommand(bin);
180
+ console.log(`+ ${bin.name} (${cmd})`);
181
+ if (!(await runShell(cmd))) {
182
+ console.warn(`⚠ failed to install ${bin.name} via ${bin.via}`);
183
+ }
184
+ }
185
+ }
186
+
187
+ /** Map a manifest skill ref to the SkillInfo shape installSkill expects. */
188
+ function toSkillInfo(ref: ProfileSkillRef): SkillInfo {
189
+ return {
190
+ id: `${ref.repo}/${ref.path}`,
191
+ name: ref.name,
192
+ source: { label: ref.repo, repo: ref.repo, skillsPath: path.dirname(ref.path) },
193
+ repoPath: ref.path,
194
+ gitBlobSha: "",
195
+ frontmatter: null,
196
+ installed: false,
197
+ installedScope: null,
198
+ hasUpdate: false,
199
+ };
200
+ }
201
+
202
+ async function installSkills(
203
+ skills: ProfileSkillRef[],
204
+ projectPath: string,
205
+ ): Promise<void> {
206
+ for (const ref of skills) {
207
+ try {
208
+ console.log(`+ skill ${ref.name}`);
209
+ await installSkill(toSkillInfo(ref), "project", projectPath);
210
+ } catch (e) {
211
+ console.warn(`⚠ skill ${ref.name}: ${(e as Error).message}`);
212
+ }
213
+ }
214
+ }
215
+
216
+ async function collectEnv(
217
+ closure: ResolvedClosure,
218
+ projectPath: string,
219
+ yes: boolean,
220
+ ): Promise<void> {
221
+ const status = computeEnvStatus(closure);
222
+ if (status.missingRequired.length === 0) return;
223
+
224
+ console.log("\nMissing required environment variables:");
225
+ if (yes) {
226
+ for (const name of status.missingRequired) console.log(` ! ${name} (unset)`);
227
+ console.log("Run without --yes to enter values, or set them in .claude/settings.local.json.");
228
+ return;
229
+ }
230
+
231
+ const local = await readLocalSettings(projectPath);
232
+ local.env = local.env ?? {};
233
+ for (const name of status.missingRequired) {
234
+ const value = await promptValue(` ${name} = `);
235
+ if (value) local.env[name] = value;
236
+ }
237
+ await writeLocalSettings(local, projectPath);
238
+ console.log("Saved to .claude/settings.local.json (personal, gitignored).");
239
+ }
240
+
241
+ // ── entry point ──────────────────────────────────────────────────────────────
242
+
243
+ export async function runInstallCommand(
244
+ args: string[],
245
+ projectPath: string = process.cwd(),
246
+ ): Promise<number> {
247
+ const flags = parseArgs(args);
248
+
249
+ const manifest = await readManifest(projectPath);
250
+ const errors = validateManifest(manifest);
251
+ if (errors.length > 0) {
252
+ console.error("Invalid .claude/profiles.json:");
253
+ for (const e of errors) console.error(` ${e.path}: ${e.message}`);
254
+ return 1;
255
+ }
256
+
257
+ const profileIds = Object.keys(manifest.profiles);
258
+ if (flags.profile && !manifest.profiles[flags.profile]) {
259
+ console.error(
260
+ `Profile "${flags.profile}" not found. Available: ${profileIds.join(", ")}`,
261
+ );
262
+ return 1;
263
+ }
264
+
265
+ // Closure: a single profile, or the union of all.
266
+ const closure = flags.profile
267
+ ? await resolveProfile(manifest, flags.profile)
268
+ : await resolveAllProfiles(manifest);
269
+
270
+ if (flags.check) {
271
+ return runCheck(closure, manifest.strictVersions === true);
272
+ }
273
+
274
+ // Drift guard: refuse to clobber unsynced local edits to the active profile
275
+ // (materialize would overwrite them) unless --force is given.
276
+ if (!flags.force) {
277
+ const active = await activeProfile(projectPath);
278
+ if (active && (flags.profile === undefined || flags.profile === active)) {
279
+ const drift = await detectProfileDrift(active, projectPath);
280
+ if (drift.length > 0) {
281
+ console.error(
282
+ `Active profile "${active}" has unsynced local edits that install would overwrite:`,
283
+ );
284
+ for (const c of drift) console.error(` ${c.kind}: ${c.detail}`);
285
+ console.error(
286
+ `\nRun \`claudeup profile sync\` to promote them into .claude/profiles.json,\n` +
287
+ `or re-run with --force to discard them.`,
288
+ );
289
+ return 1;
290
+ }
291
+ }
292
+ }
293
+
294
+ // Plan
295
+ console.log(`\nPlan for ${flags.profile ?? "all profiles"}:`);
296
+ for (const line of summarizeClosure(closure)) console.log(` ${line}`);
297
+ if (closure.conflicts?.length) {
298
+ console.log("\n⚠ Version conflicts across profiles (resolved to the higher pin):");
299
+ for (const c of closure.conflicts) console.log(` ${c}`);
300
+ }
301
+ console.log();
302
+
303
+ if (!flags.yes && !(await confirm("Apply?"))) {
304
+ console.log("Aborted.");
305
+ return 0;
306
+ }
307
+
308
+ if (!(await isClaudeAvailable())) {
309
+ console.error("claude CLI not found on PATH — cannot install plugins/marketplaces.");
310
+ return 1;
311
+ }
312
+
313
+ await ensureToolchains(closure, flags.yes);
314
+ await registerMarketplaces(closure);
315
+ await installPlugins(closure);
316
+ await installBins(closure.bins);
317
+
318
+ // Materialize every profile (so `profile switch` needs no reinstall), then
319
+ // activate the target (or the sole/first profile).
320
+ const toMaterialize = flags.profile ? [flags.profile] : profileIds;
321
+ for (const id of toMaterialize) {
322
+ const profileClosure = await resolveProfile(manifest, id);
323
+ await materializeProfile(id, profileClosure, projectPath);
324
+ }
325
+
326
+ // Keep the generated dir + active-profile symlinks out of git before
327
+ // activating (source of truth is .claude/profiles.json).
328
+ await ensureGitignoreEntries(
329
+ path.join(projectPath, ".gitignore"),
330
+ PROFILE_GITIGNORE_ENTRIES,
331
+ "# claudeup: generated profile build output + active-profile symlinks",
332
+ );
333
+
334
+ const activeId = flags.profile ?? (await pickActiveProfile(profileIds, flags.yes));
335
+ if (activeId) {
336
+ await activateProfile(activeId, projectPath);
337
+ console.log(`\n✓ Active profile: ${activeId}`);
338
+ // Skills route through the now-active .claude/skills symlink.
339
+ const activeClosure = await resolveProfile(manifest, activeId);
340
+ await installSkills(activeClosure.skills, projectPath);
341
+ await collectEnv(activeClosure, projectPath, flags.yes);
342
+ }
343
+
344
+ console.log("\nDone.");
345
+ return 0;
346
+ }
347
+
348
+ async function pickActiveProfile(
349
+ profileIds: string[],
350
+ yes: boolean,
351
+ ): Promise<string | undefined> {
352
+ if (profileIds.length === 0) return undefined;
353
+ if (profileIds.length === 1) return profileIds[0];
354
+ if (yes) return profileIds[0];
355
+ console.log("\nWhich profile should be active?");
356
+ profileIds.forEach((id, i) => console.log(` ${i + 1}) ${id}`));
357
+ const answer = await promptValue("Choose [1]: ");
358
+ const idx = answer ? Number.parseInt(answer, 10) - 1 : 0;
359
+ return profileIds[idx] ?? profileIds[0];
360
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * `claudeup profile <list|show|switch|sync> [name]`
3
+ *
4
+ * Inspect and switch between team profiles materialized under
5
+ * `.claude/_profiles/`. Switching is cheap and offline — it just repoints the
6
+ * shared-config symlinks. `sync` (Phase 5) promotes local drift back into the
7
+ * committed manifest.
8
+ */
9
+
10
+ import path from "node:path";
11
+ import { readManifest } from "../services/manifest.js";
12
+ import { resolveProfile } from "../services/resolver.js";
13
+ import { summarizeClosure } from "../services/install-plan.js";
14
+ import {
15
+ activateProfile,
16
+ activeProfile,
17
+ profileDir,
18
+ PROFILE_GITIGNORE_ENTRIES,
19
+ } from "../services/symlink-manager.js";
20
+ import { ensureGitignoreEntries } from "../services/conventions-manager.js";
21
+ import { syncProfile } from "../services/profile-sync.js";
22
+ import { checkBinaries, missingBinaries } from "../services/doctor-bins.js";
23
+ import fs from "fs-extra";
24
+
25
+ export async function runProfileCommand(args: string[]): Promise<number> {
26
+ const [sub, ...rest] = args;
27
+ const projectPath = process.cwd();
28
+
29
+ switch (sub) {
30
+ case undefined:
31
+ case "list":
32
+ return listProfiles(projectPath);
33
+ case "show":
34
+ return showProfile(rest[0], projectPath);
35
+ case "switch":
36
+ return switchProfile(rest[0], projectPath);
37
+ case "sync":
38
+ return syncActiveProfile(rest[0], projectPath);
39
+ default:
40
+ console.error(
41
+ `Unknown profile command "${sub}". Use: list | show <name> | switch <name> | sync`,
42
+ );
43
+ return 1;
44
+ }
45
+ }
46
+
47
+ export async function listProfiles(projectPath: string): Promise<number> {
48
+ const manifest = await readManifest(projectPath);
49
+ const ids = Object.keys(manifest.profiles);
50
+ if (ids.length === 0) {
51
+ console.log("No profiles defined in .claude/profiles.json.");
52
+ return 0;
53
+ }
54
+ const active = await activeProfile(projectPath);
55
+ console.log("Profiles:");
56
+ for (const id of ids) {
57
+ const entry = manifest.profiles[id]!;
58
+ const marker = id === active ? "●" : " ";
59
+ const materialized = (await fs.pathExists(profileDir(id, projectPath)))
60
+ ? ""
61
+ : " (not installed)";
62
+ console.log(` ${marker} ${id} — ${entry.name}${materialized}`);
63
+ }
64
+ if (active) console.log(`\nActive: ${active}`);
65
+ return 0;
66
+ }
67
+
68
+ export async function showProfile(
69
+ name: string | undefined,
70
+ projectPath: string,
71
+ ): Promise<number> {
72
+ if (!name) {
73
+ console.error("Usage: claudeup profile show <name>");
74
+ return 1;
75
+ }
76
+ const manifest = await readManifest(projectPath);
77
+ if (!manifest.profiles[name]) {
78
+ console.error(
79
+ `Profile "${name}" not found. Available: ${Object.keys(manifest.profiles).join(", ")}`,
80
+ );
81
+ return 1;
82
+ }
83
+ const closure = await resolveProfile(manifest, name);
84
+ console.log(`Profile: ${name} (${manifest.profiles[name]!.name})`);
85
+ for (const line of summarizeClosure(closure)) console.log(` ${line}`);
86
+ return 0;
87
+ }
88
+
89
+ export async function switchProfile(
90
+ name: string | undefined,
91
+ projectPath: string,
92
+ ): Promise<number> {
93
+ if (!name) {
94
+ console.error("Usage: claudeup profile switch <name>");
95
+ return 1;
96
+ }
97
+ const manifest = await readManifest(projectPath);
98
+ if (!manifest.profiles[name]) {
99
+ console.error(
100
+ `Profile "${name}" not found. Available: ${Object.keys(manifest.profiles).join(", ")}`,
101
+ );
102
+ return 1;
103
+ }
104
+ if (!(await fs.pathExists(profileDir(name, projectPath)))) {
105
+ console.error(
106
+ `Profile "${name}" isn't installed yet. Run: claudeup install ${name}`,
107
+ );
108
+ return 1;
109
+ }
110
+
111
+ // In profile mode the source of truth is .claude/profiles.json; the
112
+ // generated dir AND the per-developer symlinks must stay out of git so
113
+ // switching profiles causes no churn.
114
+ await ensureGitignoreEntries(
115
+ path.join(projectPath, ".gitignore"),
116
+ PROFILE_GITIGNORE_ENTRIES,
117
+ "# claudeup: generated profile build output + active-profile symlinks",
118
+ );
119
+
120
+ await activateProfile(name, projectPath);
121
+ console.log(`✓ Switched to profile: ${name}`);
122
+
123
+ // Verify the profile's binary deps are actually usable — the switch itself
124
+ // succeeds (symlinks are valid), but warn so "switched" isn't misleading.
125
+ try {
126
+ const closure = await resolveProfile(manifest, name);
127
+ const missing = missingBinaries(await checkBinaries(closure.bins));
128
+ if (missing.length > 0) {
129
+ console.log(
130
+ `\n⚠ ${missing.length} binary dependenc${missing.length === 1 ? "y is" : "ies are"} missing:`,
131
+ );
132
+ for (const m of missing) console.log(` ✗ ${m.name} — run: ${m.installCommand}`);
133
+ console.log(`Run \`claudeup install ${name}\` to install them.`);
134
+ }
135
+ } catch {
136
+ // Verification is best-effort — never fail a valid switch on it.
137
+ }
138
+ return 0;
139
+ }
140
+
141
+ export async function syncActiveProfile(
142
+ nameArg: string | undefined,
143
+ projectPath: string,
144
+ ): Promise<number> {
145
+ const name = nameArg ?? (await activeProfile(projectPath));
146
+ if (!name) {
147
+ console.error(
148
+ "No active profile to sync. Pass a name: claudeup profile sync <name>",
149
+ );
150
+ return 1;
151
+ }
152
+ try {
153
+ const { changes, wrote } = await syncProfile(name, projectPath);
154
+ if (!wrote) {
155
+ console.log(`Profile "${name}" is already in sync with the manifest.`);
156
+ return 0;
157
+ }
158
+ console.log(`Synced "${name}" → .claude/profiles.json:`);
159
+ for (const c of changes) console.log(` ${c.kind}: ${c.detail}`);
160
+ console.log("\nReview the diff and commit .claude/profiles.json.");
161
+ return 0;
162
+ } catch (e) {
163
+ console.error((e as Error).message);
164
+ return 1;
165
+ }
166
+ }