claudeup 6.2.1 → 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 (150) 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__/install-command.test.ts +3 -3
  31. package/src/__tests__/install-plan.test.ts +13 -7
  32. package/src/__tests__/manifest.test.ts +16 -5
  33. package/src/__tests__/markdown-renderer.test.ts +0 -1
  34. package/src/__tests__/marketplace-badge.test.ts +111 -90
  35. package/src/__tests__/marketplace-version.test.ts +64 -61
  36. package/src/__tests__/plugin-manager-fallback.test.ts +249 -239
  37. package/src/__tests__/plugin-requires.test.ts +18 -4
  38. package/src/__tests__/plugin-setup.test.ts +12 -12
  39. package/src/__tests__/profile-command.test.ts +1 -7
  40. package/src/__tests__/profile-materializer.test.ts +31 -8
  41. package/src/__tests__/profile-sync.test.ts +23 -5
  42. package/src/__tests__/registry-version-resolution.test.ts +61 -47
  43. package/src/__tests__/resolve-executable.test.ts +1 -1
  44. package/src/__tests__/resolver.test.ts +38 -13
  45. package/src/__tests__/scope-action.test.ts +52 -50
  46. package/src/__tests__/scope-squares.test.tsx +4 -5
  47. package/src/__tests__/skill-install-files.test.ts +111 -103
  48. package/src/__tests__/symlink-manager.test.ts +27 -10
  49. package/src/__tests__/theme-env.test.ts +281 -0
  50. package/src/__tests__/theme-resolve.test.ts +380 -0
  51. package/src/__tests__/update-apply.test.ts +1 -3
  52. package/src/__tests__/useGitignoreModal.test.ts +69 -61
  53. package/src/__tests__/version-snapshot.test.ts +183 -165
  54. package/src/cli/ansi.ts +23 -15
  55. package/src/cli/doctor.ts +10 -9
  56. package/src/cli/install.ts +3 -3
  57. package/src/cli/profile.ts +5 -4
  58. package/src/cli/prompt.ts +0 -1
  59. package/src/cli/router.ts +69 -2
  60. package/src/cli/update-view.ts +11 -6
  61. package/src/cli/update.ts +9 -4
  62. package/src/cli/upgrade.ts +6 -4
  63. package/src/data/alias-flags.ts +260 -260
  64. package/src/data/gitignore-defaults.ts +20 -20
  65. package/src/data/gitignore-reasons.ts +86 -92
  66. package/src/data/gitignore-templates.ts +14 -14
  67. package/src/data/predefined-profiles.ts +1 -2
  68. package/src/data/skill-repos.ts +43 -35
  69. package/src/main.tsx +29 -6
  70. package/src/opentui.d.ts +1 -3
  71. package/src/prerunner/index.ts +7 -8
  72. package/src/services/alias-settings.ts +20 -23
  73. package/src/services/alias-shell-writer.ts +800 -803
  74. package/src/services/alias-store.ts +77 -77
  75. package/src/services/claude-settings.ts +27 -18
  76. package/src/services/content-drift.ts +13 -3
  77. package/src/services/conventions-manager.ts +692 -617
  78. package/src/services/doctor.ts +464 -403
  79. package/src/services/gitignore-detector.ts +113 -103
  80. package/src/services/gitignore-fixer.ts +204 -189
  81. package/src/services/gitignore-prerun.ts +26 -26
  82. package/src/services/gitignore-resolver.ts +162 -144
  83. package/src/services/gitignore-service.ts +120 -117
  84. package/src/services/install-plan.ts +4 -4
  85. package/src/services/manifest.ts +1 -1
  86. package/src/services/marketplace-catalog-git.ts +5 -2
  87. package/src/services/marketplace-fetcher.ts +241 -235
  88. package/src/services/plugin-manager.ts +17 -20
  89. package/src/services/plugin-mcp-config.ts +2 -2
  90. package/src/services/plugin-requires.ts +9 -6
  91. package/src/services/plugin-setup.ts +7 -9
  92. package/src/services/plugin-version-check.ts +4 -4
  93. package/src/services/profile-materializer.ts +9 -5
  94. package/src/services/profile-sync.ts +12 -4
  95. package/src/services/profiles.ts +3 -3
  96. package/src/services/resolver.ts +10 -6
  97. package/src/services/settings-manager.ts +82 -46
  98. package/src/services/skills-manager.ts +11 -9
  99. package/src/services/symlink-manager.ts +2 -5
  100. package/src/services/toolchain.ts +3 -3
  101. package/src/services/update-cache.ts +1 -1
  102. package/src/services/update-engine.ts +4 -4
  103. package/src/services/update-plan.ts +9 -11
  104. package/src/services/version-snapshot.ts +88 -88
  105. package/src/types/gitignore.ts +28 -28
  106. package/src/ui/App.tsx +36 -36
  107. package/src/ui/adapters/settingsAdapter.ts +2 -2
  108. package/src/ui/components/CategoryHeader.tsx +1 -1
  109. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  110. package/src/ui/components/ScopeIndicator.tsx +3 -1
  111. package/src/ui/components/ScrollableList.tsx +2 -1
  112. package/src/ui/components/StyledText.tsx +1 -1
  113. package/src/ui/components/layout/FooterHints.tsx +5 -3
  114. package/src/ui/components/layout/Panel.tsx +2 -2
  115. package/src/ui/components/layout/ScreenLayout.tsx +5 -4
  116. package/src/ui/components/modals/InputModal.tsx +7 -1
  117. package/src/ui/components/modals/ModalContainer.tsx +3 -3
  118. package/src/ui/components/modals/SelectModal.tsx +1 -1
  119. package/src/ui/components/primitives/DetailSection.tsx +1 -1
  120. package/src/ui/components/primitives/KeyValueLine.tsx +1 -1
  121. package/src/ui/components/primitives/ScopeSquares.tsx +2 -2
  122. package/src/ui/hooks/index.ts +0 -1
  123. package/src/ui/hooks/useGitignoreModal.ts +1 -5
  124. package/src/ui/hooks/useMismatchModal.ts +4 -10
  125. package/src/ui/renderers/cliToolRenderers.tsx +2 -2
  126. package/src/ui/renderers/gitignoreRenderers.tsx +2 -2
  127. package/src/ui/renderers/mcpRenderers.tsx +1 -1
  128. package/src/ui/renderers/pluginRenderers.tsx +3 -9
  129. package/src/ui/renderers/profileRenderers.tsx +9 -8
  130. package/src/ui/renderers/settingsRenderers.tsx +4 -4
  131. package/src/ui/renderers/skillRenderers.tsx +14 -14
  132. package/src/ui/screens/AliasScreen.tsx +31 -34
  133. package/src/ui/screens/CliToolsScreen.tsx +14 -13
  134. package/src/ui/screens/EnvVarsScreen.tsx +8 -8
  135. package/src/ui/screens/GitignoreScreen.tsx +15 -14
  136. package/src/ui/screens/McpRegistryScreen.tsx +10 -7
  137. package/src/ui/screens/McpScreen.tsx +10 -10
  138. package/src/ui/screens/PluginsScreen.tsx +17 -21
  139. package/src/ui/screens/ProfilesScreen.tsx +25 -24
  140. package/src/ui/screens/SkillsScreen.tsx +16 -14
  141. package/src/ui/state/AppContext.tsx +4 -8
  142. package/src/ui/state/DimensionsContext.tsx +3 -2
  143. package/src/ui/theme-mode.ts +20 -38
  144. package/src/ui/theme-resolve.ts +148 -0
  145. package/src/utils/clipboard.ts +5 -5
  146. package/src/utils/command-utils.ts +2 -2
  147. package/src/utils/config-dir.ts +1 -3
  148. package/src/utils/file-locking.ts +88 -86
  149. package/src/ui/hooks/useAsyncData.ts +0 -127
  150. package/src/ui/state/AnimationContext.tsx +0 -76
