claudeup 4.31.0 → 4.32.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 +4 -4
- package/src/__tests__/content-drift.test.ts +152 -0
- package/src/__tests__/dual-write-prevention.test.ts +151 -8
- package/src/__tests__/marketplace-refresh.test.ts +7 -2
- package/src/__tests__/moved-marketplace.test.ts +87 -0
- package/src/__tests__/scope-action.test.ts +82 -0
- package/src/__tests__/version-snapshot.test.ts +85 -0
- package/src/prerunner/index.ts +152 -6
- package/src/services/claude-cli.ts +31 -0
- package/src/services/claude-settings.ts +17 -1
- package/src/services/content-drift.ts +121 -0
- package/src/services/marketplace-refresh.ts +15 -3
- package/src/services/plugin-manager.ts +144 -2
- package/src/services/version-snapshot.ts +57 -8
- package/src/ui/renderers/pluginRenderers.tsx +116 -52
- package/src/ui/screens/PluginsScreen.tsx +100 -45
package/src/prerunner/index.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import os from "node:os";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import fs from "fs-extra";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
isClaudeAvailable,
|
|
6
|
+
type PluginScope,
|
|
7
|
+
repairPlugin,
|
|
8
|
+
updatePlugin,
|
|
9
|
+
} from "../services/claude-cli.js";
|
|
5
10
|
import { runClaude } from "../services/claude-runner.js";
|
|
6
11
|
import {
|
|
7
12
|
cleanupExtraKnownMarketplaces,
|
|
@@ -10,6 +15,7 @@ import {
|
|
|
10
15
|
readGlobalSettings,
|
|
11
16
|
recoverMarketplaceSettings,
|
|
12
17
|
saveGlobalInstalledPluginVersion,
|
|
18
|
+
saveLocalInstalledPluginVersion,
|
|
13
19
|
writeGlobalSettings,
|
|
14
20
|
} from "../services/claude-settings.js";
|
|
15
21
|
import {
|
|
@@ -18,9 +24,12 @@ import {
|
|
|
18
24
|
} from "../services/gitignore-prerun.js";
|
|
19
25
|
import { refreshRegisteredMarketplaces } from "../services/marketplace-refresh.js";
|
|
20
26
|
import { autoAddMissingMarketplaces } from "../services/marketplace-sync.js";
|
|
27
|
+
import { clearContentDriftCache } from "../services/content-drift.js";
|
|
21
28
|
import {
|
|
22
29
|
clearMarketplaceCache,
|
|
30
|
+
compareVersions,
|
|
23
31
|
getAvailablePlugins,
|
|
32
|
+
saveInstalledPluginVersion,
|
|
24
33
|
} from "../services/plugin-manager.js";
|
|
25
34
|
import {
|
|
26
35
|
checkPluginVersionMismatches,
|
|
@@ -32,6 +41,27 @@ export interface PrerunOptions {
|
|
|
32
41
|
force?: boolean; // Bypass cache and force update check
|
|
33
42
|
}
|
|
34
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
|
+
|
|
35
65
|
const CONTINUITY_PLUGIN_SENTINEL = "tmux-claude-continuity";
|
|
36
66
|
const CONTINUITY_PLUGIN_SCRIPT = path.join(
|
|
37
67
|
os.homedir(),
|
|
@@ -290,12 +320,28 @@ export async function prerunClaude(
|
|
|
290
320
|
`✓ Refreshed marketplaces: ${mpRefresh.refreshed.join(", ")}`,
|
|
291
321
|
);
|
|
292
322
|
}
|
|
323
|
+
if (mpRefresh.autoUpdateDisabled.length > 0) {
|
|
324
|
+
// Silence here is what let a clone sit 11 days behind: the catalog
|
|
325
|
+
// stops moving, so every plugin from that marketplace reads as up
|
|
326
|
+
// to date and no update is ever offered.
|
|
327
|
+
for (const name of mpRefresh.autoUpdateDisabled) {
|
|
328
|
+
console.log(
|
|
329
|
+
`⚠ ${name}: auto-update disabled — its plugin catalog will not refresh, so updates stay hidden.`,
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
console.log(
|
|
333
|
+
` Re-enable with: claude plugin marketplace update ${mpRefresh.autoUpdateDisabled[0]} (or set autoUpdate:true in ~/.claude/plugins/known_marketplaces.json)`,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
293
336
|
for (const name of mpRefresh.failed) {
|
|
294
337
|
console.warn(`⚠ Failed to refresh marketplace: ${name}`);
|
|
295
338
|
}
|
|
296
339
|
|
|
297
340
|
// STEP 2: Clear cache to force fresh plugin info
|
|
298
341
|
clearMarketplaceCache();
|
|
342
|
+
// The marketplace refresh above may have moved each clone's HEAD, and
|
|
343
|
+
// drift answers are relative to HEAD.
|
|
344
|
+
clearContentDriftCache();
|
|
299
345
|
|
|
300
346
|
// STEP 3: Get updated plugin info (to detect versions)
|
|
301
347
|
const plugins = await getAvailablePlugins();
|
|
@@ -305,6 +351,13 @@ export async function prerunClaude(
|
|
|
305
351
|
pluginId: string;
|
|
306
352
|
oldVersion: string;
|
|
307
353
|
newVersion: string;
|
|
354
|
+
/** Scopes actually updated — never assume "user". */
|
|
355
|
+
scopes: ReadonlyArray<PluginScope>;
|
|
356
|
+
}> = [];
|
|
357
|
+
/** Plugins whose files drifted under an unchanged version. */
|
|
358
|
+
const repairedPlugins: Array<{
|
|
359
|
+
pluginId: string;
|
|
360
|
+
scope: PluginScope;
|
|
308
361
|
}> = [];
|
|
309
362
|
|
|
310
363
|
const cliAvailable = await isClaudeAvailable();
|
|
@@ -314,6 +367,43 @@ export async function prerunClaude(
|
|
|
314
367
|
// 1. Plugin is enabled
|
|
315
368
|
// 2. Plugin has an update available
|
|
316
369
|
// 3. Plugin has both installedVersion and version (newVersion)
|
|
370
|
+
// A content-stale plugin has NO version bump to detect — its files
|
|
371
|
+
// changed under an unchanged version — so it must be repaired
|
|
372
|
+
// (uninstall+install) rather than updated. Auto-fixing broken state
|
|
373
|
+
// without human interaction is claudeup's job; leaving a plugin
|
|
374
|
+
// silently running deleted skills is exactly the state it exists
|
|
375
|
+
// to prevent.
|
|
376
|
+
if (
|
|
377
|
+
plugin.enabled &&
|
|
378
|
+
!plugin.hasUpdate &&
|
|
379
|
+
plugin.contentStale &&
|
|
380
|
+
plugin.installedVersion
|
|
381
|
+
) {
|
|
382
|
+
if (!cliAvailable) continue;
|
|
383
|
+
const staleScopes = (
|
|
384
|
+
[
|
|
385
|
+
["user", plugin.userScope],
|
|
386
|
+
["project", plugin.projectScope],
|
|
387
|
+
["local", plugin.localScope],
|
|
388
|
+
] as const
|
|
389
|
+
)
|
|
390
|
+
.filter(([, status]) => !!status?.version)
|
|
391
|
+
.map(([scope]) => scope);
|
|
392
|
+
|
|
393
|
+
for (const scope of staleScopes) {
|
|
394
|
+
try {
|
|
395
|
+
await repairPlugin(plugin.id, scope, process.cwd());
|
|
396
|
+
repairedPlugins.push({ pluginId: plugin.id, scope });
|
|
397
|
+
} catch (error) {
|
|
398
|
+
console.warn(
|
|
399
|
+
`⚠ Failed to repair ${plugin.id} (${scope}):`,
|
|
400
|
+
error instanceof Error ? error.message : "Unknown error",
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
|
|
317
407
|
if (
|
|
318
408
|
plugin.enabled &&
|
|
319
409
|
plugin.hasUpdate &&
|
|
@@ -325,14 +415,51 @@ export async function prerunClaude(
|
|
|
325
415
|
// CLI unavailable — skip update entirely, don't write phantom state
|
|
326
416
|
continue;
|
|
327
417
|
}
|
|
328
|
-
|
|
329
|
-
//
|
|
330
|
-
|
|
418
|
+
|
|
419
|
+
// Update every scope that actually holds an outdated install.
|
|
420
|
+
//
|
|
421
|
+
// This used to be a hardcoded `updatePlugin(plugin.id, "user")`,
|
|
422
|
+
// which was wrong for almost every real install: `hasUpdate` is
|
|
423
|
+
// computed from the CURRENT PROJECT's resolved version, while
|
|
424
|
+
// installs live per project in installed_plugins.json. On this
|
|
425
|
+
// machine 495 of 496 magus installs are project-scoped, so the
|
|
426
|
+
// prerunner detected project drift, "fixed" it at user scope,
|
|
427
|
+
// reported success — and left the project row untouched. Next run
|
|
428
|
+
// it detected the same drift again. The update never converged,
|
|
429
|
+
// which is what "auto-update ran but an update is still available"
|
|
430
|
+
// actually was.
|
|
431
|
+
const outdatedScopes = (
|
|
432
|
+
[
|
|
433
|
+
["user", plugin.userScope],
|
|
434
|
+
["project", plugin.projectScope],
|
|
435
|
+
["local", plugin.localScope],
|
|
436
|
+
] as const
|
|
437
|
+
)
|
|
438
|
+
.filter(
|
|
439
|
+
([, status]) =>
|
|
440
|
+
!!status?.version &&
|
|
441
|
+
compareVersions(plugin.version, status.version) > 0,
|
|
442
|
+
)
|
|
443
|
+
.map(([scope]) => scope);
|
|
444
|
+
|
|
445
|
+
if (outdatedScopes.length === 0) continue;
|
|
446
|
+
|
|
447
|
+
for (const scope of outdatedScopes) {
|
|
448
|
+
await updatePlugin(plugin.id, scope);
|
|
449
|
+
// The CLI does not maintain installedPluginVersions, so keep
|
|
450
|
+
// claudeup's own copy in step for the scope we just touched.
|
|
451
|
+
await saveInstalledPluginVersionForScope(
|
|
452
|
+
plugin.id,
|
|
453
|
+
plugin.version,
|
|
454
|
+
scope,
|
|
455
|
+
);
|
|
456
|
+
}
|
|
331
457
|
|
|
332
458
|
autoUpdatedPlugins.push({
|
|
333
459
|
pluginId: plugin.id,
|
|
334
460
|
oldVersion: plugin.installedVersion,
|
|
335
461
|
newVersion: plugin.version,
|
|
462
|
+
scopes: outdatedScopes,
|
|
336
463
|
});
|
|
337
464
|
} catch (error) {
|
|
338
465
|
// Non-fatal: Log warning and continue
|
|
@@ -357,8 +484,27 @@ export async function prerunClaude(
|
|
|
357
484
|
// STEP 6: Display auto-update summary
|
|
358
485
|
if (autoUpdatedPlugins.length > 0) {
|
|
359
486
|
console.log(`✓ Auto-updated ${autoUpdatedPlugins.length} plugin(s):`);
|
|
360
|
-
for (const {
|
|
361
|
-
|
|
487
|
+
for (const {
|
|
488
|
+
pluginId,
|
|
489
|
+
oldVersion,
|
|
490
|
+
newVersion,
|
|
491
|
+
scopes,
|
|
492
|
+
} of autoUpdatedPlugins) {
|
|
493
|
+
// Name the scope. "Auto-updated" that silently touched only user
|
|
494
|
+
// scope, while the project stayed behind, is exactly how this became
|
|
495
|
+
// untrustworthy.
|
|
496
|
+
console.log(
|
|
497
|
+
` - ${pluginId}: ${oldVersion} → ${newVersion} (${scopes.join(", ")})`,
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (repairedPlugins.length > 0) {
|
|
503
|
+
console.log(
|
|
504
|
+
`✓ Repaired ${repairedPlugins.length} plugin(s) whose files changed without a version bump:`,
|
|
505
|
+
);
|
|
506
|
+
for (const { pluginId, scope } of repairedPlugins) {
|
|
507
|
+
console.log(` - ${pluginId} (${scope})`);
|
|
362
508
|
}
|
|
363
509
|
}
|
|
364
510
|
}
|
|
@@ -160,6 +160,37 @@ export async function uninstallPlugin(
|
|
|
160
160
|
}
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Repair a plugin whose files drifted from the marketplace under an unchanged
|
|
165
|
+
* version — uninstall, then install again at the same scope.
|
|
166
|
+
*
|
|
167
|
+
* `claude plugin install` cannot do this alone. Measured against the real
|
|
168
|
+
* `MadAppGang/magus` marketplace (autotest/plugin-system/probe-05): with the
|
|
169
|
+
* version unchanged it reports *"Plugin is already installed"* and copies
|
|
170
|
+
* nothing — cache content and the recorded `gitCommitSha` both stay put.
|
|
171
|
+
* Uninstall-then-install refreshes both. That is why the only cure users ever
|
|
172
|
+
* found was removing the plugin and adding it back.
|
|
173
|
+
*
|
|
174
|
+
* The window between the two calls is real: if the install fails, the plugin is
|
|
175
|
+
* left uninstalled. The caller must surface that rather than swallow it, which
|
|
176
|
+
* is why the install error is rethrown with the state spelled out.
|
|
177
|
+
*/
|
|
178
|
+
export async function repairPlugin(
|
|
179
|
+
pluginId: string,
|
|
180
|
+
scope: PluginScope = "user",
|
|
181
|
+
projectPath?: string,
|
|
182
|
+
): Promise<void> {
|
|
183
|
+
await uninstallPlugin(pluginId, scope, projectPath);
|
|
184
|
+
try {
|
|
185
|
+
await execClaude(["plugin", "install", pluginId, "--scope", scope], 60000);
|
|
186
|
+
} catch (error) {
|
|
187
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
188
|
+
throw new Error(
|
|
189
|
+
`${pluginId} was uninstalled from ${scope} scope but could not be reinstalled: ${msg}. Reinstall it manually with: claude plugin install ${pluginId} --scope ${scope}`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
163
194
|
/**
|
|
164
195
|
* Enable a previously disabled plugin
|
|
165
196
|
*/
|
|
@@ -302,7 +302,23 @@ async function overlayRegistryVersions(
|
|
|
302
302
|
const merged = { ...base };
|
|
303
303
|
for (const [pluginId, entries] of Object.entries(registry.plugins ?? {})) {
|
|
304
304
|
const pick = pickRegistryEntry(entries, scope, projectPath);
|
|
305
|
-
if (pick?.version)
|
|
305
|
+
if (pick?.version) {
|
|
306
|
+
merged[pluginId] = pick.version;
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// The registry knows this plugin but has no entry for this scope, so it
|
|
311
|
+
// is NOT installed here. Any leftover `installedPluginVersions` value is
|
|
312
|
+
// a claim about an install that does not exist.
|
|
313
|
+
//
|
|
314
|
+
// Claude Code never writes that field — it appears 0 times in the 2.1.223
|
|
315
|
+
// binary, and `claude plugin install` writes it at no scope — so only
|
|
316
|
+
// claudeup's own past writes can be in there, and they go stale silently.
|
|
317
|
+
// Measured: user settings claimed browser-use 1.1.2 / terminal 4.0.2 /
|
|
318
|
+
// code-analysis 5.1.0 while the registry said 1.4.0 / 4.1.4 / 5.3.1, and
|
|
319
|
+
// none of the claimed versions had a cache directory. Keeping the stale
|
|
320
|
+
// value made every one of them read as a permanent pending update.
|
|
321
|
+
if (entries && entries.length > 0) delete merged[pluginId];
|
|
306
322
|
}
|
|
307
323
|
return merged;
|
|
308
324
|
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* content-drift.ts — detect a plugin whose files changed without its version.
|
|
3
|
+
*
|
|
4
|
+
* The whole update system keys off one signal: the version string.
|
|
5
|
+
* `hasUpdate` is `compareVersions(catalogVersion, installedVersion) > 0`, so a
|
|
6
|
+
* marketplace that republishes different content under an unchanged version is
|
|
7
|
+
* invisible. No update is offered, and the only cure is a reinstall — which is
|
|
8
|
+
* exactly the "plugin says installed but its skills are missing" report.
|
|
9
|
+
*
|
|
10
|
+
* This is not a hypothetical. `publish-dist.sh` force-pushes a rebuilt tree, so
|
|
11
|
+
* same-version-different-content is a normal outcome of the release process.
|
|
12
|
+
* Measured on 2026-08-06: `cache/magus/dev/3.0.1/skills` held
|
|
13
|
+
* {audit, tui-progress} while `marketplaces/magus/plugins/dev/skills` held
|
|
14
|
+
* {security-audit} — both stamped 3.0.1.
|
|
15
|
+
*
|
|
16
|
+
* The fix uses a signal already present in `installed_plugins.json`: each entry
|
|
17
|
+
* records `gitCommitSha`, the marketplace commit it was installed from. Asking
|
|
18
|
+
* git whether THIS PLUGIN'S SUBTREE changed between that commit and the
|
|
19
|
+
* marketplace's current HEAD answers the question exactly.
|
|
20
|
+
*
|
|
21
|
+
* Comparing whole-marketplace SHAs would be wrong: any commit to any plugin
|
|
22
|
+
* would flag every plugin as drifted. The subtree scope is what makes this
|
|
23
|
+
* precise enough to act on.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { spawn } from "node:child_process";
|
|
27
|
+
import os from "node:os";
|
|
28
|
+
import path from "node:path";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolved per call, honouring CLAUDE_CONFIG_DIR — the same override Claude
|
|
32
|
+
* Code uses. A module-level constant would bake in `os.homedir()` at import
|
|
33
|
+
* time, which on macOS ignores $HOME and makes the service impossible to test
|
|
34
|
+
* without touching the operator's real marketplaces.
|
|
35
|
+
*/
|
|
36
|
+
function marketplacesDir(): string {
|
|
37
|
+
const configDir =
|
|
38
|
+
process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
|
|
39
|
+
return path.join(configDir, "plugins", "marketplaces");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Session cache. Keyed by marketplace + installed sha + plugin, because the
|
|
44
|
+
* answer cannot change while claudeup is open — the marketplace clone is only
|
|
45
|
+
* refreshed by Claude Code, out of process.
|
|
46
|
+
*/
|
|
47
|
+
const cache = new Map<string, boolean>();
|
|
48
|
+
|
|
49
|
+
export function clearContentDriftCache(): void {
|
|
50
|
+
cache.clear();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function git(cwd: string, args: string[]): Promise<{ code: number; out: string }> {
|
|
54
|
+
return new Promise((resolve) => {
|
|
55
|
+
const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"] });
|
|
56
|
+
let out = "";
|
|
57
|
+
child.stdout.on("data", (d) => {
|
|
58
|
+
out += String(d);
|
|
59
|
+
});
|
|
60
|
+
child.on("error", () => resolve({ code: -1, out: "" }));
|
|
61
|
+
child.on("close", (code) => resolve({ code: code ?? -1, out: out.trim() }));
|
|
62
|
+
// Never let a wedged git block the UI.
|
|
63
|
+
setTimeout(() => {
|
|
64
|
+
child.kill();
|
|
65
|
+
resolve({ code: -1, out: "" });
|
|
66
|
+
}, 5000).unref?.();
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface DriftQuery {
|
|
71
|
+
marketplace: string;
|
|
72
|
+
/** Plugin name without the @marketplace suffix. */
|
|
73
|
+
pluginName: string;
|
|
74
|
+
/** `gitCommitSha` from the installed_plugins.json entry. */
|
|
75
|
+
installedSha: string | undefined;
|
|
76
|
+
/**
|
|
77
|
+
* Path of the plugin inside the marketplace repo, from the catalog `source`
|
|
78
|
+
* field (e.g. "./plugins/dev"). Defaults to `plugins/<name>`.
|
|
79
|
+
*/
|
|
80
|
+
sourcePath?: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* True when the plugin's files in the marketplace differ from the commit it was
|
|
85
|
+
* installed from — i.e. a reinstall would deliver different content.
|
|
86
|
+
*
|
|
87
|
+
* Returns false whenever the question cannot be answered honestly: no recorded
|
|
88
|
+
* sha, no clone, git unavailable, or the recorded commit is absent locally
|
|
89
|
+
* (force-push, shallow clone). A false negative leaves today's behaviour; a
|
|
90
|
+
* false positive would nag the user to reinstall for no reason.
|
|
91
|
+
*/
|
|
92
|
+
export async function hasContentDrift(q: DriftQuery): Promise<boolean> {
|
|
93
|
+
if (!q.installedSha) return false;
|
|
94
|
+
|
|
95
|
+
const key = `${q.marketplace}\0${q.pluginName}\0${q.installedSha}`;
|
|
96
|
+
const hit = cache.get(key);
|
|
97
|
+
if (hit !== undefined) return hit;
|
|
98
|
+
|
|
99
|
+
const repo = path.join(marketplacesDir(), q.marketplace);
|
|
100
|
+
const rel = (q.sourcePath ?? `plugins/${q.pluginName}`).replace(/^\.\//, "");
|
|
101
|
+
|
|
102
|
+
// The recorded commit must exist locally, or the diff is meaningless.
|
|
103
|
+
const known = await git(repo, ["cat-file", "-e", `${q.installedSha}^{commit}`]);
|
|
104
|
+
if (known.code !== 0) {
|
|
105
|
+
cache.set(key, false);
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Exit 0 = no difference, 1 = differs, anything else = could not tell.
|
|
110
|
+
const diff = await git(repo, [
|
|
111
|
+
"diff",
|
|
112
|
+
"--quiet",
|
|
113
|
+
q.installedSha,
|
|
114
|
+
"HEAD",
|
|
115
|
+
"--",
|
|
116
|
+
rel,
|
|
117
|
+
]);
|
|
118
|
+
const drifted = diff.code === 1;
|
|
119
|
+
cache.set(key, drifted);
|
|
120
|
+
return drifted;
|
|
121
|
+
}
|
|
@@ -34,8 +34,19 @@ export interface RefreshResult {
|
|
|
34
34
|
refreshed: string[];
|
|
35
35
|
/** Pull errored (network, diverged, timeout) — clone left intact and stale. */
|
|
36
36
|
failed: string[];
|
|
37
|
-
/** Nothing to do: no clone on disk, not a git repo, dirty tree
|
|
37
|
+
/** Nothing to do: no clone on disk, not a git repo, or dirty tree. */
|
|
38
38
|
skipped: string[];
|
|
39
|
+
/**
|
|
40
|
+
* Skipped specifically because `autoUpdate: false` in known_marketplaces.json.
|
|
41
|
+
*
|
|
42
|
+
* Called out separately from `skipped` because the consequence is invisible
|
|
43
|
+
* and open-ended: the clone is the catalog, so a marketplace opted out here
|
|
44
|
+
* never learns about new plugin versions, and every plugin from it reads as
|
|
45
|
+
* up to date forever. The reporter's `magus` clone sat 11 days behind this
|
|
46
|
+
* way — Claude Code would not refresh it, and this function skipped it too,
|
|
47
|
+
* with nothing printed either time.
|
|
48
|
+
*/
|
|
49
|
+
autoUpdateDisabled: string[];
|
|
39
50
|
}
|
|
40
51
|
|
|
41
52
|
function cloneDir(name: string): string {
|
|
@@ -136,6 +147,7 @@ export async function refreshRegisteredMarketplaces(
|
|
|
136
147
|
const refreshed: string[] = [];
|
|
137
148
|
const failed: string[] = [];
|
|
138
149
|
const skipped: string[] = [];
|
|
150
|
+
const autoUpdateDisabled: string[] = [];
|
|
139
151
|
|
|
140
152
|
const configured = await getConfiguredMarketplaces();
|
|
141
153
|
const skipSet = new Set(skip);
|
|
@@ -149,7 +161,7 @@ export async function refreshRegisteredMarketplaces(
|
|
|
149
161
|
continue;
|
|
150
162
|
}
|
|
151
163
|
if ((await getMarketplaceAutoUpdate(name)) === false) {
|
|
152
|
-
|
|
164
|
+
autoUpdateDisabled.push(name);
|
|
153
165
|
continue;
|
|
154
166
|
}
|
|
155
167
|
eligible.push(name);
|
|
@@ -169,5 +181,5 @@ export async function refreshRegisteredMarketplaces(
|
|
|
169
181
|
else skipped.push(name); // absent | skipped
|
|
170
182
|
});
|
|
171
183
|
|
|
172
|
-
return { refreshed, failed, skipped };
|
|
184
|
+
return { refreshed, failed, skipped, autoUpdateDisabled };
|
|
173
185
|
}
|
|
@@ -12,11 +12,17 @@ import {
|
|
|
12
12
|
getLocalEnabledPlugins,
|
|
13
13
|
getLocalInstalledPluginVersions,
|
|
14
14
|
getProjectInstalledPluginVersions,
|
|
15
|
+
pickRegistryEntry,
|
|
16
|
+
readInstalledPluginsRegistry,
|
|
15
17
|
updateInstalledPluginsRegistry,
|
|
16
18
|
removeFromInstalledPluginsRegistry,
|
|
17
19
|
} from "./claude-settings.js";
|
|
20
|
+
import { hasContentDrift } from "./content-drift.js";
|
|
18
21
|
import { defaultMarketplaces } from "../data/marketplaces.js";
|
|
19
|
-
import type {
|
|
22
|
+
import type {
|
|
23
|
+
InstalledPluginsRegistry,
|
|
24
|
+
PluginRelease,
|
|
25
|
+
} from "../types/index.js";
|
|
20
26
|
import {
|
|
21
27
|
scanLocalMarketplaces,
|
|
22
28
|
repairAllMarketplaces,
|
|
@@ -75,6 +81,23 @@ export interface PluginInfo {
|
|
|
75
81
|
mcpServers?: string[];
|
|
76
82
|
lspServers?: Record<string, unknown>;
|
|
77
83
|
isOrphaned?: boolean;
|
|
84
|
+
/**
|
|
85
|
+
* Set when the installed version equals the catalog version, but the
|
|
86
|
+
* plugin's files in the marketplace have changed since the commit it was
|
|
87
|
+
* installed from. The version compare says "up to date" and is wrong: only
|
|
88
|
+
* a reinstall delivers the current content. See content-drift.ts.
|
|
89
|
+
*/
|
|
90
|
+
contentStale?: boolean;
|
|
91
|
+
/**
|
|
92
|
+
* For an orphaned plugin: another configured marketplace that publishes a
|
|
93
|
+
* plugin of the same name — i.e. it did not disappear, it moved.
|
|
94
|
+
*
|
|
95
|
+
* Splitting `magus` into `magus` + `magus-marketing` left seo, instantly,
|
|
96
|
+
* video-editing and image-generate installed under a namespace that no
|
|
97
|
+
* longer lists them. Rendered as bare "deprecated", the only offered action
|
|
98
|
+
* was deletion, which silently drops a plugin that is still published.
|
|
99
|
+
*/
|
|
100
|
+
movedTo?: string;
|
|
78
101
|
/**
|
|
79
102
|
* Set when this plugin's installed version changed since claudeup last
|
|
80
103
|
* rendered it — including updates made by Claude Code or the prerunner
|
|
@@ -112,6 +135,48 @@ export function isEnabledButNotInstalled(plugin: PluginInfo): boolean {
|
|
|
112
135
|
return enabledSomewhere && !plugin.installedVersion;
|
|
113
136
|
}
|
|
114
137
|
|
|
138
|
+
/**
|
|
139
|
+
* True when a scope genuinely has the plugin installed.
|
|
140
|
+
*
|
|
141
|
+
* `enabled` on its own is not proof. It is the `enabledPlugins` flag out of a
|
|
142
|
+
* settings file, and only claudeup and the user maintain it; `version` comes
|
|
143
|
+
* from `installed_plugins.json` via `overlayRegistryVersions`, which is what
|
|
144
|
+
* Claude Code actually loaded. When the two disagree — flag set, no registry
|
|
145
|
+
* entry — the plugin is in the broken enabled-but-not-installed state that the
|
|
146
|
+
* list already renders as "not installed".
|
|
147
|
+
*
|
|
148
|
+
* Every action that touches the filesystem must use this, not the flag alone.
|
|
149
|
+
* Deciding on the flag is what made the scope keys uninstall a plugin the row
|
|
150
|
+
* had just labelled "not installed".
|
|
151
|
+
*
|
|
152
|
+
* A version of "0.0.0" still counts as installed — Anthropic's official plugins
|
|
153
|
+
* record exactly that — which is why this tests presence, not `isKnownVersion`.
|
|
154
|
+
*/
|
|
155
|
+
export function isInstalledInScope(scope: ScopeStatus | undefined): boolean {
|
|
156
|
+
return !!scope?.enabled && !!scope.version;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* What a scope toggle (`u` / `p` / `l`, or the Enter scope picker) should do.
|
|
161
|
+
*
|
|
162
|
+
* `install` doubles as the repair for enabled-but-not-installed. Measured
|
|
163
|
+
* against Claude Code 2.1.223 in exactly that state — `dev@magus` flagged in
|
|
164
|
+
* `.claude/settings.json` with no registry entry for the project path —
|
|
165
|
+
* `claude plugin install dev@magus --scope project` installed it normally and
|
|
166
|
+
* wrote the registry entry. It does not report "already installed", so this
|
|
167
|
+
* needs no uninstall-first dance (unlike content drift, see repairPlugin).
|
|
168
|
+
*/
|
|
169
|
+
export function resolveScopeAction(
|
|
170
|
+
scope: ScopeStatus | undefined,
|
|
171
|
+
latestVersion: string,
|
|
172
|
+
): "install" | "update" | "uninstall" {
|
|
173
|
+
if (!isInstalledInScope(scope)) return "install";
|
|
174
|
+
const installed = scope?.version;
|
|
175
|
+
const hasUpdate =
|
|
176
|
+
!!installed && latestVersion !== "0.0.0" && installed !== latestVersion;
|
|
177
|
+
return hasUpdate ? "update" : "uninstall";
|
|
178
|
+
}
|
|
179
|
+
|
|
115
180
|
|
|
116
181
|
export async function getAvailablePlugins(
|
|
117
182
|
projectPath?: string,
|
|
@@ -282,6 +347,9 @@ export async function getAvailablePlugins(
|
|
|
282
347
|
// Try to get plugin info from local marketplace cache (fallback)
|
|
283
348
|
const localMp = localMarketplaces.get(mpName);
|
|
284
349
|
const localPlugin = localMp?.plugins.find((p) => p.name === pluginName);
|
|
350
|
+
const movedTo = localPlugin
|
|
351
|
+
? undefined
|
|
352
|
+
: findPluginInOtherMarketplace(pluginName, mpName, localMarketplaces);
|
|
285
353
|
|
|
286
354
|
const latestVersion = localPlugin?.version || installedVersion || "unknown";
|
|
287
355
|
const description = localPlugin?.description || "Installed plugin";
|
|
@@ -301,10 +369,12 @@ export async function getAvailablePlugins(
|
|
|
301
369
|
installedVersion: installedVersion,
|
|
302
370
|
hasUpdate,
|
|
303
371
|
isOrphaned: true,
|
|
372
|
+
movedTo,
|
|
304
373
|
...scopeStatus,
|
|
305
374
|
});
|
|
306
375
|
}
|
|
307
376
|
|
|
377
|
+
await annotateContentDrift(plugins, "project", projectPath);
|
|
308
378
|
return plugins;
|
|
309
379
|
}
|
|
310
380
|
|
|
@@ -474,6 +544,9 @@ export async function getGlobalAvailablePlugins(): Promise<PluginInfo[]> {
|
|
|
474
544
|
// Try to get plugin info from local marketplace cache (fallback)
|
|
475
545
|
const localMp = localMarketplaces.get(mpName);
|
|
476
546
|
const localPlugin = localMp?.plugins.find((p) => p.name === pluginName);
|
|
547
|
+
const movedTo = localPlugin
|
|
548
|
+
? undefined
|
|
549
|
+
: findPluginInOtherMarketplace(pluginName, mpName, localMarketplaces);
|
|
477
550
|
|
|
478
551
|
const latestVersion = localPlugin?.version || installedVersion || "unknown";
|
|
479
552
|
const description = localPlugin?.description || "Installed plugin";
|
|
@@ -494,14 +567,83 @@ export async function getGlobalAvailablePlugins(): Promise<PluginInfo[]> {
|
|
|
494
567
|
installedVersion: installedVersion,
|
|
495
568
|
hasUpdate,
|
|
496
569
|
isOrphaned: true,
|
|
570
|
+
movedTo,
|
|
497
571
|
});
|
|
498
572
|
}
|
|
499
573
|
|
|
574
|
+
await annotateContentDrift(plugins, "user");
|
|
500
575
|
return plugins;
|
|
501
576
|
}
|
|
502
577
|
|
|
578
|
+
/**
|
|
579
|
+
* Find another configured marketplace that still publishes this plugin name.
|
|
580
|
+
*
|
|
581
|
+
* Answers "did it disappear, or did it move?" for an orphaned plugin, so the
|
|
582
|
+
* UI can offer migration rather than only deletion.
|
|
583
|
+
*/
|
|
584
|
+
function findPluginInOtherMarketplace(
|
|
585
|
+
pluginName: string,
|
|
586
|
+
currentMarketplace: string,
|
|
587
|
+
localMarketplaces: Map<string, LocalMarketplace>,
|
|
588
|
+
): string | undefined {
|
|
589
|
+
for (const [name, mp] of localMarketplaces) {
|
|
590
|
+
if (name === currentMarketplace) continue;
|
|
591
|
+
if (mp.plugins.some((p) => p.name === pluginName)) return name;
|
|
592
|
+
}
|
|
593
|
+
return undefined;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Flag plugins whose files moved without their version moving.
|
|
598
|
+
*
|
|
599
|
+
* Runs after the plugin list is assembled so every code path that builds a
|
|
600
|
+
* PluginInfo gets the same treatment. Only plugins that already look "up to
|
|
601
|
+
* date" are worth checking — anything with a pending version bump will be
|
|
602
|
+
* reinstalled by that update anyway.
|
|
603
|
+
*
|
|
604
|
+
* Best-effort throughout: a plugin whose drift cannot be determined is left
|
|
605
|
+
* exactly as it is today.
|
|
606
|
+
*/
|
|
607
|
+
async function annotateContentDrift(
|
|
608
|
+
plugins: PluginInfo[],
|
|
609
|
+
scope: "user" | "project" | "local",
|
|
610
|
+
projectPath?: string,
|
|
611
|
+
): Promise<void> {
|
|
612
|
+
let registry: InstalledPluginsRegistry;
|
|
613
|
+
try {
|
|
614
|
+
registry = await readInstalledPluginsRegistry();
|
|
615
|
+
} catch {
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
await Promise.all(
|
|
620
|
+
plugins.map(async (plugin) => {
|
|
621
|
+
if (plugin.hasUpdate || plugin.isOrphaned) return;
|
|
622
|
+
if (!plugin.installedVersion || !plugin.version) return;
|
|
623
|
+
if (compareVersions(plugin.version, plugin.installedVersion) !== 0) return;
|
|
624
|
+
|
|
625
|
+
const entry = pickRegistryEntry(
|
|
626
|
+
registry.plugins[plugin.id],
|
|
627
|
+
scope,
|
|
628
|
+
projectPath,
|
|
629
|
+
);
|
|
630
|
+
if (!entry?.gitCommitSha) return;
|
|
631
|
+
|
|
632
|
+
try {
|
|
633
|
+
plugin.contentStale = await hasContentDrift({
|
|
634
|
+
marketplace: plugin.marketplace,
|
|
635
|
+
pluginName: plugin.name,
|
|
636
|
+
installedSha: entry.gitCommitSha,
|
|
637
|
+
});
|
|
638
|
+
} catch {
|
|
639
|
+
/* leave unflagged */
|
|
640
|
+
}
|
|
641
|
+
}),
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
|
|
503
645
|
// Simple version comparison (returns 1 if a > b, -1 if a < b, 0 if equal)
|
|
504
|
-
function compareVersions(
|
|
646
|
+
export function compareVersions(
|
|
505
647
|
a: string | null | undefined,
|
|
506
648
|
b: string | null | undefined,
|
|
507
649
|
): number {
|