claudeup 4.30.0 → 4.32.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.
package/README.md CHANGED
@@ -1,128 +1,134 @@
1
1
  # claudeup
2
2
 
3
- TUI tool for managing Claude Code plugins, MCPs, and configuration.
3
+ Manage a Claude Code setup — plugins, MCP servers, skills, CLI tools and the
4
+ binaries plugins depend on — from a TUI, a CLI, or a committed team manifest.
4
5
 
5
- ## Installation
6
+ ## Install
6
7
 
7
8
  ```bash
8
- # Using pnpm
9
- pnpm add -g claudeup
10
-
11
- # Using pnpx (no install)
12
- pnpx claudeup
13
-
14
- # Using npm
15
- npm install -g claudeup
16
-
17
- # Using Bun
18
- bun add -g claudeup
9
+ bun add -g claudeup # recommended
10
+ npm install -g claudeup # also works
19
11
  ```
20
12
 
21
- ## Features
22
-
23
- ### 1. MCP Server Setup
24
- Configure Model Context Protocol servers with a curated list:
25
- - **File System** - Read/write files, search directories
26
- - **Database** - SQLite, PostgreSQL connections
27
- - **Developer Tools** - GitHub, GitLab, Chrome DevTools, Puppeteer
28
- - **API & Web** - HTTP requests, Brave Search
29
- - **Productivity** - Google Drive, Slack, Memory
30
- - **AI & Intelligence** - Claude Context (semantic search), Sequential Thinking
31
-
32
- ### 2. Plugin Marketplaces
33
- Add official and community plugin marketplaces:
34
- - **Anthropic Official** - Official Claude Code plugins
35
- - **MadAppGang** - Frontend, Backend, Code Analysis plugins
36
-
37
- ### 3. Manage Plugins
38
- Install and configure plugins from added marketplaces:
39
- - Frontend Development (React/TypeScript)
40
- - Code Analysis (Deep investigation)
41
- - Bun Backend (TypeScript backend)
42
- - Orchestration (Multi-agent patterns)
43
- - Agent Development (Create agents)
44
-
45
- ### 4. Status Line Configuration
46
- Configure the Claude Code status line with presets:
47
- - Minimal, Standard, Detailed
48
- - Git-aware, Token-focused
49
- - Time-tracking, Developer
50
-
51
- ## Usage
13
+ Updates go through claudeup itself, not the package manager:
52
14
 
53
15
  ```bash
54
- # Launch TUI
55
- claudeup
56
-
57
- # Or with npx
58
- npx claudeup
16
+ claudeup update
59
17
  ```
60
18
 
61
- ### Navigation
62
- - `↑/↓` or `j/k` - Move selection
63
- - `Enter` - Select item
64
- - `Escape/q` - Go back / Exit
65
- - `?` - Show help
66
- - `1-4` - Quick jump to screens
19
+ You get a self-contained binary for your platform (an `optionalDependencies`
20
+ package gated on `os`/`cpu`). On a platform without a prebuilt binary the
21
+ launcher falls back to running from source under Bun, so the install still works.
67
22
 
68
- ## Development
23
+ ## Two ways to use it
69
24
 
70
- ```bash
71
- # Install dependencies
72
- pnpm install
25
+ **Interactive** — run `claudeup` with no arguments for the TUI. Good for
26
+ exploring what is available and toggling things on one machine.
73
27
 
74
- # Build
75
- pnpm build
28
+ **Declarative** — commit a `.claude/profiles.json` and run `claudeup install`.
29
+ Good for making a teammate's machine match yours. This is the
30
+ [team configuration guide](docs/team-configuration.md).
76
31
 
77
- # Run in development
78
- pnpm start:bun # With Bun
79
- pnpm start # With Node
80
- ```
32
+ ## Commands
81
33
 
