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,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/image-generate/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!["image-generate@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
+ }