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.
@@ -0,0 +1,87 @@
1
+ /**
2
+ * An orphaned plugin that merely changed marketplace is not deprecated.
3
+ *
4
+ * Splitting `magus` into `magus` + `magus-marketing` left `seo@magus`,
5
+ * `instantly@magus`, `video-editing@magus` and `nanobanana@magus` installed
6
+ * under a namespace that no longer lists them. They rendered as bare
7
+ * "deprecated", whose only action is deletion — which drops a plugin that is
8
+ * still published, just under a different id.
9
+ */
10
+
11
+ import { describe, expect, test } from "bun:test";
12
+ import type { LocalMarketplace } from "../services/local-marketplace.js";
13
+
14
+ /**
15
+ * Mirrors `findPluginInOtherMarketplace` in plugin-manager.ts, which is module
16
+ * -private. Kept in lockstep with it; the behaviour, not the binding, is what
17
+ * these tests pin.
18
+ */
19
+ function findPluginInOtherMarketplace(
20
+ pluginName: string,
21
+ currentMarketplace: string,
22
+ localMarketplaces: Map<string, LocalMarketplace>,
23
+ ): string | undefined {
24
+ for (const [name, mp] of localMarketplaces) {
25
+ if (name === currentMarketplace) continue;
26
+ if (mp.plugins.some((p) => p.name === pluginName)) return name;
27
+ }
28
+ return undefined;
29
+ }
30
+
31
+ const mp = (name: string, plugins: string[]): [string, LocalMarketplace] => [
32
+ name,
33
+ {
34
+ name,
35
+ description: "",
36
+ plugins: plugins.map((p) => ({
37
+ name: p,
38
+ version: "1.0.0",
39
+ description: "",
40
+ })),
41
+ },
42
+ ];
43
+
44
+ describe("moved-marketplace detection", () => {
45
+ test("finds a plugin that moved to a sibling marketplace", () => {
46
+ const marketplaces = new Map([
47
+ mp("magus", ["dev", "terminal"]),
48
+ mp("magus-marketing", ["seo", "instantly", "video-editing"]),
49
+ ]);
50
+
51
+ expect(findPluginInOtherMarketplace("seo", "magus", marketplaces)).toBe(
52
+ "magus-marketing",
53
+ );
54
+ });
55
+
56
+ test("a genuinely retired plugin reports no destination", () => {
57
+ // `conductor` was removed at magus v8.0.0 and republished nowhere.
58
+ const marketplaces = new Map([
59
+ mp("magus", ["dev"]),
60
+ mp("magus-marketing", ["seo"]),
61
+ ]);
62
+
63
+ expect(
64
+ findPluginInOtherMarketplace("conductor", "magus", marketplaces),
65
+ ).toBeUndefined();
66
+ });
67
+
68
+ test("does not propose the marketplace it is already installed from", () => {
69
+ const marketplaces = new Map([mp("magus", ["dev"])]);
70
+ expect(findPluginInOtherMarketplace("dev", "magus", marketplaces)).toBe(
71
+ undefined,
72
+ );
73
+ });
74
+
75
+ test("a renamed plugin is not matched — the name is the identity", () => {
76
+ // nanobanana became image-generate. Names differ, so this correctly
77
+ // reports no destination rather than guessing at a rename.
78
+ const marketplaces = new Map([
79
+ mp("magus", ["dev"]),
80
+ mp("magus-marketing", ["image-generate"]),
81
+ ]);
82
+
83
+ expect(
84
+ findPluginInOtherMarketplace("nanobanana", "magus", marketplaces),
85
+ ).toBeUndefined();
86
+ });
87
+ });
@@ -38,6 +38,36 @@ describe("buildProfileSettings", () => {
38
38
  });
39
39
  expect(s.enableAllProjectMcpServers).toBe(true);
40
40
  });
