claudeup 6.1.0 → 6.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudeup",
3
- "version": "6.1.0",
3
+ "version": "6.2.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": "6.1.0",
68
- "claudeup-darwin-x64": "6.1.0",
69
- "claudeup-linux-x64": "6.1.0"
67
+ "claudeup-darwin-arm64": "6.2.0",
68
+ "claudeup-darwin-x64": "6.2.0",
69
+ "claudeup-linux-x64": "6.2.0"
70
70
  }
71
71
  }
@@ -152,19 +152,57 @@ describe("rate-limit cooldown — survives a relaunch", () => {
152
152
  base = Date.now();
153
153
  });
154
154
 
155
- /** Let a write-behind cooldown reach disk. */
156
- const flush = () => new Promise((r) => setTimeout(r, 80));
157
-
158
- // Only this block triggers write-behind, so only this block pays for draining
159
- // it. Held here rather than in the outer afterEach so the catalog tests — which
160
- // await every write are not each charged the delay too.
161
- afterEach(flush);
155
+ /**
156
+ * Wait until a write-behind cooldown has actually reached disk.
157
+ *
158
+ * This used to be `setTimeout(80)` a guess at how long an unawaited write
159
+ * takes, which is not a synchronisation point. `recordRateLimit` persists
160
+ * fire-and-forget by design (`github-budget.ts` explains why), so on a loaded
161
+ * runner the write had not landed when the next `hydrateGitHubBudget()` read,
162
+ * and the restored strike count came back low. That failed CI as
163
+ * `Expected: 3, Received: 2` on Linux while passing 15/15 on a quiet macOS
164
+ * laptop — the signature of a timing guess, not of a real defect.
165
+ *
166
+ * Polling the store itself removes the guess: it returns as soon as the value
167
+ * is observable, and only gives up after a deadline no healthy machine
168
+ * reaches. `expected` is the strike count the caller just recorded; passing
169
+ * nothing waits for any persisted entry at all.
170
+ */
171
+ const flush = async (
172
+ host: string,
173
+ expected?: number,
174
+ ): Promise<void> => {
175
+ const deadline = Date.now() + 5000;
176
+ for (;;) {
177
+ // `base` keeps the read on the same clock the writes used: cooldowns are
178
+ // dropped on read once expired, so reading with a later `now` could hide
179
+ // an entry that is genuinely there.
180
+ const stored = (await readStoredCooldowns(base))[host];
181
+ if (stored && (expected === undefined || stored.strikes >= expected)) {
182
+ return;
183
+ }
184
+ if (Date.now() > deadline) {
185
+ throw new Error(
186
+ `cooldown for ${host} never reached disk (wanted strikes >= ${expected ?? "any"}, got ${stored?.strikes ?? "nothing"})`,
187
+ );
188
+ }
189
+ await new Promise((r) => setTimeout(r, 10));
190
+ }
191
+ };
192
+
193
+ // Teardown hygiene, NOT a synchronisation point — which is why it is a plain
194
+ // sleep and `flush` is not. Its only job is to let an in-flight write land
195
+ // before the temp config dir is removed; a write that arrives afterwards
196
+ // recreates a file in a deleted directory and harms nothing. Polling for a
197
+ // specific host here would be wrong: several tests in this block end with
198
+ // nothing on disk on purpose.
199
+ afterEach(() => new Promise((r) => setTimeout(r, 50)));
162
200
 
