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
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An orphaned plugin that merely changed marketplace is not deprecated.
|
|
3
|
+
*
|
|
4
|
+
* Splitting `magus` into `magus` + `magus-marketing` left `seo@magus`,
|
|
5
|
+
* `instantly@magus`, `video-editing@magus` and `nanobanana@magus` installed
|
|
6
|
+
* under a namespace that no longer lists them. They rendered as bare
|
|
7
|
+
* "deprecated", whose only action is deletion — which drops a plugin that is
|
|
8
|
+
* still published, just under a different id.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, test } from "bun:test";
|
|
12
|
+
import type { LocalMarketplace } from "../services/local-marketplace.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Mirrors `findPluginInOtherMarketplace` in plugin-manager.ts, which is module
|
|
16
|
+
* -private. Kept in lockstep with it; the behaviour, not the binding, is what
|
|
17
|
+
* these tests pin.
|
|
18
|
+
*/
|
|
19
|
+
function findPluginInOtherMarketplace(
|
|
20
|
+
pluginName: string,
|
|
21
|
+
currentMarketplace: string,
|
|
22
|
+
localMarketplaces: Map<string, LocalMarketplace>,
|
|
23
|
+
): string | undefined {
|
|
24
|
+
for (const [name, mp] of localMarketplaces) {
|
|
25
|
+
if (name === currentMarketplace) continue;
|
|
26
|
+
if (mp.plugins.some((p) => p.name === pluginName)) return name;
|
|
27
|
+
}
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const mp = (name: string, plugins: string[]): [string, LocalMarketplace] => [
|
|
32
|
+
name,
|
|
33
|
+
{
|
|
34
|
+
name,
|
|
35
|
+
description: "",
|
|
36
|
+
plugins: plugins.map((p) => ({
|
|
37
|
+
name: p,
|
|
38
|
+
version: "1.0.0",
|
|
39
|
+
description: "",
|
|
40
|
+
})),
|
|
41
|
+
},
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
describe("moved-marketplace detection", () => {
|
|
45
|
+
test("finds a plugin that moved to a sibling marketplace", () => {
|
|
46
|
+
const marketplaces = new Map([
|
|
47
|
+
mp("magus", ["dev", "terminal"]),
|
|
48
|
+
mp("magus-marketing", ["seo", "instantly", "video-editing"]),
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
expect(findPluginInOtherMarketplace("seo", "magus", marketplaces)).toBe(
|
|
52
|
+
"magus-marketing",
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("a genuinely retired plugin reports no destination", () => {
|
|
57
|
+
// `conductor` was removed at magus v8.0.0 and republished nowhere.
|
|
58
|
+
const marketplaces = new Map([
|
|
59
|
+
mp("magus", ["dev"]),
|
|
60
|
+
mp("magus-marketing", ["seo"]),
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
expect(
|
|
64
|
+
findPluginInOtherMarketplace("conductor", "magus", marketplaces),
|
|
65
|
+
).toBeUndefined();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("does not propose the marketplace it is already installed from", () => {
|
|
69
|
+
const marketplaces = new Map([mp("magus", ["dev"])]);
|
|
70
|
+
expect(findPluginInOtherMarketplace("dev", "magus", marketplaces)).toBe(
|
|
71
|
+
undefined,
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("a renamed plugin is not matched — the name is the identity", () => {
|
|
76
|
+
// nanobanana became image-generate. Names differ, so this correctly
|
|
77
|
+
// reports no destination rather than guessing at a rename.
|
|
78
|
+
const marketplaces = new Map([
|
|
79
|
+
mp("magus", ["dev"]),
|
|
80
|
+
mp("magus-marketing", ["image-generate"]),
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
expect(
|
|
84
|
+
findPluginInOtherMarketplace("nanobanana", "magus", marketplaces),
|
|
85
|
+
).toBeUndefined();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -38,6 +38,36 @@ describe("buildProfileSettings", () => {
|
|
|
38
38
|
});
|
|
39
39
|
expect(s.enableAllProjectMcpServers).toBe(true);
|
|
40
40
|
});
|
|
41
|
+
|
|
42
|
+
// Regression: `install` enables the manifest-wide union at USER scope, and
|
|
43
|
+
// Claude Code resolves enabledPlugins per plugin id — an id the project
|
|
44
|
+
// scope omits falls through to the user scope's `true`. A profile that
|
|
45
|
+
// merely omits another profile's plugins therefore never disables them, so
|
|
46
|
+
// `profile switch` silently becomes additive instead of exclusive.
|
|
47
|
+
test("explicitly disables union plugins the profile excludes", () => {
|
|
48
|
+
const s = buildProfileSettings(
|
|
49
|
+
closure({ plugins: { "designer@magus": "latest" } }),
|
|
50
|
+
["designer@magus", "terminal@magus", "gtd@magus"],
|
|
51
|
+
);
|
|
52
|
+
expect(s.enabledPlugins).toEqual({
|
|
53
|
+
"designer@magus": true,
|
|
54
|
+
"terminal@magus": false,
|
|
55
|
+
"gtd@magus": false,
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("a profile's own plugin wins over its union entry", () => {
|
|
60
|
+
const s = buildProfileSettings(
|
|
61
|
+
closure({ plugins: { "terminal@magus": "4.1.4" } }),
|
|
62
|
+
["terminal@magus"],
|
|
63
|
+
);
|
|
64
|
+
expect(s.enabledPlugins).toEqual({ "terminal@magus": true });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("no union given -> no false entries (unchanged legacy shape)", () => {
|
|
68
|
+
const s = buildProfileSettings(closure({ plugins: { "dev@magus": "latest" } }));
|
|
69
|
+
expect(s.enabledPlugins).toEqual({ "dev@magus": true });
|
|
70
|
+
});
|
|
41
71
|
});
|
|
42
72
|
|
|
43
73
|
describe("buildProfileMcp", () => {
|
|
@@ -79,4 +109,61 @@ describe("materializeProfile", () => {
|
|
|
79
109
|
const settings = await fs.readJson(join(dir, "settings.json"));
|
|
80
110
|
expect(settings.enabledPlugins).toEqual({ "b@m": true });
|
|
81
111
|
});
|
|
112
|
+
|
|
113
|
+
// Regression: activating a profile replaces .claude/skills with a symlink,
|
|
114
|
+
// and replacing means fs.remove first. A repo that committed its own project
|
|
115
|
+
// skills before adopting profiles would lose them at that moment.
|
|
116
|
+
test("seeds skills/ from the project's pre-existing .claude/skills", async () => {
|
|
117
|
+
const src = join(project, ".claude", "skills", "systematic-debugging");
|
|
118
|
+
await fs.outputFile(join(src, "SKILL.md"), "# committed project skill\n");
|
|
119
|
+
|
|
120
|
+
const dir = await materializeProfile("p", closure(), project);
|
|
121
|
+
|
|
122
|
+
expect(
|
|
123
|
+
await fs.readFile(join(dir, "skills", "systematic-debugging", "SKILL.md"), "utf8"),
|
|
124
|
+
).toContain("committed project skill");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("does not re-seed skills/ once the profile dir exists", async () => {
|
|
128
|
+
await fs.outputFile(
|
|
129
|
+
join(project, ".claude", "skills", "gone", "SKILL.md"),
|
|
130
|
+
"# removed later\n",
|
|
131
|
+
);
|
|
132
|
+
const dir = await materializeProfile("p", closure(), project);
|
|
133
|
+
await fs.remove(join(dir, "skills", "gone"));
|
|
134
|
+
|
|
135
|
+
await materializeProfile("p", closure(), project);
|
|
136
|
+
|
|
137
|
+
expect(await fs.pathExists(join(dir, "skills", "gone"))).toBe(false);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("still creates an empty skills/ when the project has none", async () => {
|
|
141
|
+
const dir = await materializeProfile("p", closure(), project);
|
|
142
|
+
expect(await fs.pathExists(join(dir, "skills"))).toBe(true);
|
|
143
|
+
expect(await fs.readdir(join(dir, "skills"))).toEqual([]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("two profiles from one union each disable the other's plugins", async () => {
|
|
147
|
+
const union = ["designer@magus", "terminal@magus"];
|
|
148
|
+
const fe = await materializeProfile(
|
|
149
|
+
"frontend",
|
|
150
|
+
closure({ plugins: { "designer@magus": "latest" } }),
|
|
151
|
+
project,
|
|
152
|
+
union,
|
|
153
|
+
);
|
|
154
|
+
const be = await materializeProfile(
|
|
155
|
+
"backend",
|
|
156
|
+
closure({ plugins: { "terminal@magus": "latest" } }),
|
|
157
|
+
project,
|
|
158
|
+
union,
|
|
159
|
+
);
|
|
160
|
+
expect((await fs.readJson(join(fe, "settings.json"))).enabledPlugins).toEqual({
|
|
161
|
+
"designer@magus": true,
|
|
162
|
+
"terminal@magus": false,
|
|
163
|
+
});
|
|
164
|
+
expect((await fs.readJson(join(be, "settings.json"))).enabledPlugins).toEqual({
|
|
165
|
+
"designer@magus": false,
|
|
166
|
+
"terminal@magus": true,
|
|
167
|
+
});
|
|
168
|
+
});
|
|
82
169
|
});
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
2
5
|
import {
|
|
3
6
|
diffVersions,
|
|
4
7
|
isKnownVersion,
|
|
8
|
+
loadSeenVersions,
|
|
9
|
+
saveSeenVersions,
|
|
5
10
|
UNKNOWN_VERSION,
|
|
6
11
|
} from "../services/version-snapshot.js";
|
|
7
12
|
|
|
@@ -109,3 +114,83 @@ describe("diffVersions — removals and mixed sets", () => {
|
|
|
109
114
|
]);
|
|
110
115
|
});
|
|
111
116
|
});
|
|
117
|
+
|
|
118
|
+
describe("per-project baselines", () => {
|
|
119
|
+
/**
|
|
120
|
+
* The baseline used to be one flat map for the whole machine, but the
|
|
121
|
+
* versions written into it are resolved per project. Two projects
|
|
122
|
+
* legitimately on different versions overwrote each other's baseline, so
|
|
123
|
+
* every differing plugin was reported as "updated" on the next render — a
|
|
124
|
+
* change that never happened.
|
|
125
|
+
*
|
|
126
|
+
* CLAUDE_CONFIG_DIR, not HOME: os.homedir() reads the passwd database on
|
|
127
|
+
* macOS and ignores $HOME, so a HOME-only override writes to the operator's
|
|
128
|
+
* real ~/.claude. That is not hypothetical — it happened while writing these.
|
|
129
|
+
*/
|
|
130
|
+
const withSandbox = async (fn: () => Promise<void>): Promise<void> => {
|
|
131
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "vsnap-"));
|
|
132
|
+
const prev = process.env.CLAUDE_CONFIG_DIR;
|
|
133
|
+
process.env.CLAUDE_CONFIG_DIR = dir;
|
|
134
|
+
try {
|
|
135
|
+
await fn();
|
|
136
|
+
} finally {
|
|
137
|
+
if (prev === undefined) delete process.env.CLAUDE_CONFIG_DIR;
|
|
138
|
+
else process.env.CLAUDE_CONFIG_DIR = prev;
|
|
139
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
test("a project with no baseline of its own reports nothing", async () => {
|
|
144
|
+
await withSandbox(async () => {
|
|
145
|
+
await saveSeenVersions("/proj/a", { "statusline@magus": "2.2.0" });
|
|
146
|
+
const baseline = await loadSeenVersions("/proj/b");
|
|
147
|
+
expect(baseline).toBeNull();
|
|
148
|
+
expect(diffVersions(baseline, { "statusline@magus": "2.5.0" })).toEqual([]);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("saving one project preserves the others", async () => {
|
|
153
|
+
await withSandbox(async () => {
|
|
154
|
+
await saveSeenVersions("/proj/a", { "gtd@magus": "2.0.0" });
|
|
155
|
+
await saveSeenVersions("/proj/b", { "gtd@magus": "2.0.1" });
|
|
156
|
+
expect(await loadSeenVersions("/proj/a")).toEqual({ "gtd@magus": "2.0.0" });
|
|
157
|
+
expect(await loadSeenVersions("/proj/b")).toEqual({ "gtd@magus": "2.0.1" });
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("a real update inside one project is still reported", async () => {
|
|
162
|
+
await withSandbox(async () => {
|
|
163
|
+
await saveSeenVersions("/proj/a", { "dev@magus": "3.0.0" });
|
|
164
|
+
const previous = await loadSeenVersions("/proj/a");
|
|
165
|
+
expect(diffVersions(previous, { "dev@magus": "3.0.1" })).toEqual([
|
|
166
|
+
{ pluginId: "dev@magus", kind: "updated", from: "3.0.0", to: "3.0.1" },
|
|
167
|
+
]);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("a pre-migration flat baseline is adopted once, then replaced", async () => {
|
|
172
|
+
await withSandbox(async () => {
|
|
173
|
+
const file = path.join(
|
|
174
|
+
process.env.CLAUDE_CONFIG_DIR as string,
|
|
175
|
+
"claudeup-version-snapshot.json",
|
|
176
|
+
);
|
|
177
|
+
await fs.writeFile(
|
|
178
|
+
file,
|
|
179
|
+
JSON.stringify({
|
|
180
|
+
seen: { "dev@magus": "3.0.0" },
|
|
181
|
+
seededAt: "2026-07-29T00:00:00.000Z",
|
|
182
|
+
updatedAt: "2026-07-29T00:00:00.000Z",
|
|
183
|
+
}),
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
// Legacy shape carries no project, so it stands in for whoever asks first
|
|
187
|
+
// rather than being thrown away.
|
|
188
|
+
expect(await loadSeenVersions("/proj/a")).toEqual({ "dev@magus": "3.0.0" });
|
|
189
|
+
|
|
190
|
+
await saveSeenVersions("/proj/a", { "dev@magus": "3.0.1" });
|
|
191
|
+
// Once per-project entries exist, the flat map is no longer consulted.
|
|
192
|
+
expect(await loadSeenVersions("/proj/b")).toBeNull();
|
|
193
|
+
expect(await loadSeenVersions("/proj/a")).toEqual({ "dev@magus": "3.0.1" });
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
});
|
package/src/cli/install.ts
CHANGED
|
@@ -126,7 +126,16 @@ async function runCheck(
|
|
|
126
126
|
// ── install steps ───────────────────────────────────────────────────────────
|
|
127
127
|
|
|
128
128
|
async function ensureToolchains(closure: ResolvedClosure, yes: boolean): Promise<void> {
|
|
129
|
-
|
|
129
|
+
// Only the binaries actually missing can need a toolchain. Checking all of
|
|
130
|
+
// them warns "pip isn't installed" on a machine whose pip-provided binaries
|
|
131
|
+
// are already on PATH — an alarming message about work that will not happen.
|
|
132
|
+
const missing: ResolvedBin[] = [];
|
|
133
|
+
for (const bin of closure.bins) {
|
|
134
|
+
if (!(await resolveExecutable(bin.name))) missing.push(bin);
|
|
135
|
+
}
|
|
136
|
+
if (missing.length === 0) return;
|
|
137
|
+
|
|
138
|
+
const toolchains = await detectToolchains(missing);
|
|
130
139
|
for (const tc of toolchains) {
|
|
131
140
|
if (tc.present) continue;
|
|
132
141
|
const bootstrap = TOOLCHAIN_BOOTSTRAP[tc.name];
|
|
@@ -160,11 +169,25 @@ async function registerMarketplaces(closure: ResolvedClosure): Promise<void> {
|
|
|
160
169
|
}
|
|
161
170
|
}
|
|
162
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Install the closure's plugins at PROJECT scope.
|
|
174
|
+
*
|
|
175
|
+
* A profile is a property of one repo, so its plugins must be too. At user
|
|
176
|
+
* scope, adopting profiles in a single repo would enable that repo's plugins in
|
|
177
|
+
* every other project on the machine — and since Claude Code resolves
|
|
178
|
+
* enabledPlugins per id with fall-through, those would stay on everywhere.
|
|
179
|
+
* Project scope keeps the blast radius to this repo. The plugin *cache* is
|
|
180
|
+
* global either way, so `profile switch` stays offline.
|
|
181
|
+
*
|
|
182
|
+
* Whatever the CLI writes into .claude/settings.json here is transient:
|
|
183
|
+
* materialization rewrites that file from the manifest, and activation replaces
|
|
184
|
+
* it with a symlink. The manifest stays authoritative.
|
|
185
|
+
*/
|
|
163
186
|
async function installPlugins(closure: ResolvedClosure): Promise<void> {
|
|
164
187
|
for (const pluginId of Object.keys(closure.plugins)) {
|
|
165
188
|
try {
|
|
166
189
|
console.log(`+ plugin ${pluginId}`);
|
|
167
|
-
await installPlugin(pluginId, "
|
|
190
|
+
await installPlugin(pluginId, "project");
|
|
168
191
|
} catch (e) {
|
|
169
192
|
console.warn(`⚠ plugin ${pluginId}: ${(e as Error).message}`);
|
|
170
193
|
}
|
|
@@ -317,10 +340,18 @@ export async function runInstallCommand(
|
|
|
317
340
|
|
|
318
341
|
// Materialize every profile (so `profile switch` needs no reinstall), then
|
|
319
342
|
// activate the target (or the sole/first profile).
|
|
320
|
-
|
|
321
|
-
|
|
343
|
+
//
|
|
344
|
+
// Every profile is materialized even when one was named: a profile arg scopes
|
|
345
|
+
// what gets *installed* and which becomes active, not which settings files
|
|
346
|
+
// exist. It also keeps the union below consistent across all of them — a
|
|
347
|
+
// profile left holding an older union would stop disabling plugins added
|
|
348
|
+
// since, silently making `switch` additive again.
|
|
349
|
+
const unionPluginIds = Object.keys(
|
|
350
|
+
(flags.profile ? await resolveAllProfiles(manifest) : closure).plugins,
|
|
351
|
+
);
|
|
352
|
+
for (const id of profileIds) {
|
|
322
353
|
const profileClosure = await resolveProfile(manifest, id);
|
|
323
|
-
await materializeProfile(id, profileClosure, projectPath);
|
|
354
|
+
await materializeProfile(id, profileClosure, projectPath, unionPluginIds);
|
|
324
355
|
}
|
|
325
356
|
|
|
326
357
|
// Keep the generated dir + active-profile symlinks out of git before
|
package/src/cli/router.ts
CHANGED
|
@@ -73,25 +73,33 @@ function printHelp(version: string): void {
|
|
|
73
73
|
|
|
74
74
|
TUI tool for managing Claude Code plugins, MCPs, and configuration.
|
|
75
75
|
|
|
76
|
-
Usage: claudeup
|
|
77
|
-
claudeup
|
|
78
|
-
claudeup update Update claudeup to latest version
|
|
76
|
+
Usage: claudeup Open the interactive TUI
|
|
77
|
+
claudeup <command> [args]
|
|
79
78
|
|
|
80
79
|
Options:
|
|
81
80
|
-v, --version Show version and check for updates
|
|
82
81
|
-h, --help Show this help message
|
|
83
82
|
--no-refresh Skip auto-refresh of marketplaces on startup
|
|
84
83
|
|
|
85
|
-
|
|
84
|
+
Team profiles — reproduce this repo's Claude Code setup on any machine.
|
|
85
|
+
The manifest is .claude/profiles.json, committed. See docs/team-configuration.md.
|
|
86
|
+
install [profile] Install the profile's plugins, binaries, skills and env,
|
|
87
|
+
then activate it. No argument installs every profile.
|
|
88
|
+
--check Report drift only, write nothing (exits 1 in strict mode)
|
|
89
|
+
--yes, -y Skip the confirmation prompt
|
|
90
|
+
--force Discard unsynced local edits instead of refusing
|
|
91
|
+
profile list Show every profile; ● marks the active one
|
|
92
|
+
profile show <n> Print a profile's fully resolved closure
|
|
93
|
+
profile switch <n> Repoint the active profile — offline, no reinstall
|
|
94
|
+
profile sync Promote local edits back into .claude/profiles.json
|
|
95
|
+
doctor Check binary deps, profile symlinks, and conventions
|
|
96
|
+
--fix Apply the repairs it can make itself
|
|
97
|
+
|
|
98
|
+
Other commands:
|
|
86
99
|
claude [args...] Check for plugin updates (1h cache), then run claude
|
|
87
100
|
-f, --force Force update check (bypass 1h cache)
|
|
88
101
|
update Update claudeup itself to latest version
|
|
89
102
|
|
|
90
|
-
Experimental (in development):
|
|
91
|
-
install [profile] Install a team profile from .claude/profiles.json
|
|
92
|
-
profile <cmd> list | show | switch | sync team profiles
|
|
93
|
-
doctor Diagnose & repair plugin/marketplace/profile state
|
|
94
|
-
|
|
95
103
|
Navigation (TUI):
|
|
96
104
|
[1] Plugins [4] Settings [7] Git State
|
|
97
105
|
[2] Skills [5] Profiles [8] Alias
|
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
|
*/
|