41
+
42
+ // Regression: `install` enables the manifest-wide union at USER scope, and
43
+ // Claude Code resolves enabledPlugins per plugin id — an id the project
44
+ // scope omits falls through to the user scope's `true`. A profile that
45
+ // merely omits another profile's plugins therefore never disables them, so
46
+ // `profile switch` silently becomes additive instead of exclusive.
47
+ test("explicitly disables union plugins the profile excludes", () => {
48
+ const s = buildProfileSettings(
49
+ closure({ plugins: { "designer@magus": "latest" } }),
50
+ ["designer@magus", "terminal@magus", "gtd@magus"],
51
+ );
52
+ expect(s.enabledPlugins).toEqual({
53
+ "designer@magus": true,
54
+ "terminal@magus": false,
55
+ "gtd@magus": false,
56
+ });
57
+ });
58
+
59
+ test("a profile's own plugin wins over its union entry", () => {
60
+ const s = buildProfileSettings(
61
+ closure({ plugins: { "terminal@magus": "4.1.4" } }),
62
+ ["terminal@magus"],
63
+ );
64
+ expect(s.enabledPlugins).toEqual({ "terminal@magus": true });
65
+ });
66
+
67
+ test("no union given -> no false entries (unchanged legacy shape)", () => {
68
+ const s = buildProfileSettings(closure({ plugins: { "dev@magus": "latest" } }));
69
+ expect(s.enabledPlugins).toEqual({ "dev@magus": true });
70
+ });
41
71
  });
42
72
 
43
73
  describe("buildProfileMcp", () => {
@@ -79,4 +109,61 @@ describe("materializeProfile", () => {
79
109
  const settings = await fs.readJson(join(dir, "settings.json"));
80
110
  expect(settings.enabledPlugins).toEqual({ "b@m": true });
81
111
  });
112
+
113
+ // Regression: activating a profile replaces .claude/skills with a symlink,
114
+ // and replacing means fs.remove first. A repo that committed its own project
115
+ // skills before adopting profiles would lose them at that moment.
116
+ test("seeds skills/ from the project's pre-existing .claude/skills", async () => {
117
+ const src = join(project, ".claude", "skills", "systematic-debugging");
118
+ await fs.outputFile(join(src, "SKILL.md"), "# committed project skill\n");
119
+
120
+ const dir = await materializeProfile("p", closure(), project);
121
+
122
+ expect(
123
+ await fs.readFile(join(dir, "skills", "systematic-debugging", "SKILL.md"), "utf8"),
124
+ ).toContain("committed project skill");
125
+ });
126
+
127
+ test("does not re-seed skills/ once the profile dir exists", async () => {
128
+ await fs.outputFile(
129
+ join(project, ".claude", "skills", "gone", "SKILL.md"),
130
+ "# removed later\n",
131
+ );
132
+ const dir = await materializeProfile("p", closure(), project);
133
+ await fs.remove(join(dir, "skills", "gone"));
134
+
135
+ await materializeProfile("p", closure(), project);
136
+
137
+ expect(await fs.pathExists(join(dir, "skills", "gone"))).toBe(false);
138
+ });
139
+
140
+ test("still creates an empty skills/ when the project has none", async () => {
141
+ const dir = await materializeProfile("p", closure(), project);
142
+ expect(await fs.pathExists(join(dir, "skills"))).toBe(true);
143
+ expect(await fs.readdir(join(dir, "skills"))).toEqual([]);
144
+ });
145
+
146
+ test("two profiles from one union each disable the other's plugins", async () => {
147
+ const union = ["designer@magus", "terminal@magus"];
148
+ const fe = await materializeProfile(
149
+ "frontend",
150
+ closure({ plugins: { "designer@magus": "latest" } }),
151
+ project,
152
+ union,
153
+ );
154
+ const be = await materializeProfile(
155
+ "backend",
156
+ closure({ plugins: { "terminal@magus": "latest" } }),
157
+ project,
158
+ union,
159
+ );
160
+ expect((await fs.readJson(join(fe, "settings.json"))).enabledPlugins).toEqual({
161
+ "designer@magus": true,
162
+ "terminal@magus": false,
163
+ });
164
+ expect((await fs.readJson(join(be, "settings.json"))).enabledPlugins).toEqual({
165
+ "designer@magus": false,
166
+ "terminal@magus": true,
167
+ });
168
+ });
82
169
  });
@@ -1,7 +1,12 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import { promises as fs } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
2
5
  import {
3
6
  diffVersions,
4
7
  isKnownVersion,
8
+ loadSeenVersions,
9
+ saveSeenVersions,
5
10
  UNKNOWN_VERSION,
6
11
  } from "../services/version-snapshot.js";
7
12
 
@@ -109,3 +114,83 @@ describe("diffVersions — removals and mixed sets", () => {
109
114
  ]);
110
115
  });
111
116
  });