163
201
  it("is still in force in the next process", async () => {
164
202
  // THE defect this store exists to fix. Without persistence, launch two fired
165
203
  // six more doomed requests at a host it had already been refused by.
166
204
  recordRateLimit("raw.githubusercontent.com", new Headers(), base);
167
- await flush();
205
+ await flush("raw.githubusercontent.com");
168
206
 
169
207
  resetGitHubBudget();
170
208
  resetCatalogCacheMemo();
@@ -185,7 +223,7 @@ describe("rate-limit cooldown — survives a relaunch", () => {
185
223
  // requests, learns it is limited once, and exits.
186
224
  const first = recordRateLimit("raw.githubusercontent.com", undefined, base);
187
225
  expect(first.strikes).toBe(1);
188
- await flush();
226
+ await flush("raw.githubusercontent.com", 1);
189
227
 
190
228
  resetGitHubBudget();
191
229
  resetCatalogCacheMemo();
@@ -194,7 +232,7 @@ describe("rate-limit cooldown — survives a relaunch", () => {
194
232
  const second = recordRateLimit("raw.githubusercontent.com", undefined, base);
195
233
  expect(second.strikes).toBe(2);
196
234
  expect(second.until).toBeGreaterThan(first.until);
197
- await flush();
235
+ await flush("raw.githubusercontent.com", 2);
198
236
 
199
237
  resetGitHubBudget();
200
238
  resetCatalogCacheMemo();
@@ -213,7 +251,7 @@ describe("rate-limit cooldown — survives a relaunch", () => {
213
251
  new Headers({ "retry-after": "600" }),
214
252
  base,
215
253
  );
216
- await flush();
254
+ await flush("api.github.com");
217
255
 
218
256
  resetGitHubBudget();
219
257
  await hydrateGitHubBudget();
@@ -41,6 +41,7 @@ function item(over: Partial<PluginUpdateItem> = {}): PluginUpdateItem {
41
41
 
42
42
  function deps(): PluginApplyDeps {
43
43
  return {
44
+ install: mock(async () => {}),
44
45
  update: mock(async () => {}),
45
46
  repair: mock(async () => {}),
46
47
  readInstalled: mock(async () => "2.0.0"),
@@ -199,14 +200,14 @@ describe("installPluginInScope — the scope-targeted path", () => {
199
200
  // The whole reason this exists beside applyPlugins: the user picked a
200
201
  // scope from a menu, so "every scope that is behind" would override them.
201
202
  const d = deps();
202
- const updated: Array<[string, string]> = [];
203
- d.update = mock(async (id, scope) => {
204
- updated.push([id, scope]);
203
+ const installed: Array<[string, string]> = [];
204
+ d.install = mock(async (id, scope) => {
205
+ installed.push([id, scope]);
205
206
  });
206
207
 
207
208
  await installPluginInScope("dev@magus", "local", PROJECT, d);
208
209
 
209
- expect(updated).toEqual([["dev@magus", "local"]]);
210
+ expect(installed).toEqual([["dev@magus", "local"]]);
210
211
  });
211
212
 
212
213
  test("an unreadable version is reported, never guessed", async () => {
@@ -0,0 +1,109 @@
1
+ /**
2
+ * What `claude` subcommand does each plugin operation actually run?
3
+ *
4
+ * This file exists because the answer was wrong for a long time and nothing
5
+ * could see it. `updatePlugin` ran `claude plugin install`, on a comment that
6
+ * asserted install "handles both fresh installs and re-installs (upgrades)".
7
+ * It does not. Measured against Claude Code 2.1.252, with designer@magus
8
+ * installed at 0.6.0 and 0.6.1 published:
9
+ *
10
+ * $ claude plugin install designer@magus --scope project
11
+ * ✔ Plugin "designer@magus" is already installed (scope: project) # exit 0
12
+ *
13
+ * $ claude plugin update designer@magus --scope project
14
+ * ✔ Plugin "designer" updated from 0.6.0 to 0.6.1 for scope project # exit 0
15
+ *
16
+ * Both exit 0. The no-op is indistinguishable from success at every layer
17
+ * above, so every claudeup update path — the TUI's `U`, "update all", the
18
+ * scope menu, `claudeup update`, the prerunner's auto-update — reported
19
+ * success and changed nothing, for ever.
20
+ *
21
+ * Mocking the CLI cannot catch that: a stub answers whatever the test asks it
22
+ * to. Only the argv is checkable here, so the argv is what these assert, with a
23
+ * real `claude` shim on PATH recording what it was called with.
24
+ */
25
+
26
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
27
+ import {
28
+ chmodSync,
29
+ mkdirSync,
30
+ mkdtempSync,
31
+ readFileSync,
32
+ rmSync,
33
+ writeFileSync,
34
+ } from "node:fs";
35
+ import { tmpdir } from "node:os";
36
+ import path from "node:path";
37
+ import {
38
+ installPlugin,
39
+ repairPlugin,
40
+ updatePlugin,
41
+ } from "../services/claude-cli.js";
42
+
43
+ let root: string;
44
+ let logFile: string;
45
+ let savedPath: string | undefined;
46
+
47
+ beforeEach(() => {
48
+ root = mkdtempSync(path.join(tmpdir(), "claude-argv-"));
49
+ const bin = path.join(root, "bin");
50
+ mkdirSync(bin);
51
+ logFile = path.join(root, "argv.log");
52
+
53
+ // A real executable, so this goes through which(1), execFile and the whole
54
+ // wrapper rather than a module double.
55
+ const shim = path.join(bin, "claude");
56
+ writeFileSync(shim, `#!/bin/sh\necho "$@" >> "${logFile}"\nexit 0\n`);
57
+ chmodSync(shim, 0o755);
58
+
59
+ savedPath = process.env.PATH;
60
+ // The shim dir goes FIRST so it shadows a real claude, but the system dirs
61
+ // stay: `getClaudePath` resolves through which(1), which lives in /usr/bin.
62
+ process.env.PATH = [bin, "/usr/bin", "/bin"].join(path.delimiter);
63
+ });
64
+
65
+ afterEach(() => {
66
+ process.env.PATH = savedPath ?? "";
67
+ rmSync(root, { recursive: true, force: true });
68
+ });
69
+
70
+ /** Every argv the shim saw, one line per invocation. */
71
+ function calls(): string[] {
72
+ try {
73
+ return readFileSync(logFile, "utf8").trim().split("\n").filter(Boolean);
74
+ } catch {
75
+ return [];
76
+ }
77
+ }
78
+
79
+ describe("which claude subcommand each operation runs", () => {
80
+ test("updatePlugin runs `plugin update` — NOT `plugin install`", async () => {
81
+ await updatePlugin("designer@magus", "project");
82
+ expect(calls()).toEqual(["plugin update designer@magus --scope project"]);
83
+ });
84
+
85
+ test("installPlugin runs `plugin install`", async () => {
86
+ await installPlugin("designer@magus", "project");
87
+ expect(calls()).toEqual(["plugin install designer@magus --scope project"]);
88
+ });
89
+
90
+ test("repairPlugin uninstalls then installs, in that order", async () => {
91
+ // Content drift under an unchanged version: `update` has no version bump
92
+ // to act on and `install` answers "already installed", so only the
93
+ // uninstall+install pair re-copies the files.
94
+ await repairPlugin("designer@magus", "project", root);
95
+ expect(calls()).toEqual([
96
+ "plugin uninstall designer@magus --scope project",
97
+ "plugin install designer@magus --scope project",
98
+ ]);
99
+ });
100
+
101
+ test("the scope reaches the CLI verbatim, at every scope", async () => {
102
+ await updatePlugin("dev@magus", "user");
103
+ await updatePlugin("dev@magus", "local");
104
+ expect(calls()).toEqual([
105
+ "plugin update dev@magus --scope user",
106
+ "plugin update dev@magus --scope local",
107
+ ]);
108
+ });
109
+ });
@@ -11,6 +11,7 @@ import {
11
11
  type PluginApplyDeps,
12
12
  applyPlugins,
13
13
  installPluginInScope,
14
+ updatePluginInScope,
14
15
  } from "../services/update-engine.js";
15
16
  import type { PluginScope } from "../services/claude-cli.js";
16
17
  import type { PluginUpdateItem } from "../services/update-plan.js";
@@ -22,10 +23,14 @@ function stubDeps(
22
23
  installedAfter: string | null | ((scope: PluginScope) => string | null),
23
24
  ) {
24
25
  const saved: Array<{ id: string; version: string; scope: PluginScope }> = [];
26
+ const installed: Array<{ id: string; scope: PluginScope }> = [];
25
27
  const updated: Array<{ id: string; scope: PluginScope }> = [];
26
28
  const repaired: Array<{ id: string; scope: PluginScope }> = [];
27
29
 
28
30
  const deps: PluginApplyDeps = {
31
+ install: mock(async (id: string, scope: PluginScope) => {
32
+ installed.push({ id, scope });
33
+ }),
29
34
  update: mock(async (id: string, scope: PluginScope) => {
30
35
  updated.push({ id, scope });
31
36
  }),
@@ -43,7 +48,7 @@ function stubDeps(
43
48
  },
44
49
  ),
45
50
  };
46
- return { deps, saved, updated, repaired };
51
+ return { deps, saved, installed, updated, repaired };
47
52
  }
48
53
 
49
54
  function item(over: Partial<PluginUpdateItem>): PluginUpdateItem {
@@ -132,13 +137,77 @@ describe("what gets RECORDED is what got installed", () => {
132
137
 
133
138
  describe("scope handling", () => {
134
139
  test("a fresh install goes to project scope only", async () => {
135
- const { deps, updated } = stubDeps("2.0.0");
140
+ const { deps, installed } = stubDeps("2.0.0");
136
141
  await applyPlugins(
137
142
  [item({ action: "install", installed: null, scopes: [] })],
138
143
  PROJECT,
139
144
  deps,
140
145
  );
146
+ expect(installed).toEqual([{ id: "dev@magus", scope: "project" }]);
147
+ });
148
+
149
+ // Three actions, three Claude Code commands, each a silent no-op in the
150
+ // other two cases. Folding `install` and `update` together is what made
151
+ // every claudeup update path exit 0 having changed nothing: measured on
152
+ // Claude Code 2.1.252, `claude plugin install designer@magus --scope
153
+ // project` answers "already installed" and leaves 0.6.0 in place while
154
+ // 0.6.1 is published, and `claude plugin update` is the command that moves
155
+ // it. The reverse holds too — `update` on a plugin absent from the scope
156
+ // answers "already at the latest version" and installs nothing — so this
157
+ // asserts BOTH directions, not just the one that was broken.
158
+
159
+ test("an update runs `update`, never `install`", async () => {
160
+ const { deps, installed, updated } = stubDeps("2.0.0");
161
+ await applyPlugins([item({ action: "update" })], PROJECT, deps);
141
162
  expect(updated).toEqual([{ id: "dev@magus", scope: "project" }]);
163
+ expect(installed).toEqual([]);
164
+ });
165
+
166
+ test("an update with NO row for this project installs instead", async () => {
167
+ // `claude plugin update --scope project` resolves the project from the
168
+ // working directory, and with no row there it updates some OTHER
169
+ // project's row instead. Measured on 2.1.252 from a directory holding no
170
+ // madbench row, it moved /Users/jack/mag/tmux-mcp — a project nobody
171
+ // named. Live for git worktrees, which inherit the parent repo's version
172
+ // while owning no row of their own.
173
+ const { deps, installed, updated } = stubDeps(null);
174
+ await applyPlugins([item({ action: "update" })], PROJECT, deps);
175
+ expect(installed).toEqual([{ id: "dev@magus", scope: "project" }]);
176
+ expect(updated).toEqual([]);
177
+ });
178
+
179
+ test("the row check is per scope, not per plugin", async () => {
180
+ // user scope holds a row, project scope does not: one must update and
181
+ // the other install, in the same pass.
182
+ const { deps, installed, updated } = stubDeps((scope) =>
183
+ scope === "user" ? "1.5.0" : null,
184
+ );
185
+ await applyPlugins([item({ scopes: ["user", "project"] })], PROJECT, deps);
186
+ expect(updated).toEqual([{ id: "dev@magus", scope: "user" }]);
187
+ expect(installed).toEqual([{ id: "dev@magus", scope: "project" }]);
188
+ });
189
+
190
+ test("a fresh install runs `install`, never `update`", async () => {
191
+ const { deps, installed, updated } = stubDeps("2.0.0");
192
+ await applyPlugins(
193
+ [item({ action: "install", installed: null, scopes: [] })],
194
+ PROJECT,
195
+ deps,
196
+ );
197
+ expect(installed).toEqual([{ id: "dev@magus", scope: "project" }]);
198
+ expect(updated).toEqual([]);
199
+ });
200
+
201
+ test("a repair runs neither — content drift needs uninstall+install", async () => {
202
+ const { deps, installed, updated, repaired } = stubDeps("1.0.0");
203
+ await applyPlugins(
204
+ [item({ action: "repair", target: "1.0.0", scopes: ["project"] })],
205
+ PROJECT,
206
+ deps,
207
+ );
208
+ expect(repaired).toEqual([{ id: "dev@magus", scope: "project" }]);
209
+ expect(installed).toEqual([]);
210
+ expect(updated).toEqual([]);
142
211
  });
143
212
 
144
213
  test("every outdated scope is updated AND recorded separately", async () => {
@@ -265,4 +334,24 @@ describe("installPluginInScope — a fresh install records what landed", () => {
265
334
  ).toBeNull();
266
335
  expect(saved).toEqual([]);
267
336
  });
337
+
338
+ // The scope menu's update branch went through the install helper for as
339
+ // long as both issued `claude plugin install`. They no longer do, so the
340
+ // two helpers must reach different commands or the menu silently no-ops
341
+ // exactly the way "update all" did.
342
+ test("the install helper runs `install`, the update helper runs `update`", async () => {
343
+ const a = stubDeps("2.0.0");
344
+ await installPluginInScope("dev@magus", "user", PROJECT, a.deps);
345
+ expect(a.installed).toEqual([{ id: "dev@magus", scope: "user" }]);
346
+ expect(a.updated).toEqual([]);
347
+
348
+ const b = stubDeps("2.0.0");
349
+ await updatePluginInScope("dev@magus", "user", PROJECT, b.deps);
350
+ expect(b.updated).toEqual([{ id: "dev@magus", scope: "user" }]);
351
+ expect(b.installed).toEqual([]);
352
+ });
353
+
354
+ test("the update helper takes no version parameter either", () => {
355
+ expect(updatePluginInScope.length).toBe(3);
356
+ });
268
357
  });
@@ -109,25 +109,28 @@ async function recoverMissingMarketplace(
109
109
  }
110
110
 
111
111
  /**
112
- * Install a plugin using claude CLI
113
- * Handles enabling + version tracking + cache copy in one shot.
112
+ * Run a `claude plugin` subcommand, recovering once from a missing or stale
113
+ * marketplace.
114
114
  *
115
- * If the install fails because the plugin is "not found in marketplace",
116
- * attempts to recover (add the marketplace if missing, or refresh it if
117
- * stale) and retries once.
115
+ * Shared by install and update because the recovery is identical and the two
116
+ * had already been written twice; a third copy for `update` is how the next
117
+ * divergence starts.
118
118
  */
119
- export async function installPlugin(
119
+ async function execPluginCommand(
120
+ verb: "install" | "update",
120
121
  pluginId: string,
121
- scope: PluginScope = "user",
122
+ scope: PluginScope,
123
+ timeoutMs?: number,
122
124
  ): Promise<void> {
125
+ const argv = ["plugin", verb, pluginId, "--scope", scope];
123
126
  try {
124
- await execClaude(["plugin", "install", pluginId, "--scope", scope]);
127
+ await execClaude(argv, timeoutMs);
125
128
  } catch (error) {
126
129
  const msg = error instanceof Error ? error.message : String(error);
127
130
  if (msg.includes("not found in marketplace")) {
128
131
  const marketplace = pluginId.split("@")[1];
129
132
  if (marketplace && (await recoverMissingMarketplace(marketplace))) {
130
- await execClaude(["plugin", "install", pluginId, "--scope", scope]);
133
+ await execClaude(argv, timeoutMs);
131
134
  return;
132
135
  }
133
136
  }
@@ -135,6 +138,29 @@ export async function installPlugin(
135
138
  }
136
139
  }
137
140
 
141
+ /**
142
+ * Install a plugin using claude CLI
143
+ * Handles enabling + version tracking + cache copy in one shot.
144
+ *
145
+ * ONLY for a plugin that is not installed at this scope yet. On one already
146
+ * installed there, `claude plugin install` is a no-op that ignores the version
147
+ * — see {@link updatePlugin}.
148
+ *
149
+ * If the install fails because the plugin is "not found in marketplace",
150
+ * attempts to recover (add the marketplace if missing, or refresh it if
151
+ * stale) and retries once.
152
+ */
153
+ export async function installPlugin(
154
+ pluginId: string,
155
+ scope: PluginScope = "user",
156
+ ): Promise<void> {
157
+ // 60s, matching update and repair. Before the install/update split this path
158
+ // ran through `updatePlugin`'s 60s budget; leaving it on the 30s default
159
+ // would have quietly halved the time a fresh install — the SLOWEST of the
160
+ // three, since it downloads and copies — is allowed to take.
161
+ await execPluginCommand("install", pluginId, scope, 60000);
162
+ }
163
+
138
164
  /**
139
165
  * Uninstall a plugin using claude CLI.
140
166
  * Falls back to direct settings removal if CLI uninstall fails
@@ -212,11 +238,37 @@ export async function disablePlugin(
212
238
  }
213
239
 
214
240
  /**
215
- * Update a plugin to the latest version.
216
- * Uses `install` as primary method since `update` only works for plugins
217
- * originally installed via the CLI. `install` handles both fresh installs
218
- * and re-installs (upgrades) of existing plugins regardless of how they
219
- * were originally added.
241
+ * Update an ALREADY-INSTALLED plugin to the latest version.
242
+ *
243
+ * Runs `claude plugin update`, not `claude plugin install`.
244
+ *
245
+ * This used to run `install`, on the documented premise that it "handles both
246
+ * fresh installs and re-installs (upgrades) of existing plugins". That premise
247
+ * is false, and every claudeup update path was silently doing nothing because
248
+ * of it. Measured against Claude Code 2.1.252, with designer@magus at 0.6.0 and
249
+ * 0.6.1 published:
250
+ *
251
+ * $ claude plugin install designer@magus --scope project
252
+ * Installing plugin "designer@magus"...
253
+ * ✔ Plugin "designer@magus" is already installed (scope: project)
254
+ * EXIT: 0
255
+ *
256
+ * $ claude plugin update designer@magus --scope project
257
+ * Checking for updates for plugin "designer@magus" at project scope…
258
+ * ✔ Plugin "designer" updated from 0.6.0 to 0.6.1 for scope project. Restart to apply.
259
+ * EXIT: 0
260
+ *
261
+ * `install` is idempotent on (plugin, scope) and never looks at the version, so
262
+ * it exits 0 having done nothing — indistinguishable from success to every
263
+ * caller. The registry keeps the old version, the read-back in `update-engine`
264
+ * records that old version as "what landed", and the same update is offered
265
+ * again on the next run, for ever.
266
+ *
267
+ * The two commands are NOT interchangeable in the other direction either:
268
+ * `claude plugin update` on a plugin absent from this scope reports "already at
269
+ * the latest version" and installs nothing. A fresh install must go through
270
+ * {@link installPlugin}, and content drift under an unchanged version still
271
+ * needs {@link repairPlugin} — `update` has no version bump to act on there.
220
272
  *
221
273
  * Retries with marketplace update on "not found" errors (same as installPlugin).
222
274
  */
@@ -224,22 +276,7 @@ export async function updatePlugin(
224
276
  pluginId: string,
225
277
  scope: PluginScope = "user",
226
278
  ): Promise<void> {
227
- try {
228
- await execClaude(["plugin", "install", pluginId, "--scope", scope], 60000);
229
- } catch (error) {
230
- const msg = error instanceof Error ? error.message : String(error);
231
- if (msg.includes("not found in marketplace")) {
232
- const marketplace = pluginId.split("@")[1];
233
- if (marketplace && (await recoverMissingMarketplace(marketplace))) {
234
- await execClaude(
235
- ["plugin", "install", pluginId, "--scope", scope],
236
- 60000,
237
- );
238
- return;
239
- }
240
- }
241
- throw error;
242
- }
279
+ await execPluginCommand("update", pluginId, scope, 60000);
243
280
  }
244
281
 
245
282
  /**
@@ -20,7 +20,7 @@
20
20
  * So the engine lives here, in `services/`, and both front ends drive it.
21
21
  * `cli/` and `ui/` are sibling presentation layers: they own how progress looks
22
22
  * and nothing else. Everything IO-shaped is injected — `PluginApplyDeps` for
23
- * the four Claude Code calls, `ApplyReporter` for progress, `runCommand` for
23
+ * the five Claude Code calls, `ApplyReporter` for progress, `runCommand` for
24
24
  * installers — so the same code drives an animated terminal meter, a TUI modal,
25
25
  * and a test that wants silence.
26
26
  *
@@ -31,7 +31,7 @@
31
31
 
32
32
  import path from "node:path";
33
33
  import type { PluginScope } from "./claude-cli.js";
34
- import { repairPlugin, updatePlugin } from "./claude-cli.js";
34
+ import { installPlugin, repairPlugin, updatePlugin } from "./claude-cli.js";
35
35
  import {
36
36
  readInstalledVersionForScope,
37
37
  saveInstalledPluginVersionForScope,
@@ -114,6 +114,14 @@ export const SILENT_REPORTER: ApplyReporter = {
114
114
  * not there.
115
115
  */
116
116
  export interface PluginApplyDeps {
117
+ /**
118
+ * A plugin NOT installed at this scope yet.
119
+ *
120
+ * Separate from {@link PluginApplyDeps.update} because the two are separate
121
+ * Claude Code commands that each no-op in the other's case — see the note on
122
+ * `updatePlugin` in claude-cli.ts.
123
+ */
124
+ install: (pluginId: string, scope: PluginScope) => Promise<void>;
117
125
  update: (pluginId: string, scope: PluginScope) => Promise<void>;
118
126
  repair: (
119
127
  pluginId: string,
@@ -134,6 +142,7 @@ export interface PluginApplyDeps {
134
142
  }
135
143
 
136
144
  export const REAL_PLUGIN_DEPS: PluginApplyDeps = {
145
+ install: installPlugin,
137
146
  update: updatePlugin,
138
147
  repair: repairPlugin,
139
148
  readInstalled: readInstalledVersionForScope,
@@ -173,6 +182,43 @@ export function pluginNeedsWork(item: PluginUpdateItem): boolean {
173
182
 
174
183
  // -- plugins -----------------------------------------------------------------
175
184
 
185
+ /**
186
+ * Advance a plugin at `scope`, choosing the command by whether THIS project
187
+ * actually holds an install row there.
188
+ *
189
+ * The check is not defensive tidiness — without it `update` mutates a project
190
+ * nobody named. `claude plugin update --scope project` resolves the project
191
+ * from the CLI's working directory, and when that directory has no row for the
192
+ * plugin it falls back to some OTHER project's row and updates that one.
193
+ * Measured on Claude Code 2.1.252, run from a directory with no madbench row:
194
+ *
195
+ * $ claude plugin update madbench@magus --scope project
196
+ * ✔ Plugin "madbench" updated from 0.2.4 to 0.3.0 for scope project
197
+ * (/Users/jack/mag/tmux-mcp). Restart to apply changes.
198
+ *
199
+ * `/Users/jack/mag/tmux-mcp` was not the working directory and had nothing to
200
+ * do with the request. `plugin install` has no such fallback — it keys on the
201
+ * working directory — so the absent case must go there instead.
202
+ *
203
+ * This is live for git worktrees in particular: claudeup resolves a worktree's
204
+ * installed version by inheriting the parent repo's rows, so a worktree can
205
+ * report "0.2.4 installed, 0.3.0 available" while owning no row of its own.
206
+ * That is exactly the shape that used to reach `update`.
207
+ *
208
+ * `readInstalled` matches the project path EXACTLY (no worktree inheritance),
209
+ * which is what makes it the right question to ask here.
210
+ */
211
+ async function advanceInScope(
212
+ deps: PluginApplyDeps,
213
+ pluginId: string,
214
+ scope: PluginScope,
215
+ projectPath: string,
216
+ ): Promise<void> {
217
+ const here = await deps.readInstalled(pluginId, scope, projectPath);
218
+ if (here) await deps.update(pluginId, scope);
219
+ else await deps.install(pluginId, scope);
220
+ }
221
+
176
222
  export async function applyPlugins(
177
223
  items: PluginUpdateItem[],
178
224
  projectPath: string,
@@ -190,10 +236,22 @@ export async function applyPlugins(
190
236
 
191
237
  try {
192
238
  for (const scope of scopes) {
239
+ // Three actions, three DIFFERENT Claude Code commands. Each is a
240
+ // silent no-op in the other two cases:
241
+ // repair — same version, changed files: only uninstall+install
242
+ // re-copies; `update` sees no version bump to act on.
243
+ // install — absent at this scope: `update` answers "already at the
244
+ // latest version" and installs nothing.
245
+ // update — installed but behind: `install` answers "already
246
+ // installed", ignores the version, and exits 0.
247
+ // Folding install and update together is the bug that made every
248
+ // claudeup update path do nothing while reporting success.
193
249
  if (item.action === "repair") {
194
250
  await deps.repair(item.pluginId, scope, projectPath);
251
+ } else if (item.action === "install") {
252
+ await deps.install(item.pluginId, scope);
195
253
  } else {
196
- await deps.update(item.pluginId, scope);
254
+ await advanceInScope(deps, item.pluginId, scope, projectPath);
197
255
  }
198
256
 
199
257
  // Record what LANDED, not what we asked for.
@@ -240,26 +298,40 @@ export async function applyPlugins(
240
298
  }
241
299
 
242
300
  /**
243
- * Install ONE plugin into ONE explicitly chosen scope, recording what landed.
244
- *
245
- * "Install" covers updating too, because at the CLI layer there is no such
246
- * distinction: `claude-cli.ts`'s `installPlugin` and `updatePlugin` issue the
247
- * identical `claude plugin install <id> --scope <scope>`, and `install` is
248
- * documented there as how an update is performed. Naming this after the command
249
- * actually run is what makes it obvious that the fresh-install path belongs here
250
- * too — while it was called `updatePluginInScope`, the TUI's install branches
251
- * read as a different operation and grew their own record-keeping.
301
+ * One plugin, ONE explicitly chosen scope, recording what landed.
252
302
  *
253
303
  * For the scope-targeted actions — a menu where the user picked "install in user
254
304
  * scope" — where {@link applyPlugins}'s "every scope that is behind" would
255
305
  * override the choice they just made.
256
306
  *
257
- * It exists so those call sites still get the read-back: writing the version
307
+ * These exist so those call sites still get the read-back: writing the version
258
308
  * you EXPECTED is the bug documented at length in `applyPlugins`, and it was
259
309
  * live in the TUI's per-scope menu long after the CLI had fixed it.
260
310
  *
261
- * Takes no version parameter, deliberately: there is nothing for a caller to
262
- * pass that could be wrong.
311
+ * They take no version parameter, deliberately: there is nothing for a caller
312
+ * to pass that could be wrong.
313
+ */
314
+ async function applyOneInScope(
315
+ run: (pluginId: string, scope: PluginScope) => Promise<void>,
316
+ pluginId: string,
317
+ scope: PluginScope,
318
+ projectPath: string,
319
+ deps: PluginApplyDeps,
320
+ ): Promise<string | null> {
321
+ await run(pluginId, scope);
322
+ const actual = await deps.readInstalled(pluginId, scope, projectPath);
323
+ if (actual) await deps.saveInstalled(pluginId, actual, scope, projectPath);
324
+ return actual;
325
+ }
326
+
327
+ /**
328
+ * Install a plugin NOT yet present at `scope`.
329
+ *
330
+ * This used to cover updating too, on the premise that `installPlugin` and
331
+ * `updatePlugin` issued the identical command. They no longer do, and never
332
+ * safely did: `claude plugin install` ignores the version of an install that is
333
+ * already there. Sending an update through here is why the TUI's scope menu
334
+ * reported success and changed nothing. Use {@link updatePluginInScope}.
263
335
  *
264
336
  * @returns the version actually on disk afterwards, or null if it could not be
265
337
  * read — which is reported, never guessed.
@@ -270,10 +342,29 @@ export async function installPluginInScope(
270
342
  projectPath: string,
271
343
  deps: PluginApplyDeps = REAL_PLUGIN_DEPS,
272
344
  ): Promise<string | null> {
273
- await deps.update(pluginId, scope);
274
- const actual = await deps.readInstalled(pluginId, scope, projectPath);
275
- if (actual) await deps.saveInstalled(pluginId, actual, scope, projectPath);
276
- return actual;
345
+ return applyOneInScope(deps.install, pluginId, scope, projectPath, deps);
346
+ }
347
+
348
+ /**
349
+ * Advance a plugin ALREADY installed at `scope` to the latest published version.
350
+ *
351
+ * @returns the version actually on disk afterwards, or null if it could not be
352
+ * read — which is reported, never guessed.
353
+ */
354
+ export async function updatePluginInScope(
355
+ pluginId: string,
356
+ scope: PluginScope,
357
+ projectPath: string,
358
+ deps: PluginApplyDeps = REAL_PLUGIN_DEPS,
359
+ ): Promise<string | null> {
360
+ // Same guard as the apply loop, for the same reason — see advanceInScope.
361
+ return applyOneInScope(
362
+ (id, sc) => advanceInScope(deps, id, sc, projectPath),
363
+ pluginId,
364
+ scope,
365
+ projectPath,
366
+ deps,
367
+ );
277
368
  }
278
369
 
279
370
  // -- binaries ----------------------------------------------------------------
@@ -24,6 +24,7 @@ import {
24
24
  applyPlugins,
25
25
  pluginNeedsWork,
26
26
  installPluginInScope,
27
+ updatePluginInScope,
27
28
  } from "../../services/update-engine.js";
28
29
  import { planPluginUpdates } from "../../services/update-plan.js";
29
30
  import {
@@ -932,7 +933,7 @@ export function PluginsScreen() {
932
933
  // Scope is the user's explicit choice here, so this does NOT go
933
934
  // through applyPlugins (which advances every scope behind). It
934
935
  // still records the version READ BACK, not `latestVersion`.
935
- await installPluginInScope(
936
+ await updatePluginInScope(
936
937
  plugin.id,
937
938
  scope,
938
939
  state.projectPath || process.cwd(),
@@ -1162,7 +1163,7 @@ export function PluginsScreen() {
1162
1163
  `Updating ${plugin.name} in ${scopeLabel}…\nclaude plugin install ${plugin.id} --scope ${scope}`,
1163
1164
  );
1164
1165
  // Explicit scope, read-back version — see the note above.
1165
- await installPluginInScope(
1166
+ await updatePluginInScope(
1166
1167
  plugin.id,
1167
1168
  scope,
1168
1169
  state.projectPath || process.cwd(),