@@ -11,14 +11,14 @@
11
11
  * but NOT when CLI is unavailable or update fails (no phantom state).
12
12
  */
13
13
 
14
- import { describe, it, expect, mock, beforeEach, afterEach } from "bun:test";
14
+ import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
15
15
  import * as fs from "node:fs";
16
16
  import * as path from "node:path";
17
17
  import * as url from "node:url";
18
+ import * as actualClaudeCli from "../services/claude-cli.js";
18
19
  // Real modules captured before the mock.module() overrides below, so the mocks
19
20
  // can spread the full export surface instead of stripping it.
20
21
  import * as actualClaudeSettings from "../services/claude-settings.js";
21
- import * as actualClaudeCli from "../services/claude-cli.js";
22
22
  import * as actualPluginManager from "../services/plugin-manager.js";
23
23
 
24
24
  // ---------------------------------------------------------------------------
@@ -34,55 +34,55 @@ import * as actualPluginManager from "../services/plugin-manager.js";
34
34
 
35
35
  const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
36
36
  const PLUGINS_SCREEN_PATH = path.resolve(
37
- __dirname,
38
- "../ui/screens/PluginsScreen.tsx",
37
+ __dirname,
38
+ "../ui/screens/PluginsScreen.tsx",
39
39
  );
40
40
 
