claudeup 6.1.0 → 6.2.1

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.1",
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.1",
68
+ "claudeup-darwin-x64": "6.2.1",
69
+ "claudeup-linux-x64": "6.2.1"
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,43 @@
1
+ /**
2
+ * goBinaryName: the one rule for turning a Go module path into the binary
3
+ * `go install` produces. Both consumers (plugin-setup's Go path and
4
+ * plugin-requires' `setup.go` branch) are tested through their own surfaces;
5
+ * this file pins the rule itself.
6
+ */
7
+
8
+ import { describe, expect, test } from "bun:test";
9
+ import { goBinaryName } from "../utils/go-module.js";
10
+
11
+ describe("goBinaryName", () => {
12
+ test("takes the last segment of a v0/v1 module path", () => {
13
+ expect(goBinaryName("github.com/MadAppGang/tmux-mcp")).toBe("tmux-mcp");
14
+ });
15
+
16
+ test("strips a trailing /v2 major-version suffix before taking the name", () => {
17
+ expect(goBinaryName("github.com/MadAppGang/tmux-mcp/v2")).toBe("tmux-mcp");
18
+ });
19
+
20
+ test("strips multi-digit suffixes (/v10, /v42)", () => {
21
+ expect(goBinaryName("github.com/x/tool/v10")).toBe("tool");
22
+ expect(goBinaryName("github.com/x/tool/v42")).toBe("tool");
23
+ });
24
+
25
+ test("a segment that merely starts with v2 is a name, not a suffix", () => {
26
+ expect(goBinaryName("github.com/x/v2tool")).toBe("v2tool");
27
+ });
28
+
29
+ test("v0 and v1 are never path suffixes, so they stay as names", () => {
30
+ // Nobody names a binary this, but the rule must not invent an exception.
31
+ expect(goBinaryName("github.com/x/v1")).toBe("v1");
32
+ expect(goBinaryName("github.com/x/v0")).toBe("v0");
33
+ });
34
+
35
+ test("a suffix in the middle of the path is not trailing and is kept", () => {
36
+ expect(goBinaryName("github.com/x/repo/v2/cmd/cli")).toBe("cli");
37
+ });
38
+
39
+ test("single-segment and empty inputs pass through", () => {
40
+ expect(goBinaryName("mytool")).toBe("mytool");
41
+ expect(goBinaryName("")).toBe("");
42
+ });
43
+ });
@@ -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
+ });
@@ -69,6 +69,46 @@ describe("parsePluginRequires — setup block (the established convention)", ()
69
69
  expect(tmux.formula).toBe("tmux");
70
70
  });
71
71
 