82
- ## Configuration Files
34
+ ```
35
+ claudeup Open the interactive TUI
36
+
37
+ install [profile] Install a profile's plugins, binaries, skills and
38
+ env, then activate it. No argument = every profile.
39
+ --check Report drift only, write nothing (CI gate)
40
+ --yes, -y Skip the confirmation prompt
41
+ --force Discard unsynced local edits instead of refusing
42
+ profile list Show every profile; ● marks the active one
43
+ profile show <name> Print a profile's fully resolved closure
44
+ profile switch <name> Repoint the active profile — offline, no reinstall
45
+ profile sync Promote local edits back into .claude/profiles.json
46
+ doctor [--fix] Check binary deps, profile symlinks, conventions
47
+
48
+ claude [args...] Check for plugin updates (1h cache), then run claude
49
+ --force, -f Force the update check
50
+ update Update claudeup itself
51
+ --version, -v Version + update check
52
+ --help, -h This list
53
+ ```
83
54
 
84
- claudeup modifies these Claude Code configuration files:
55
+ `claudeup doctor` exits non-zero when it finds a problem, so it works as a CI
56
+ check. So does `claudeup install --check`.
85
57
 
86
- - `.claude/settings.json` - Shared settings (plugins, marketplaces)
87
- - `.claude/settings.local.json` - Local settings (MCP servers, allowMcp)
58
+ ## What the TUI covers
88
59
 