41
41
  describe("PluginsScreen — no dual-write after CLI operations", () => {
42
- let source: string;
43
-
44
- beforeEach(() => {
45
- source = fs.readFileSync(PLUGINS_SCREEN_PATH, "utf8");
46
- });
47
-
48
- it("does not call saveVersionAfterInstall immediately after cliUpdatePlugin", () => {
49
- // Find every occurrence of cliUpdatePlugin in the source. Each one should
50
- // NOT be immediately followed (within 3 lines) by saveVersionAfterInstall.
51
- const lines = source.split("\n");
52
- const violations: number[] = [];
53
-
54
- for (let i = 0; i < lines.length; i++) {
55
- if (lines[i].includes("cliUpdatePlugin(")) {
56
- // Check the next 3 lines for a dual-write call
57
- for (let j = i + 1; j <= Math.min(i + 3, lines.length - 1); j++) {
58
- if (lines[j].includes("saveVersionAfterInstall(")) {
59
- violations.push(i + 1); // 1-indexed line number
60
- break;
61
- }
62
- }
63
- }
64
- }
65
-
66
- expect(violations).toEqual([]);
67
- });
68
-
69
- it("does not call saveVersionAfterInstall immediately after cliInstallPlugin", () => {
70
- const lines = source.split("\n");
71
- const violations: number[] = [];
72
-
73
- for (let i = 0; i < lines.length; i++) {
74
- if (lines[i].includes("cliInstallPlugin(")) {
75
- for (let j = i + 1; j <= Math.min(i + 3, lines.length - 1); j++) {
76
- if (lines[j].includes("saveVersionAfterInstall(")) {
77
- violations.push(i + 1);
78
- break;
79
- }
80
- }
81
- }
82
- }
83
-
84
- expect(violations).toEqual([]);
85
- });
42
+ let source: string;
43
+
44
+ beforeEach(() => {
45
+ source = fs.readFileSync(PLUGINS_SCREEN_PATH, "utf8");
46
+ });
47
+
48
+ it("does not call saveVersionAfterInstall immediately after cliUpdatePlugin", () => {
49
+ // Find every occurrence of cliUpdatePlugin in the source. Each one should
50
+ // NOT be immediately followed (within 3 lines) by saveVersionAfterInstall.
51
+ const lines = source.split("\n");
52
+ const violations: number[] = [];
53
+
54
+ for (let i = 0; i < lines.length; i++) {
55
+ if (lines[i].includes("cliUpdatePlugin(")) {
56
+ // Check the next 3 lines for a dual-write call
57
+ for (let j = i + 1; j <= Math.min(i + 3, lines.length - 1); j++) {
58
+ if (lines[j].includes("saveVersionAfterInstall(")) {
59
+ violations.push(i + 1); // 1-indexed line number
60
+ break;
61
+ }
62
+ }
63
+ }
64
+ }
65
+
66
+ expect(violations).toEqual([]);
67
+ });
68
+
69
+ it("does not call saveVersionAfterInstall immediately after cliInstallPlugin", () => {
70
+ const lines = source.split("\n");
71
+ const violations: number[] = [];
72
+
73
+ for (let i = 0; i < lines.length; i++) {
74
+ if (lines[i].includes("cliInstallPlugin(")) {
75
+ for (let j = i + 1; j <= Math.min(i + 3, lines.length - 1); j++) {
76
+ if (lines[j].includes("saveVersionAfterInstall(")) {
77
+ violations.push(i + 1);
78
+ break;
79
+ }
80
+ }
81
+ }
82
+ }
83
+
84
+ expect(violations).toEqual([]);
85
+ });
86
86
  });
87
87
 
88
88
  // ---------------------------------------------------------------------------
@@ -108,92 +108,91 @@ let mockGetAvailablePlugins: ReturnType<typeof mock>;
108
108
  // bindings are evaluated before these mock.module() calls run, so they capture
109
109
  // the real modules.
