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,136 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import fs from "fs-extra";
6
+ import { computeSync, syncProfile } from "../services/profile-sync.js";
7
+ import { profileDir } from "../services/symlink-manager.js";
8
+ import type { ProfileManifestEntry } from "../types/index.js";
9
+
10
+ describe("computeSync (pure)", () => {
11
+ const entry: ProfileManifestEntry = {
12
+ name: "Frontend",
13
+ plugins: { "dev@magus": "2.9.0", "terminal@magus": "latest" },
14
+ settings: { effortLevel: "high" },
15
+ };
16
+
17
+ test("adds a newly-enabled plugin, preserving existing pins", () => {
18
+ const { entry: next, changes } = computeSync(
19
+ entry,
20
+ {
21
+ enabledPlugins: {
22
+ "dev@magus": true,
23
+ "terminal@magus": true,
24
+ "designer@magus": true,
25
+ },
26
+ effortLevel: "high",
27
+ },
28
+ { mcpServers: {} },
29
+ );
30
+ // existing pin preserved (not downgraded to latest)
31
+ expect(next.plugins!["dev@magus"]).toBe("2.9.0");
32
+ // new plugin added as latest
33
+ expect(next.plugins!["designer@magus"]).toBe("latest");
34
+ expect(changes).toContainEqual({ kind: "plugin-added", detail: "designer@magus" });
35
+ });
36
+
37
+ test("removes a plugin that was disabled", () => {
38
+ const { entry: next, changes } = computeSync(
39
+ entry,
40
+ { enabledPlugins: { "dev@magus": true }, effortLevel: "high" },
41
+ { mcpServers: {} },
42
+ );
43
+ expect(next.plugins!["terminal@magus"]).toBeUndefined();
44
+ expect(changes).toContainEqual({ kind: "plugin-removed", detail: "terminal@magus" });
45
+ });
46
+
47
+ test("captures a settings change and an mcp change", () => {
48
+ const { entry: next, changes } = computeSync(
49
+ entry,
50
+ {
51
+ enabledPlugins: { "dev@magus": true, "terminal@magus": true },
52
+ effortLevel: "max",
53
+ },
54
+ { mcpServers: { "chrome-devtools": { command: "npx" } } },
55
+ );
56
+ expect(next.settings!.effortLevel).toBe("max");
57
+ expect(next.mcpServers!["chrome-devtools"]).toEqual({ command: "npx" });
58
+ expect(changes.some((c) => c.kind === "settings-changed")).toBe(true);
59
+ expect(changes.some((c) => c.kind === "mcp-changed")).toBe(true);
60
+ });
61
+
62
+ test("no changes when the materialized state matches the manifest", () => {
63
+ const { changes } = computeSync(
64
+ entry,
65
+ {
66
+ enabledPlugins: { "dev@magus": true, "terminal@magus": true },
67
+ effortLevel: "high",
68
+ },
69
+ { mcpServers: {} },
70
+ );
71
+ expect(changes).toEqual([]);
72
+ });
73
+
74
+ test("refreshes updatedAt to the injected timestamp when changed", () => {
75
+ const withStamp: ProfileManifestEntry = { ...entry, updatedAt: "OLD" };
76
+ const { entry: changed } = computeSync(
77
+ withStamp,
78
+ { enabledPlugins: { "dev@magus": true, "terminal@magus": true, "new@magus": true }, effortLevel: "high" },
79
+ { mcpServers: {} },
80
+ "2026-07-27T00:00:00.000Z",
81
+ );
82
+ expect(changed.updatedAt).toBe("2026-07-27T00:00:00.000Z");
83
+
84
+ // Unchanged sync leaves updatedAt alone.
85
+ const { entry: same } = computeSync(
86
+ withStamp,
87
+ { enabledPlugins: { "dev@magus": true, "terminal@magus": true }, effortLevel: "high" },
88
+ { mcpServers: {} },
89
+ "2026-07-27T00:00:00.000Z",
90
+ );
91
+ expect(same.updatedAt).toBe("OLD");
92
+ });
93
+ });
94
+
95
+ describe("syncProfile (io)", () => {
96
+ let dir: string;
97
+ beforeEach(async () => {
98
+ dir = await mkdtemp(join(tmpdir(), "sync-"));
99
+ await fs.ensureDir(join(dir, ".claude"));
100
+ await fs.writeJson(join(dir, ".claude", "profiles.json"), {
101
+ version: 2,
102
+ profiles: { frontend: { name: "Frontend", plugins: { "dev@magus": "2.9.0" } } },
103
+ });
104
+ });
105
+ afterEach(async () => {
106
+ await rm(dir, { recursive: true, force: true });
107
+ });
108
+
109
+ test("promotes an edit in the materialized dir into the manifest file", async () => {
110
+ // A teammate enabled a new plugin directly in the active profile's settings.
111
+ const pd = profileDir("frontend", dir);
112
+ await fs.ensureDir(pd);
113
+ await fs.writeJson(join(pd, "settings.json"), {
114
+ enabledPlugins: { "dev@magus": true, "designer@magus": true },
115
+ });
116
+ await fs.writeJson(join(pd, "mcp.json"), { mcpServers: {} });
117
+
118
+ const { changes, wrote } = await syncProfile("frontend", dir);
119
+ expect(wrote).toBe(true);
120
+ expect(changes.some((c) => c.detail === "designer@magus")).toBe(true);
121
+
122
+ const manifest = await fs.readJson(join(dir, ".claude", "profiles.json"));
123
+ expect(manifest.profiles.frontend.plugins["designer@magus"]).toBe("latest");
124
+ expect(manifest.profiles.frontend.plugins["dev@magus"]).toBe("2.9.0"); // pin kept
125
+ });
126
+
127
+ test("no-op when nothing drifted", async () => {
128
+ const pd = profileDir("frontend", dir);
129
+ await fs.ensureDir(pd);
130
+ await fs.writeJson(join(pd, "settings.json"), {
131
+ enabledPlugins: { "dev@magus": true },
132
+ });
133
+ const { wrote } = await syncProfile("frontend", dir);
134
+ expect(wrote).toBe(false);
135
+ });
136
+ });
@@ -0,0 +1,74 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { pickRegistryEntry } from "../services/claude-settings.js";
3
+ import type { InstalledPluginEntry } from "../types/index.js";
4
+
5
+ /**
6
+ * Regression cover for the "no outdated plugins shown" bug.
7
+ *
8
+ * claudeup used to read installed versions from `settings.installedPluginVersions`
9
+ * only. Claude Code's own `plugin install` writes installed_plugins.json but not
10
+ * that settings key, so a genuinely installed plugin could be missing from it.
11
+ * The update check is `installedVersion && compare(latest, installedVersion) > 0`,
12
+ * so a missing version resolved to `hasUpdate: false` — an available update was
13
+ * reported as "up to date" rather than "unknown".
14
+ */
15
+
16
+ const entry = (
17
+ scope: InstalledPluginEntry["scope"],
18
+ version: string,
19
+ projectPath?: string,
20
+ ): InstalledPluginEntry => ({
21
+ scope,
22
+ version,
23
+ projectPath,
24
+ installPath: `/cache/${version}`,
25
+ installedAt: "2026-01-01T00:00:00.000Z",
26
+ lastUpdated: "2026-01-01T00:00:00.000Z",
27
+ });
28
+
29
+ describe("pickRegistryEntry — scope resolution", () => {
30
+ test("user scope takes the user entry, ignoring project installs", () => {
31
+ const entries = [
32
+ entry("project", "1.0.0", "/proj/a"),
33
+ entry("user", "2.0.0"),
34
+ entry("project", "3.0.0", "/proj/b"),
35
+ ];
36
+ expect(pickRegistryEntry(entries, "user")?.version).toBe("2.0.0");
37
+ });
38
+
39
+ test("project scope prefers the entry pinned to that exact project", () => {
40
+ const entries = [
41
+ entry("user", "2.0.0"),
42
+ entry("project", "1.0.0", "/proj/a"),
43
+ entry("project", "3.0.0", "/proj/b"),
44
+ ];
45
+ expect(pickRegistryEntry(entries, "project", "/proj/b")?.version).toBe("3.0.0");
46
+ });
47
+
48
+ test("project scope falls back to the user entry when the project has no install", () => {
49
+ // This is the statusline case: installed at user scope, absent from project
50
+ // settings, previously surfaced as "no version" and skipped by update checks.
51
+ const entries = [entry("user", "2.1.2"), entry("project", "1.0.0", "/proj/other")];
52
+ expect(pickRegistryEntry(entries, "project", "/proj/mine")?.version).toBe("2.1.2");
53
+ });
54
+
55
+ test("project paths are compared resolved, not as raw strings", () => {
56
+ const entries = [entry("project", "4.2.0", "/proj/a/../a")];
57
+ expect(pickRegistryEntry(entries, "project", "/proj/a")?.version).toBe("4.2.0");
58
+ });
59
+
60
+ test("a local-scope entry satisfies a project lookup for the same path", () => {
61
+ const entries = [entry("local", "5.0.0", "/proj/a"), entry("user", "1.0.0")];
62
+ expect(pickRegistryEntry(entries, "project", "/proj/a")?.version).toBe("5.0.0");
63
+ });
64
+
65
+ test("no project path and no user entry yields undefined, not a wrong project's version", () => {
66
+ const entries = [entry("project", "9.9.9", "/proj/somewhere-else")];
67
+ expect(pickRegistryEntry(entries, "project")).toBeUndefined();
68
+ });
69
+
70
+ test("empty and missing entry lists are handled", () => {
71
+ expect(pickRegistryEntry([], "user")).toBeUndefined();
72
+ expect(pickRegistryEntry(undefined, "user")).toBeUndefined();
73
+ });
74
+ });
@@ -0,0 +1,42 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, rm, symlink, writeFile, chmod } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { resolveExecutable } from "../utils/command-utils.js";
6
+
7
+ /**
8
+ * resolveExecutable must treat a dangling symlink as NOT available — the exact
9
+ * failure mode behind a plugin's binary looking present (the symlink exists)
10
+ * while failing at runtime (its target is gone).
11
+ */
12
+ describe("resolveExecutable — dangling symlink detection", () => {
13
+ let dir: string;
14
+ let prevPath: string | undefined;
15
+
16
+ beforeEach(async () => {
17
+ dir = await mkdtemp(join(tmpdir(), "resolveexe-"));
18
+ prevPath = process.env.PATH;
19
+ process.env.PATH = `${dir}:${prevPath}`;
20
+ });
21
+ afterEach(async () => {
22
+ process.env.PATH = prevPath;
23
+ await rm(dir, { recursive: true, force: true });
24
+ });
25
+
26
+ test("a live executable resolves", async () => {
27
+ const bin = join(dir, "livetool");
28
+ await writeFile(bin, "#!/bin/sh\necho hi\n");
29
+ await chmod(bin, 0o755);
30
+ expect(await resolveExecutable("livetool")).not.toBeNull();
31
+ });
32
+
33
+ test("a dangling symlink resolves to null", async () => {
34
+ const link = join(dir, "ghosttool");
35
+ await symlink(join(dir, "does-not-exist"), link);
36
+ expect(await resolveExecutable("ghosttool")).toBeNull();
37
+ });
38
+
39
+ test("a missing command resolves to null", async () => {
40
+ expect(await resolveExecutable("definitely-not-a-real-cmd-xyz")).toBeNull();
41
+ });
42
+ });
@@ -0,0 +1,218 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ dedupeBins,
4
+ resolveAllProfiles,
5
+ resolveExtends,
6
+ resolveProfile,
7
+ } from "../services/resolver.js";
8
+ import type {
9
+ BinRequirement,
10
+ ProfileManifest,
11
+ ProfileManifestEntry,
12
+ } from "../types/index.js";
13
+
14
+ // A binResolver that returns canned requires.bin per plugin — keeps the
15
+ // resolver tests hermetic (no marketplace cache / filesystem).
16
+ function fakeBinResolver(
17
+ table: Record<string, BinRequirement[]>,
18
+ ): (pluginId: string) => Promise<BinRequirement[]> {
19
+ return async (pluginId) => table[pluginId] ?? [];
20
+ }
21
+
22
+ function manifestOf(
23
+ profiles: Record<string, ProfileManifestEntry>,
24
+ ): ProfileManifest {
25
+ return { version: 2, profiles };
26
+ }
27
+
28
+ describe("dedupeBins — precedence by order", () => {
29
+ test("first-seen wins installer/version; later contributes only provenance", () => {
30
+ const claudishPin: BinRequirement = {
31
+ name: "claudish",
32
+ via: "bun",
33
+ package: "claudish",
34
+ version: "1.2.0",
35
+ };
36
+ const claudishLoose: BinRequirement = {
37
+ name: "claudish",
38
+ via: "npm",
39
+ package: "claudish",
40
+ };
41
+ const result = dedupeBins([
42
+ { bin: claudishPin, source: "profile:x.cliTools" },
43
+ { bin: claudishLoose, source: "plugin:foo@magus" },
44
+ ]);
45
+ expect(result).toHaveLength(1);
46
+ expect(result[0]!.via).toBe("bun");
47
+ expect(result[0]!.version).toBe("1.2.0");
48
+ expect(result[0]!.sources).toEqual([
49
+ "profile:x.cliTools",
50
+ "plugin:foo@magus",
51
+ ]);
52
+ });
53
+
54
+ test("a later entry fills a version the winner left unset", () => {
55
+ const loose: BinRequirement = { name: "tmux", via: "brew" };
56
+ const pinned: BinRequirement = { name: "tmux", via: "brew", version: "3.4" };
57
+ const result = dedupeBins([
58
+ { bin: loose, source: "plugin:a" },
59
+ { bin: pinned, source: "plugin:b" },
60
+ ]);
61
+ expect(result[0]!.version).toBe("3.4");
62
+ });
63
+ });
64
+
65
+ describe("resolveExtends", () => {
66
+ test("inherits predefined plugins/settings under the entry's own", () => {
67
+ const entry: ProfileManifestEntry = {
68
+ name: "Custom",
69
+ extends: "must-have",
70
+ plugins: { "dev@magus": "2.9.0" },
71
+ settings: { effortLevel: "max" },
72
+ };
73
+ const merged = resolveExtends(entry);
74
+ // must-have contributes statusline@magus + multimodel@magus at "latest".
75
+ expect(merged.plugins!["statusline@magus"]).toBe("latest");
76
+ expect(merged.plugins!["multimodel@magus"]).toBe("latest");
77
+ // Entry's own plugin pin is preserved.
78
+ expect(merged.plugins!["dev@magus"]).toBe("2.9.0");
79
+ // Entry setting wins over the inherited one.
80
+ expect(merged.settings!.effortLevel).toBe("max");
81
+ });
82
+
83
+ test("honors an explicit @marketplace on a predefined plugin name", () => {
84
+ const entry: ProfileManifestEntry = {
85
+ name: "Growth",
86
+ extends: "growth-marketer",
87
+ };
88
+ const merged = resolveExtends(entry);
89
+ // seo/nanobanana/video-editing ship on the magus-marketing channel and
90
+ // pin it explicitly — they must NOT be rewritten to @magus.
91
+ expect(merged.plugins!["seo@magus-marketing"]).toBe("latest");
92
+ expect(merged.plugins!["nanobanana@magus-marketing"]).toBe("latest");
93
+ expect(merged.plugins!["video-editing@magus-marketing"]).toBe("latest");
94
+ expect(merged.plugins!["seo@magus"]).toBeUndefined();
95
+ // Bare names still default to @magus.
96
+ expect(merged.plugins!["browser-use@magus"]).toBe("latest");
97
+ });
98
+
99
+ test("no extends is a no-op", () => {
100
+ const entry: ProfileManifestEntry = { name: "Plain", plugins: {} };
101
+ expect(resolveExtends(entry)).toBe(entry);
102
+ });
103
+
104
+ test("unknown extends id is ignored (returns entry unchanged)", () => {
105
+ const entry: ProfileManifestEntry = { name: "X", extends: "nope" };
106
+ expect(resolveExtends(entry)).toBe(entry);
107
+ });
108
+ });
109
+
110
+ describe("resolveProfile", () => {
111
+ test("normalizes plugins, unions cliTools with plugin bins (project pin wins)", async () => {
112
+ const manifest = manifestOf({
113
+ frontend: {
114
+ name: "Frontend",
115
+ plugins: {
116
+ "terminal@magus": "4.1.0",
117
+ "dev@magus": true, // → latest
118
+ "old@magus": false, // → omitted
119
+ },
120
+ cliTools: { claudish: "1.2.0" },
121
+ env: { required: ["FIGMA_ACCESS_TOKEN"] },
122
+ },
123
+ });
124
+
125
+ const closure = await resolveProfile(manifest, "frontend", {
126
+ binResolver: fakeBinResolver({
127
+ "terminal@magus": [
128
+ { name: "tmux", via: "brew", formula: "tmux" },
129
+ // terminal ALSO wants claudish, but the profile pinned it →
130
+ // profile pin must win.
131
+ { name: "claudish", via: "npm", package: "claudish" },
132
+ ],
133
+ }),
134
+ });
135
+
136
+ expect(closure.plugins).toEqual({
137
+ "terminal@magus": "4.1.0",
138
+ "dev@magus": "latest",
139
+ });
140
+ expect(closure.env.required).toEqual(["FIGMA_ACCESS_TOKEN"]);
141
+
142
+ const claudish = closure.bins.find((b) => b.name === "claudish")!;
143
+ expect(claudish.via).toBe("bun"); // from the cli-tools catalog, not npm
144
+ expect(claudish.version).toBe("1.2.0");
145
+ expect(claudish.sources[0]).toContain("cliTools");
146
+
147
+ const tmux = closure.bins.find((b) => b.name === "tmux")!;
148
+ expect(tmux.via).toBe("brew");
149
+ expect(tmux.sources).toContain("plugin:terminal@magus");
150
+ });
151
+
152
+ test("throws for an unknown profile id", async () => {
153
+ await expect(
154
+ resolveProfile(manifestOf({}), "ghost"),
155
+ ).rejects.toThrow('Profile "ghost" not found');
156
+ });
157
+ });
158
+
159
+ describe("resolveAllProfiles — union", () => {
160
+ test("unions plugins/bins/skills/env; a pin beats latest across profiles", async () => {
161
+ const manifest = manifestOf({
162
+ frontend: {
163
+ name: "Frontend",
164
+ plugins: { "dev@magus": "latest", "terminal@magus": "4.1.0" },
165
+ skills: [{ name: "A", repo: "o/r", path: "skills/a" }],
166
+ env: { required: ["FIGMA_ACCESS_TOKEN"] },
167
+ },
168
+ backend: {
169
+ name: "Backend",
170
+ plugins: { "dev@magus": "2.9.0" }, // pin should win over "latest"
171
+ cliTools: { mnemex: "latest" },
172
+ skills: [{ name: "A", repo: "o/r", path: "skills/a" }], // dupe
173
+ env: { required: ["DATABASE_URL"] },
174
+ },
175
+ });
176
+
177
+ const union = await resolveAllProfiles(manifest, {
178
+ binResolver: fakeBinResolver({}),
179
+ });
180
+
181
+ expect(union.plugins["dev@magus"]).toBe("2.9.0"); // pin beat latest
182
+ expect(union.plugins["terminal@magus"]).toBe("4.1.0");
183
+ expect(union.skills).toHaveLength(1); // deduped by repo#path
184
+ expect(union.env.required.sort()).toEqual([
185
+ "DATABASE_URL",
186
+ "FIGMA_ACCESS_TOKEN",
187
+ ]);
188
+ expect(union.bins.find((b) => b.name === "mnemex")?.via).toBe("bun");
189
+ expect(union.conflicts).toBeUndefined(); // latest-vs-pin isn't a conflict
190
+ });
191
+
192
+ test("conflicting explicit pins resolve to the higher semver, order-independent, and are reported", async () => {
193
+ const mk = (order: "ab" | "ba"): ProfileManifest => {
194
+ const a: ProfileManifestEntry = { name: "A", plugins: { "dev@magus": "2.9.0" } };
195
+ const b: ProfileManifestEntry = { name: "B", plugins: { "dev@magus": "2.11.0" } };
196
+ return manifestOf(order === "ab" ? { a, b } : { b, a });
197
+ };
198
+ const ab = await resolveAllProfiles(mk("ab"), { binResolver: fakeBinResolver({}) });
199
+ const ba = await resolveAllProfiles(mk("ba"), { binResolver: fakeBinResolver({}) });
200
+
201
+ // Deterministic: higher pin wins regardless of profile order.
202
+ expect(ab.plugins["dev@magus"]).toBe("2.11.0");
203
+ expect(ba.plugins["dev@magus"]).toBe("2.11.0");
204
+ // And the conflict is surfaced, not silent.
205
+ expect(ab.conflicts?.some((c) => c.includes("dev@magus"))).toBe(true);
206
+ expect(ba.conflicts?.some((c) => c.includes("dev@magus"))).toBe(true);
207
+ });
208
+
209
+ test("conflicting bin pins across profiles resolve deterministically + report", async () => {
210
+ const manifest = manifestOf({
211
+ a: { name: "A", cliTools: { claudish: "1.2.0" } },
212
+ b: { name: "B", cliTools: { claudish: "1.5.0" } },
213
+ });
214
+ const union = await resolveAllProfiles(manifest, { binResolver: fakeBinResolver({}) });
215
+ expect(union.bins.find((x) => x.name === "claudish")?.version).toBe("1.5.0");
216
+ expect(union.conflicts?.some((c) => c.includes("claudish"))).toBe(true);
217
+ });
218
+ });
@@ -0,0 +1,99 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, rm, lstat, readlink } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import fs from "fs-extra";
6
+ import {
7
+ activateProfile,
8
+ activeProfile,
9
+ profileDir,
10
+ } from "../services/symlink-manager.js";
11
+
12
+ /** Materialize a fake profile dir with the given files. */
13
+ async function materialize(
14
+ project: string,
15
+ name: string,
16
+ files: { settings?: object; mcp?: object; skills?: boolean },
17
+ ) {
18
+ const dir = profileDir(name, project);
19
+ await fs.ensureDir(dir);
20
+ if (files.settings) await fs.writeJson(join(dir, "settings.json"), files.settings);
21
+ if (files.mcp) await fs.writeJson(join(dir, "mcp.json"), files.mcp);
22
+ if (files.skills) await fs.ensureDir(join(dir, "skills"));
23
+ }
24
+
25
+ describe("symlink-manager", () => {
26
+ let project: string;
27
+ beforeEach(async () => {
28
+ project = await mkdtemp(join(tmpdir(), "symlink-"));
29
+ });
30
+ afterEach(async () => {
31
+ await rm(project, { recursive: true, force: true });
32
+ });
33
+
34
+ test("activateProfile links settings.json, .mcp.json, and skills/", async () => {
35
+ await materialize(project, "frontend", {
36
+ settings: { enabledPlugins: { "dev@magus": true } },
37
+ mcp: { mcpServers: {} },
38
+ skills: true,
39
+ });
40
+ await activateProfile("frontend", project);
41
+
42
+ // settings.json is a symlink into the profile dir
43
+ expect((await lstat(join(project, ".claude", "settings.json"))).isSymbolicLink()).toBe(true);
44
+ expect((await lstat(join(project, ".mcp.json"))).isSymbolicLink()).toBe(true);
45
+ expect((await lstat(join(project, ".claude", "skills"))).isSymbolicLink()).toBe(true);
46
+
47
+ // reading through the link returns the profile's content
48
+ const settings = await fs.readJson(join(project, ".claude", "settings.json"));
49
+ expect(settings.enabledPlugins["dev@magus"]).toBe(true);
50
+ });
51
+
52
+ test("writeJson through the settings symlink lands in the profile dir", async () => {
53
+ await materialize(project, "frontend", { settings: { a: 1 } });
54
+ await activateProfile("frontend", project);
55
+ await fs.writeJson(join(project, ".claude", "settings.json"), { a: 2 });
56
+ // The real file inside the profile dir was updated (symlink preserved).
57
+ expect((await lstat(join(project, ".claude", "settings.json"))).isSymbolicLink()).toBe(true);
58
+ expect((await fs.readJson(profileDir("frontend", project) + "/settings.json")).a).toBe(2);
59
+ });
60
+
61
+ test("switching repoints links and activeProfile tracks it", async () => {
62
+ await materialize(project, "frontend", { settings: { which: "frontend" } });
63
+ await materialize(project, "backend", { settings: { which: "backend" } });
64
+
65
+ await activateProfile("frontend", project);
66
+ expect(await activeProfile(project)).toBe("frontend");
67
+ expect((await fs.readJson(join(project, ".claude", "settings.json"))).which).toBe("frontend");
68
+
69
+ await activateProfile("backend", project);
70
+ expect(await activeProfile(project)).toBe("backend");
71
+ expect((await fs.readJson(join(project, ".claude", "settings.json"))).which).toBe("backend");
72
+ });
73
+
74
+ test("uses relative (not absolute) symlinks", async () => {
75
+ await materialize(project, "frontend", { settings: { a: 1 } });
76
+ await activateProfile("frontend", project);
77
+ const target = await readlink(join(project, ".claude", "settings.json"));
78
+ expect(target.startsWith("/")).toBe(false);
79
+ expect(target).toContain("_profiles");
80
+ });
81
+
82
+ test("a profile without skills/ creates no skills link", async () => {
83
+ await materialize(project, "frontend", { settings: { a: 1 } });
84
+ await activateProfile("frontend", project);
85
+ expect(await fs.pathExists(join(project, ".claude", "skills"))).toBe(false);
86
+ });
87
+
88
+ test("activateProfile throws if the profile isn't materialized", async () => {
89
+ await expect(activateProfile("ghost", project)).rejects.toThrow(
90
+ "not materialized",
91
+ );
92
+ });
93
+
94
+ test("activeProfile returns null when settings.json is a real file", async () => {
95
+ await fs.ensureDir(join(project, ".claude"));
96
+ await fs.writeJson(join(project, ".claude", "settings.json"), { a: 1 });
97
+ expect(await activeProfile(project)).toBeNull();
98
+ });
99
+ });
@@ -0,0 +1,56 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ binInstallCommand,
4
+ detectToolchains,
5
+ } from "../services/toolchain.js";
6
+ import type { ResolvedBin } from "../types/index.js";
7
+
8
+ describe("binInstallCommand", () => {
9
+ test("bun with and without a version pin", () => {
10
+ expect(binInstallCommand({ name: "claudish", via: "bun", package: "claudish", version: "1.2.0" })).toBe(
11
+ "bun install -g claudish@1.2.0",
12
+ );
13
+ expect(binInstallCommand({ name: "claudish", via: "bun" })).toBe(
14
+ "bun install -g claudish",
15
+ );
16
+ });
17
+
18
+ test("npm, pip, brew, go each render correctly", () => {
19
+ expect(binInstallCommand({ name: "x", via: "npm", package: "x" })).toBe(
20
+ "npm install -g x",
21
+ );
22
+ expect(binInstallCommand({ name: "aider", via: "pip", package: "aider", version: "0.1" })).toBe(
23
+ "pip install aider==0.1",
24
+ );
25
+ expect(binInstallCommand({ name: "tmux", via: "brew", formula: "tmux" })).toBe(
26
+ "brew install tmux",
27
+ );
28
+ expect(binInstallCommand({ name: "t", via: "go", module: "github.com/x/t" })).toBe(
29
+ "go install github.com/x/t@latest",
30
+ );
31
+ });
32
+ });
33
+
34
+ describe("detectToolchains", () => {
35
+ test("groups bins by installer and reports requiredBy + presence", async () => {
36
+ const bins: ResolvedBin[] = [
37
+ { name: "claudish", via: "bun", sources: ["a"] },
38
+ { name: "mnemex", via: "bun", sources: ["b"] },
39
+ { name: "tmux", via: "brew", sources: ["c"] },
40
+ ];
41
+ const statuses = await detectToolchains(bins);
42
+ const bun = statuses.find((s) => s.name === "bun")!;
43
+ const brew = statuses.find((s) => s.name === "brew")!;
44
+ expect(bun.requiredBy.sort()).toEqual(["claudish", "mnemex"]);
45
+ expect(brew.requiredBy).toEqual(["tmux"]);
46
+ // bun/brew are bootstrappable; the field is populated.
47
+ expect(bun.bootstrap).toContain("bun.sh");
48
+ expect(typeof bun.present).toBe("boolean");
49
+ // stable order: bun before brew
50
+ expect(statuses.map((s) => s.name)).toEqual(["bun", "brew"]);
51
+ });
52
+
53
+ test("returns only toolchains actually needed (empty for no bins)", async () => {
54
+ expect(await detectToolchains([])).toEqual([]);
55
+ });
56
+ });
@@ -0,0 +1,21 @@
1
+ /**
2
+ * `claudeup claude [args...]` — prerunner mode.
3
+ *
4
+ * Runs plugin/marketplace update & migration checks (1h cache, or `-f`/`--force`
5
+ * to bypass), then execs `claude` with the remaining args. Returns claude's exit
6
+ * code so the caller can propagate it.
7
+ */
8
+
9
+ import { prerunClaude } from "../prerunner/index.js";
10
+
11
+ export async function runClaudeCommand(claudeArgs: string[]): Promise<number> {
12
+ const forceUpdate =
13
+ claudeArgs.includes("--force") || claudeArgs.includes("-f");
14
+
15
+ // Remove prerunner-specific flags before passing args to claude.
16
+ const filteredArgs = claudeArgs.filter(
17
+ (arg) => arg !== "--force" && arg !== "-f",
18
+ );
19
+
20
+ return prerunClaude(filteredArgs, { force: forceUpdate });
21
+ }