claudeup 6.2.0 → 6.3.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 (153) hide show
  1. package/README.md +22 -0
  2. package/bin/claudeup.js +6 -1
  3. package/package.json +6 -6
  4. package/src/__tests__/alias-adopt.test.ts +331 -329
  5. package/src/__tests__/alias-parser.test.ts +384 -376
  6. package/src/__tests__/alias-shell-writer.test.ts +638 -625
  7. package/src/__tests__/alias-store.test.ts +143 -143
  8. package/src/__tests__/catalog-cache-store.test.ts +265 -265
  9. package/src/__tests__/catalog-notice.test.ts +123 -123
  10. package/src/__tests__/cli-apply-seams.test.ts +12 -8
  11. package/src/__tests__/cli-router.test.ts +132 -3
  12. package/src/__tests__/cli-update-view.test.ts +27 -8
  13. package/src/__tests__/community-staleness.test.ts +0 -2
  14. package/src/__tests__/component-badge.test.ts +4 -4
  15. package/src/__tests__/content-drift.test.ts +5 -1
  16. package/src/__tests__/conventions-integration.test.ts +774 -689
  17. package/src/__tests__/conventions-manager.test.ts +1216 -1152
  18. package/src/__tests__/doctor-bins.test.ts +14 -6
  19. package/src/__tests__/doctor.test.ts +438 -425
  20. package/src/__tests__/dual-write-prevention.test.ts +277 -282
  21. package/src/__tests__/enabled-not-installed.test.ts +71 -63
  22. package/src/__tests__/file-locking.test.ts +196 -196
  23. package/src/__tests__/gap-fill-versions.test.ts +318 -311
  24. package/src/__tests__/github-budget.test.ts +170 -164
  25. package/src/__tests__/gitignore-detector.test.ts +160 -152
  26. package/src/__tests__/gitignore-fixer.test.ts +285 -247
  27. package/src/__tests__/gitignore-prerun.test.ts +57 -57
  28. package/src/__tests__/gitignore-resolver.test.ts +173 -146
  29. package/src/__tests__/gitignore-service.test.ts +121 -119
  30. package/src/__tests__/go-module.test.ts +43 -0
  31. package/src/__tests__/install-command.test.ts +3 -3
  32. package/src/__tests__/install-plan.test.ts +13 -7
  33. package/src/__tests__/manifest.test.ts +16 -5
  34. package/src/__tests__/markdown-renderer.test.ts +0 -1
  35. package/src/__tests__/marketplace-badge.test.ts +111 -90
  36. package/src/__tests__/marketplace-version.test.ts +64 -61
  37. package/src/__tests__/plugin-manager-fallback.test.ts +249 -239
  38. package/src/__tests__/plugin-requires.test.ts +55 -1
  39. package/src/__tests__/plugin-setup.test.ts +111 -9
  40. package/src/__tests__/profile-command.test.ts +1 -7
  41. package/src/__tests__/profile-materializer.test.ts +31 -8
  42. package/src/__tests__/profile-sync.test.ts +23 -5
  43. package/src/__tests__/registry-version-resolution.test.ts +61 -47
  44. package/src/__tests__/resolve-executable.test.ts +1 -1
  45. package/src/__tests__/resolver.test.ts +38 -13
  46. package/src/__tests__/scope-action.test.ts +52 -50
  47. package/src/__tests__/scope-squares.test.tsx +4 -5
  48. package/src/__tests__/skill-install-files.test.ts +111 -103
  49. package/src/__tests__/symlink-manager.test.ts +27 -10
  50. package/src/__tests__/theme-env.test.ts +281 -0
  51. package/src/__tests__/theme-resolve.test.ts +380 -0
  52. package/src/__tests__/update-apply.test.ts +1 -3
  53. package/src/__tests__/useGitignoreModal.test.ts +69 -61
  54. package/src/__tests__/version-snapshot.test.ts +183 -165
  55. package/src/cli/ansi.ts +23 -15
  56. package/src/cli/doctor.ts +10 -9
  57. package/src/cli/install.ts +3 -3
  58. package/src/cli/profile.ts +5 -4
  59. package/src/cli/prompt.ts +0 -1
  60. package/src/cli/router.ts +69 -2
  61. package/src/cli/update-view.ts +11 -6
  62. package/src/cli/update.ts +9 -4
  63. package/src/cli/upgrade.ts +6 -4
  64. package/src/data/alias-flags.ts +260 -260
  65. package/src/data/gitignore-defaults.ts +20 -20
  66. package/src/data/gitignore-reasons.ts +86 -92
  67. package/src/data/gitignore-templates.ts +14 -14
  68. package/src/data/predefined-profiles.ts +1 -2
  69. package/src/data/skill-repos.ts +43 -35
  70. package/src/main.tsx +29 -6
  71. package/src/opentui.d.ts +1 -3
  72. package/src/prerunner/index.ts +7 -8
  73. package/src/services/alias-settings.ts +20 -23
  74. package/src/services/alias-shell-writer.ts +800 -803
  75. package/src/services/alias-store.ts +77 -77
  76. package/src/services/claude-settings.ts +27 -18
  77. package/src/services/content-drift.ts +14 -4
  78. package/src/services/conventions-manager.ts +692 -617
  79. package/src/services/doctor.ts +464 -403
  80. package/src/services/gitignore-detector.ts +113 -103
  81. package/src/services/gitignore-fixer.ts +204 -189
  82. package/src/services/gitignore-prerun.ts +26 -26
  83. package/src/services/gitignore-resolver.ts +162 -144
  84. package/src/services/gitignore-service.ts +120 -117
  85. package/src/services/install-plan.ts +4 -4
  86. package/src/services/local-marketplace.ts +7 -5
  87. package/src/services/manifest.ts +1 -1
  88. package/src/services/marketplace-catalog-git.ts +5 -2
  89. package/src/services/marketplace-fetcher.ts +241 -235
  90. package/src/services/plugin-manager.ts +17 -20
  91. package/src/services/plugin-mcp-config.ts +2 -2
  92. package/src/services/plugin-requires.ts +16 -8
  93. package/src/services/plugin-setup.ts +32 -15
  94. package/src/services/plugin-version-check.ts +4 -4
  95. package/src/services/profile-materializer.ts +9 -5
  96. package/src/services/profile-sync.ts +12 -4
  97. package/src/services/profiles.ts +3 -3
  98. package/src/services/resolver.ts +10 -6
  99. package/src/services/settings-manager.ts +82 -46
  100. package/src/services/skills-manager.ts +11 -9
  101. package/src/services/symlink-manager.ts +2 -5
  102. package/src/services/toolchain.ts +3 -3
  103. package/src/services/update-cache.ts +1 -1
  104. package/src/services/update-engine.ts +4 -4
  105. package/src/services/update-plan.ts +9 -11
  106. package/src/services/version-snapshot.ts +88 -88
  107. package/src/types/gitignore.ts +28 -28
  108. package/src/ui/App.tsx +36 -36
  109. package/src/ui/adapters/settingsAdapter.ts +2 -2
  110. package/src/ui/components/CategoryHeader.tsx +1 -1
  111. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  112. package/src/ui/components/ScopeIndicator.tsx +3 -1
  113. package/src/ui/components/ScrollableList.tsx +2 -1
  114. package/src/ui/components/StyledText.tsx +1 -1
  115. package/src/ui/components/layout/FooterHints.tsx +5 -3
  116. package/src/ui/components/layout/Panel.tsx +2 -2
  117. package/src/ui/components/layout/ScreenLayout.tsx +5 -4
  118. package/src/ui/components/modals/InputModal.tsx +7 -1
  119. package/src/ui/components/modals/ModalContainer.tsx +3 -3
  120. package/src/ui/components/modals/SelectModal.tsx +1 -1
  121. package/src/ui/components/primitives/DetailSection.tsx +1 -1
  122. package/src/ui/components/primitives/KeyValueLine.tsx +1 -1
  123. package/src/ui/components/primitives/ScopeSquares.tsx +2 -2
  124. package/src/ui/hooks/index.ts +0 -1
  125. package/src/ui/hooks/useGitignoreModal.ts +1 -5
  126. package/src/ui/hooks/useMismatchModal.ts +4 -10
  127. package/src/ui/renderers/cliToolRenderers.tsx +2 -2
  128. package/src/ui/renderers/gitignoreRenderers.tsx +2 -2
  129. package/src/ui/renderers/mcpRenderers.tsx +1 -1
  130. package/src/ui/renderers/pluginRenderers.tsx +3 -9
  131. package/src/ui/renderers/profileRenderers.tsx +9 -8
  132. package/src/ui/renderers/settingsRenderers.tsx +4 -4
  133. package/src/ui/renderers/skillRenderers.tsx +14 -14
  134. package/src/ui/screens/AliasScreen.tsx +31 -34
  135. package/src/ui/screens/CliToolsScreen.tsx +14 -13
  136. package/src/ui/screens/EnvVarsScreen.tsx +8 -8
  137. package/src/ui/screens/GitignoreScreen.tsx +15 -14
  138. package/src/ui/screens/McpRegistryScreen.tsx +10 -7
  139. package/src/ui/screens/McpScreen.tsx +10 -10
  140. package/src/ui/screens/PluginsScreen.tsx +17 -21
  141. package/src/ui/screens/ProfilesScreen.tsx +25 -24
  142. package/src/ui/screens/SkillsScreen.tsx +16 -14
  143. package/src/ui/state/AppContext.tsx +4 -8
  144. package/src/ui/state/DimensionsContext.tsx +3 -2
  145. package/src/ui/theme-mode.ts +20 -38
  146. package/src/ui/theme-resolve.ts +148 -0
  147. package/src/utils/clipboard.ts +5 -5
  148. package/src/utils/command-utils.ts +2 -2
  149. package/src/utils/config-dir.ts +1 -3
  150. package/src/utils/file-locking.ts +88 -86
  151. package/src/utils/go-module.ts +36 -0
  152. package/src/ui/hooks/useAsyncData.ts +0 -127
  153. package/src/ui/state/AnimationContext.tsx +0 -76