117
+
118
+ describe("per-project baselines", () => {
119
+ /**
120
+ * The baseline used to be one flat map for the whole machine, but the
121
+ * versions written into it are resolved per project. Two projects
122
+ * legitimately on different versions overwrote each other's baseline, so
123
+ * every differing plugin was reported as "updated" on the next render — a
124
+ * change that never happened.
125
+ *
126
+ * CLAUDE_CONFIG_DIR, not HOME: os.homedir() reads the passwd database on
127
+ * macOS and ignores $HOME, so a HOME-only override writes to the operator's
128
+ * real ~/.claude. That is not hypothetical — it happened while writing these.
129
+ */
130
+ const withSandbox = async (fn: () => Promise<void>): Promise<void> => {
131
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "vsnap-"));
132
+ const prev = process.env.CLAUDE_CONFIG_DIR;
133
+ process.env.CLAUDE_CONFIG_DIR = dir;
134
+ try {
135
+ await fn();
136
+ } finally {
137
+ if (prev === undefined) delete process.env.CLAUDE_CONFIG_DIR;
138
+ else process.env.CLAUDE_CONFIG_DIR = prev;
139
+ await fs.rm(dir, { recursive: true, force: true });
140
+ }
141
+ };
142
+
143
+ test("a project with no baseline of its own reports nothing", async () => {
144
+ await withSandbox(async () => {
145
+ await saveSeenVersions("/proj/a", { "statusline@magus": "2.2.0" });
146
+ const baseline = await loadSeenVersions("/proj/b");
147
+ expect(baseline).toBeNull();
148
+ expect(diffVersions(baseline, { "statusline@magus": "2.5.0" })).toEqual([]);
149
+ });
150
+ });
151
+
152
+ test("saving one project preserves the others", async () => {
153
+ await withSandbox(async () => {
154
+ await saveSeenVersions("/proj/a", { "gtd@magus": "2.0.0" });
155
+ await saveSeenVersions("/proj/b", { "gtd@magus": "2.0.1" });
156
+ expect(await loadSeenVersions("/proj/a")).toEqual({ "gtd@magus": "2.0.0" });
157
+ expect(await loadSeenVersions("/proj/b")).toEqual({ "gtd@magus": "2.0.1" });
158
+ });
159
+ });
160
+
161
+ test("a real update inside one project is still reported", async () => {
162
+ await withSandbox(async () => {
163
+ await saveSeenVersions("/proj/a", { "dev@magus": "3.0.0" });
164
+ const previous = await loadSeenVersions("/proj/a");
165
+ expect(diffVersions(previous, { "dev@magus": "3.0.1" })).toEqual([
166
+ { pluginId: "dev@magus", kind: "updated", from: "3.0.0", to: "3.0.1" },
167
+ ]);
168
+ });
169
+ });
170
+
171
+ test("a pre-migration flat baseline is adopted once, then replaced", async () => {
172
+ await withSandbox(async () => {
173
+ const file = path.join(
174
+ process.env.CLAUDE_CONFIG_DIR as string,
175
+ "claudeup-version-snapshot.json",
176
+ );
177
+ await fs.writeFile(
178
+ file,
179
+ JSON.stringify({
180
+ seen: { "dev@magus": "3.0.0" },
181
+ seededAt: "2026-07-29T00:00:00.000Z",
182
+ updatedAt: "2026-07-29T00:00:00.000Z",
183
+ }),
184
+ );
185
+
186
+ // Legacy shape carries no project, so it stands in for whoever asks first
187
+ // rather than being thrown away.
188
+ expect(await loadSeenVersions("/proj/a")).toEqual({ "dev@magus": "3.0.0" });
189
+
190
+ await saveSeenVersions("/proj/a", { "dev@magus": "3.0.1" });
191
+ // Once per-project entries exist, the flat map is no longer consulted.
192
+ expect(await loadSeenVersions("/proj/b")).toBeNull();
193
+ expect(await loadSeenVersions("/proj/a")).toEqual({ "dev@magus": "3.0.1" });
194
+ });
195
+ });
196
+ });
@@ -126,7 +126,16 @@ async function runCheck(
126
126
  // ── install steps ───────────────────────────────────────────────────────────
127
127
 
128
128
  async function ensureToolchains(closure: ResolvedClosure, yes: boolean): Promise<void> {
129
- const toolchains = await detectToolchains(closure.bins);
129
+ // Only the binaries actually missing can need a toolchain. Checking all of
130
+ // them warns "pip isn't installed" on a machine whose pip-provided binaries
131
+ // are already on PATH — an alarming message about work that will not happen.
132
+ const missing: ResolvedBin[] = [];
133
+ for (const bin of closure.bins) {
134
+ if (!(await resolveExecutable(bin.name))) missing.push(bin);
135
+ }
136
+ if (missing.length === 0) return;
137
+
138
+ const toolchains = await detectToolchains(missing);
130
139
  for (const tc of toolchains) {
131
140
  if (tc.present) continue;
132
141
  const bootstrap = TOOLCHAIN_BOOTSTRAP[tc.name];
@@ -160,11 +169,25 @@ async function registerMarketplaces(closure: ResolvedClosure): Promise<void> {
160
169
  }
161
170
  }
162
171
 
172
+ /**
173
+ * Install the closure's plugins at PROJECT scope.
174
+ *
175
+ * A profile is a property of one repo, so its plugins must be too. At user
176
+ * scope, adopting profiles in a single repo would enable that repo's plugins in
177
+ * every other project on the machine — and since Claude Code resolves
178
+ * enabledPlugins per id with fall-through, those would stay on everywhere.
179
+ * Project scope keeps the blast radius to this repo. The plugin *cache* is
180
+ * global either way, so `profile switch` stays offline.
181
+ *
182
+ * Whatever the CLI writes into .claude/settings.json here is transient:
183
+ * materialization rewrites that file from the manifest, and activation replaces
184
+ * it with a symlink. The manifest stays authoritative.
185
+ */
163
186
  async function installPlugins(closure: ResolvedClosure): Promise<void> {
164
187
  for (const pluginId of Object.keys(closure.plugins)) {
165
188
  try {
166
189
  console.log(`+ plugin ${pluginId}`);
167
- await installPlugin(pluginId, "user");
190
+ await installPlugin(pluginId, "project");
168
191
  } catch (e) {
169
192
  console.warn(`⚠ plugin ${pluginId}: ${(e as Error).message}`);
170
193
  }
@@ -317,10 +340,18 @@ export async function runInstallCommand(
317
340
 
318
341
  // Materialize every profile (so `profile switch` needs no reinstall), then
319
342
  // activate the target (or the sole/first profile).
320
- const toMaterialize = flags.profile ? [flags.profile] : profileIds;
321
- for (const id of toMaterialize) {
343
+ //
344
+ // Every profile is materialized even when one was named: a profile arg scopes
345
+ // what gets *installed* and which becomes active, not which settings files
346
+ // exist. It also keeps the union below consistent across all of them — a
347
+ // profile left holding an older union would stop disabling plugins added
348
+ // since, silently making `switch` additive again.
349
+ const unionPluginIds = Object.keys(
350
+ (flags.profile ? await resolveAllProfiles(manifest) : closure).plugins,
351
+ );
352
+ for (const id of profileIds) {
322
353
  const profileClosure = await resolveProfile(manifest, id);
323
- await materializeProfile(id, profileClosure, projectPath);
354
+ await materializeProfile(id, profileClosure, projectPath, unionPluginIds);
324
355
  }
325
356
 
326
357
  // Keep the generated dir + active-profile symlinks out of git before
package/src/cli/router.ts CHANGED
@@ -73,25 +73,33 @@ function printHelp(version: string): void {
73
73
 
74
74
  TUI tool for managing Claude Code plugins, MCPs, and configuration.
75
75
 
76
- Usage: claudeup [options]
77
- claudeup claude [args...] Run claude with auto-update prerunner
78
- claudeup update Update claudeup to latest version
76
+ Usage: claudeup Open the interactive TUI
77
+ claudeup <command> [args]
79
78
 
80
79
  Options:
81
80
  -v, --version Show version and check for updates
82
81
  -h, --help Show this help message
83
82
  --no-refresh Skip auto-refresh of marketplaces on startup
84
83
 
85
- Commands:
84
+ Team profiles — reproduce this repo's Claude Code setup on any machine.
85
+ The manifest is .claude/profiles.json, committed. See docs/team-configuration.md.
86
+ install [profile] Install the profile's plugins, binaries, skills and env,
87
+ then activate it. No argument installs every profile.
88
+ --check Report drift only, write nothing (exits 1 in strict mode)
89
+ --yes, -y Skip the confirmation prompt
90
+ --force Discard unsynced local edits instead of refusing
91
+ profile list Show every profile; ● marks the active one
92
+ profile show <n> Print a profile's fully resolved closure
93
+ profile switch <n> Repoint the active profile — offline, no reinstall
94
+ profile sync Promote local edits back into .claude/profiles.json
95
+ doctor Check binary deps, profile symlinks, and conventions
96
+ --fix Apply the repairs it can make itself
97
+
98
+ Other commands:
86
99
  claude [args...] Check for plugin updates (1h cache), then run claude
87
100
  -f, --force Force update check (bypass 1h cache)
88
101
  update Update claudeup itself to latest version
89
102
 
90
- Experimental (in development):
91
- install [profile] Install a team profile from .claude/profiles.json
92
- profile <cmd> list | show | switch | sync team profiles
93
- doctor Diagnose & repair plugin/marketplace/profile state
94
-
95
103
  Navigation (TUI):
96
104
  [1] Plugins [4] Settings [7] Git State
97
105
  [2] Skills [5] Profiles [8] Alias
@@ -1,7 +1,12 @@
1
1
  import os from "node:os";
2
2
  import path from "node:path";
3
3
  import fs from "fs-extra";
4
- import { isClaudeAvailable, updatePlugin } from "../services/claude-cli.js";
4
+ import {
5
+ isClaudeAvailable,
6
+ type PluginScope,
7
+ repairPlugin,
8
+ updatePlugin,
9
+ } from "../services/claude-cli.js";
5
10
  import { runClaude } from "../services/claude-runner.js";
6
11
  import {
7
12
  cleanupExtraKnownMarketplaces,
@@ -10,6 +15,7 @@ import {
10
15
  readGlobalSettings,
11
16
  recoverMarketplaceSettings,
12
17
  saveGlobalInstalledPluginVersion,
18
+ saveLocalInstalledPluginVersion,
13
19
  writeGlobalSettings,
14
20
  } from "../services/claude-settings.js";
15
21
  import {
@@ -18,9 +24,12 @@ import {
18
24
  } from "../services/gitignore-prerun.js";
19
25
  import { refreshRegisteredMarketplaces } from "../services/marketplace-refresh.js";
20
26
  import { autoAddMissingMarketplaces } from "../services/marketplace-sync.js";
27
+ import { clearContentDriftCache } from "../services/content-drift.js";
21
28
  import {
22
29
  clearMarketplaceCache,
30
+ compareVersions,
23
31
  getAvailablePlugins,
32
+ saveInstalledPluginVersion,
24
33
  } from "../services/plugin-manager.js";
25
34
  import {
26
35
  checkPluginVersionMismatches,
@@ -32,6 +41,27 @@ export interface PrerunOptions {
32
41
  force?: boolean; // Bypass cache and force update check
33
42
  }
34
43
 
44
+ /**
45
+ * Record an installed version in the settings file that owns the given scope.
46
+ *
47
+ * Claude Code's CLI does not maintain `installedPluginVersions`, so claudeup
48
+ * keeps its own copy. Writing it at the WRONG scope is worse than not writing
49
+ * it: it makes a scope claim a version it does not have installed.
50
+ */
51
+ async function saveInstalledPluginVersionForScope(
52
+ pluginId: string,
53
+ version: string,
54
+ scope: PluginScope,
55
+ ): Promise<void> {
56
+ if (scope === "user") {
57
+ await saveGlobalInstalledPluginVersion(pluginId, version);
58
+ } else if (scope === "local") {
59
+ await saveLocalInstalledPluginVersion(pluginId, version, process.cwd());
60
+ } else {
61
+ await saveInstalledPluginVersion(pluginId, version, process.cwd());
62
+ }
63
+ }
64
+
35
65
  const CONTINUITY_PLUGIN_SENTINEL = "tmux-claude-continuity";
36
66
  const CONTINUITY_PLUGIN_SCRIPT = path.join(
37
67
  os.homedir(),
@@ -290,12 +320,28 @@ export async function prerunClaude(
290
320
  `✓ Refreshed marketplaces: ${mpRefresh.refreshed.join(", ")}`,
291
321
  );
292
322
  }
323
+ if (mpRefresh.autoUpdateDisabled.length > 0) {
324
+ // Silence here is what let a clone sit 11 days behind: the catalog
325
+ // stops moving, so every plugin from that marketplace reads as up
326
+ // to date and no update is ever offered.
327
+ for (const name of mpRefresh.autoUpdateDisabled) {
328
+ console.log(
329
+ `⚠ ${name}: auto-update disabled — its plugin catalog will not refresh, so updates stay hidden.`,
330
+ );
331
+ }
332
+ console.log(
333
+ ` Re-enable with: claude plugin marketplace update ${mpRefresh.autoUpdateDisabled[0]} (or set autoUpdate:true in ~/.claude/plugins/known_marketplaces.json)`,
334
+ );
335
+ }
293
336
  for (const name of mpRefresh.failed) {
294
337
  console.warn(`⚠ Failed to refresh marketplace: ${name}`);
295
338
  }
296
339
 
297
340
  // STEP 2: Clear cache to force fresh plugin info
298
341
  clearMarketplaceCache();
342
+ // The marketplace refresh above may have moved each clone's HEAD, and
343
+ // drift answers are relative to HEAD.
344
+ clearContentDriftCache();
299
345
 
300
346
  // STEP 3: Get updated plugin info (to detect versions)
301
347
  const plugins = await getAvailablePlugins();
@@ -305,6 +351,13 @@ export async function prerunClaude(
305
351
  pluginId: string;
306
352
  oldVersion: string;
307
353
  newVersion: string;
354
+ /** Scopes actually updated — never assume "user". */
355
+ scopes: ReadonlyArray<PluginScope>;
356
+ }> = [];
357
+ /** Plugins whose files drifted under an unchanged version. */
358
+ const repairedPlugins: Array<{
359
+ pluginId: string;
360
+ scope: PluginScope;
308
361
  }> = [];
309
362
 
310
363
  const cliAvailable = await isClaudeAvailable();
@@ -314,6 +367,43 @@ export async function prerunClaude(
314
367
  // 1. Plugin is enabled
315
368
  // 2. Plugin has an update available
316
369
  // 3. Plugin has both installedVersion and version (newVersion)
370
+ // A content-stale plugin has NO version bump to detect — its files
371
+ // changed under an unchanged version — so it must be repaired
372
+ // (uninstall+install) rather than updated. Auto-fixing broken state
373
+ // without human interaction is claudeup's job; leaving a plugin
374
+ // silently running deleted skills is exactly the state it exists
375
+ // to prevent.
376
+ if (
377
+ plugin.enabled &&
378
+ !plugin.hasUpdate &&
379
+ plugin.contentStale &&
380
+ plugin.installedVersion
381
+ ) {
382
+ if (!cliAvailable) continue;
383
+ const staleScopes = (
384
+ [
385
+ ["user", plugin.userScope],
386
+ ["project", plugin.projectScope],
387
+ ["local", plugin.localScope],
388
+ ] as const
389
+ )
390
+ .filter(([, status]) => !!status?.version)
391
+ .map(([scope]) => scope);
392
+
393
+ for (const scope of staleScopes) {
394
+ try {
395
+ await repairPlugin(plugin.id, scope, process.cwd());
396
+ repairedPlugins.push({ pluginId: plugin.id, scope });
397
+ } catch (error) {
398
+ console.warn(
399
+ `⚠ Failed to repair ${plugin.id} (${scope}):`,
400
+ error instanceof Error ? error.message : "Unknown error",
401
+ );
402
+ }
403
+ }
404
+ continue;
405
+ }
406
+
317
407
  if (
318
408
  plugin.enabled &&
319
409
  plugin.hasUpdate &&
@@ -325,14 +415,51 @@ export async function prerunClaude(
325
415
  // CLI unavailable — skip update entirely, don't write phantom state
326
416
  continue;
327
417
  }
328
- await updatePlugin(plugin.id, "user");
329
- // CLI does NOT update installedPluginVersions save it ourselves
330
- await saveGlobalInstalledPluginVersion(plugin.id, plugin.version);
418
+
419
+ // Update every scope that actually holds an outdated install.
420
+ //
421
+ // This used to be a hardcoded `updatePlugin(plugin.id, "user")`,
422
+ // which was wrong for almost every real install: `hasUpdate` is
423
+ // computed from the CURRENT PROJECT's resolved version, while
424
+ // installs live per project in installed_plugins.json. On this
425
+ // machine 495 of 496 magus installs are project-scoped, so the
426
+ // prerunner detected project drift, "fixed" it at user scope,
427
+ // reported success — and left the project row untouched. Next run
428
+ // it detected the same drift again. The update never converged,
429
+ // which is what "auto-update ran but an update is still available"
430
+ // actually was.
431
+ const outdatedScopes = (
432
+ [
433
+ ["user", plugin.userScope],
434
+ ["project", plugin.projectScope],
435
+ ["local", plugin.localScope],
436
+ ] as const
437
+ )
438
+ .filter(
439
+ ([, status]) =>
440
+ !!status?.version &&
441
+ compareVersions(plugin.version, status.version) > 0,
442
+ )
443
+ .map(([scope]) => scope);
444
+
445
+ if (outdatedScopes.length === 0) continue;
446
+
447
+ for (const scope of outdatedScopes) {
448
+ await updatePlugin(plugin.id, scope);
449
+ // The CLI does not maintain installedPluginVersions, so keep
450
+ // claudeup's own copy in step for the scope we just touched.
451
+ await saveInstalledPluginVersionForScope(
452
+ plugin.id,
453
+ plugin.version,
454
+ scope,
455
+ );
456
+ }
331
457
 
332
458
  autoUpdatedPlugins.push({
333
459
  pluginId: plugin.id,
334
460
  oldVersion: plugin.installedVersion,
335
461
  newVersion: plugin.version,
462
+ scopes: outdatedScopes,
336
463
  });
337
464
  } catch (error) {
338
465
  // Non-fatal: Log warning and continue
@@ -357,8 +484,27 @@ export async function prerunClaude(
357
484
  // STEP 6: Display auto-update summary
358
485
  if (autoUpdatedPlugins.length > 0) {
359
486
  console.log(`✓ Auto-updated ${autoUpdatedPlugins.length} plugin(s):`);
360
- for (const { pluginId, oldVersion, newVersion } of autoUpdatedPlugins) {
361
- console.log(` - ${pluginId}: ${oldVersion} → ${newVersion}`);
487
+ for (const {
488
+ pluginId,
489
+ oldVersion,
490
+ newVersion,
491
+ scopes,
492
+ } of autoUpdatedPlugins) {
493
+ // Name the scope. "Auto-updated" that silently touched only user
494
+ // scope, while the project stayed behind, is exactly how this became
495
+ // untrustworthy.
496
+ console.log(
497
+ ` - ${pluginId}: ${oldVersion} → ${newVersion} (${scopes.join(", ")})`,
498
+ );
499
+ }
500
+ }
501
+
502
+ if (repairedPlugins.length > 0) {
503
+ console.log(
504
+ `✓ Repaired ${repairedPlugins.length} plugin(s) whose files changed without a version bump:`,
505
+ );
506
+ for (const { pluginId, scope } of repairedPlugins) {
507
+ console.log(` - ${pluginId} (${scope})`);
362
508
  }
363
509
  }
364
510
  }
@@ -160,6 +160,37 @@ export async function uninstallPlugin(
160
160
  }
161
161
  }
162
162
 
163
+ /**
164
+ * Repair a plugin whose files drifted from the marketplace under an unchanged
165
+ * version — uninstall, then install again at the same scope.
166
+ *
167
+ * `claude plugin install` cannot do this alone. Measured against the real
168
+ * `MadAppGang/magus` marketplace (autotest/plugin-system/probe-05): with the
169
+ * version unchanged it reports *"Plugin is already installed"* and copies
170
+ * nothing — cache content and the recorded `gitCommitSha` both stay put.
171
+ * Uninstall-then-install refreshes both. That is why the only cure users ever
172
+ * found was removing the plugin and adding it back.
173
+ *
174
+ * The window between the two calls is real: if the install fails, the plugin is
175
+ * left uninstalled. The caller must surface that rather than swallow it, which
176
+ * is why the install error is rethrown with the state spelled out.
177
+ */
178
+ export async function repairPlugin(
179
+ pluginId: string,
180
+ scope: PluginScope = "user",
181
+ projectPath?: string,
182
+ ): Promise<void> {
183
+ await uninstallPlugin(pluginId, scope, projectPath);
184
+ try {
185
+ await execClaude(["plugin", "install", pluginId, "--scope", scope], 60000);
186
+ } catch (error) {
187
+ const msg = error instanceof Error ? error.message : String(error);
188
+ throw new Error(
189
+ `${pluginId} was uninstalled from ${scope} scope but could not be reinstalled: ${msg}. Reinstall it manually with: claude plugin install ${pluginId} --scope ${scope}`,
190
+ );
191
+ }
192
+ }
193
+
163
194
  /**
164
195
  * Enable a previously disabled plugin
165
196
  */