89
- ## Release Process
60
+ | Screen | What it manages |
61
+ |---|---|
62
+ | Plugins | Install, enable, disable; shows version changes and newly installed plugins |
63
+ | Skills | Browse and install skills from configured skill repos |
64
+ | MCP | Add MCP servers from a curated catalog |
65
+ | Settings | Claude Code settings, from a catalog of known keys |
66
+ | Profiles | Saved plugin sets (the TUI's own, older profile concept) |
67
+ | CLI Tools | Install claudish, mnemex and friends via the right package manager |
68
+ | Git State | Gitignore conventions and repo hygiene |
69
+ | Alias | Shell alias + flag management for launching `claude` |
90
70
 
91
- Releases are automated via GitHub Actions using [npm Trusted Publishing](https://docs.npmjs.com/trusted-publishers/) with OIDC - no tokens required.
71
+ Navigate with `↑/↓` or `j/k`, `Enter` to select, `r` to refresh, `?` for help,
72
+ `q`/`Escape` to go back. Number keys `1`–`8` jump straight to a screen.
92
73
 
93
- ### One-Time Setup
74
+ ## Files claudeup touches
94
75
 
95
- 1. **Publish first version manually** (required for new packages):
96
- ```bash
97
- npm run build
98
- npm publish --access public
99
- ```
76
+ | Path | Owner | What claudeup does |
77
+ |---|---|---|
78
+ | `.claude/profiles.json` | you, committed | reads it; `profile sync` writes to it |
79
+ | `.claude/_profiles/<name>/` | claudeup, gitignored | generated build output |
80
+ | `.claude/settings.json` | Claude Code | in profile mode, a symlink into `_profiles/` |
81
+ | `.claude/settings.local.json` | you, gitignored | env values collected during install |
82
+ | `.mcp.json` | Claude Code | in profile mode, a symlink into `_profiles/` |
83
+ | `~/.claude/plugins/*` | Claude Code | never written directly — always via the `claude` CLI |
100
84
 
101
- 2. **Configure Trusted Publisher on npmjs.com** (already done):
102
- - Repository: `MadAppGang/magus`
103
- - Workflow: `claudeup-release.yml`
85
+ claudeup never hand-edits Claude Code's own registries
86
+ (`installed_plugins.json`, `known_marketplaces.json`, the plugin cache). Those go
87
+ through `claude plugin ...` so Claude Code stays the single writer.
104
88
 
105
- ### Releasing New Versions
89
+ ## Development
106
90
 
107
91
  ```bash
108
- # 1. Update version in package.json
109
- npm version patch # or minor, major
110
-
111
- # 2. Push the tag
112
- git push origin main --tags
92
+ bun install
93
+ bun test # full suite
94
+ bun run typecheck
95
+ bun run lint
96
+ bun run src/main.tsx # run from source
97
+ bun run build:binaries # cross-compile all platform binaries + their packages
113
98
  ```
114
99
 
115
- This triggers:
116
- 1. **claudeup-release.yml** - Builds and publishes to npm via OIDC (no tokens needed)
117
- 2. **create-release.yml** - Creates a GitHub release with changelog
100
+ Tests run per-file isolated in CI (`bun run test:ci`) because `mock.module`
101
+ leaks across files in a shared process.
102
+
103
+ **Do not bump `@opentui` past 0.1.x.** 0.4.x breaks `bun build --compile`: it
104
+ resolves tree-sitter workers eagerly, bypassing the `OTUI_ASSET_ROOT` override,
105
+ so a bare `import "@opentui/core"` crashes at import time in the compiled
106
+ binary. Re-run the compile spike before changing that pin.
118
107
 
119
- ### Manual Release
108
+ ## Releasing
109
+
110
+ Tag-driven. `.github/workflows/claudeup-release.yml` fires on `tools/claudeup/v*`:
111
+
112
+ 1. **verify** — install, typecheck, `test:ci`, version and optional-deps gates
113
+ 2. **binaries** — a matrix building each target on its **native** runner
114
+ (`bun --compile` cannot cross-compile in CI because opentui ships a
115
+ per-platform native library)
116
+ 3. **publish** — platform packages first, main package last, so its
117
+ `optionalDependencies` already resolve
120
118
 
121
119
  ```bash
122
- npm run build
123
- npm publish --access public
120
+ # bump "version" AND the three optionalDependencies to match
121
+ bun run check:optional-deps
122
+ git commit -am "feat(claudeup): vX.Y.Z - description"
123
+ git tag -a tools/claudeup/vX.Y.Z -m "Release message"
124
+ git push origin main --tags
124
125
  ```
125
126
 
127
+ Auth is npm OIDC trusted publishing — the workflow references no secrets. Adding
128
+ a **new** platform target needs a one-time manual publish to create the package
129
+ name (OIDC can only authenticate against a package that already exists); see
130
+ [`docs/npm-trusted-publishing-setup.md`](../../docs/npm-trusted-publishing-setup.md).
131
+
126
132
  ## License
127
133
 
128
134
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudeup",
3
- "version": "4.30.0",
3
+ "version": "4.32.0",
4
4
  "description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
5
5
  "type": "module",
6
6
  "main": "src/main.tsx",
@@ -64,8 +64,8 @@
64
64
  "typescript": "^5.6.3"
65
65
  },
66
66
  "optionalDependencies": {
67
- "claudeup-darwin-arm64": "4.30.0",
68
- "claudeup-darwin-x64": "4.30.0",
69
- "claudeup-linux-x64": "4.30.0"
67
+ "claudeup-darwin-arm64": "4.32.0",
68
+ "claudeup-darwin-x64": "4.32.0",
69
+ "claudeup-linux-x64": "4.32.0"
70
70
  }
71
71
  }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Content drift: a plugin whose files changed without its version changing.
