claudeup 4.30.0 → 4.32.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/README.md +98 -92
- 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__/profile-materializer.test.ts +87 -0
- package/src/__tests__/version-snapshot.test.ts +85 -0
- package/src/cli/install.ts +36 -5
- package/src/cli/router.ts +17 -9
- 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 +102 -2
- package/src/services/profile-materializer.ts +55 -4
- package/src/services/version-snapshot.ts +57 -8
- package/src/ui/renderers/pluginRenderers.tsx +20 -4
- package/src/ui/screens/PluginsScreen.tsx +82 -10
|
@@ -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
|
|
@@ -282,6 +305,9 @@ export async function getAvailablePlugins(
|
|
|
282
305
|
// Try to get plugin info from local marketplace cache (fallback)
|
|
283
306
|
const localMp = localMarketplaces.get(mpName);
|
|
284
307
|
const localPlugin = localMp?.plugins.find((p) => p.name === pluginName);
|
|
308
|
+
const movedTo = localPlugin
|
|
309
|
+
? undefined
|
|
310
|
+
: findPluginInOtherMarketplace(pluginName, mpName, localMarketplaces);
|
|
285
311
|
|
|
286
312
|
const latestVersion = localPlugin?.version || installedVersion || "unknown";
|
|
287
313
|
const description = localPlugin?.description || "Installed plugin";
|
|
@@ -301,10 +327,12 @@ export async function getAvailablePlugins(
|
|
|
301
327
|
installedVersion: installedVersion,
|
|
302
328
|
hasUpdate,
|
|
303
329
|
isOrphaned: true,
|
|
330
|
+
movedTo,
|
|
304
331
|
...scopeStatus,
|
|
305
332
|
});
|
|
306
333
|
}
|
|
307
334
|
|
|
335
|
+
await annotateContentDrift(plugins, "project", projectPath);
|
|
308
336
|
return plugins;
|
|
309
337
|
}
|
|
310
338
|
|
|
@@ -474,6 +502,9 @@ export async function getGlobalAvailablePlugins(): Promise<PluginInfo[]> {
|
|
|
474
502
|
// Try to get plugin info from local marketplace cache (fallback)
|
|
475
503
|
const localMp = localMarketplaces.get(mpName);
|
|
476
504
|
const localPlugin = localMp?.plugins.find((p) => p.name === pluginName);
|
|
505
|
+
const movedTo = localPlugin
|
|
506
|
+
? undefined
|
|
507
|
+
: findPluginInOtherMarketplace(pluginName, mpName, localMarketplaces);
|
|
477
508
|
|
|
478
509
|
const latestVersion = localPlugin?.version || installedVersion || "unknown";
|
|
479
510
|
const description = localPlugin?.description || "Installed plugin";
|
|
@@ -494,14 +525,83 @@ export async function getGlobalAvailablePlugins(): Promise<PluginInfo[]> {
|
|
|
494
525
|
installedVersion: installedVersion,
|
|
495
526
|
hasUpdate,
|
|
496
527
|
isOrphaned: true,
|
|
528
|
+
movedTo,
|
|
497
529
|
});
|
|
498
530
|
}
|
|
499
531
|
|
|
532
|
+
await annotateContentDrift(plugins, "user");
|
|
500
533
|
return plugins;
|
|
501
534
|
}
|
|
502
535
|
|
|
536
|
+
/**
|
|
537
|
+
* Find another configured marketplace that still publishes this plugin name.
|
|
538
|
+
*
|
|
539
|
+
* Answers "did it disappear, or did it move?" for an orphaned plugin, so the
|
|
540
|
+
* UI can offer migration rather than only deletion.
|
|
541
|
+
*/
|
|
542
|
+
function findPluginInOtherMarketplace(
|
|
543
|
+
pluginName: string,
|
|
544
|
+
currentMarketplace: string,
|
|
545
|
+
localMarketplaces: Map<string, LocalMarketplace>,
|
|
546
|
+
): string | undefined {
|
|
547
|
+
for (const [name, mp] of localMarketplaces) {
|
|
548
|
+
if (name === currentMarketplace) continue;
|
|
549
|
+
if (mp.plugins.some((p) => p.name === pluginName)) return name;
|
|
550
|
+
}
|
|
551
|
+
return undefined;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Flag plugins whose files moved without their version moving.
|
|
556
|
+
*
|
|
557
|
+
* Runs after the plugin list is assembled so every code path that builds a
|
|
558
|
+
* PluginInfo gets the same treatment. Only plugins that already look "up to
|
|
559
|
+
* date" are worth checking — anything with a pending version bump will be
|
|
560
|
+
* reinstalled by that update anyway.
|
|
561
|
+
*
|
|
562
|
+
* Best-effort throughout: a plugin whose drift cannot be determined is left
|
|
563
|
+
* exactly as it is today.
|
|
564
|
+
*/
|
|
565
|
+
async function annotateContentDrift(
|
|
566
|
+
plugins: PluginInfo[],
|
|
567
|
+
scope: "user" | "project" | "local",
|
|
568
|
+
projectPath?: string,
|
|
569
|
+
): Promise<void> {
|
|
570
|
+
let registry: InstalledPluginsRegistry;
|
|
571
|
+
try {
|
|
572
|
+
registry = await readInstalledPluginsRegistry();
|
|
573
|
+
} catch {
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
await Promise.all(
|
|
578
|
+
plugins.map(async (plugin) => {
|
|
579
|
+
if (plugin.hasUpdate || plugin.isOrphaned) return;
|
|
580
|
+
if (!plugin.installedVersion || !plugin.version) return;
|
|
581
|
+
if (compareVersions(plugin.version, plugin.installedVersion) !== 0) return;
|
|
582
|
+
|
|
583
|
+
const entry = pickRegistryEntry(
|
|
584
|
+
registry.plugins[plugin.id],
|
|
585
|
+
scope,
|
|
586
|
+
projectPath,
|
|
587
|
+
);
|
|
588
|
+
if (!entry?.gitCommitSha) return;
|
|
589
|
+
|
|
590
|
+
try {
|
|
591
|
+
plugin.contentStale = await hasContentDrift({
|
|
592
|
+
marketplace: plugin.marketplace,
|
|
593
|
+
pluginName: plugin.name,
|
|
594
|
+
installedSha: entry.gitCommitSha,
|
|
595
|
+
});
|
|
596
|
+
} catch {
|
|
597
|
+
/* leave unflagged */
|
|
598
|
+
}
|
|
599
|
+
}),
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
|
|
503
603
|
// Simple version comparison (returns 1 if a > b, -1 if a < b, 0 if equal)
|
|
504
|
-
function compareVersions(
|
|
604
|
+
export function compareVersions(
|
|
505
605
|
a: string | null | undefined,
|
|
506
606
|
b: string | null | undefined,
|
|
507
607
|
): number {
|
|
@@ -21,11 +21,26 @@ import { profileDir } from "./symlink-manager.js";
|
|
|
21
21
|
* enabled, merged with the profile's declared settings. The profile's settings
|
|
22
22
|
* win over the derived enabledPlugins only if it explicitly sets that key
|
|
23
23
|
* (it normally won't).
|
|
24
|
+
*
|
|
25
|
+
* `allPluginIds` is the manifest-wide union of plugins across every profile.
|
|
26
|
+
* Plugins in the union that this profile does NOT want are written as an
|
|
27
|
+
* explicit `false`, which is what makes `profile switch` exclusive rather than
|
|
28
|
+
* additive. Claude Code resolves enabledPlugins **per plugin id**, walking from
|
|
29
|
+
* the highest-precedence scope down and taking the first scope that mentions
|
|
30
|
+
* the id — an id a scope omits falls through to the next one. Since
|
|
31
|
+
* `claudeup install` installs the union at user scope, every non-member would
|
|
32
|
+
* otherwise inherit that user-scope `true` and stay enabled while a profile
|
|
33
|
+
* that excludes it is active. Omitting the union (the default) preserves the
|
|
34
|
+
* old additive behavior for callers that have no cross-profile view.
|
|
24
35
|
*/
|
|
25
36
|
export function buildProfileSettings(
|
|
26
37
|
closure: ResolvedClosure,
|
|
38
|
+
allPluginIds: readonly string[] = [],
|
|
27
39
|
): Record<string, unknown> {
|
|
28
40
|
const enabledPlugins: Record<string, boolean> = {};
|
|
41
|
+
for (const pluginId of allPluginIds) {
|
|
42
|
+
enabledPlugins[pluginId] = false;
|
|
43
|
+
}
|
|
29
44
|
for (const pluginId of Object.keys(closure.plugins)) {
|
|
30
45
|
enabledPlugins[pluginId] = true;
|
|
31
46
|
}
|
|
@@ -39,6 +54,37 @@ export function buildProfileMcp(
|
|
|
39
54
|
return { mcpServers: { ...closure.mcpServers } };
|
|
40
55
|
}
|
|
41
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Seed a profile's skills/ dir from the project's pre-existing `.claude/skills/`.
|
|
59
|
+
*
|
|
60
|
+
* Activating a profile replaces `.claude/skills` with a symlink into the profile
|
|
61
|
+
* dir, and replacing means `fs.remove` first. A project that committed its own
|
|
62
|
+
* skills before adopting profiles would lose them at that moment. So the first
|
|
63
|
+
* time a profile's skills/ dir is created, any real (non-symlink) project skills
|
|
64
|
+
* are copied in — every profile inherits what the project already had, and the
|
|
65
|
+
* link swap destroys nothing.
|
|
66
|
+
*
|
|
67
|
+
* Only runs when the profile's skills/ dir does not exist yet, so it never
|
|
68
|
+
* fights a later `skills-manager` install or re-adds a skill the user removed.
|
|
69
|
+
*/
|
|
70
|
+
async function seedSkillsFromProject(
|
|
71
|
+
skillsDir: string,
|
|
72
|
+
projectPath?: string,
|
|
73
|
+
): Promise<void> {
|
|
74
|
+
if (await fs.pathExists(skillsDir)) return;
|
|
75
|
+
|
|
76
|
+
const projectSkills = path.join(projectPath ?? process.cwd(), ".claude", "skills");
|
|
77
|
+
try {
|
|
78
|
+
// lstat, not pathExists: an existing profile symlink is not a source.
|
|
79
|
+
const stat = await fs.lstat(projectSkills);
|
|
80
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) return;
|
|
81
|
+
if ((await fs.readdir(projectSkills)).length === 0) return;
|
|
82
|
+
await fs.copy(projectSkills, skillsDir, { dereference: true });
|
|
83
|
+
} catch {
|
|
84
|
+
// No project skills dir (or unreadable) — nothing to carry over.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
42
88
|
/**
|
|
43
89
|
* Write `_profiles/<name>/` from a resolved closure. Idempotent: overwrites
|
|
44
90
|
* settings.json / mcp.json and ensures skills/ exists. Returns the dir path.
|
|
@@ -47,15 +93,20 @@ export async function materializeProfile(
|
|
|
47
93
|
name: string,
|
|
48
94
|
closure: ResolvedClosure,
|
|
49
95
|
projectPath?: string,
|
|
96
|
+
allPluginIds: readonly string[] = [],
|
|
50
97
|
): Promise<string> {
|
|
51
98
|
const dir = profileDir(name, projectPath);
|
|
52
99
|
await fs.ensureDir(dir);
|
|
53
|
-
await fs.writeJson(
|
|
54
|
-
|
|
55
|
-
|
|
100
|
+
await fs.writeJson(
|
|
101
|
+
path.join(dir, "settings.json"),
|
|
102
|
+
buildProfileSettings(closure, allPluginIds),
|
|
103
|
+
{ spaces: 2 },
|
|
104
|
+
);
|
|
56
105
|
await fs.writeJson(path.join(dir, "mcp.json"), buildProfileMcp(closure), {
|
|
57
106
|
spaces: 2,
|
|
58
107
|
});
|
|
59
|
-
|
|
108
|
+
const skillsDir = path.join(dir, "skills");
|
|
109
|
+
await seedSkillsFromProject(skillsDir, projectPath);
|
|
110
|
+
await fs.ensureDir(skillsDir);
|
|
60
111
|
return dir;
|
|
61
112
|
}
|
|
@@ -42,22 +42,50 @@ export interface PluginChange {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
interface SnapshotFile {
|
|
45
|
-
/**
|
|
46
|
-
|
|
45
|
+
/**
|
|
46
|
+
* Legacy flat baseline: pluginId -> version, with no record of which project
|
|
47
|
+
* it was observed from. Kept only so an existing file can be migrated.
|
|
48
|
+
*/
|
|
49
|
+
seen?: Record<string, string>;
|
|
50
|
+
/**
|
|
51
|
+
* projectPath -> (pluginId -> installedVersion) as of the last time claudeup
|
|
52
|
+
* rendered that project.
|
|
53
|
+
*
|
|
54
|
+
* Versions are per-project (installed_plugins.json keys installs by
|
|
55
|
+
* projectPath), but this file is global. A single flat map therefore let two
|
|
56
|
+
* projects legitimately on different versions overwrite each other's
|
|
57
|
+
* baseline, and every differing plugin was badged "updated" on the next
|
|
58
|
+
* render — a change that never happened. Keying by project removes the
|
|
59
|
+
* cross-talk entirely.
|
|
60
|
+
*/
|
|
61
|
+
byProject?: Record<string, Record<string, string>>;
|
|
47
62
|
/** Set on the run that first created the baseline. */
|
|
48
63
|
seededAt: string;
|
|
49
64
|
updatedAt: string;
|
|
50
65
|
}
|
|
51
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Honour CLAUDE_CONFIG_DIR — the same override Claude Code itself uses.
|
|
69
|
+
*
|
|
70
|
+
* `os.homedir()` reads the passwd database on macOS and ignores $HOME, so a
|
|
71
|
+
* test that only overrides HOME writes to the operator's real snapshot file.
|
|
72
|
+
* That is not hypothetical: it happened while building the tests for this fix.
|
|
73
|
+
*/
|
|
52
74
|
function snapshotPath(): string {
|
|
53
|
-
|
|
75
|
+
const configDir =
|
|
76
|
+
process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
|
|
77
|
+
return path.join(configDir, "claudeup-version-snapshot.json");
|
|
54
78
|
}
|
|
55
79
|
|
|
56
80
|
async function read(): Promise<SnapshotFile | null> {
|
|
57
81
|
try {
|
|
58
82
|
const raw = await fs.readFile(snapshotPath(), "utf-8");
|
|
59
83
|
const data = JSON.parse(raw) as SnapshotFile;
|
|
60
|
-
if (!data
|
|
84
|
+
if (!data) return null;
|
|
85
|
+
const hasFlat = typeof data.seen === "object" && data.seen !== null;
|
|
86
|
+
const hasByProject =
|
|
87
|
+
typeof data.byProject === "object" && data.byProject !== null;
|
|
88
|
+
if (!hasFlat && !hasByProject) return null;
|
|
61
89
|
return data;
|
|
62
90
|
} catch {
|
|
63
91
|
return null; // absent or corrupt — treated as "no baseline yet"
|
|
@@ -109,10 +137,28 @@ export function diffVersions(
|
|
|
109
137
|
return changes;
|
|
110
138
|
}
|
|
111
139
|
|
|
112
|
-
/**
|
|
113
|
-
|
|
140
|
+
/**
|
|
141
|
+
* Load the baseline for one project, or null when that project has none.
|
|
142
|
+
*
|
|
143
|
+
* A project seen for the first time returns null, so `diffVersions` stays
|
|
144
|
+
* silent — the same rule as a first ever run. That is deliberate: opening
|
|
145
|
+
* claudeup in a new project must not badge every plugin as "updated" just
|
|
146
|
+
* because this file has never seen that path before.
|
|
147
|
+
*
|
|
148
|
+
* A pre-migration flat file is adopted as the baseline for whichever project
|
|
149
|
+
* asks first, which is the closest thing to the truth it contains.
|
|
150
|
+
*/
|
|
151
|
+
export async function loadSeenVersions(
|
|
152
|
+
projectPath: string,
|
|
153
|
+
): Promise<Record<string, string> | null> {
|
|
114
154
|
const data = await read();
|
|
115
|
-
|
|
155
|
+
if (!data) return null;
|
|
156
|
+
const scoped = data.byProject?.[projectPath];
|
|
157
|
+
if (scoped) return scoped;
|
|
158
|
+
// Legacy flat baseline, project unknown — use it once, then it is replaced
|
|
159
|
+
// by per-project entries on save.
|
|
160
|
+
if (data.seen && !data.byProject) return data.seen;
|
|
161
|
+
return null;
|
|
116
162
|
}
|
|
117
163
|
|
|
118
164
|
/**
|
|
@@ -121,6 +167,7 @@ export async function loadSeenVersions(): Promise<Record<string, string> | null>
|
|
|
121
167
|
* shows again next launch.
|
|
122
168
|
*/
|
|
123
169
|
export async function saveSeenVersions(
|
|
170
|
+
projectPath: string,
|
|
124
171
|
current: Record<string, string>,
|
|
125
172
|
): Promise<void> {
|
|
126
173
|
try {
|
|
@@ -129,7 +176,9 @@ export async function saveSeenVersions(
|
|
|
129
176
|
const existing = await read();
|
|
130
177
|
const now = new Date().toISOString();
|
|
131
178
|
const payload: SnapshotFile = {
|
|
132
|
-
|
|
179
|
+
// Other projects' baselines are preserved — writing only this project's
|
|
180
|
+
// slot is the entire point of the fix.
|
|
181
|
+
byProject: { ...(existing?.byProject ?? {}), [projectPath]: current },
|
|
133
182
|
seededAt: existing?.seededAt ?? now,
|
|
134
183
|
updatedAt: now,
|
|
135
184
|
};
|
|
@@ -136,10 +136,20 @@ function pluginRow(item: PluginPluginItem, isSelected: boolean): React.ReactNode
|
|
|
136
136
|
|
|
137
137
|
let versionStr = "";
|
|
138
138
|
if (plugin.isOrphaned) {
|
|
139
|
-
|
|
139
|
+
// "deprecated" alone reads as "delete me" — but a plugin that merely moved
|
|
140
|
+
// marketplace is still published, and deleting it loses it for no reason.
|
|
141
|
+
versionStr = plugin.movedTo
|
|
142
|
+
? ` moved → ${plugin.movedTo}`
|
|
143
|
+
: " deprecated";
|
|
140
144
|
} else if (notInstalled) {
|
|
141
145
|
versionStr = " not installed";
|
|
142
146
|
} else if (hasAnyScope) {
|
|
147
|
+
// "what just changed" and "what is still available" are independent facts,
|
|
148
|
+
// and both can be true at once: the prerunner can move a plugin 2.9.0→3.0.1
|
|
149
|
+
// while the marketplace already offers 3.3.1. These used to share one
|
|
150
|
+
// if/else chain, so the "updated" badge silently swallowed the pending
|
|
151
|
+
// update — and the update only reappeared on the next refresh, which read
|
|
152
|
+
// as the state spontaneously changing. Render them as separate clauses.
|
|
143
153
|
if (plugin.recentlyUpdatedFrom && shownVersion) {
|
|
144
154
|
// Show what actually happened, not just where it landed — the update may
|
|
145
155
|
// have come from Claude Code or the prerunner without ever telling us.
|
|
@@ -150,9 +160,15 @@ function pluginRow(item: PluginPluginItem, isSelected: boolean): React.ReactNode
|
|
|
150
160
|
versionStr = shownVersion ? ` v${shownVersion} new` : " new";
|
|
151
161
|
} else if (shownVersion) {
|
|
152
162
|
versionStr = ` v${shownVersion}`;
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Pending update, regardless of which badge above applied.
|
|
166
|
+
if (plugin.hasUpdate && plugin.version && versionStr) {
|
|
167
|
+
versionStr += ` → v${plugin.version} available`;
|
|
168
|
+
} else if (plugin.contentStale && versionStr) {
|
|
169
|
+
// Same version, different files. The version compare says "up to date"
|
|
170
|
+
// and is wrong — only a reinstall delivers the current content.
|
|
171
|
+
versionStr += " stale — reinstall";
|
|
156
172
|
}
|
|
157
173
|
}
|
|
158
174
|
|