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 +4 -4
- package/src/__tests__/catalog-cache-store.test.ts +49 -11
- package/src/__tests__/cli-apply-seams.test.ts +5 -4
- package/src/__tests__/plugin-cli-argv.test.ts +109 -0
- package/src/__tests__/update-apply.test.ts +91 -2
- package/src/services/claude-cli.ts +67 -30
- package/src/services/update-engine.ts +110 -19
- package/src/ui/screens/PluginsScreen.tsx +3 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claudeup",
|
|
3
|
-
"version": "6.
|
|
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.
|
|
68
|
-
"claudeup-darwin-x64": "6.
|
|
69
|
-
"claudeup-linux-x64": "6.
|
|
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
|
-
/**
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
203
|
-
d.
|
|
204
|
-
|
|
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(
|
|
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,
|
|
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
|
-
*
|
|
113
|
-
*
|
|
112
|
+
* Run a `claude plugin` subcommand, recovering once from a missing or stale
|
|
113
|
+
* marketplace.
|
|
114
114
|
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
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
|
-
|
|
119
|
+
async function execPluginCommand(
|
|
120
|
+
verb: "install" | "update",
|
|
120
121
|
pluginId: string,
|
|
121
|
-
scope: PluginScope
|
|
122
|
+
scope: PluginScope,
|
|
123
|
+
timeoutMs?: number,
|
|
122
124
|
): Promise<void> {
|
|
125
|
+
const argv = ["plugin", verb, pluginId, "--scope", scope];
|
|
123
126
|
try {
|
|
124
|
-
await execClaude(
|
|
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(
|
|
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
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
|
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
|
|
1166
|
+
await updatePluginInScope(
|
|
1166
1167
|
plugin.id,
|
|
1167
1168
|
scope,
|
|
1168
1169
|
state.projectPath || process.cwd(),
|