@@ -1,18 +1,25 @@
1
- import { describe, it, expect, beforeEach, afterAll, beforeAll } from "bun:test";
1
+ import {
2
+ afterAll,
3
+ beforeAll,
4
+ beforeEach,
5
+ describe,
6
+ expect,
7
+ it,
8
+ } from "bun:test";
2
9
  import fs from "node:fs";
3
10
  import os from "node:os";
4
11
  import path from "node:path";
5
- import {
6
- clearMarketplaceCache,
7
- fetchMarketplacePlugins,
8
- getMarketplaceFetchFailures,
9
- resolveMarketplacePlugins,
10
- } from "../services/marketplace-fetcher";
11
12
  import { resetGitHubBudget } from "../services/github-budget";
12
13
  import type {
13
- LocalMarketplace,
14
- LocalMarketplacePlugin,
14
+ LocalMarketplace,
15
+ LocalMarketplacePlugin,
15
16
  } from "../services/local-marketplace";
17
+ import {
18
+ clearMarketplaceCache,
19
+ fetchMarketplacePlugins,
20
+ getMarketplaceFetchFailures,
21
+ resolveMarketplacePlugins,
22
+ } from "../services/marketplace-fetcher";
16
23
 
17
24
  /**
18
25
  * Point the marketplace-clone lookup at an EMPTY temp directory.
@@ -27,15 +34,16 @@ let prevConfigDir: string | undefined;
27
34
  let emptyConfigDir: string;
28
35
 
29
36
  beforeAll(() => {
30
- emptyConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), "mp-fallback-"));
31
- prevConfigDir = process.env.CLAUDE_CONFIG_DIR;
32
- process.env.CLAUDE_CONFIG_DIR = emptyConfigDir;
37
+ emptyConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), "mp-fallback-"));
38
+ prevConfigDir = process.env.CLAUDE_CONFIG_DIR;
39
+ process.env.CLAUDE_CONFIG_DIR = emptyConfigDir;
33
40
  });
34
41
 
35
42
  afterAll(() => {
36
- if (prevConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
37
- else process.env.CLAUDE_CONFIG_DIR = prevConfigDir;
38
- fs.rmSync(emptyConfigDir, { recursive: true, force: true });
43
+ // biome-ignore lint/performance/noDelete: assigning undefined to process.env stores the string "undefined"; only delete unsets the variable
44
+ if (prevConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
45
+ else process.env.CLAUDE_CONFIG_DIR = prevConfigDir;
46
+ fs.rmSync(emptyConfigDir, { recursive: true, force: true });
39
47
  });
40
48
 
41
49
  /**
@@ -52,136 +60,136 @@ afterAll(() => {
52
60
  */
