claudeup 6.0.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/src/cli/update.ts CHANGED
@@ -34,13 +34,7 @@
34
34
  * `cli/update-view.ts` owns every string. Nothing in this file formats.
35
35
  */
36
36
 
37
- import path from "node:path";
38
- import type { PluginScope } from "../services/claude-cli.js";
39
- import {
40
- isClaudeAvailable,
41
- repairPlugin,
42
- updatePlugin,
43
- } from "../services/claude-cli.js";
37
+ import { isClaudeAvailable } from "../services/claude-cli.js";
44
38
  import { clearContentDriftCache } from "../services/content-drift.js";
45
39
  import { checkBinaries } from "../services/doctor-bins.js";
46
40
  import { validateManifest } from "../services/manifest.js";
@@ -50,16 +44,18 @@ import {
50
44
  type PluginInfo,
51
45
  clearMarketplaceCache,
52
46
  getAvailablePlugins,
53
- readInstalledVersionForScope,
54
- saveInstalledPluginVersionForScope,
55
47
  } from "../services/plugin-manager.js";
56
48
  import { resolveProfile } from "../services/resolver.js";
57
- import { installSkill } from "../services/skills-manager.js";
58
49
  import { getInstalledSkillNames } from "../services/skills-manager.js";
59
50
  import { activeProfile } from "../services/symlink-manager.js";