72
+ // Negative control for the /vN fix: basename(base) yields "v2" here, so
73
+ // without the go-module helper this test FAILS on `name`.
74
+ test("terminal's v2 pin: /v2 is the module's major version, not the binary", () => {
75
+ const r = parsePluginRequires({
76
+ name: "terminal",
77
+ setup: { go: ["github.com/MadAppGang/tmux-mcp/v2@v2.0.0"] },
78
+ });
79
+ expect(r.bin).toEqual([
80
+ {
81
+ name: "tmux-mcp",
82
+ via: "go",
83
+ module: "github.com/MadAppGang/tmux-mcp/v2",
84
+ version: "v2.0.0",
85
+ check: "tmux-mcp --version",
86
+ },
87
+ ]);
88
+ });
89
+
90
+ // One manifest per spec: entries are keyed by binary name, so a v1 and a v2
91
+ // pin of the same module in ONE setup would (correctly) collapse to one.
92
+ test.each([
93
+ [
94
+ "github.com/MadAppGang/tmux-mcp@v1.7.1",
95
+ { name: "tmux-mcp", module: "github.com/MadAppGang/tmux-mcp", version: "v1.7.1" },
96
+ ],
97
+ [
98
+ "github.com/MadAppGang/tmux-mcp/v2",
99
+ { name: "tmux-mcp", module: "github.com/MadAppGang/tmux-mcp/v2", version: undefined },
100
+ ],
101
+ [
102
+ "github.com/x/v2tool@v1.0.0",
103
+ { name: "v2tool", module: "github.com/x/v2tool", version: "v1.0.0" },
104
+ ],
105
+ ])("go spec %s → name/module/version", (spec, expected) => {
106
+ const r = parsePluginRequires({ setup: { go: [spec] } });
107
+ expect(
108
+ r.bin!.map((b) => ({ name: b.name, module: b.module, version: b.version })),
109
+ ).toEqual([expected]);
110
+ });
111
+
72
112
  test("browser-use's setup: pip packages", () => {
73
113
  const r = parsePluginRequires({
74
114
  name: "browser-use",
@@ -86,6 +86,7 @@ const {
86
86
  extractGoVersion,
87
87
  extractBinaryName,
88
88
  parseAtSpec,
89
+ parseGoSpec,
89
90
  parsePipSpec,
90
91
  goDepNeedsInstall,
91
92
  goDepDecision,
@@ -127,6 +128,89 @@ describe("extractGoBinaryName", () => {
127
128
  it("handles empty string gracefully", () => {
128
129
  expect(extractGoBinaryName("")).toBe("");
129
130
  });
131
+
132
+ // Negative control for the /vN fix: a plain basename returns "v2" here.
133
+ // Without the go-module helper wired in, this test FAILS.
134
+ it("names the binary after the module, not its /v2 major-version suffix", () => {
135
+ expect(
136
+ extractGoBinaryName("github.com/MadAppGang/tmux-mcp/v2@v2.0.0"),
137
+ ).toBe("tmux-mcp");
138
+ });
139
+
140
+ it("strips the /v2 suffix from an unpinned module path too", () => {
141
+ expect(extractGoBinaryName("github.com/MadAppGang/tmux-mcp/v2")).toBe(
142
+ "tmux-mcp",
143
+ );
144
+ });
145
+
146
+ it("keeps a name that merely starts with v2", () => {
147
+ expect(extractGoBinaryName("github.com/x/v2tool@v1.0.0")).toBe("v2tool");
148
+ });
149
+ });
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // 1b. parseGoSpec — the Go-specific view over parseAtSpec
153
+ // ---------------------------------------------------------------------------
154
+
155
+ describe("parseGoSpec", () => {
156
+ it("v1 module: name and pinned version", () => {
157
+ expect(parseGoSpec("github.com/MadAppGang/tmux-mcp@v1.7.1")).toEqual({
158
+ spec: "github.com/MadAppGang/tmux-mcp@v1.7.1",
159
+ name: "tmux-mcp",
160
+ version: "v1.7.1",
161
+ });
162
+ });
163
+
164
+ it("v2 module: the /v2 suffix is not the name, the version survives", () => {
165
+ expect(parseGoSpec("github.com/MadAppGang/tmux-mcp/v2@v2.0.0")).toEqual({
166
+ spec: "github.com/MadAppGang/tmux-mcp/v2@v2.0.0",
167
+ name: "tmux-mcp",
168
+ version: "v2.0.0",
169
+ });
170
+ });
171
+
172
+ it("v2 module without a pin: name resolved, version null", () => {
173
+ expect(parseGoSpec("github.com/MadAppGang/tmux-mcp/v2")).toEqual({
174
+ spec: "github.com/MadAppGang/tmux-mcp/v2",
175
+ name: "tmux-mcp",
176
+ version: null,
177
+ });
178
+ });
179
+
180
+ it("a segment that starts with v2 is a real name", () => {
181
+ expect(parseGoSpec("github.com/x/v2tool@v1.0.0")).toEqual({
182
+ spec: "github.com/x/v2tool@v1.0.0",
183
+ name: "v2tool",
184
+ version: "v1.0.0",
185
+ });
186
+ });
187
+
188
+ it("@latest is unpinned, same as parseAtSpec", () => {
189
+ expect(parseGoSpec("github.com/MadAppGang/tmux-mcp/v2@latest")).toEqual({
190
+ spec: "github.com/MadAppGang/tmux-mcp/v2@latest",
191
+ name: "tmux-mcp",
192
+ version: null,
193
+ });
194
+ });
195
+
196
+ // Terminal 5.0.0 contract cases (test plan Area 3, C5 and C6). They live
197
+ // here rather than in their own file because any test file that imports
198
+ // plugin-setup.js ahead of the mock.module seam above bakes the real
199
+ // execFile into promisify() and fails the installPluginDeps suites.
200
+ it("C5: a /v10 suffix is stripped in full, not only its first digit", () => {
201
+ const { name, version } = parseGoSpec("github.com/x/tool/v10@v10.1.0");
202
+ expect({ name, version }).toEqual({ name: "tool", version: "v10.1.0" });
203
+ });
204
+
205
+ it("C6: /v1 is never a Go major-version suffix, so it stays the name", () => {
206
+ const { name, version } = parseGoSpec("github.com/x/tool/v1@v1.0.0");
207
+ expect({ name, version }).toEqual({ name: "v1", version: "v1.0.0" });
208
+ });
209
+
210
+ it("C6 control: the same shape at /v2 is a suffix and is stripped", () => {
211
+ const { name, version } = parseGoSpec("github.com/x/tool/v2@v2.0.0");
212
+ expect({ name, version }).toEqual({ name: "tool", version: "v2.0.0" });
213
+ });
130
214
  });
131
215
 
132
216
  // ---------------------------------------------------------------------------
@@ -295,6 +379,14 @@ describe("extractGoVersion", () => {
295
379
  it("returns null when unpinned", () => {
296
380
  expect(extractGoVersion("github.com/user/tool")).toBeNull();
297
381
  });
382
+ it("reads the pin behind a /v2 module path", () => {
383
+ expect(
384
+ extractGoVersion("github.com/MadAppGang/tmux-mcp/v2@v2.0.0"),
385
+ ).toBe("v2.0.0");
386
+ });
387
+ it("a /v2 module path with no pin is unpinned, not pinned to v2", () => {
388
+ expect(extractGoVersion("github.com/MadAppGang/tmux-mcp/v2")).toBeNull();
389
+ });
298
390
  });
299
391
 
300
392
  describe("goDepNeedsInstall — version awareness", () => {
@@ -666,6 +758,16 @@ describe("parseAtSpec", () => {
666
758
  expect(parseAtSpec("tool@latest").version).toBeNull();
667
759
  });
668
760
 
761
+ it("does NOT apply the Go /vN rule — an npm scope may be named v2", () => {
762
+ // The Go major-version strip lives in parseGoSpec only; folding it in
763
+ // here would turn this scoped npm package into "@scope".
764
+ expect(parseAtSpec("@scope/v2@1.0.0")).toEqual({
765
+ spec: "@scope/v2@1.0.0",
766
+ name: "v2",
767
+ version: "1.0.0",
768
+ });
769
+ });
770
+
669
771
  it("treats a bare name as unpinned", () => {
670
772
  expect(parseAtSpec("tool")).toEqual({
671
773
  spec: "tool",
@@ -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
  /**
@@ -7,7 +7,7 @@
7
7
  * invisible. No update is offered, and the only cure is a reinstall — which is
8
8
  * exactly the "plugin says installed but its skills are missing" report.
9
9
  *
10
- * This is not a hypothetical. `publish-dist.sh` force-pushes a rebuilt tree, so
10
+ * This is not a hypothetical. `publish-dist.sh` pushes a rebuilt tree, so
11
11
  * same-version-different-content is a normal outcome of the release process.
12
12
  * Measured on 2026-08-06: `cache/magus/dev/3.0.1/skills` held
13
13
  * {audit, tui-progress} while `marketplaces/magus/plugins/dev/skills` held
@@ -113,11 +113,13 @@ function getGitRemote(marketplacePath: string): string | undefined {
113
113
  * Names of the skills under `skillsDir`, recursing through grouping directories.
114
114
  *
115
115
  * A skill is a directory holding a SKILL.md, and it may sit one or more levels
116
- * down: dev groups its 43 skills under `skills/frontend/`, `skills/backend/`
117
- * and so on, and mattpocock groups its 37 under `skills/engineering/`. Reading
118
- * only the top level counted the *groups* — dev reported 9 skills against 43 on
119
- * disk, mattpocock 5 against 37 — and that count is what the plugin detail
120
- * panel prints.
116
+ * down: dev nests its skills by category under `skills/frontend/`,
117
+ * `skills/backend/` and so on, and mattpocock groups its 37 under
118
+ * `skills/engineering/`. Reading only the top level counted the *groups* — dev
119
+ * once reported 9 skills against 43 on disk, mattpocock 5 against 37 — and that
120
+ * count is what the plugin detail panel prints. dev also ships a `knowledge/`
121
+ * tree beside `skills/`: reference manuals reached by path, holding no SKILL.md
122
+ * and registering nothing, so this scan correctly never sees them.
121
123
  *
122
124
  * Depth is capped because this runs over every plugin of every cloned
123
125
  * marketplace on each list load, and a skill nested four levels below `skills/`
@@ -15,6 +15,7 @@ import type {
15
15
  BinRequirement,
16
16
  PluginRequires,
17
17
  } from "../types/index.js";
18
+ import { goBinaryName } from "../utils/go-module.js";
18
19
  import { parsePluginId } from "../utils/string-utils.js";
19
20
  import { getLocalMarketplacesInfo } from "./plugin-manager.js";
20
21
 
@@ -98,15 +99,19 @@ function parseSetup(setup: unknown): BinRequirement[] {
98
99
  pkgManager("npm");
99
100
  pkgManager("pip");
100
101
 
102
+ // Not basename(): a Go module's trailing /vN is its major version, not the
103
+ // binary. `tmux-mcp/v2` installs `tmux-mcp`; naming it `v2` here made every
104
+ // downstream presence check look for a binary that never exists.
101
105
  for (const spec of (s.go as string[] | undefined) ?? []) {
102
106
  if (typeof spec !== "string") continue;
103
107
  const { base, version } = splitVersion(spec);
108
+ const name = goBinaryName(base);
104
109
  bins.push({
105
- name: basename(base),
110
+ name,
106
111
  via: "go",
107
112
  module: base,
108
113
  version,
109
- check: `${basename(base)} --version`,
114
+ check: `${name} --version`,
110
115
  });
111
116
  }
112
117
  for (const spec of (s.brew as string[] | undefined) ?? []) {
@@ -33,6 +33,7 @@ import { constants as fsConstants } from "node:fs";
33
33
  import path from "node:path";
34
34
  import os from "node:os";
35
35
  import { which } from "../utils/command-utils.js";
36
+ import { goBinaryName } from "../utils/go-module.js";
36
37
 
37
38
  const execFileAsync = promisify(execFile);
38
39
 
@@ -392,14 +393,32 @@ async function installCargoPackages(
392
393
  }
393
394
  }
394
395
 
396
+ /**
397
+ * {@link parseAtSpec} for a Go module spec.
398
+ *
399
+ * Same split, but the name comes from {@link goBinaryName}: Go's `/vN`
400
+ * major-version suffix is part of the module path, not the binary, so
401
+ * "github.com/MadAppGang/tmux-mcp/v2@v2.0.0" installs `tmux-mcp`, not `v2`.
402
+ * The generic parser cannot do this — an npm scope may legitimately be `v2`.
403
+ */
404
+ export function parseGoSpec(spec: string): ParsedSpec {
405
+ const parsed = parseAtSpec(spec);
406
+ // Same separator rule as parseAtSpec: a leading `@` is never a version.
407
+ const at = spec.lastIndexOf("@");
408
+ const modulePath = at > 0 ? spec.slice(0, at) : spec;
409
+ return { ...parsed, name: goBinaryName(modulePath) };
410
+ }
411
+
395
412
  /**
396
413
  * Extract binary name from a Go module path.
397
- * The binary name is the last path segment before @version.
398
- * e.g., "github.com/MadAppGang/tmux-mcp@latest" → "tmux-mcp"
399
- * "github.com/user/tool" → "tool"
414
+ * The binary name is the last path segment before @version, minus any /vN
415
+ * major-version suffix.
416
+ * e.g., "github.com/MadAppGang/tmux-mcp@latest" → "tmux-mcp"
417
+ * "github.com/MadAppGang/tmux-mcp/v2@v2.0.0" → "tmux-mcp"
418
+ * "github.com/user/tool" → "tool"
400
419
  */
401
420
  export function extractGoBinaryName(pkg: string): string {
402
- return parseAtSpec(pkg).name;
421
+ return parseGoSpec(pkg).name;
403
422
  }
404
423
 
405
424
  /**
@@ -409,7 +428,7 @@ export function extractGoBinaryName(pkg: string): string {
409
428
  * "github.com/user/tool" → null
410
429
  */
411
430
  export function extractGoVersion(pkg: string): string | null {
412
- return parseAtSpec(pkg).version;
431
+ return parseGoSpec(pkg).version;
413
432
  }
414
433
 
415
434
  /** Normalize a semver-ish string for comparison: trim and drop a leading "v". */
@@ -641,7 +660,7 @@ export async function binaryDepDecision(
641
660
 
642
661
  /** {@link binaryDepDecision} for a Go module path. */
643
662
  export async function goDepDecision(pkg: string): Promise<GoDepDecision> {
644
- return binaryDepDecision(parseAtSpec(pkg), getGoInstallDir);
663
+ return binaryDepDecision(parseGoSpec(pkg), getGoInstallDir);
645
664
  }
646
665
 
647
666
  /**
@@ -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(),
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Go module path → installed binary name.
3
+ *
4
+ * `go install` names the binary after the last path element of the module
5
+ * path, EXCEPT when that element is a major-version suffix: the module
6
+ * `github.com/MadAppGang/tmux-mcp/v2` installs `tmux-mcp`, not `v2`. Taking a
7
+ * plain basename therefore points every later check at a binary that never
8
+ * exists — presence probes fail, so the module is reinstalled on every run,
9
+ * doctor reports it missing, and the pin comparison never runs at all.
10
+ *
11
+ * Only the Go rule lives here. npm and cargo share the `name@version` syntax
12
+ * but not the suffix convention — an npm scoped package can legitimately be
13
+ * called `@scope/v2` — so this must not be folded into the generic spec parser.
14
+ */
15
+
16
+ /**
17
+ * Trailing major-version suffix per the Go modules spec: `/vN` with N >= 2.
18
+ * Anchored to the END of the path with nothing after `vN`, so a segment that
19
+ * merely starts with "v2" (`github.com/x/v2tool`) is a real name, not a suffix.
20
+ * v0 and v1 are never written as a path suffix. The alternation, not a plain
21
+ * `[2-9][0-9]*`, because that shorthand rejects v10–v19 — a first digit of 1
22
+ * is only excluded when it is the ONLY digit.
23
+ */
24
+ const MAJOR_VERSION_SUFFIX = /\/v(?:[2-9]|[1-9][0-9]+)$/;
25
+
26
+ /**
27
+ * The binary `go install <modulePath>` produces.
28
+ *
29
+ * Takes the MODULE PATH — the part before any `@version` — so callers that
30
+ * hold a full `path@version` spec split it first. An empty or single-segment
31
+ * input is returned unchanged.
32
+ */
33
+ export function goBinaryName(modulePath: string): string {
34
+ const withoutSuffix = modulePath.replace(MAJOR_VERSION_SUFFIX, "");
35
+ return withoutSuffix.split("/").pop() || withoutSuffix;
36
+ }