53
61
 
54
62
  function buildLocalMarketplace(
55
- name: string,
56
- plugins: LocalMarketplacePlugin[],
63
+ name: string,
64
+ plugins: LocalMarketplacePlugin[],
57
65
  ): LocalMarketplace {
58
- return { name, description: "", plugins };
66
+ return { name, description: "", plugins };
59
67
  }
60
68
 
61
69
  describe("resolveMarketplacePlugins — offline fallback", () => {
62
- beforeEach(() => {
63
- // Session-level cache could otherwise carry over a previous test's
64
- // successful fetch and short-circuit the fallback path.
65
- clearMarketplaceCache();
66
- // The per-host cooldown is deliberately NOT cleared by clearMarketplaceCache
67
- // — it reflects GitHub's state, not ours — so a 429 in one test would
68
- // otherwise gate every fetch in the next for the full backoff.
69
- resetGitHubBudget();
70
- });
70
+ beforeEach(() => {
71
+ // Session-level cache could otherwise carry over a previous test's
72
+ // successful fetch and short-circuit the fallback path.
73
+ clearMarketplaceCache();
74
+ // The per-host cooldown is deliberately NOT cleared by clearMarketplaceCache
75
+ // — it reflects GitHub's state, not ours — so a 429 in one test would
76
+ // otherwise gate every fetch in the next for the full backoff.
77
+ resetGitHubBudget();
78
+ });
71
79
 
72
- it("falls back to the local cache when the remote fetch returns empty", async () => {
73
- const local = new Map<string, LocalMarketplace>([
74
- [
75
- "magus",
76
- buildLocalMarketplace("magus", [
77
- {
78
- name: "code-analysis",
79
- version: "5.3.0",
80
- description: "Codebase investigation tools",
81
- category: "development",
82
- author: { name: "Jack Rudenko" },
83
- },
84
- {
85
- name: "dev",
86
- version: "2.7.0",
87
- description: "Dev workflow",
88
- },
89
- ]),
90
- ],
91
- ]);
80
+ it("falls back to the local cache when the remote fetch returns empty", async () => {
81
+ const local = new Map<string, LocalMarketplace>([
82
+ [
83
+ "magus",
84
+ buildLocalMarketplace("magus", [
85
+ {
86
+ name: "code-analysis",
87
+ version: "5.3.0",
88
+ description: "Codebase investigation tools",
89
+ category: "development",
90
+ author: { name: "Jack Rudenko" },
91
+ },
92
+ {
93
+ name: "dev",
94
+ version: "2.7.0",
95
+ description: "Dev workflow",
96
+ },
97
+ ]),
98
+ ],
99
+ ]);
92
100
 
93
- // An invalid repo string short-circuits `fetchMarketplacePlugins` to
94
- // `[]` without any network call, simulating the offline case.
95
- const resolved = await resolveMarketplacePlugins(
96
- "magus",
97
- "not a valid repo string",
98
- local,
99
- );
101
+ // An invalid repo string short-circuits `fetchMarketplacePlugins` to
102
+ // `[]` without any network call, simulating the offline case.
103
+ const resolved = await resolveMarketplacePlugins(
104
+ "magus",
105
+ "not a valid repo string",
106
+ local,
107
+ );
100
108
 
101
- expect(resolved.plugins).toHaveLength(2);
102
- expect(resolved.plugins.map((p) => p.name).sort()).toEqual([
103
- "code-analysis",
104
- "dev",
105
- ]);
106
- const codeAnalysis = resolved.plugins.find(
107
- (p) => p.name === "code-analysis",
108
- );
109
- expect(codeAnalysis?.version).toBe("5.3.0");
110
- expect(codeAnalysis?.description).toBe("Codebase investigation tools");
111
- expect(codeAnalysis?.category).toBe("development");
112
- expect(codeAnalysis?.author?.name).toBe("Jack Rudenko");
113
- });
109
+ expect(resolved.plugins).toHaveLength(2);
110
+ expect(resolved.plugins.map((p) => p.name).sort()).toEqual([
111
+ "code-analysis",
112
+ "dev",
113
+ ]);
114
+ const codeAnalysis = resolved.plugins.find(
115
+ (p) => p.name === "code-analysis",
116
+ );
117
+ expect(codeAnalysis?.version).toBe("5.3.0");
118
+ expect(codeAnalysis?.description).toBe("Codebase investigation tools");
119
+ expect(codeAnalysis?.category).toBe("development");
120
+ expect(codeAnalysis?.author?.name).toBe("Jack Rudenko");
121
+ });
114
122
 
115
- it("labels the fallback answer as local-clone, not remote", async () => {
116
- // The regression this guards: the fallback plugins were returned in the
117
- // same shape as a successful remote fetch, so the caller compared installed
118
- // versions against a clone nothing refreshes and reported "up to date".
119
- // The source must travel with the data.
120
- const local = new Map<string, LocalMarketplace>([
121
- [
122
- "magus",
123
- buildLocalMarketplace("magus", [
124
- { name: "terminal", version: "4.1.4", description: "" },
125
- ]),
126
- ],
127
- ]);
128
- const resolved = await resolveMarketplacePlugins(
129
- "magus",
130
- "not a valid repo string",
131
- local,
132
- );
123
+ it("labels the fallback answer as local-clone, not remote", async () => {
124
+ // The regression this guards: the fallback plugins were returned in the
125
+ // same shape as a successful remote fetch, so the caller compared installed
126
+ // versions against a clone nothing refreshes and reported "up to date".
127
+ // The source must travel with the data.
128
+ const local = new Map<string, LocalMarketplace>([
129
+ [
130
+ "magus",
131
+ buildLocalMarketplace("magus", [
132
+ { name: "terminal", version: "4.1.4", description: "" },
133
+ ]),
134
+ ],
135
+ ]);
136
+ const resolved = await resolveMarketplacePlugins(
137
+ "magus",
138
+ "not a valid repo string",
139
+ local,
140
+ );
133
141
 
134
- expect(resolved.source).toBe("local-clone");
135
- expect(resolved.failure).toBeDefined();
136
- expect(resolved.failure?.kind).toBe("invalid-repo");
137
- expect(resolved.failure?.marketplace).toBe("magus");
138
- });
142
+ expect(resolved.source).toBe("local-clone");
143
+ expect(resolved.failure).toBeDefined();
144
+ expect(resolved.failure?.kind).toBe("invalid-repo");
145
+ expect(resolved.failure?.marketplace).toBe("magus");
146
+ });
139
147
 
140
- it("returns empty when both remote and cache are empty", async () => {
141
- const local = new Map<string, LocalMarketplace>();
142
- const resolved = await resolveMarketplacePlugins(
143
- "magus",
144
- "not a valid repo string",
145
- local,
146
- );
147
- expect(resolved.plugins).toEqual([]);
148
- expect(resolved.source).toBe("none");
149
- expect(resolved.failure).toBeDefined();
150
- });
148
+ it("returns empty when both remote and cache are empty", async () => {
149
+ const local = new Map<string, LocalMarketplace>();
150
+ const resolved = await resolveMarketplacePlugins(
151
+ "magus",
152
+ "not a valid repo string",
153
+ local,
154
+ );
155
+ expect(resolved.plugins).toEqual([]);
156
+ expect(resolved.source).toBe("none");
157
+ expect(resolved.failure).toBeDefined();
158
+ });
151
159
 
152
- it("returns empty when remote fails AND cache exists but has no plugins", async () => {
153
- const local = new Map<string, LocalMarketplace>([
154
- ["magus", buildLocalMarketplace("magus", [])],
155
- ]);
156
- const resolved = await resolveMarketplacePlugins(
157
- "magus",
158
- "not a valid repo string",
159
- local,
160
- );
161
- expect(resolved.plugins).toEqual([]);
162
- expect(resolved.source).toBe("none");
163
- });
160
+ it("returns empty when remote fails AND cache exists but has no plugins", async () => {
161
+ const local = new Map<string, LocalMarketplace>([
162
+ ["magus", buildLocalMarketplace("magus", [])],
163
+ ]);
164
+ const resolved = await resolveMarketplacePlugins(
165
+ "magus",
166
+ "not a valid repo string",
167
+ local,
168
+ );
169
+ expect(resolved.plugins).toEqual([]);
170
+ expect(resolved.source).toBe("none");
171
+ });
164
172
 
165
- it("does not consult the cache when the remote returns plugins", async () => {
166
- // We can't easily exercise the success branch without a live network,
167
- // but we can verify that a cache entry for a DIFFERENT marketplace
168
- // doesn't bleed into the requested one.
169
- const local = new Map<string, LocalMarketplace>([
170
- [
171
- "other-marketplace",
172
- buildLocalMarketplace("other-marketplace", [
173
- { name: "should-not-leak", version: "0.0.1", description: "" },
174
- ]),
175
- ],
176
- ]);
177
- const resolved = await resolveMarketplacePlugins(
178
- "magus",
179
- "not a valid repo string",
180
- local,
181
- );
182
- expect(resolved.plugins).toEqual([]);
183
- expect(resolved.source).toBe("none");
184
- });
173
+ it("does not consult the cache when the remote returns plugins", async () => {
174
+ // We can't easily exercise the success branch without a live network,
175
+ // but we can verify that a cache entry for a DIFFERENT marketplace
176
+ // doesn't bleed into the requested one.
177
+ const local = new Map<string, LocalMarketplace>([
178
+ [
179
+ "other-marketplace",
180
+ buildLocalMarketplace("other-marketplace", [
181
+ { name: "should-not-leak", version: "0.0.1", description: "" },
182
+ ]),
183
+ ],
184
+ ]);
185
+ const resolved = await resolveMarketplacePlugins(
186
+ "magus",
187
+ "not a valid repo string",
188
+ local,
189
+ );
190
+ expect(resolved.plugins).toEqual([]);
191
+ expect(resolved.source).toBe("none");
192
+ });
185
193
  });