60
- import { binInstallCommand, binUpgradeCommand } from "../services/toolchain.js";
51
+ // The apply half lives in services/ so the TUI runs the SAME code — see the
52
+ // header of update-engine.ts for what drifted while it did not.
53
+ import {
54
+ type ApplyReporter,
55
+ applyPlan,
56
+ planWorkItems,
57
+ } from "../services/update-engine.js";
61
58
  import {
62
- type PluginUpdateItem,
63
59
  type UpdatePlan,
64
60
  planBinUpdates,
65
61
  planHasWork,
@@ -68,12 +64,10 @@ import {
68
64
  planSkillUpdates,
69
65
  summarizePlan,
70
66
  } from "../services/update-plan.js";
71
- import type { ProfileSkillRef, SkillInfo } from "../types/index.js";
72
67
  import { brand } from "../ui/theme.js";
73
68
  import { bold, dim, fg, width } from "./ansi.js";
74
69
  import { ensureManifest } from "./bootstrap.js";
75
70
  import { LiveRegion, withPaused } from "./live.js";
76
- import { runShell } from "./prompt.js";
77
71
  import {
78
72
  type ApplyState,
79
73
  type Step,
@@ -114,253 +108,6 @@ export function parseArgs(args: string[]): UpdateFlags {
114
108
  };
115
109
  }
116
110
 
117
- // -- apply -------------------------------------------------------------------
118
-
119
- /** Map a manifest skill ref to the SkillInfo shape installSkill expects. */
120
- function toSkillInfo(ref: ProfileSkillRef): SkillInfo {
121
- return {
122
- id: `${ref.repo}/${ref.path}`,
123
- name: ref.name,
124
- source: {
125
- label: ref.repo,
126
- repo: ref.repo,
127
- skillsPath: path.dirname(ref.path),
128
- },
129
- repoPath: ref.path,
130
- gitBlobSha: "",
131
- frontmatter: null,
132
- installed: false,
133
- installedScope: null,
134
- hasUpdate: false,
135
- };
136
- }
137
-
138
- interface ApplyResult {
139
- ok: number;
140
- failed: string[];
141
- }
142
-
143
- /** An exact pin the installer could not deliver, discovered by reading back. */
144
- export interface PinMismatch {
145
- pluginId: string;
146
- pinned: string;
147
- actual: string | null;
148
- scope: PluginScope;
149
- }
150
-
151
- export interface PluginApplyResult extends ApplyResult {
152
- mismatches: PinMismatch[];
153
- }
154
-
155
- /**
156
- * How the apply loop reports itself.
157
- *
158
- * Injected rather than hard-coded, because the loop is the same work whether it
159
- * is driving an animated meter, printing plain lines into a pipe, or running
160
- * under a test that wants silence. The default prints one line per item, which
161
- * is what every non-interactive caller wants.
162
- */
163
- export interface ApplyReporter {
164
- /** An item is starting. Called before any of its work. */
165
- begin(name: string): void;
166
- /** An item finished. `detail` is already formatted for display. */
167
- finish(ok: boolean, name: string, detail: string, ms: number): void;
168
- /**
169
- * Run `work` with the terminal released.
170
- *
171
- * A binary upgrade spawns `brew`/`npm` with inherited stdio: it writes and
172
- * moves the cursor itself, so any animated block must be erased first or the
173
- * two interleave into garbage.
174
- */
175
- detach<T>(work: () => Promise<T>): Promise<T>;
176
- }
177
-
178
- const PLAIN_REPORTER: ApplyReporter = {
179
- begin: () => {},
180
- finish: (ok, name, detail) => {
181
- const line = `${ok ? "✓" : "⚠"} ${name}${detail ? ` ${detail}` : ""}`;
182
- if (ok) console.log(line);
183
- else console.warn(line);
184
- },
185
- detach: (work) => work(),
186
- };
187
-
188
- /**
189
- * The four side-effecting calls the plugin apply path makes, injectable so the
190
- * path itself can be tested.
191
- *
192
- * This exists because the apply half shipped with no coverage while the pure
193
- * planner had forty tests — and every real defect found in review lived here,
194
- * not there. Following the same shape `checkBinaries` and `resolveProfile`
195
- * already use for their impure edges.
196
- */
197
- export interface PluginApplyDeps {
198
- update: (pluginId: string, scope: PluginScope) => Promise<void>;
199
- repair: (
200
- pluginId: string,
201
- scope: PluginScope,
202
- projectPath: string,
203
- ) => Promise<void>;
204
- readInstalled: (
205
- pluginId: string,
206
- scope: PluginScope,
207
- projectPath: string,
208
- ) => Promise<string | null>;
209
- saveInstalled: (
210
- pluginId: string,
211
- version: string,
212
- scope: PluginScope,
213
- projectPath: string,
214
- ) => Promise<void>;
215
- }
216
-
217
- const REAL_PLUGIN_DEPS: PluginApplyDeps = {
218
- update: updatePlugin,
219
- repair: repairPlugin,
220
- readInstalled: readInstalledVersionForScope,
221
- saveInstalled: saveInstalledPluginVersionForScope,
222
- };
223
-
224
- /**
225
- * Scopes the apply loop would touch for `item`. Empty means it is skipped.
226
- *
227
- * A plugin the profile declares but nothing has installed goes in at PROJECT
228
- * scope, matching `install`: a profile is a property of one repo, so its
229
- * plugins must not be enabled machine-wide.
230
- */
231
- function scopesToApply(item: PluginUpdateItem): PluginScope[] {
232
- if (item.action === "current" || item.action === "unknown") return [];
233
- return item.action === "install" ? ["project"] : item.scopes;
234
- }
235
-
236
- /**
237
- * Whether the apply loop will do anything for `item`.
238
- *
239
- * Exported shape for ONE reason: the progress meter's denominator must be the
240
- * same predicate the loop actually applies. It was previously re-derived at the
241
- * call site and replicated only the first of the two skips — an actionable item
242
- * with empty `scopes` was counted but never run, so the bar would stop short of
243
- * full and sit there looking hung at the end of a successful update.
244
- */
245
- export function pluginNeedsWork(item: PluginUpdateItem): boolean {
246
- return scopesToApply(item).length > 0;
247
- }
248
-
249
- export async function applyPlugins(
250
- items: PluginUpdateItem[],
251
- projectPath: string,
252
- deps: PluginApplyDeps = REAL_PLUGIN_DEPS,
253
- report: ApplyReporter = PLAIN_REPORTER,
254
- ): Promise<PluginApplyResult> {
255
- const result: PluginApplyResult = { ok: 0, failed: [], mismatches: [] };
256
-
257
- for (const item of items) {
258
- const scopes = scopesToApply(item);
259
- if (scopes.length === 0) continue;
260
-
261
- report.begin(item.pluginId);
262
- const startedAt = Date.now();
263
-
264
- try {
265
- for (const scope of scopes) {
266
- if (item.action === "repair") {
267
- await deps.repair(item.pluginId, scope, projectPath);
268
- } else {
269
- await deps.update(item.pluginId, scope);
270
- }
271
-
272
- // Record what LANDED, not what we asked for.
273
- //
274
- // `claude plugin install` accepts no version, so an exact pin is a
275
- // request the installer cannot honour — it fetches whatever the
276
- // marketplace publishes now. Writing `item.target` here recorded the
277
- // pin as satisfied whatever actually installed, and `install --check`
278
- // reads that same field to decide whether the machine has drifted.
279
- // The gate would have compared the pin against a copy of itself and
280
- // reported clean forever.
281
- const actual = await deps.readInstalled(
282
- item.pluginId,
283
- scope,
284
- projectPath,
285
- );
286
- if (actual) {
287
- await deps.saveInstalled(item.pluginId, actual, scope, projectPath);
288
- }
289
- if (item.pinned !== "latest" && actual !== item.pinned) {
290
- result.mismatches.push({
291
- pluginId: item.pluginId,
292
- pinned: item.pinned,
293
- actual,
294
- scope,
295
- });
296
- }
297
- }
298
- report.finish(
299
- true,
300
- item.pluginId,
301
- `${scopes.join(", ")}${item.target ? ` → ${item.target}` : ""}`,
302
- Date.now() - startedAt,
303
- );
304
- result.ok++;
305
- } catch (error) {
306
- const msg = error instanceof Error ? error.message : String(error);
307
- report.finish(false, item.pluginId, msg, Date.now() - startedAt);
308
- result.failed.push(item.pluginId);
309
- }
310
- }
311
-
312
- return result;
313
- }
314
-
315
- async function applyBins(
316
- plan: UpdatePlan,
317
- report: ApplyReporter = PLAIN_REPORTER,
318
- ): Promise<ApplyResult> {
319
- const result: ApplyResult = { ok: 0, failed: [] };
320
- for (const item of plan.bins) {
321
- if (item.action === "current") continue;
322
- const bin = plan.binSpecs.get(item.name);
323
- if (!bin) continue;
324
- const cmd =
325
- item.action === "install"
326
- ? binInstallCommand(bin)
327
- : binUpgradeCommand(bin);
328
- report.begin(item.name);
329
- const startedAt = Date.now();
330
- // The installer owns the terminal for the duration: it streams its own
331
- // output through inherited stdio.
332
- const succeeded = await report.detach(async () => {
333
- console.log(`\n$ ${cmd}`);
334
- return runShell(cmd);
335
- });
336
- report.finish(succeeded, item.name, cmd, Date.now() - startedAt);
337
- if (succeeded) result.ok++;
338
- else result.failed.push(item.name);
339
- }
340
- return result;
341
- }
342
-
343
- async function applySkills(
344
- plan: UpdatePlan,
345
- projectPath: string,
346
- report: ApplyReporter = PLAIN_REPORTER,
347
- ): Promise<ApplyResult> {
348
- const result: ApplyResult = { ok: 0, failed: [] };
349
- for (const item of plan.skills) {
350
- report.begin(item.name);
351
- const startedAt = Date.now();
352
- try {
353
- await installSkill(toSkillInfo(item.ref), "project", projectPath);
354
- report.finish(true, item.name, item.ref.repo, Date.now() - startedAt);
355
- result.ok++;
356
- } catch (error) {
357
- const msg = error instanceof Error ? error.message : String(error);
358
- report.finish(false, item.name, msg, Date.now() - startedAt);
359
- result.failed.push(item.name);
360
- }
361
- }
362
- return result;
363
- }
364
111
 
365
112
  // -- phase runner ------------------------------------------------------------
366
113
 
@@ -637,14 +384,10 @@ export async function runUpdateCommand(
637
384
  return 1;
638
385
  }
639
386
 
640
- // Exactly the items the three apply loops will touch. `pluginNeedsWork` is
641
- // the SAME predicate applyPlugins uses re-deriving it is how a meter
642
- // drifts from the loop it measures.
643
- const workItems = [
644
- ...plan.plugins.filter(pluginNeedsWork).map((p) => p.pluginId),
645
- ...plan.bins.filter((b) => b.action !== "current").map((b) => b.name),
646
- ...plan.skills.map((s) => s.name),
647
- ];
387
+ // Exactly the items the engine will touch. Derived by the engine from the
388
+ // same predicates its loops use, so the meter's denominator cannot drift
389
+ // from the work a bar stuck short of full reads as a hang.
390
+ const workItems = planWorkItems(plan);
648
391
 
649
392
  if (!planHasWork(plan) || workItems.length === 0) {
650
393
  for (const line of summaryLine(
@@ -741,26 +484,14 @@ export async function runUpdateCommand(
741
484
  console.log("");
742
485
  applyLive.start((tick) => applyFrame(state, tick, Date.now()));
743
486
 
744
- const pluginResult = await applyPlugins(
745
- plan.plugins,
746
- projectPath,
747
- REAL_PLUGIN_DEPS,
748
- reporter,
749
- );
750
- const binResult = await applyBins(plan, reporter);
751
- const skillResult = await applySkills(plan, projectPath, reporter);
487
+ const outcome = await applyPlan(plan, projectPath, { report: reporter });
752
488
 
753
489
  applyLive.stop();
754
490
 
755
- const failed = [
756
- ...pluginResult.failed,
757
- ...binResult.failed,
758
- ...skillResult.failed,
759
- ];
760
- const changed = pluginResult.ok + binResult.ok + skillResult.ok;
491
+ const { failed } = outcome;
761
492
 
762
493
  for (const line of summaryLine(
763
- changed,
494
+ outcome.changed,
764
495
  counts.current,
765
496
  counts.unknown,
766
497
  failed.length,
@@ -773,13 +504,13 @@ export async function runUpdateCommand(
773
504
  // of the manifest rather than something this run did wrong, and failing here
774
505
  // would make `claudeup update` exit 1 forever for any profile with a pin.
775
506
  // `install --check` with `strictVersions` is where pin drift is GATED.
776
- if (pluginResult.mismatches.length > 0) {
507
+ if (outcome.plugins.mismatches.length > 0) {
777
508
  console.log(
778
509
  warnLine(
779
- `${bold(String(pluginResult.mismatches.length))} pinned version(s) are not what is installed. Only the newest published version can be installed:`,
510
+ `${bold(String(outcome.plugins.mismatches.length))} pinned version(s) are not what is installed. Only the newest published version can be installed:`,
780
511
  ),
781
512
  );
782
- for (const m of pluginResult.mismatches) {
513
+ for (const m of outcome.plugins.mismatches) {
783
514
  console.log(
784
515
  noteLine(
785
516
  `${m.pluginId} (${m.scope}): manifest pins ${m.pinned}, installed ${m.actual ?? "unknown"}`,
@@ -1,12 +1,30 @@
1
+ import type { BinInstaller } from "../types/index.js";
2
+
3
+ /**
4
+ * A CLI tool claudeup can install, check and update.
5
+ *
6
+ * There is deliberately NO `installCommand` field. Entries used to carry one
7
+ * alongside `packageManager` + `packageName`, which describe the same thing —
8
+ * so the two could disagree, and did: `aider` declared
9
+ * `packageName: "aider-chat"` while its `installCommand` installed
10
+ * `aider-install`, and the version check queried the former. The command is
11
+ * derived by `services/cli-tool-commands.ts` instead, which also means a
12
+ * catalogue entry can no longer smuggle shell syntax (that same entry's command
13
+ * was a `&&` chain) into something that gets executed.
14
+ */
1
15
  export interface CliTool {
2
16
  name: string;
3
17
  displayName: string;
4
18
  description: string;
5
- installCommand: string;
6
19
  checkCommand: string;
7
20
  website: string;
8
21
  category: "ai-coding" | "utility";
9
- packageManager: "bun" | "npm" | "pip" | "brew" | "go";
22
+ /**
23
+ * Reuses `BinInstaller` rather than restating its members, so the
24
+ * correspondence with the resolver in `services/toolchain.ts` — which is
25
+ * what turns this field into an actual command — is a compile-time fact.
26
+ */
27
+ packageManager: BinInstaller;
10
28
  packageName: string;
11
29
  }
12
30
 
@@ -16,7 +34,6 @@ export const cliTools: CliTool[] = [
16
34
  displayName: "claudeup",
17
35
  description:
18
36
  "TUI tool for managing Claude Code plugins, MCPs, and configuration",
19
- installCommand: "bun install -g claudeup",
20
37
  checkCommand: "claudeup --version",
21
38
  website: "https://github.com/MadAppGang/magus/tree/main/tools/claudeup",
22
39
  category: "ai-coding",
@@ -28,7 +45,6 @@ export const cliTools: CliTool[] = [
28
45
  displayName: "Mnemex",
29
46
  description:
30
47
  "AST-aware code search with PageRank, callers/callees, and semantic embeddings",
31
- installCommand: "bun install -g mnemex",
32
48
  checkCommand: "mnemex --version",
33
49
  website: "https://github.com/MadAppGang/mnemex",
34
50
  category: "ai-coding",
@@ -39,7 +55,6 @@ export const cliTools: CliTool[] = [
39
55
  name: "claudish",
40
56
  displayName: "Claudish",
41
57
  description: "Run Claude Code with OpenRouter models (Grok, GPT-5, Gemini)",
42
- installCommand: "bun install -g claudish",
43
58
  checkCommand: "claudish --version",
44
59
  website: "https://github.com/MadAppGang/claudish",
45
60
  category: "ai-coding",
@@ -50,7 +65,6 @@ export const cliTools: CliTool[] = [
50
65
  name: "claude",
51
66
  displayName: "Claude Code",
52
67
  description: "Anthropic official agentic coding tool",
53
- installCommand: "npm install -g @anthropic-ai/claude-code",
54
68
  checkCommand: "claude --version",
55
69
  website: "https://claude.ai/code",
56
70
  category: "ai-coding",
@@ -62,7 +76,6 @@ export const cliTools: CliTool[] = [
62
76
  displayName: "OpenAI Codex",
63
77
  description:
64
78
  "Lightweight coding agent from OpenAI that runs in your terminal",
65
- installCommand: "npm install -g @openai/codex",
66
79
  checkCommand: "codex --version",
67
80
  website: "https://github.com/openai/codex",
68
81
  category: "ai-coding",
@@ -73,7 +86,6 @@ export const cliTools: CliTool[] = [
73
86
  name: "gemini",
74
87
  displayName: "Gemini CLI",
75
88
  description: "Google AI agent with 1M token context, free tier available",
76
- installCommand: "npm install -g @google/gemini-cli",
77
89
  checkCommand: "gemini --version",
78
90
  website: "https://github.com/google-gemini/gemini-cli",
79
91
  category: "ai-coding",
@@ -84,7 +96,6 @@ export const cliTools: CliTool[] = [
84
96
  name: "qwen",
85
97
  displayName: "Qwen Code",
86
98
  description: "Alibaba coding agent optimized for Qwen3-Coder models",
87
- installCommand: "npm install -g @qwen-code/qwen-code",
88
99
  checkCommand: "qwen --version",
89
100
  website: "https://github.com/QwenLM/qwen-code",
90
101
  category: "ai-coding",
@@ -96,7 +107,6 @@ export const cliTools: CliTool[] = [
96
107
  displayName: "Cline",
97
108
  description:
98
109
  "Autonomous coding agent with plan & act, checkpoints, browser use",
99
- installCommand: "npm install -g cline",
100
110
  checkCommand: "cline --version",
101
111
  website: "https://cline.bot",
102
112
  category: "ai-coding",
@@ -107,7 +117,6 @@ export const cliTools: CliTool[] = [
107
117
  name: "opencode",
108
118
  displayName: "OpenCode",
109
119
  description: "Open source AI coding agent trusted by 400k+ developers",
110
- installCommand: "npm install -g opencode-ai",
111
120
  checkCommand: "opencode --version",
112
121
  website: "https://opencode.ai",
113
122
  category: "ai-coding",
@@ -118,7 +127,6 @@ export const cliTools: CliTool[] = [
118
127
  name: "aider",
119
128
  displayName: "Aider",
120
129
  description: "AI pair programming - works with Claude, GPT-4, local models",
121
- installCommand: "pip install aider-install && aider-install",
122
130
  checkCommand: "aider --version",
123
131
  website: "https://aider.chat",
124
132
  category: "ai-coding",
@@ -129,7 +137,6 @@ export const cliTools: CliTool[] = [
129
137
  name: "crush",
130
138
  displayName: "Crush",
131
139
  description: "AI coding assistant from Charm with terminal-native UX",
132
- installCommand: "npm install -g @charmland/crush",
133
140
  checkCommand: "crush --version",
134
141
  website: "https://github.com/charmbracelet/crush",
135
142
  category: "ai-coding",
@@ -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
  /**