110
110
  mock.module("../services/claude-settings.js", () => ({
111
- ...actualClaudeSettings,
112
- recoverMarketplaceSettings: mock(() =>
113
- Promise.resolve({ enabledAutoUpdate: [], removed: [], reregistered: [] }),
114
- ),
115
- migrateMarketplaceRename: mock(() =>
116
- Promise.resolve({
117
- projectMigrated: 0,
118
- globalMigrated: 0,
119
- localMigrated: 0,
120
- registryMigrated: 0,
121
- knownMarketplacesMigrated: false,
122
- }),
123
- ),
124
- getGlobalEnabledPlugins: mock(() => Promise.resolve({})),
125
- getEnabledPlugins: mock(() => Promise.resolve({})),
126
- getLocalEnabledPlugins: mock(() => Promise.resolve({})),
127
- saveGlobalInstalledPluginVersion: (...args: unknown[]) =>
128
- mockSaveGlobal(...args),
129
- saveLocalInstalledPluginVersion: (...args: unknown[]) =>
130
- mockSaveLocal(...args),
131
- readGlobalSettings: mock(() => Promise.resolve({ hooks: {} })),
132
- writeGlobalSettings: mock(() => Promise.resolve()),
111
+ ...actualClaudeSettings,
112
+ recoverMarketplaceSettings: mock(() =>
113
+ Promise.resolve({ enabledAutoUpdate: [], removed: [], reregistered: [] }),
114
+ ),
115
+ migrateMarketplaceRename: mock(() =>
116
+ Promise.resolve({
117
+ projectMigrated: 0,
118
+ globalMigrated: 0,
119
+ localMigrated: 0,
120
+ registryMigrated: 0,
121
+ knownMarketplacesMigrated: false,
122
+ }),
123
+ ),
124
+ getGlobalEnabledPlugins: mock(() => Promise.resolve({})),
125
+ getEnabledPlugins: mock(() => Promise.resolve({})),
126
+ getLocalEnabledPlugins: mock(() => Promise.resolve({})),
127
+ saveGlobalInstalledPluginVersion: (...args: unknown[]) =>
128
+ mockSaveGlobal(...args),
129
+ saveLocalInstalledPluginVersion: (...args: unknown[]) =>
130
+ mockSaveLocal(...args),
131
+ readGlobalSettings: mock(() => Promise.resolve({ hooks: {} })),
132
+ writeGlobalSettings: mock(() => Promise.resolve()),
133
133
  }));
134
134
 
135
135
  mock.module("../services/claude-cli.js", () => ({
136
- ...actualClaudeCli,
137
- updatePlugin: (...args: unknown[]) => mockUpdatePlugin(...args),
138
- repairPlugin: (...args: unknown[]) => mockRepairPlugin(...args),
139
- isClaudeAvailable: (...args: unknown[]) => mockIsClaudeAvailable(...args),
140
- addMarketplace: mock(() => Promise.resolve()),
141
- updateMarketplace: mock(() => Promise.resolve()),
136
+ ...actualClaudeCli,
137
+ updatePlugin: (...args: unknown[]) => mockUpdatePlugin(...args),
138
+ repairPlugin: (...args: unknown[]) => mockRepairPlugin(...args),
139
+ isClaudeAvailable: (...args: unknown[]) => mockIsClaudeAvailable(...args),
140
+ addMarketplace: mock(() => Promise.resolve()),
141
+ updateMarketplace: mock(() => Promise.resolve()),
142
142
  }));
143
143
 
144
144
  mock.module("../services/plugin-manager.js", () => ({
145
- ...actualPluginManager,
146
- getAvailablePlugins: (...args: unknown[]) =>
147
- mockGetAvailablePlugins(...args),
148
- saveInstalledPluginVersion: (...args: unknown[]) => mockSaveProject(...args),
149
- clearMarketplaceCache: mock(() => undefined),
145
+ ...actualPluginManager,
146
+ getAvailablePlugins: (...args: unknown[]) => mockGetAvailablePlugins(...args),
147
+ saveInstalledPluginVersion: (...args: unknown[]) => mockSaveProject(...args),
148
+ clearMarketplaceCache: mock(() => undefined),
150
149
  }));
151
150
 
152
151
  mock.module("../services/update-cache.js", () => ({
153
- UpdateCache: class {
154
- shouldCheckForUpdates = mock(() => Promise.resolve(true));
155
- saveCheck = mock(() => Promise.resolve());
156
- },
152
+ UpdateCache: class {
153
+ shouldCheckForUpdates = mock(() => Promise.resolve(true));
154
+ saveCheck = mock(() => Promise.resolve());
155
+ },
157
156
  }));
158
157
 
159
158
  mock.module("../services/claude-runner.js", () => ({
160
- runClaude: mock(() => Promise.resolve(0)),
159
+ runClaude: mock(() => Promise.resolve(0)),
161
160
  }));