186
194
 
187
195
  /**
@@ -192,121 +200,123 @@ describe("resolveMarketplacePlugins — offline fallback", () => {
192
200
  * to guess, and all of them guessed "fine".
193
201
  */
194
202
  describe("fetchMarketplacePlugins — failures are values", () => {
195
- beforeEach(() => {
196
- clearMarketplaceCache();
197
- resetGitHubBudget();
198
- });
203
+ beforeEach(() => {
204
+ clearMarketplaceCache();
205
+ resetGitHubBudget();
206
+ });
199
207
 
200
- it("classifies a rate limit distinctly from a network problem", async () => {
201
- const original = globalThis.fetch;
202
- globalThis.fetch = (async () =>
203
- new Response("rate limited", {
204
- status: 429,
205
- statusText: "Too Many Requests",
206
- })) as typeof globalThis.fetch;
207
- try {
208
- const result = await fetchMarketplacePlugins("magus", "MadAppGang/magus");
209
- expect(result.plugins).toEqual([]);
210
- expect(result.failure?.kind).toBe("rate-limited");
211
- expect(result.failure?.httpStatus).toBe(429);
212
- expect(result.failure?.detail).toContain("rate limit");
213
- } finally {
214
- globalThis.fetch = original;
215
- }
216
- });
208
+ it("classifies a rate limit distinctly from a network problem", async () => {
209
+ const original = globalThis.fetch;
210
+ globalThis.fetch = (async () =>
211
+ new Response("rate limited", {
212
+ status: 429,
213
+ statusText: "Too Many Requests",
214
+ })) as typeof globalThis.fetch;
215
+ try {
216
+ const result = await fetchMarketplacePlugins("magus", "MadAppGang/magus");
217
+ expect(result.plugins).toEqual([]);
218
+ expect(result.failure?.kind).toBe("rate-limited");
219
+ expect(result.failure?.httpStatus).toBe(429);
220
+ expect(result.failure?.detail).toContain("rate limit");
221
+ } finally {
222
+ globalThis.fetch = original;
223
+ }
224
+ });
217
225
 
218
- it("records the failure so the UI can report it without re-fetching", async () => {
219
- const original = globalThis.fetch;
220
- let calls = 0;
221
- globalThis.fetch = (async () => {
222
- calls++;
223
- return new Response("nope", { status: 500, statusText: "Server Error" });
224
- }) as typeof globalThis.fetch;
225
- try {
226
- await fetchMarketplacePlugins("magus", "MadAppGang/magus");
226
+ it("records the failure so the UI can report it without re-fetching", async () => {
227
+ const original = globalThis.fetch;
228
+ let calls = 0;
229
+ globalThis.fetch = (async () => {
230
+ calls++;
231
+ return new Response("nope", { status: 500, statusText: "Server Error" });
232
+ }) as typeof globalThis.fetch;
233
+ try {
234
+ await fetchMarketplacePlugins("magus", "MadAppGang/magus");
227
235
 
228
- const failures = getMarketplaceFetchFailures();
229
- expect(failures.map((f) => f.marketplace)).toContain("magus");
230
- expect(failures.find((f) => f.marketplace === "magus")?.kind).toBe("http");
236
+ const failures = getMarketplaceFetchFailures();
237
+ expect(failures.map((f) => f.marketplace)).toContain("magus");
238
+ expect(failures.find((f) => f.marketplace === "magus")?.kind).toBe(
239
+ "http",
240
+ );
231
241
 
232
- // Second call must be served from the negative cache. Re-attempting on
233
- // every screen mount is what cost 30-45s of "Loading..." per tab switch.
234
- await fetchMarketplacePlugins("magus", "MadAppGang/magus");
235
- expect(calls).toBe(1);
236
- } finally {
237
- globalThis.fetch = original;
238
- }
239
- });
242
+ // Second call must be served from the negative cache. Re-attempting on
243
+ // every screen mount is what cost 30-45s of "Loading..." per tab switch.
244
+ await fetchMarketplacePlugins("magus", "MadAppGang/magus");
245
+ expect(calls).toBe(1);
246
+ } finally {
247
+ globalThis.fetch = original;
248
+ }
249
+ });
240
250
 
241
- it("reports no failures when the fetch succeeds", async () => {
242
- const original = globalThis.fetch;
243
- globalThis.fetch = (async () =>
244
- Response.json({
245
- metadata: { version: "9.0.3" },
246
- plugins: [{ name: "terminal", version: "4.1.6" }],
247
- })) as typeof globalThis.fetch;
248
- try {
249
- const result = await fetchMarketplacePlugins("magus", "MadAppGang/magus");
250
- expect(result.failure).toBeUndefined();
251
- expect(result.plugins).toHaveLength(1);
252
- expect(getMarketplaceFetchFailures()).toEqual([]);
253
- } finally {
254
- globalThis.fetch = original;
255
- }
256
- });
251
+ it("reports no failures when the fetch succeeds", async () => {
252
+ const original = globalThis.fetch;
253
+ globalThis.fetch = (async () =>
254
+ Response.json({
255
+ metadata: { version: "9.0.3" },
256
+ plugins: [{ name: "terminal", version: "4.1.6" }],
257
+ })) as typeof globalThis.fetch;
258
+ try {
259
+ const result = await fetchMarketplacePlugins("magus", "MadAppGang/magus");
260
+ expect(result.failure).toBeUndefined();
261
+ expect(result.plugins).toHaveLength(1);
262
+ expect(getMarketplaceFetchFailures()).toEqual([]);
263
+ } finally {
264
+ globalThis.fetch = original;
265
+ }
266
+ });
257
267
 
258
- it("does not send a request while the rate-limit window is still open", async () => {
259
- // Deliberate: an explicit refresh clears OUR caches but not GitHub's limit.
260
- // Retrying inside a known window cannot succeed, and on some endpoints a
261
- // rejected request extends the penalty — so the screen waits and counts down
262
- // instead. `clearMarketplaceCache()` must not be a way around that.
263
- const original = globalThis.fetch;
264
- let calls = 0;
265
- globalThis.fetch = (async () => {
266
- calls++;
267
- return new Response("nope", { status: 429 });
268
- }) as typeof globalThis.fetch;
269
- try {
270
- await fetchMarketplacePlugins("magus", "MadAppGang/magus");
271
- expect(calls).toBe(1);
268
+ it("does not send a request while the rate-limit window is still open", async () => {
269
+ // Deliberate: an explicit refresh clears OUR caches but not GitHub's limit.
270
+ // Retrying inside a known window cannot succeed, and on some endpoints a
271
+ // rejected request extends the penalty — so the screen waits and counts down
272
+ // instead. `clearMarketplaceCache()` must not be a way around that.
273
+ const original = globalThis.fetch;
274
+ let calls = 0;
275
+ globalThis.fetch = (async () => {
276
+ calls++;
277
+ return new Response("nope", { status: 429 });
278
+ }) as typeof globalThis.fetch;
279
+ try {
280
+ await fetchMarketplacePlugins("magus", "MadAppGang/magus");
281
+ expect(calls).toBe(1);
272
282
 
273
- clearMarketplaceCache();
274
- const second = await fetchMarketplacePlugins("magus", "MadAppGang/magus");
283
+ clearMarketplaceCache();
284
+ const second = await fetchMarketplacePlugins("magus", "MadAppGang/magus");
275
285
 
276
- expect(calls).toBe(1); // no second request went out
277
- expect(second.failure?.kind).toBe("rate-limited");
278
- expect(second.failure?.retryAt).toBeGreaterThan(Date.now());
279
- } finally {
280
- globalThis.fetch = original;
281
- }
282
- });
286
+ expect(calls).toBe(1); // no second request went out
287
+ expect(second.failure?.kind).toBe("rate-limited");
288
+ expect(second.failure?.retryAt).toBeGreaterThan(Date.now());
289
+ } finally {
290
+ globalThis.fetch = original;
291
+ }
292
+ });
283
293
 
284
- it("clears the recorded failure once the window passes and the fetch succeeds", async () => {
285
- const original = globalThis.fetch;
286
- globalThis.fetch = (async () =>
287
- new Response("nope", { status: 429 })) as typeof globalThis.fetch;
288
- try {
289
- await fetchMarketplacePlugins("magus", "MadAppGang/magus");
290
- expect(getMarketplaceFetchFailures()).toHaveLength(1);
294
+ it("clears the recorded failure once the window passes and the fetch succeeds", async () => {
295
+ const original = globalThis.fetch;
296
+ globalThis.fetch = (async () =>
297
+ new Response("nope", { status: 429 })) as typeof globalThis.fetch;
298
+ try {
299
+ await fetchMarketplacePlugins("magus", "MadAppGang/magus");
300
+ expect(getMarketplaceFetchFailures()).toHaveLength(1);
291
301
 
292
- // The cooldown expiring is what unblocks the retry — this stands in for the
293
- // clock reaching `retryAt`, which is when the screen refetches by itself.
294
- resetGitHubBudget();
295
- clearMarketplaceCache();
296
- globalThis.fetch = (async () =>
297
- Response.json({
298
- plugins: [{ name: "terminal", version: "4.2.0" }],
299
- })) as typeof globalThis.fetch;
302
+ // The cooldown expiring is what unblocks the retry — this stands in for the
303
+ // clock reaching `retryAt`, which is when the screen refetches by itself.
304
+ resetGitHubBudget();
305
+ clearMarketplaceCache();
306
+ globalThis.fetch = (async () =>
307
+ Response.json({
308
+ plugins: [{ name: "terminal", version: "4.2.0" }],
309
+ })) as typeof globalThis.fetch;
300
310
 
301
- const recovered = await fetchMarketplacePlugins(
302
- "magus",
303
- "MadAppGang/magus",
304
- );
305
- expect(recovered.failure).toBeUndefined();
306
- expect(recovered.plugins).toHaveLength(1);
307
- expect(getMarketplaceFetchFailures()).toEqual([]);
308
- } finally {
309
- globalThis.fetch = original;
310
- }
311
- });
311
+ const recovered = await fetchMarketplacePlugins(
312
+ "magus",
313
+ "MadAppGang/magus",
314
+ );
315
+ expect(recovered.failure).toBeUndefined();
316
+ expect(recovered.plugins).toHaveLength(1);
317
+ expect(getMarketplaceFetchFailures()).toEqual([]);
318
+ } finally {
319
+ globalThis.fetch = original;
320
+ }
321
+ });
312
322
  });
