claudeup 4.42.1 → 5.0.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
@@ -1,85 +1,473 @@
1
1
  /**
2
- * `claudeup update` self-update.
2
+ * `claudeup update [profile] [--check] [--yes]`
3
3
  *
4
- * Detects claudeup installs across bun + npm global, warns on duplicates
5
- * (active vs shadowed), then re-installs `claudeup@latest` with whichever
6
- * package manager owns the active binary. Resolves with the child's exit code.
4
+ * Dependency management, not package management: no resolver, no lockfile, no
5
+ * version negotiation. `claudeup install` puts the profile's declared set on
6
+ * this machine; `claudeup update` keeps it current anything the profile
7
+ * declares but the machine lacks is installed, and anything behind the version
8
+ * its marketplace publishes is advanced to it.
9
+ *
10
+ * Scope is the ACTIVE profile, not the union of every profile: "update my
11
+ * dependencies" means the set actually in use. Pass a name to update another.
12
+ *
13
+ * Self-update moved to `claudeup upgrade`.
7
14
  */
8
15
 
9
- import { spawn } from "node:child_process";
10
-
11
- export async function runUpdateCommand(): Promise<number> {
12
- const { execSync } = await import("node:child_process");
13
- const { existsSync } = await import("node:fs");
14
-
15
- // Detect all installations of claudeup across package managers
16
- const installations: Array<{ manager: "bun" | "npm"; path: string }> = [];
17
-
18
- // Check bun global
19
- try {
20
- const bunGlobalBin = execSync("bun pm -g bin", {
21
- encoding: "utf-8",
22
- timeout: 5000,
23
- }).trim();
24
- const bunPath = `${bunGlobalBin}/claudeup`;
25
- if (existsSync(bunPath)) installations.push({ manager: "bun", path: bunPath });
26
- } catch {
27
- // bun not installed or no global claudeup
28
- }
29
-
30
- // Check npm global
31
- try {
32
- const npmPrefix = execSync("npm prefix -g", {
33
- encoding: "utf-8",
34
- timeout: 5000,
35
- }).trim();
36
- const npmPath = `${npmPrefix}/bin/claudeup`;
37
- if (existsSync(npmPath)) installations.push({ manager: "npm", path: npmPath });
38
- } catch {
39
- // npm not installed or no global claudeup
40
- }
41
-
42
- // Warn about duplicate installations
43
- if (installations.length > 1) {
44
- const activePath = execSync("which claudeup", {
45
- encoding: "utf-8",
46
- timeout: 5000,
47
- }).trim();
48
-
49
- console.log(`⚠ claudeup is installed via multiple package managers:\n`);
50
- for (const inst of installations) {
51
- const tag = inst.path === activePath ? " (active)" : " (shadowed)";
52
- console.log(` ${inst.manager}: ${inst.path}${tag}`);
16
+ import path from "node:path";
17
+ import type { PluginScope } from "../services/claude-cli.js";
18
+ import {
19
+ isClaudeAvailable,
20
+ repairPlugin,
21
+ updatePlugin,
22
+ } from "../services/claude-cli.js";
23
+ import { clearContentDriftCache } from "../services/content-drift.js";
24
+ import { checkBinaries } from "../services/doctor-bins.js";
25
+ import { validateManifest } from "../services/manifest.js";
26
+ import { refreshRegisteredMarketplaces } from "../services/marketplace-refresh.js";
27
+ import { registerClosureMarketplaces } from "../services/marketplace-sync.js";
28
+ import {
29
+ type PluginInfo,
30
+ clearMarketplaceCache,
31
+ getAvailablePlugins,
32
+ readInstalledVersionForScope,
33
+ saveInstalledPluginVersionForScope,
34
+ } from "../services/plugin-manager.js";
35
+ import { resolveProfile } from "../services/resolver.js";
36
+ import { installSkill } from "../services/skills-manager.js";
37
+ import { getInstalledSkillNames } from "../services/skills-manager.js";
38
+ import { activeProfile } from "../services/symlink-manager.js";
39
+ import { binInstallCommand, binUpgradeCommand } from "../services/toolchain.js";
40
+ import {
41
+ type PluginUpdateItem,
42
+ type UpdatePlan,
43
+ planBinUpdates,
44
+ planHasWork,
45
+ planIsBehind,
46
+ planPluginUpdates,
47
+ planSkillUpdates,
48
+ summarizePlan,
49
+ } from "../services/update-plan.js";
50
+ import type { ProfileSkillRef, SkillInfo } from "../types/index.js";
51
+ import { ensureManifest } from "./bootstrap.js";
52
+ import { confirm, runShell } from "./prompt.js";
53
+
54
+ interface UpdateFlags {
55
+ check: boolean;
56
+ yes: boolean;
57
+ profile?: string;
58
+ }
59
+
60
+ function parseArgs(args: string[]): UpdateFlags {
61
+ return {
62
+ check: args.includes("--check"),
63
+ yes: args.includes("--yes") || args.includes("-y"),
64
+ profile: args.find((a) => !a.startsWith("-")),
65
+ };
66
+ }
67
+
68
+ // ── report ───────────────────────────────────────────────────────────────────
69
+
70
+ /** Left-pad the id column so versions line up in the report. */
71
+ function pad(text: string, width: number): string {
72
+ return text.length >= width ? text : text + " ".repeat(width - text.length);
73
+ }
74
+
75
+ function describePlugin(item: PluginUpdateItem): string {
76
+ // An exact pin that cannot be delivered carries a note on install/update/
77
+ // current alike. It must ride along on every one of them: the whole point is
78
+ // that the manifest says one thing and the machine will hold another, and a
79
+ // row reading a bare "= 9.9.9" against a pin of 4.6.1 hides exactly that.
80
+ const suffix = item.note ? ` (${item.note})` : "";
81
+ switch (item.action) {
82
+ case "install":
83
+ return `+ install ${item.target ?? "latest"}${suffix}`;
84
+ case "update":
85
+ return `↑ ${item.installed} → ${item.target}${suffix}`;
86
+ case "repair":
87
+ return `⟳ repair ${item.installed} (${item.note})`;
88
+ case "unknown":
89
+ return `? unknown ${item.installed} (${item.note})`;
90
+ case "current":
91
+ return `= ${item.installed}${suffix}`;
92
+ }
93
+ }
94
+
95
+ function printPlan(plan: UpdatePlan): void {
96
+ const width = Math.max(
97
+ 12,
98
+ ...plan.plugins.map((p) => p.pluginId.length),
99
+ ...plan.bins.map((b) => b.name.length),
100
+ ...plan.skills.map((s) => s.name.length),
101
+ );
102
+
103
+ if (plan.plugins.length > 0) {
104
+ console.log("Plugins:");
105
+ for (const item of plan.plugins) {
106
+ console.log(` ${pad(item.pluginId, width)} ${describePlugin(item)}`);
53
107
  }
54
- console.log(`\nTo fix, keep one and remove the other:`);
55
- for (const inst of installations) {
56
- console.log(` ${inst.manager} uninstall -g claudeup`);
108
+ }
109
+
110
+ if (plan.bins.length > 0) {
111
+ console.log("\nCLI tools:");
112
+ for (const item of plan.bins) {
113
+ const spec = plan.binSpecs.get(item.name);
114
+ const verb =
115
+ item.action === "current" || !spec
116
+ ? `= pinned ${item.version}`
117
+ : item.action === "install"
118
+ ? `+ install ${binInstallCommand(spec)}`
119
+ : `↑ upgrade ${binUpgradeCommand(spec)}`;
120
+ console.log(` ${pad(item.name, width)} ${verb}`);
57
121
  }
58
- console.log();
59
122
  }
60
123
 
61
- // Determine which package manager to use for the update
62
- let usesBun = false;
63
- try {
64
- const claudeupPath = execSync("which claudeup", {
65
- encoding: "utf-8",
66
- }).trim();
67
- usesBun = claudeupPath.includes(".bun") || claudeupPath.includes("bun/bin");
68
- } catch {
69
- // If which fails, default to npm
124
+ if (plan.skills.length > 0) {
125
+ console.log("\nSkills:");
126
+ for (const item of plan.skills) {
127
+ console.log(
128
+ ` ${pad(item.name, width)} ${item.action === "install" ? "+ install" : "↑ refresh"}`,
129
+ );
130
+ }
70
131
  }
132
+ }
133
+
134
+ // ── apply ────────────────────────────────────────────────────────────────────
135
+
136
+ /** Map a manifest skill ref to the SkillInfo shape installSkill expects. */
137
+ function toSkillInfo(ref: ProfileSkillRef): SkillInfo {
138
+ return {
139
+ id: `${ref.repo}/${ref.path}`,
140
+ name: ref.name,
141
+ source: {
142
+ label: ref.repo,
143
+ repo: ref.repo,
144
+ skillsPath: path.dirname(ref.path),
145
+ },
146
+ repoPath: ref.path,
147
+ gitBlobSha: "",
148
+ frontmatter: null,
149
+ installed: false,
150
+ installedScope: null,
151
+ hasUpdate: false,
152
+ };
153
+ }
71
154
 
72
- const pkgManager = usesBun ? "bun" : "npm";
73
- console.log(`Updating claudeup using ${pkgManager}...`);
155
+ interface ApplyResult {
156
+ ok: number;
157
+ failed: string[];
158
+ }
159
+
160
+ /** An exact pin the installer could not deliver, discovered by reading back. */
161
+ export interface PinMismatch {
162
+ pluginId: string;
163
+ pinned: string;
164
+ actual: string | null;
165
+ scope: PluginScope;
166
+ }
167
+
168
+ export interface PluginApplyResult extends ApplyResult {
169
+ mismatches: PinMismatch[];
170
+ }
171
+
172
+ /**
173
+ * The four side-effecting calls the plugin apply path makes, injectable so the
174
+ * path itself can be tested.
175
+ *
176
+ * This exists because the apply half shipped with no coverage while the pure
177
+ * planner had forty tests — and every real defect found in review lived here,
178
+ * not there. Following the same shape `checkBinaries` and `resolveProfile`
179
+ * already use for their impure edges.
180
+ */
181
+ export interface PluginApplyDeps {
182
+ update: (pluginId: string, scope: PluginScope) => Promise<void>;
183
+ repair: (
184
+ pluginId: string,
185
+ scope: PluginScope,
186
+ projectPath: string,
187
+ ) => Promise<void>;
188
+ readInstalled: (
189
+ pluginId: string,
190
+ scope: PluginScope,
191
+ projectPath: string,
192
+ ) => Promise<string | null>;
193
+ saveInstalled: (
194
+ pluginId: string,
195
+ version: string,
196
+ scope: PluginScope,
197
+ projectPath: string,
198
+ ) => Promise<void>;
199
+ }
200
+
201
+ const REAL_PLUGIN_DEPS: PluginApplyDeps = {
202
+ update: updatePlugin,
203
+ repair: repairPlugin,
204
+ readInstalled: readInstalledVersionForScope,
205
+ saveInstalled: saveInstalledPluginVersionForScope,
206
+ };
74
207
 
75
- const installArgs = ["install", "-g", "claudeup@latest"];
208
+ export async function applyPlugins(
209
+ items: PluginUpdateItem[],
210
+ projectPath: string,
211
+ deps: PluginApplyDeps = REAL_PLUGIN_DEPS,
212
+ ): Promise<PluginApplyResult> {
213
+ const result: PluginApplyResult = { ok: 0, failed: [], mismatches: [] };
76
214
 
77
- return new Promise<number>((resolve) => {
78
- const proc = spawn(pkgManager, installArgs, {
79
- stdio: "inherit",
80
- shell: false, // Avoid shell for security (fixes DEP0190 warning)
81
- });
82
- proc.on("exit", (code) => resolve(code ?? 0));
83
- proc.on("error", () => resolve(1));
215
+ for (const item of items) {
216
+ if (item.action === "current" || item.action === "unknown") continue;
217
+
218
+ // A plugin the profile declares but nothing has installed goes in at
219
+ // PROJECT scope, matching `install`: a profile is a property of one repo,
220
+ // so its plugins must not be enabled machine-wide.
221
+ const scopes: PluginScope[] =
222
+ item.action === "install" ? ["project"] : item.scopes;
223
+ if (scopes.length === 0) continue;
224
+
225
+ try {
226
+ for (const scope of scopes) {
227
+ if (item.action === "repair") {
228
+ await deps.repair(item.pluginId, scope, projectPath);
229
+ } else {
230
+ await deps.update(item.pluginId, scope);
231
+ }
232
+
233
+ // Record what LANDED, not what we asked for.
234
+ //
235
+ // `claude plugin install` accepts no version, so an exact pin is a
236
+ // request the installer cannot honour — it fetches whatever the
237
+ // marketplace publishes now. Writing `item.target` here recorded the
238
+ // pin as satisfied whatever actually installed, and `install --check`
239
+ // reads that same field to decide whether the machine has drifted.
240
+ // The gate would have compared the pin against a copy of itself and
241
+ // reported clean forever.
242
+ const actual = await deps.readInstalled(
243
+ item.pluginId,
244
+ scope,
245
+ projectPath,
246
+ );
247
+ if (actual) {
248
+ await deps.saveInstalled(item.pluginId, actual, scope, projectPath);
249
+ }
250
+ if (item.pinned !== "latest" && actual !== item.pinned) {
251
+ result.mismatches.push({
252
+ pluginId: item.pluginId,
253
+ pinned: item.pinned,
254
+ actual,
255
+ scope,
256
+ });
257
+ }
258
+ }
259
+ console.log(`✓ ${item.pluginId} (${scopes.join(", ")})`);
260
+ result.ok++;
261
+ } catch (error) {
262
+ const msg = error instanceof Error ? error.message : String(error);
263
+ console.warn(`⚠ ${item.pluginId}: ${msg}`);
264
+ result.failed.push(item.pluginId);
265
+ }
266
+ }
267
+
268
+ return result;
269
+ }
270
+
271
+ async function applyBins(plan: UpdatePlan): Promise<ApplyResult> {
272
+ const result: ApplyResult = { ok: 0, failed: [] };
273
+ for (const item of plan.bins) {
274
+ if (item.action === "current") continue;
275
+ const bin = plan.binSpecs.get(item.name);
276
+ if (!bin) continue;
277
+ const cmd =
278
+ item.action === "install"
279
+ ? binInstallCommand(bin)
280
+ : binUpgradeCommand(bin);
281
+ console.log(`\n$ ${cmd}`);
282
+ if (await runShell(cmd)) result.ok++;
283
+ else result.failed.push(item.name);
284
+ }
285
+ return result;
286
+ }
287
+
288
+ async function applySkills(
289
+ plan: UpdatePlan,
290
+ projectPath: string,
291
+ ): Promise<ApplyResult> {
292
+ const result: ApplyResult = { ok: 0, failed: [] };
293
+ for (const item of plan.skills) {
294
+ try {
295
+ await installSkill(toSkillInfo(item.ref), "project", projectPath);
296
+ console.log(`✓ skill ${item.name}`);
297
+ result.ok++;
298
+ } catch (error) {
299
+ const msg = error instanceof Error ? error.message : String(error);
300
+ console.warn(`⚠ skill ${item.name}: ${msg}`);
301
+ result.failed.push(item.name);
302
+ }
303
+ }
304
+ return result;
305
+ }
306
+
307
+ // ── entry point ──────────────────────────────────────────────────────────────
308
+
309
+ export async function runUpdateCommand(
310
+ args: string[],
311
+ projectPath: string = process.cwd(),
312
+ ): Promise<number> {
313
+ const flags = parseArgs(args);
314
+
315
+ const manifest = await ensureManifest(projectPath, {
316
+ yes: flags.yes,
317
+ check: flags.check,
84
318
  });
319
+ if (!manifest) return 1;
320
+
321
+ const errors = validateManifest(manifest);
322
+ if (errors.length > 0) {
323
+ console.error("Invalid .claude/profiles.json:");
324
+ for (const e of errors) console.error(` ${e.path}: ${e.message}`);
325
+ return 1;
326
+ }
327
+
328
+ const profileIds = Object.keys(manifest.profiles);
329
+ if (flags.profile && !manifest.profiles[flags.profile]) {
330
+ console.error(
331
+ `Profile "${flags.profile}" not found. Available: ${profileIds.join(", ")}`,
332
+ );
333
+ return 1;
334
+ }
335
+
336
+ // Active profile, not the union — "update my dependencies" means the set in
337
+ // use. A single-profile manifest needs no activation to be unambiguous.
338
+ const targetId =
339
+ flags.profile ??
340
+ (await activeProfile(projectPath)) ??
341
+ (profileIds.length === 1 ? profileIds[0] : undefined);
342
+ if (!targetId) {
343
+ console.error(
344
+ `No active profile. Pass one: claudeup update <${profileIds.join("|")}>`,
345
+ );
346
+ return 1;
347
+ }
348
+
349
+ const closure = await resolveProfile(manifest, targetId);
350
+
351
+ // "latest" is only as fresh as the catalog. Fast-forward the marketplace
352
+ // clones first, then drop every cached answer derived from the old HEAD —
353
+ // skipping this is how a clone sits days behind while every plugin reads as
354
+ // up to date.
355
+ console.log(`Updating profile "${targetId}"…\n`);
356
+ const refresh = await refreshRegisteredMarketplaces();
357
+ if (refresh.refreshed.length > 0) {
358
+ console.log(`✓ Refreshed marketplaces: ${refresh.refreshed.join(", ")}`);
359
+ }
360
+ for (const name of refresh.autoUpdateDisabled) {
361
+ console.log(
362
+ `⚠ ${name}: auto-update disabled — its catalog will not refresh, so updates stay hidden.`,
363
+ );
364
+ }
365
+ for (const name of refresh.failed) {
366
+ console.warn(`⚠ Failed to refresh marketplace: ${name}`);
367
+ }
368
+ clearMarketplaceCache();
369
+ clearContentDriftCache();
370
+
371
+ const catalog = new Map<string, PluginInfo>(
372
+ (await getAvailablePlugins(projectPath)).map((p) => [p.id, p]),
373
+ );
374
+ const binChecks = await checkBinaries(closure.bins);
375
+ const installedSkills = await getInstalledSkillNames("project", projectPath);
376
+
377
+ const plan: UpdatePlan = {
378
+ profileId: targetId,
379
+ plugins: planPluginUpdates(closure.plugins, catalog),
380
+ bins: planBinUpdates(closure.bins, binChecks),
381
+ skills: planSkillUpdates(closure.skills, installedSkills),
382
+ binSpecs: new Map(closure.bins.map((b) => [b.name, b])),
383
+ };
384
+
385
+ console.log();
386
+ printPlan(plan);
387
+
388
+ const counts = summarizePlan(plan);
389
+ if (counts.unknown > 0) {
390
+ console.log(
391
+ `\n⚠ ${counts.unknown} plugin(s) could not be checked. They are NOT reported as up to date — re-run once the catalog is reachable.`,
392
+ );
393
+ }
394
+
395
+ // `--check` gates on planIsBehind, not planHasWork. An already-installed
396
+ // skill is re-fetched on every apply because its manifest ref carries no
397
+ // content hash, and an unpinned binary has no version probe at all — neither
398
+ // is evidence that anything is out of date, and counting them made the gate
399
+ // exit 1 forever for any profile declaring a single skill.
400
+ if (flags.check) {
401
+ if (!planIsBehind(plan)) {
402
+ console.log("\n✓ Nothing missing and nothing behind.");
403
+ return 0;
404
+ }
405
+ console.error("\nUpdates available. Run `claudeup update` to apply.");
406
+ return 1;
407
+ }
408
+
409
+ if (!planHasWork(plan)) {
410
+ console.log(`\nEverything is up to date (${counts.current} items).`);
411
+ return 0;
412
+ }
413
+
414
+ if (!flags.yes && !(await confirm("\nApply?"))) {
415
+ console.log("Aborted.");
416
+ return 0;
417
+ }
418
+
419
+ if (!(await isClaudeAvailable())) {
420
+ console.error(
421
+ "claude CLI not found on PATH — cannot install or update plugins.",
422
+ );
423
+ return 1;
424
+ }
425
+
426
+ // Register any marketplace the profile names but this machine does not know.
427
+ // Without this an install fails with "not found in marketplace" for anything
428
+ // outside claudeup's built-in list, which is every private or third-party one.
429
+ await registerClosureMarketplaces(closure.marketplaces);
430
+
431
+ console.log();
432
+ const pluginResult = await applyPlugins(plan.plugins, projectPath);
433
+ const binResult = await applyBins(plan);
434
+ const skillResult = await applySkills(plan, projectPath);
435
+
436
+ const failed = [
437
+ ...pluginResult.failed,
438
+ ...binResult.failed,
439
+ ...skillResult.failed,
440
+ ];
441
+ const changed = pluginResult.ok + binResult.ok + skillResult.ok;
442
+
443
+ console.log(
444
+ `\n${changed} updated, ${counts.current} already current${
445
+ counts.unknown > 0 ? `, ${counts.unknown} unverified` : ""
446
+ }${failed.length > 0 ? `, ${failed.length} failed` : ""}.`,
447
+ );
448
+
449
+ // An exact pin the installer could not deliver. Reported, never fatal: only
450
+ // the newest published version is installable, so this is a standing property
451
+ // of the manifest rather than something this run did wrong, and failing here
452
+ // would make `claudeup update` exit 1 forever for any profile with a pin.
453
+ // `install --check` with `strictVersions` is where pin drift is GATED.
454
+ if (pluginResult.mismatches.length > 0) {
455
+ console.log(
456
+ `\nNote — ${pluginResult.mismatches.length} pinned version(s) are not what is installed. Only the newest published version can be installed:`,
457
+ );
458
+ for (const m of pluginResult.mismatches) {
459
+ console.log(
460
+ ` ${m.pluginId} (${m.scope}): manifest pins ${m.pinned}, installed ${m.actual ?? "unknown"}`,
461
+ );
462
+ }
463
+ console.log(
464
+ "Recorded what is actually installed. Update the pin to match, or leave it as a record of intent.",
465
+ );
466
+ }
467
+
468
+ if (failed.length > 0) {
469
+ console.error(`Failed: ${failed.join(", ")}`);
470
+ return 1;
471
+ }
472
+ return 0;
85
473
  }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * `claudeup upgrade` — self-update.
3
+ *
4
+ * This is what `claudeup update` used to do. `update` now means what it means
5
+ * in every package manager — bring THIS PROJECT's dependencies up to date —
6
+ * and advancing the tool itself moved here.
7
+ *
8
+ * Detects claudeup installs across bun + npm global, warns on duplicates
9
+ * (active vs shadowed), then re-installs `claudeup@latest` with whichever
10
+ * package manager owns the active binary. Resolves with the child's exit code.
11
+ */
12
+
13
+ import { spawn } from "node:child_process";
14
+
15
+ export async function runUpgradeCommand(): Promise<number> {
16
+ const { execSync } = await import("node:child_process");
17
+ const { existsSync } = await import("node:fs");
18
+
19
+ // Detect all installations of claudeup across package managers
20
+ const installations: Array<{ manager: "bun" | "npm"; path: string }> = [];
21
+
22
+ // Check bun global
23
+ try {
24
+ const bunGlobalBin = execSync("bun pm -g bin", {
25
+ encoding: "utf-8",
26
+ timeout: 5000,
27
+ }).trim();
28
+ const bunPath = `${bunGlobalBin}/claudeup`;
29
+ if (existsSync(bunPath)) installations.push({ manager: "bun", path: bunPath });
30
+ } catch {
31
+ // bun not installed or no global claudeup
32
+ }
33
+
34
+ // Check npm global
35
+ try {
36
+ const npmPrefix = execSync("npm prefix -g", {
37
+ encoding: "utf-8",
38
+ timeout: 5000,
39
+ }).trim();
40
+ const npmPath = `${npmPrefix}/bin/claudeup`;
41
+ if (existsSync(npmPath)) installations.push({ manager: "npm", path: npmPath });
42
+ } catch {
43
+ // npm not installed or no global claudeup
44
+ }
45
+
46
+ // Warn about duplicate installations
47
+ if (installations.length > 1) {
48
+ const activePath = execSync("which claudeup", {
49
+ encoding: "utf-8",
50
+ timeout: 5000,
51
+ }).trim();
52
+
53
+ console.log(`⚠ claudeup is installed via multiple package managers:\n`);
54
+ for (const inst of installations) {
55
+ const tag = inst.path === activePath ? " (active)" : " (shadowed)";
56
+ console.log(` ${inst.manager}: ${inst.path}${tag}`);
57
+ }
58
+ console.log(`\nTo fix, keep one and remove the other:`);
59
+ for (const inst of installations) {
60
+ console.log(` ${inst.manager} uninstall -g claudeup`);
61
+ }
62
+ console.log();
63
+ }
64
+
65
+ // Determine which package manager to use for the update
66
+ let usesBun = false;
67
+ try {
68
+ const claudeupPath = execSync("which claudeup", {
69
+ encoding: "utf-8",
70
+ }).trim();
71
+ usesBun = claudeupPath.includes(".bun") || claudeupPath.includes("bun/bin");
72
+ } catch {
73
+ // If which fails, default to npm
74
+ }
75
+
76
+ const pkgManager = usesBun ? "bun" : "npm";
77
+ console.log(`Updating claudeup using ${pkgManager}...`);
78
+
79
+ const installArgs = ["install", "-g", "claudeup@latest"];
80
+
81
+ return new Promise<number>((resolve) => {
82
+ const proc = spawn(pkgManager, installArgs, {
83
+ stdio: "inherit",
84
+ shell: false, // Avoid shell for security (fixes DEP0190 warning)
85
+ });
86
+ proc.on("exit", (code) => resolve(code ?? 0));
87
+ proc.on("error", () => resolve(1));
88
+ });
89
+ }
@@ -29,7 +29,7 @@ import {
29
29
  clearMarketplaceCache,
30
30
  compareVersions,
31
31
  getAvailablePlugins,
32
- saveInstalledPluginVersion,
32
+ saveInstalledPluginVersionForScope,
33
33
  } from "../services/plugin-manager.js";
34
34
  import {
35
35
  checkPluginVersionMismatches,
@@ -41,27 +41,6 @@ export interface PrerunOptions {
41
41
  force?: boolean; // Bypass cache and force update check
42
42
  }
43
43
 
44
- /**
45
- * Record an installed version in the settings file that owns the given scope.
46
- *
47
- * Claude Code's CLI does not maintain `installedPluginVersions`, so claudeup
48
- * keeps its own copy. Writing it at the WRONG scope is worse than not writing
49
- * it: it makes a scope claim a version it does not have installed.
50
- */
51
- async function saveInstalledPluginVersionForScope(
52
- pluginId: string,
53
- version: string,
54
- scope: PluginScope,
55
- ): Promise<void> {
56
- if (scope === "user") {
57
- await saveGlobalInstalledPluginVersion(pluginId, version);
58
- } else if (scope === "local") {
59
- await saveLocalInstalledPluginVersion(pluginId, version, process.cwd());
60
- } else {
61
- await saveInstalledPluginVersion(pluginId, version, process.cwd());
62
- }
63
- }
64
-
65
44
  const CONTINUITY_PLUGIN_SENTINEL = "tmux-claude-continuity";
66
45
  const CONTINUITY_PLUGIN_SCRIPT = path.join(
67
46
  os.homedir(),