3
+ *
4
+ * Hermetic — builds a real git marketplace under a temp CLAUDE_CONFIG_DIR.
5
+ * Real git is used deliberately: the detection IS a git question, and mocking
6
+ * it would only test the mock.
7
+ */
8
+
9
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
10
+ import { spawnSync } from "node:child_process";
11
+ import { promises as fs } from "node:fs";
12
+ import os from "node:os";
13
+ import path from "node:path";
14
+ import {
15
+ clearContentDriftCache,
16
+ hasContentDrift,
17
+ } from "../services/content-drift.js";
18
+
19
+ let configDir: string;
20
+ let prevConfigDir: string | undefined;
21
+ let repo: string;
22
+
23
+ const git = (args: string[]) =>
24
+ spawnSync("git", args, { cwd: repo, encoding: "utf-8" });
25
+
26
+ const head = () => git(["rev-parse", "HEAD"]).stdout.trim();
27
+
28
+ beforeEach(async () => {
29
+ prevConfigDir = process.env.CLAUDE_CONFIG_DIR;
30
+ configDir = await fs.mkdtemp(path.join(os.tmpdir(), "drift-"));
31
+ process.env.CLAUDE_CONFIG_DIR = configDir;
32
+ clearContentDriftCache();
33
+
34
+ repo = path.join(configDir, "plugins", "marketplaces", "probemp");
35
+ await fs.mkdir(path.join(repo, "plugins", "alpha", "skills"), {
36
+ recursive: true,
37
+ });
38
+ await fs.mkdir(path.join(repo, "plugins", "beta"), { recursive: true });
39
+ await fs.writeFile(path.join(repo, "plugins", "alpha", "skills", "s.md"), "v1");
40
+ await fs.writeFile(path.join(repo, "plugins", "beta", "readme.md"), "v1");
41
+
42
+ git(["init", "-q", "-b", "main"]);
43
+ git(["config", "user.email", "probe@example.invalid"]);
44
+ git(["config", "user.name", "probe"]);
45
+ git(["add", "-A"]);
46
+ git(["commit", "-q", "-m", "release 1.0.0"]);
47
+ });
48
+
49
+ afterEach(async () => {
50
+ if (prevConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
51
+ else process.env.CLAUDE_CONFIG_DIR = prevConfigDir;
52
+ await fs.rm(configDir, { recursive: true, force: true });
53
+ });
54
+
55
+ describe("hasContentDrift", () => {
56
+ test("no drift when the plugin's files are untouched", async () => {
57
+ const sha = head();
58
+ expect(
59
+ await hasContentDrift({
60
+ marketplace: "probemp",
61
+ pluginName: "alpha",
62
+ installedSha: sha,
63
+ }),
64
+ ).toBe(false);
65
+ });
66
+
67
+ test("drift when the plugin's files change under an unchanged version", async () => {
68
+ const sha = head();
69
+ // Republish: same version, different content — what publish-dist.sh does.
70
+ await fs.writeFile(
71
+ path.join(repo, "plugins", "alpha", "skills", "s.md"),
72
+ "v2 — different content, same version",
73
+ );
74
+ git(["add", "-A"]);
75
+ git(["commit", "-q", "-m", "republish 1.0.0"]);
76
+
77
+ expect(
78
+ await hasContentDrift({
79
+ marketplace: "probemp",
80
+ pluginName: "alpha",
81
+ installedSha: sha,
82
+ }),
83
+ ).toBe(true);
84
+ });
85
+
86
+ test("a change to ANOTHER plugin does not flag this one", async () => {
87
+ // The reason this compares the plugin subtree and not the marketplace
88
+ // HEAD: any commit to any plugin would otherwise flag every plugin.
89
+ const sha = head();
90
+ await fs.writeFile(path.join(repo, "plugins", "beta", "readme.md"), "v2");
91
+ git(["add", "-A"]);
92
+ git(["commit", "-q", "-m", "unrelated change"]);
93
+
94
+ expect(
95
+ await hasContentDrift({
96
+ marketplace: "probemp",
97
+ pluginName: "alpha",
98
+ installedSha: sha,
99
+ }),
100
+ ).toBe(false);
101
+ expect(
102
+ await hasContentDrift({
103
+ marketplace: "probemp",
104
+ pluginName: "beta",
105
+ installedSha: sha,
106
+ }),
107
+ ).toBe(true);
108
+ });
109
+
110
+ test("deleting a skill counts as drift", async () => {
111
+ // The live case: dev@magus 3.0.1 lost tui-progress/SKILL.md while still
112
+ // declaring 3.0.1, so the version compare reported "up to date".
113
+ const sha = head();
114
+ await fs.rm(path.join(repo, "plugins", "alpha", "skills", "s.md"));
115
+ git(["add", "-A"]);
116
+ git(["commit", "-q", "-m", "drop a skill"]);
117
+
118
+ expect(
119
+ await hasContentDrift({
120
+ marketplace: "probemp",
121
+ pluginName: "alpha",
122
+ installedSha: sha,
123
+ }),
124
+ ).toBe(true);
125
+ });
126
+
127
+ test("stays silent when it cannot answer honestly", async () => {
128
+ // No recorded sha, unknown sha (force-push/shallow clone), and unknown
129
+ // marketplace must all report false rather than nag for a reinstall.
130
+ expect(
131
+ await hasContentDrift({
132
+ marketplace: "probemp",
133
+ pluginName: "alpha",
134
+ installedSha: undefined,
135
+ }),
136
+ ).toBe(false);
137
+ expect(
138
+ await hasContentDrift({
139
+ marketplace: "probemp",
140
+ pluginName: "alpha",
141
+ installedSha: "0".repeat(40),
142
+ }),
143
+ ).toBe(false);
144
+ expect(
145
+ await hasContentDrift({
146
+ marketplace: "does-not-exist",
147
+ pluginName: "alpha",
148
+ installedSha: head(),
149
+ }),
150
+ ).toBe(false);
151
+ });
152
+ });
@@ -94,7 +94,10 @@ describe("PluginsScreen — no dual-write after CLI operations", () => {
94
94
 
95
95
  // Mutable state shared between mock factories and test assertions.
96
96
  let mockSaveGlobal: ReturnType<typeof mock>;
97
+ let mockSaveProject: ReturnType<typeof mock>;
98
+ let mockSaveLocal: ReturnType<typeof mock>;
97
99
  let mockUpdatePlugin: ReturnType<typeof mock>;
100
+ let mockRepairPlugin: ReturnType<typeof mock>;
98
101
  let mockIsClaudeAvailable: ReturnType<typeof mock>;
99
102
  let mockGetAvailablePlugins: ReturnType<typeof mock>;
100
103
 
@@ -123,6 +126,8 @@ mock.module("../services/claude-settings.js", () => ({
123
126
  getLocalEnabledPlugins: mock(() => Promise.resolve({})),
124
127
  saveGlobalInstalledPluginVersion: (...args: unknown[]) =>
125
128
  mockSaveGlobal(...args),
129
+ saveLocalInstalledPluginVersion: (...args: unknown[]) =>
130
+ mockSaveLocal(...args),
126
131
  readGlobalSettings: mock(() => Promise.resolve({ hooks: {} })),
127
132
  writeGlobalSettings: mock(() => Promise.resolve()),
128
133
  }));
@@ -130,6 +135,7 @@ mock.module("../services/claude-settings.js", () => ({
130
135
  mock.module("../services/claude-cli.js", () => ({
131
136
  ...actualClaudeCli,
132
137
  updatePlugin: (...args: unknown[]) => mockUpdatePlugin(...args),
138
+ repairPlugin: (...args: unknown[]) => mockRepairPlugin(...args),
133
139
  isClaudeAvailable: (...args: unknown[]) => mockIsClaudeAvailable(...args),
134
140
  addMarketplace: mock(() => Promise.resolve()),
135
141
  updateMarketplace: mock(() => Promise.resolve()),
@@ -139,6 +145,7 @@ mock.module("../services/plugin-manager.js", () => ({
139
145
  ...actualPluginManager,
140
146
  getAvailablePlugins: (...args: unknown[]) =>
141
147
  mockGetAvailablePlugins(...args),
148
+ saveInstalledPluginVersion: (...args: unknown[]) => mockSaveProject(...args),
142
149
  clearMarketplaceCache: mock(() => undefined),
143
150
  }));
144
151
 
@@ -173,26 +180,35 @@ mock.module("fs-extra", () => ({
173
180
  // Now import the module under test (after mock.module registrations).
174
181
  const { prerunClaude } = await import("../prerunner/index.js");
175
182
 
176
- // Helper: build a minimal PluginInfo-like object
177
- function makePlugin(overrides: Partial<{
178
- id: string;
183
+ interface ScopeStatusLike {
179
184
  enabled: boolean;
180
- hasUpdate: boolean;
181
- installedVersion: string;
182
- version: string;
183
- }> = {}): {
185
+ version?: string;
186
+ }
187
+ interface PluginLike {
184
188
  id: string;
185
189
  enabled: boolean;
186
190
  hasUpdate: boolean;
191
+ contentStale?: boolean;
187
192
  installedVersion: string;
188
193
  version: string;
189
- } {
194
+ userScope?: ScopeStatusLike;
195
+ projectScope?: ScopeStatusLike;
196
+ localScope?: ScopeStatusLike;
197
+ }
198
+
199
+ /**
200
+ * Minimal PluginInfo-like object. The per-scope status matters: the prerunner
201
+ * updates the scopes that actually hold an outdated install, so a fixture with
202
+ * no scope status describes a plugin installed nowhere and is correctly skipped.
203
+ */
204
+ function makePlugin(overrides: Partial<PluginLike> = {}): PluginLike {
190
205
  return {
191
206
  id: "test-plugin@magus",
192
207
  enabled: true,
193
208
  hasUpdate: true,
194
209
  installedVersion: "1.0.0",
195
210
  version: "1.1.0",
211
+ userScope: { enabled: true, version: "1.0.0" },
196
212
  ...overrides,
197
213
  };
198
214
  }
@@ -200,7 +216,10 @@ function makePlugin(overrides: Partial<{
200
216
  describe("prerunner — saveGlobalInstalledPluginVersion call count", () => {
201
217
  beforeEach(() => {
202
218
  mockSaveGlobal = mock(() => Promise.resolve());
219
+ mockSaveProject = mock(() => Promise.resolve());
220
+ mockSaveLocal = mock(() => Promise.resolve());
203
221
  mockUpdatePlugin = mock(() => Promise.resolve());
222
+ mockRepairPlugin = mock(() => Promise.resolve());
204
223
  mockIsClaudeAvailable = mock(() => Promise.resolve(true));
205
224
  mockGetAvailablePlugins = mock(() =>
206
225
  Promise.resolve([makePlugin()]),
@@ -219,6 +238,130 @@ describe("prerunner — saveGlobalInstalledPluginVersion call count", () => {
219
238
  expect(mockSaveGlobal).toHaveBeenCalledTimes(1);
220
239
  });
221
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
+
222
365
  it("does NOT call saveGlobalInstalledPluginVersion when CLI is unavailable", async () => {
223
366
  // CLI unavailable — we must NOT write phantom state.
224
367
  mockIsClaudeAvailable = mock(() => Promise.resolve(false));
@@ -214,14 +214,19 @@ describe("refreshRegisteredMarketplaces — selection & skips", () => {
214
214
  expect(r.skipped).toEqual(["local"]);
215
215
  });
216
216
 
217
- it("skips a marketplace with autoUpdate === false", async () => {
217
+ it("reports a marketplace with autoUpdate === false separately from ordinary skips", async () => {
218
+ // Not lumped into `skipped`: opting out here freezes the catalog, so the
219
+ // marketplace's plugins read as up to date forever. The caller has to be
220
+ // able to tell the user, which it cannot do if this looks like "no clone
221
+ // on disk". A real magus clone sat 11 days behind exactly this way.
218
222
  installClone("magus");
219
223
  publishV2();
220
224
  configured = { magus: gh() };
221
225
  autoUpdate = { magus: false };
222
226
  const r = await refreshRegisteredMarketplaces();
223
227
  expect(r.refreshed).toEqual([]);
224
- expect(r.skipped).toEqual(["magus"]);
228
+ expect(r.autoUpdateDisabled).toEqual(["magus"]);
229
+ expect(r.skipped).toEqual([]);
225
230
  });
226
231
 
227
232
  it("honors the skip set (already handled this run)", async () => {