@@ -69,6 +69,58 @@ describe("parsePluginRequires — setup block (the established convention)", ()
69
69
  expect(tmux.formula).toBe("tmux");
70
70
  });
71
71
 
72
+ // Negative control for the /vN fix: basename(base) yields "v2" here, so
73
+ // without the go-module helper this test FAILS on `name`.
74
+ test("terminal's v2 pin: /v2 is the module's major version, not the binary", () => {
75
+ const r = parsePluginRequires({
76
+ name: "terminal",
77
+ setup: { go: ["github.com/MadAppGang/tmux-mcp/v2@v2.0.0"] },
78
+ });
79
+ expect(r.bin).toEqual([
80
+ {
81
+ name: "tmux-mcp",
82
+ via: "go",
83
+ module: "github.com/MadAppGang/tmux-mcp/v2",
84
+ version: "v2.0.0",
85
+ check: "tmux-mcp --version",
86
+ },
87
+ ]);
88
+ });
89
+
90
+ // One manifest per spec: entries are keyed by binary name, so a v1 and a v2
91
+ // pin of the same module in ONE setup would (correctly) collapse to one.
92
+ test.each([
93
+ [
94
+ "github.com/MadAppGang/tmux-mcp@v1.7.1",
95
+ {
96
+ name: "tmux-mcp",
97
+ module: "github.com/MadAppGang/tmux-mcp",
98
+ version: "v1.7.1",
99
+ },
100
+ ],
101
+ [
102
+ "github.com/MadAppGang/tmux-mcp/v2",
103
+ {
104
+ name: "tmux-mcp",
105
+ module: "github.com/MadAppGang/tmux-mcp/v2",
106
+ version: undefined,
107
+ },
108
+ ],
109
+ [
110
+ "github.com/x/v2tool@v1.0.0",
111
+ { name: "v2tool", module: "github.com/x/v2tool", version: "v1.0.0" },
112
+ ],
113
+ ])("go spec %s → name/module/version", (spec, expected) => {
114
+ const r = parsePluginRequires({ setup: { go: [spec] } });
115
+ expect(
116
+ r.bin!.map((b) => ({
117
+ name: b.name,
118
+ module: b.module,
119
+ version: b.version,
120
+ })),
121
+ ).toEqual([expected]);
122
+ });
123
+
72
124
  test("browser-use's setup: pip packages", () => {
73
125
  const r = parsePluginRequires({
74
126
  name: "browser-use",
@@ -92,7 +144,9 @@ describe("parsePluginRequires — setup block (the established convention)", ()
92
144
  test("explicit requires.bin overrides a setup entry of the same name", () => {
93
145
  const r = parsePluginRequires({
94
146
  setup: { required: ["tmux"] }, // → brew
95
- requires: { bin: [{ name: "tmux", via: "go", module: "example.com/tmux" }] },
147
+ requires: {
148
+ bin: [{ name: "tmux", via: "go", module: "example.com/tmux" }],
149
+ },
96
150
  });
97
151
  const tmux = r.bin!.find((b) => b.name === "tmux")!;
98
152
  expect(tmux.via).toBe("go"); // requires.bin won