162
161
 
163
162
  mock.module("../data/marketplaces.js", () => ({
164
- defaultMarketplaces: [],
163
+ defaultMarketplaces: [],
165
164
  }));
166
165
 
167
166
  mock.module("../utils/string-utils.js", () => ({
168
- parsePluginId: mock((_id: string) => null),
167
+ parsePluginId: mock((_id: string) => null),
169
168
  }));
170
169
 
171
170
  mock.module("fs-extra", () => ({
172
- default: {
173
- pathExists: mock(() => Promise.resolve(false)),
174
- readJson: mock(() => Promise.resolve({})),
175
- writeJson: mock(() => Promise.resolve()),
176
- ensureDir: mock(() => Promise.resolve()),
177
- },
171
+ default: {
172
+ pathExists: mock(() => Promise.resolve(false)),
173
+ readJson: mock(() => Promise.resolve({})),
174
+ writeJson: mock(() => Promise.resolve()),
175
+ ensureDir: mock(() => Promise.resolve()),
176
+ },
178
177
  }));
179
178
 
180
179
  // Now import the module under test (after mock.module registrations).
181
180
  const { prerunClaude } = await import("../prerunner/index.js");
182
181
 
183
182
  interface ScopeStatusLike {
184
- enabled: boolean;
185
- version?: string;
183
+ enabled: boolean;
184
+ version?: string;
186
185
  }
187
186
  interface PluginLike {
188
- id: string;
189
- enabled: boolean;
190
- hasUpdate: boolean;
191
- contentStale?: boolean;
192
- installedVersion: string;
193
- version: string;
194
- userScope?: ScopeStatusLike;
195
- projectScope?: ScopeStatusLike;
196
- localScope?: ScopeStatusLike;
187
+ id: string;
188
+ enabled: boolean;
189
+ hasUpdate: boolean;
190
+ contentStale?: boolean;
191
+ installedVersion: string;
192
+ version: string;
193
+ userScope?: ScopeStatusLike;
194
+ projectScope?: ScopeStatusLike;
195
+ localScope?: ScopeStatusLike;
197
196
  }
198
197
 
199
198
  /**
@@ -202,184 +201,180 @@ interface PluginLike {
202
201
  * no scope status describes a plugin installed nowhere and is correctly skipped.
203
202
  */
204
203
  function makePlugin(overrides: Partial<PluginLike> = {}): PluginLike {
205
- return {
206
- id: "test-plugin@magus",
207
- enabled: true,
208
- hasUpdate: true,
209
- installedVersion: "1.0.0",
210
- version: "1.1.0",
211
- userScope: { enabled: true, version: "1.0.0" },
212
- ...overrides,
213
- };
204
+ return {
205
+ id: "test-plugin@magus",
206
+ enabled: true,
207
+ hasUpdate: true,
208
+ installedVersion: "1.0.0",
209
+ version: "1.1.0",
210
+ userScope: { enabled: true, version: "1.0.0" },
211
+ ...overrides,
212
+ };
214
213
  }
215
214
 
216
215
  describe("prerunner — saveGlobalInstalledPluginVersion call count", () => {
217
- beforeEach(() => {
218
- mockSaveGlobal = mock(() => Promise.resolve());
219
- mockSaveProject = mock(() => Promise.resolve());
220
- mockSaveLocal = mock(() => Promise.resolve());
221
- mockUpdatePlugin = mock(() => Promise.resolve());
222
- mockRepairPlugin = mock(() => Promise.resolve());
223
- mockIsClaudeAvailable = mock(() => Promise.resolve(true));
224
- mockGetAvailablePlugins = mock(() =>
225
- Promise.resolve([makePlugin()]),
226
- );
227
- });
228
-
229
- it("calls saveGlobalInstalledPluginVersion once after successful CLI update", async () => {
230
- // CLI is available, updatePlugin succeeds.
231
- // The CLI does NOT update installedPluginVersions, so claudeup must save
232
- // the version after a successful update.
233
- mockIsClaudeAvailable = mock(() => Promise.resolve(true));
234
- mockUpdatePlugin = mock(() => Promise.resolve());
235
-
236
- await prerunClaude(["--help"], { force: true });
237
-
238
- expect(mockSaveGlobal).toHaveBeenCalledTimes(1);
239
- });
240
-
241
- /**
242
- * The prerunner used to call `updatePlugin(id, "user")` unconditionally.
243
- * `hasUpdate` is computed from the CURRENT PROJECT's resolved version, but
244
- * installs live per project in installed_plugins.json 495 of 496 magus
245
- * installs on the reporter's machine are project-scoped. So it detected
246
- * project drift, updated user scope, reported success, and left the project
247
- * untouched, re-detecting the identical drift on every subsequent run.
248
- */
249
- it("updates the PROJECT scope when that is where the outdated install is", async () => {
250
- mockGetAvailablePlugins = mock(() =>
251
- Promise.resolve([
252
- makePlugin({
253
- userScope: undefined,
254
- projectScope: { enabled: true, version: "1.0.0" },
255
- }),
256
- ]),
257
- );
258
-
259
- await prerunClaude(["--help"], { force: true });
260
-
261
- expect(mockUpdatePlugin).toHaveBeenCalledTimes(1);
262
- expect(mockUpdatePlugin.mock.calls[0]?.[1]).toBe("project");
263
- expect(mockSaveProject).toHaveBeenCalledTimes(1);
264
- expect(mockSaveGlobal).toHaveBeenCalledTimes(0);
265
- });
266
-
267
- it("updates every scope that is outdated, not just the first", async () => {
268
- mockGetAvailablePlugins = mock(() =>
269
- Promise.resolve([
270
- makePlugin({
271
- userScope: { enabled: true, version: "1.0.0" },
272
- projectScope: { enabled: true, version: "1.0.0" },
273
- localScope: { enabled: true, version: "1.1.0" }, // already current
274
- }),
275
- ]),
276
- );
277
-
278
- await prerunClaude(["--help"], { force: true });
279
-
280
- const scopes = mockUpdatePlugin.mock.calls
281
- .map((c: unknown[]) => c[1])
282
- .sort();
283
- expect(scopes).toEqual(["project", "user"]);
284
- expect(mockSaveLocal).toHaveBeenCalledTimes(0);
285
- });
286
-
287
- /**
288
- * A content-stale plugin has no version bump to install over, and
289
- * `claude plugin install` no-ops on "already installed" (verified against the
290
- * real marketplace in autotest/plugin-system/probe-05). The prerunner must
291
- * therefore repair uninstall+install not update.
292
- */
293
- it("repairs a content-stale plugin instead of updating it", async () => {
294
- mockGetAvailablePlugins = mock(() =>
295
- Promise.resolve([
296
- makePlugin({
297
- hasUpdate: false,
298
- contentStale: true,
299
- installedVersion: "1.0.0",
300
- version: "1.0.0",
301
- userScope: undefined,
302
- projectScope: { enabled: true, version: "1.0.0" },
303
- }),
304
- ]),
305
- );
306
-
307
- await prerunClaude(["--help"], { force: true });
308
-
309
- expect(mockRepairPlugin).toHaveBeenCalledTimes(1);
310
- expect(mockRepairPlugin.mock.calls[0]?.[1]).toBe("project");
311
- expect(mockUpdatePlugin).toHaveBeenCalledTimes(0);
312
- });
313
-
314
- it("prefers a real version update over a repair when both apply", async () => {
315
- // A pending version bump reinstalls the files anyway, so repairing as well
316
- // would double the work and the uninstall window.
317
- mockGetAvailablePlugins = mock(() =>
318
- Promise.resolve([
319
- makePlugin({ hasUpdate: true, contentStale: true }),
320
- ]),
321
- );
322
-
323
- await prerunClaude(["--help"], { force: true });
324
-
325
- expect(mockUpdatePlugin).toHaveBeenCalledTimes(1);
326
- expect(mockRepairPlugin).toHaveBeenCalledTimes(0);
327
- });
328
-
329
- it("does not repair when the CLI is unavailable", async () => {
330
- mockIsClaudeAvailable = mock(() => Promise.resolve(false));
331
- mockGetAvailablePlugins = mock(() =>
332
- Promise.resolve([
333
- makePlugin({
334
- hasUpdate: false,
335
- contentStale: true,
336
- version: "1.0.0",
337
- projectScope: { enabled: true, version: "1.0.0" },
338
- }),
339
- ]),
340
- );
341
-
342
- await prerunClaude(["--help"], { force: true });
343
-
344
- expect(mockRepairPlugin).toHaveBeenCalledTimes(0);
345
- });
346
-
347
- it("does nothing when no scope holds an outdated install", async () => {
348
- // hasUpdate can be true from a stale settings base while every real
349
- // install is already current. Updating "somewhere" would be a guess.
350
- mockGetAvailablePlugins = mock(() =>
351
- Promise.resolve([
352
- makePlugin({
353
- userScope: { enabled: true, version: "1.1.0" },
354
- projectScope: undefined,
355
- }),
356
- ]),
357
- );
358
-
359
- await prerunClaude(["--help"], { force: true });
360
-
361
- expect(mockUpdatePlugin).toHaveBeenCalledTimes(0);
362
- expect(mockSaveGlobal).toHaveBeenCalledTimes(0);
363
- });
364
-
365
- it("does NOT call saveGlobalInstalledPluginVersion when CLI is unavailable", async () => {
366
- // CLI unavailable — we must NOT write phantom state.
367
- mockIsClaudeAvailable = mock(() => Promise.resolve(false));
368
-
369
- await prerunClaude(["--help"], { force: true });
370
-
371
- expect(mockSaveGlobal).toHaveBeenCalledTimes(0);
372
- });
373
-
374
- it("does NOT call saveGlobalInstalledPluginVersion when CLI updatePlugin throws", async () => {
375
- // CLI available but updatePlugin fails — must NOT write phantom state.
376
- mockIsClaudeAvailable = mock(() => Promise.resolve(true));
377
- mockUpdatePlugin = mock(() =>
378
- Promise.reject(new Error("CLI update failed")),
379
- );
380
-
381
- await prerunClaude(["--help"], { force: true });
382
-
383
- expect(mockSaveGlobal).toHaveBeenCalledTimes(0);
384
- });
216
+ beforeEach(() => {
217
+ mockSaveGlobal = mock(() => Promise.resolve());
218
+ mockSaveProject = mock(() => Promise.resolve());
219
+ mockSaveLocal = mock(() => Promise.resolve());
220
+ mockUpdatePlugin = mock(() => Promise.resolve());
221
+ mockRepairPlugin = mock(() => Promise.resolve());
222
+ mockIsClaudeAvailable = mock(() => Promise.resolve(true));
223
+ mockGetAvailablePlugins = mock(() => Promise.resolve([makePlugin()]));
224
+ });
225
+
226
+ it("calls saveGlobalInstalledPluginVersion once after successful CLI update", async () => {
227
+ // CLI is available, updatePlugin succeeds.
228
+ // The CLI does NOT update installedPluginVersions, so claudeup must save
229
+ // the version after a successful update.
230
+ mockIsClaudeAvailable = mock(() => Promise.resolve(true));
231
+ mockUpdatePlugin = mock(() => Promise.resolve());
232
+
233
+ await prerunClaude(["--help"], { force: true });
234
+
235
+ expect(mockSaveGlobal).toHaveBeenCalledTimes(1);
236
+ });
237
+
238
+ /**
239
+ * The prerunner used to call `updatePlugin(id, "user")` unconditionally.
240
+ * `hasUpdate` is computed from the CURRENT PROJECT's resolved version, but
241
+ * installs live per project in installed_plugins.json 495 of 496 magus
242
+ * installs on the reporter's machine are project-scoped. So it detected
243
+ * project drift, updated user scope, reported success, and left the project
244
+ * untouched, re-detecting the identical drift on every subsequent run.
245
+ */
246
+ it("updates the PROJECT scope when that is where the outdated install is", async () => {
247
+ mockGetAvailablePlugins = mock(() =>
248
+ Promise.resolve([
249
+ makePlugin({
250
+ userScope: undefined,
251
+ projectScope: { enabled: true, version: "1.0.0" },
252
+ }),
253
+ ]),
254
+ );
255
+
256
+ await prerunClaude(["--help"], { force: true });
257
+
258
+ expect(mockUpdatePlugin).toHaveBeenCalledTimes(1);
259
+ expect(mockUpdatePlugin.mock.calls[0]?.[1]).toBe("project");
260
+ expect(mockSaveProject).toHaveBeenCalledTimes(1);
261
+ expect(mockSaveGlobal).toHaveBeenCalledTimes(0);
262
+ });
263
+
264
+ it("updates every scope that is outdated, not just the first", async () => {
265
+ mockGetAvailablePlugins = mock(() =>
266
+ Promise.resolve([
267
+ makePlugin({
268
+ userScope: { enabled: true, version: "1.0.0" },
269
+ projectScope: { enabled: true, version: "1.0.0" },
270
+ localScope: { enabled: true, version: "1.1.0" }, // already current
271
+ }),
272
+ ]),
273
+ );
274
+
275
+ await prerunClaude(["--help"], { force: true });
276
+
277
+ const scopes = mockUpdatePlugin.mock.calls
278
+ .map((c: unknown[]) => c[1])
279
+ .sort();
280
+ expect(scopes).toEqual(["project", "user"]);
281
+ expect(mockSaveLocal).toHaveBeenCalledTimes(0);
282
+ });
283
+
284
+ /**
285
+ * A content-stale plugin has no version bump to install over, and
286
+ * `claude plugin install` no-ops on "already installed" (verified against the
287
+ * real marketplace in autotest/plugin-system/probe-05). The prerunner must
288
+ * therefore repair — uninstall+install not update.
289
+ */
290
+ it("repairs a content-stale plugin instead of updating it", async () => {
291
+ mockGetAvailablePlugins = mock(() =>
292
+ Promise.resolve([
293
+ makePlugin({
294
+ hasUpdate: false,
295
+ contentStale: true,
296
+ installedVersion: "1.0.0",
297
+ version: "1.0.0",
298
+ userScope: undefined,
299
+ projectScope: { enabled: true, version: "1.0.0" },
300
+ }),
301
+ ]),
302
+ );
303
+
304
+ await prerunClaude(["--help"], { force: true });
305
+
306
+ expect(mockRepairPlugin).toHaveBeenCalledTimes(1);
307
+ expect(mockRepairPlugin.mock.calls[0]?.[1]).toBe("project");
308
+ expect(mockUpdatePlugin).toHaveBeenCalledTimes(0);
309
+ });
310
+
311
+ it("prefers a real version update over a repair when both apply", async () => {
312
+ // A pending version bump reinstalls the files anyway, so repairing as well
313
+ // would double the work and the uninstall window.
314
+ mockGetAvailablePlugins = mock(() =>
315
+ Promise.resolve([makePlugin({ hasUpdate: true, contentStale: true })]),
316
+ );
317
+
318
+ await prerunClaude(["--help"], { force: true });
319
+
320
+ expect(mockUpdatePlugin).toHaveBeenCalledTimes(1);
321
+ expect(mockRepairPlugin).toHaveBeenCalledTimes(0);
322
+ });
323
+
324
+ it("does not repair when the CLI is unavailable", async () => {
325
+ mockIsClaudeAvailable = mock(() => Promise.resolve(false));
326
+ mockGetAvailablePlugins = mock(() =>
327
+ Promise.resolve([
328
+ makePlugin({
329
+ hasUpdate: false,
330
+ contentStale: true,
331
+ version: "1.0.0",
332
+ projectScope: { enabled: true, version: "1.0.0" },
333
+ }),
334
+ ]),
335
+ );
336
+
337
+ await prerunClaude(["--help"], { force: true });
338
+
339
+ expect(mockRepairPlugin).toHaveBeenCalledTimes(0);
340
+ });
341
+
342
+ it("does nothing when no scope holds an outdated install", async () => {
343
+ // hasUpdate can be true from a stale settings base while every real
344
+ // install is already current. Updating "somewhere" would be a guess.
345
+ mockGetAvailablePlugins = mock(() =>
346
+ Promise.resolve([
347
+ makePlugin({
348
+ userScope: { enabled: true, version: "1.1.0" },
349
+ projectScope: undefined,
350
+ }),
351
+ ]),
352
+ );
353
+
354
+ await prerunClaude(["--help"], { force: true });
355
+
356
+ expect(mockUpdatePlugin).toHaveBeenCalledTimes(0);
357
+ expect(mockSaveGlobal).toHaveBeenCalledTimes(0);
358
+ });
359
+
360
+ it("does NOT call saveGlobalInstalledPluginVersion when CLI is unavailable", async () => {
361
+ // CLI unavailable — we must NOT write phantom state.
362
+ mockIsClaudeAvailable = mock(() => Promise.resolve(false));
363
+
364
+ await prerunClaude(["--help"], { force: true });
365
+
366
+ expect(mockSaveGlobal).toHaveBeenCalledTimes(0);
367
+ });
368
+
369
+ it("does NOT call saveGlobalInstalledPluginVersion when CLI updatePlugin throws", async () => {
370
+ // CLI available but updatePlugin fails — must NOT write phantom state.
371
+ mockIsClaudeAvailable = mock(() => Promise.resolve(true));
372
+ mockUpdatePlugin = mock(() =>
373
+ Promise.reject(new Error("CLI update failed")),
374
+ );
375
+
376
+ await prerunClaude(["--help"], { force: true });
377
+
378
+ expect(mockSaveGlobal).toHaveBeenCalledTimes(0);
379
+ });
385
380
  });