claudeup 4.40.0 → 4.41.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/package.json +4 -4
- package/src/__tests__/cache-provenance.test.ts +84 -0
- package/src/__tests__/content-drift.test.ts +128 -0
- package/src/__tests__/marketplaces.test.ts +84 -0
- package/src/data/marketplaces.ts +74 -0
- package/src/services/claude-settings.ts +39 -6
- package/src/services/content-drift.ts +81 -4
- package/src/services/conventions-manager.ts +6 -1
- package/src/services/plugin-manager.ts +1 -0
- package/src/services/plugin-requires.ts +9 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claudeup",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.41.0",
|
|
4
4
|
"description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/main.tsx",
|
|
@@ -64,8 +64,8 @@
|
|
|
64
64
|
"typescript": "^5.6.3"
|
|
65
65
|
},
|
|
66
66
|
"optionalDependencies": {
|
|
67
|
-
"claudeup-darwin-arm64": "4.
|
|
68
|
-
"claudeup-darwin-x64": "4.
|
|
69
|
-
"claudeup-linux-x64": "4.
|
|
67
|
+
"claudeup-darwin-arm64": "4.41.0",
|
|
68
|
+
"claudeup-darwin-x64": "4.41.0",
|
|
69
|
+
"claudeup-linux-x64": "4.41.0"
|
|
70
70
|
}
|
|
71
71
|
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REGRESSION: every installed plugin rendered "stale — reinstall" at once.
|
|
3
|
+
*
|
|
4
|
+
* `updateInstalledPluginsRegistry` re-copies a plugin's files out of the
|
|
5
|
+
* marketplace clone on every in-place update, but used to carry the pre-update
|
|
6
|
+
* `gitCommitSha` forward verbatim. The recorded commit therefore named whichever
|
|
7
|
+
* release the plugin was FIRST installed from — measured 2026-08-22 on one
|
|
8
|
+
* machine: dev claimed 4.3.0 against a sha holding 3.3.0, browser-use 1.4.1
|
|
9
|
+
* against 1.1.3 — and the subtree diff across those releases is always
|
|
10
|
+
* non-empty. Fixed in /dev:fix session dev-fix-20260822-125510-03db093b.
|
|
11
|
+
*
|
|
12
|
+
* Hermetic: exercises the provenance rule directly, with a real git repo. The
|
|
13
|
+
* registry write itself resolves through os.homedir() and is not sandboxable
|
|
14
|
+
* without new test infrastructure, so the decision is tested where it lives.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
18
|
+
import { spawnSync } from "node:child_process";
|
|
19
|
+
import { promises as fs } from "node:fs";
|
|
20
|
+
import os from "node:os";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import { resolveCacheProvenance } from "../services/claude-settings.js";
|
|
23
|
+
|
|
24
|
+
let repo: string;
|
|
25
|
+
let plain: string;
|
|
26
|
+
|
|
27
|
+
const git = (args: string[]) =>
|
|
28
|
+
spawnSync("git", ["-c", "commit.gpgsign=false", ...args], {
|
|
29
|
+
cwd: repo,
|
|
30
|
+
encoding: "utf-8",
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
beforeEach(async () => {
|
|
34
|
+
repo = await fs.mkdtemp(path.join(os.tmpdir(), "provenance-repo-"));
|
|
35
|
+
plain = await fs.mkdtemp(path.join(os.tmpdir(), "provenance-plain-"));
|
|
36
|
+
await fs.mkdir(path.join(repo, "plugins", "alpha"), { recursive: true });
|
|
37
|
+
await fs.writeFile(path.join(repo, "plugins", "alpha", "plugin.json"), "{}");
|
|
38
|
+
git(["init", "-q", "-b", "main"]);
|
|
39
|
+
git(["config", "user.email", "probe@example.invalid"]);
|
|
40
|
+
git(["config", "user.name", "probe"]);
|
|
41
|
+
git(["add", "-A"]);
|
|
42
|
+
git(["commit", "-q", "-m", "release"]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterEach(async () => {
|
|
46
|
+
await fs.rm(repo, { recursive: true, force: true });
|
|
47
|
+
await fs.rm(plain, { recursive: true, force: true });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("resolveCacheProvenance", () => {
|
|
51
|
+
test("stamps the source repo's HEAD when files were copied", async () => {
|
|
52
|
+
const head = git(["rev-parse", "HEAD"]).stdout.trim();
|
|
53
|
+
const stale = "0".repeat(40);
|
|
54
|
+
|
|
55
|
+
expect(
|
|
56
|
+
await resolveCacheProvenance(path.join(repo, "plugins", "alpha"), stale),
|
|
57
|
+
).toBe(head);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("does NOT carry a stale sha forward after a successful copy", async () => {
|
|
61
|
+
// The bug in one line: the old code returned `stale` here, so the
|
|
62
|
+
// recorded commit fell further behind with every release.
|
|
63
|
+
const stale = "0".repeat(40);
|
|
64
|
+
expect(
|
|
65
|
+
await resolveCacheProvenance(path.join(repo, "plugins", "alpha"), stale),
|
|
66
|
+
).not.toBe(stale);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("keeps the previous sha when nothing was copied", async () => {
|
|
70
|
+
// A failed copy leaves the cache untouched, so the old sha still
|
|
71
|
+
// describes it. Advancing it would claim the cache holds newer files
|
|
72
|
+
// than it does, which hides real drift.
|
|
73
|
+
const previous = "a".repeat(40);
|
|
74
|
+
expect(await resolveCacheProvenance(null, previous)).toBe(previous);
|
|
75
|
+
expect(await resolveCacheProvenance(null, undefined)).toBeUndefined();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("records no sha for a source outside any git repo", async () => {
|
|
79
|
+
// Directory-type marketplaces have no commit to name. Inventing one
|
|
80
|
+
// would assert a provenance that is false; content-drift reads an
|
|
81
|
+
// absent sha as "cannot answer" and stays silent.
|
|
82
|
+
expect(await resolveCacheProvenance(plain, "b".repeat(40))).toBeUndefined();
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -14,6 +14,7 @@ import path from "node:path";
|
|
|
14
14
|
import {
|
|
15
15
|
clearContentDriftCache,
|
|
16
16
|
hasContentDrift,
|
|
17
|
+
repoHeadSha,
|
|
17
18
|
} from "../services/content-drift.js";
|
|
18
19
|
|
|
19
20
|
let configDir: string;
|
|
@@ -34,6 +35,13 @@ const git = (args: string[]) =>
|
|
|
34
35
|
|
|
35
36
|
const head = () => git(["rev-parse", "HEAD"]).stdout.trim();
|
|
36
37
|
|
|
38
|
+
/** Rewrite a plugin's manifest version, mimicking a release. */
|
|
39
|
+
const setVersion = (plugin: string, version: string) =>
|
|
40
|
+
fs.writeFile(
|
|
41
|
+
path.join(repo, "plugins", plugin, "plugin.json"),
|
|
42
|
+
JSON.stringify({ name: plugin, version }, null, 2),
|
|
43
|
+
);
|
|
44
|
+
|
|
37
45
|
beforeEach(async () => {
|
|
38
46
|
prevConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
|
39
47
|
configDir = await fs.mkdtemp(path.join(os.tmpdir(), "drift-"));
|
|
@@ -47,6 +55,8 @@ beforeEach(async () => {
|
|
|
47
55
|
await fs.mkdir(path.join(repo, "plugins", "beta"), { recursive: true });
|
|
48
56
|
await fs.writeFile(path.join(repo, "plugins", "alpha", "skills", "s.md"), "v1");
|
|
49
57
|
await fs.writeFile(path.join(repo, "plugins", "beta", "readme.md"), "v1");
|
|
58
|
+
await setVersion("alpha", "1.0.0");
|
|
59
|
+
await setVersion("beta", "1.0.0");
|
|
50
60
|
|
|
51
61
|
git(["init", "-q", "-b", "main"]);
|
|
52
62
|
git(["config", "user.email", "probe@example.invalid"]);
|
|
@@ -69,6 +79,7 @@ describe("hasContentDrift", () => {
|
|
|
69
79
|
marketplace: "probemp",
|
|
70
80
|
pluginName: "alpha",
|
|
71
81
|
installedSha: sha,
|
|
82
|
+
installedVersion: "1.0.0",
|
|
72
83
|
}),
|
|
73
84
|
).toBe(false);
|
|
74
85
|
});
|
|
@@ -88,6 +99,7 @@ describe("hasContentDrift", () => {
|
|
|
88
99
|
marketplace: "probemp",
|
|
89
100
|
pluginName: "alpha",
|
|
90
101
|
installedSha: sha,
|
|
102
|
+
installedVersion: "1.0.0",
|
|
91
103
|
}),
|
|
92
104
|
).toBe(true);
|
|
93
105
|
});
|
|
@@ -105,6 +117,7 @@ describe("hasContentDrift", () => {
|
|
|
105
117
|
marketplace: "probemp",
|
|
106
118
|
pluginName: "alpha",
|
|
107
119
|
installedSha: sha,
|
|
120
|
+
installedVersion: "1.0.0",
|
|
108
121
|
}),
|
|
109
122
|
).toBe(false);
|
|
110
123
|
expect(
|
|
@@ -112,6 +125,7 @@ describe("hasContentDrift", () => {
|
|
|
112
125
|
marketplace: "probemp",
|
|
113
126
|
pluginName: "beta",
|
|
114
127
|
installedSha: sha,
|
|
128
|
+
installedVersion: "1.0.0",
|
|
115
129
|
}),
|
|
116
130
|
).toBe(true);
|
|
117
131
|
});
|
|
@@ -129,6 +143,7 @@ describe("hasContentDrift", () => {
|
|
|
129
143
|
marketplace: "probemp",
|
|
130
144
|
pluginName: "alpha",
|
|
131
145
|
installedSha: sha,
|
|
146
|
+
installedVersion: "1.0.0",
|
|
132
147
|
}),
|
|
133
148
|
).toBe(true);
|
|
134
149
|
});
|
|
@@ -141,6 +156,7 @@ describe("hasContentDrift", () => {
|
|
|
141
156
|
marketplace: "probemp",
|
|
142
157
|
pluginName: "alpha",
|
|
143
158
|
installedSha: undefined,
|
|
159
|
+
installedVersion: "1.0.0",
|
|
144
160
|
}),
|
|
145
161
|
).toBe(false);
|
|
146
162
|
expect(
|
|
@@ -148,6 +164,7 @@ describe("hasContentDrift", () => {
|
|
|
148
164
|
marketplace: "probemp",
|
|
149
165
|
pluginName: "alpha",
|
|
150
166
|
installedSha: "0".repeat(40),
|
|
167
|
+
installedVersion: "1.0.0",
|
|
151
168
|
}),
|
|
152
169
|
).toBe(false);
|
|
153
170
|
expect(
|
|
@@ -155,7 +172,118 @@ describe("hasContentDrift", () => {
|
|
|
155
172
|
marketplace: "does-not-exist",
|
|
156
173
|
pluginName: "alpha",
|
|
157
174
|
installedSha: head(),
|
|
175
|
+
installedVersion: "1.0.0",
|
|
176
|
+
}),
|
|
177
|
+
).toBe(false);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// REGRESSION: every installed plugin rendered "stale — reinstall" because
|
|
181
|
+
// updateInstalledPluginsRegistry bumped `version` while carrying the
|
|
182
|
+
// pre-update `gitCommitSha` forward. The sha then named a commit several
|
|
183
|
+
// releases old, so the subtree diff was non-empty for essentially every
|
|
184
|
+
// plugin. Fixed in /dev:fix session dev-fix-20260822-125510-03db093b.
|
|
185
|
+
test("a sha recorded against a DIFFERENT version cannot answer the question", async () => {
|
|
186
|
+
const oldSha = head();
|
|
187
|
+
|
|
188
|
+
// Ship 2.0.0 — the plugin genuinely changed, as a release does.
|
|
189
|
+
await fs.writeFile(
|
|
190
|
+
path.join(repo, "plugins", "alpha", "skills", "s.md"),
|
|
191
|
+
"v2 content",
|
|
192
|
+
);
|
|
193
|
+
await setVersion("alpha", "2.0.0");
|
|
194
|
+
git(["add", "-A"]);
|
|
195
|
+
git(["commit", "-q", "-m", "release 2.0.0"]);
|
|
196
|
+
|
|
197
|
+
// 2.0.0 is installed, but the registry still carries the 1.0.0 sha.
|
|
198
|
+
// The subtree diff between them is non-empty, and says nothing about
|
|
199
|
+
// whether the installed 2.0.0 files match the marketplace's 2.0.0.
|
|
200
|
+
expect(
|
|
201
|
+
await hasContentDrift({
|
|
202
|
+
marketplace: "probemp",
|
|
203
|
+
pluginName: "alpha",
|
|
204
|
+
installedSha: oldSha,
|
|
205
|
+
installedVersion: "2.0.0",
|
|
158
206
|
}),
|
|
159
207
|
).toBe(false);
|
|
160
208
|
});
|
|
209
|
+
|
|
210
|
+
test("a manifest that cannot be read at that sha reports no drift", async () => {
|
|
211
|
+
// Provenance unverifiable — the module's policy is silence, not a nag.
|
|
212
|
+
await fs.rm(path.join(repo, "plugins", "beta", "plugin.json"));
|
|
213
|
+
git(["add", "-A"]);
|
|
214
|
+
git(["commit", "-q", "-m", "beta loses its manifest"]);
|
|
215
|
+
const sha = head();
|
|
216
|
+
|
|
217
|
+
await fs.writeFile(path.join(repo, "plugins", "beta", "readme.md"), "v2");
|
|
218
|
+
git(["add", "-A"]);
|
|
219
|
+
git(["commit", "-q", "-m", "beta content changes"]);
|
|
220
|
+
|
|
221
|
+
expect(
|
|
222
|
+
await hasContentDrift({
|
|
223
|
+
marketplace: "probemp",
|
|
224
|
+
pluginName: "beta",
|
|
225
|
+
installedSha: sha,
|
|
226
|
+
installedVersion: "1.0.0",
|
|
227
|
+
}),
|
|
228
|
+
).toBe(false);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("reads a manifest at .claude-plugin/plugin.json too", async () => {
|
|
232
|
+
// magus lays plugins out as plugins/<name>/plugin.json, but Claude Code's
|
|
233
|
+
// documented location is .claude-plugin/plugin.json. Both must resolve or
|
|
234
|
+
// the guard silences drift detection for half the ecosystem.
|
|
235
|
+
await fs.mkdir(path.join(repo, "plugins", "beta", ".claude-plugin"), {
|
|
236
|
+
recursive: true,
|
|
237
|
+
});
|
|
238
|
+
await fs.rm(path.join(repo, "plugins", "beta", "plugin.json"));
|
|
239
|
+
await fs.writeFile(
|
|
240
|
+
path.join(repo, "plugins", "beta", ".claude-plugin", "plugin.json"),
|
|
241
|
+
JSON.stringify({ name: "beta", version: "1.0.0" }),
|
|
242
|
+
);
|
|
243
|
+
git(["add", "-A"]);
|
|
244
|
+
git(["commit", "-q", "-m", "beta uses .claude-plugin"]);
|
|
245
|
+
const sha = head();
|
|
246
|
+
|
|
247
|
+
await fs.writeFile(path.join(repo, "plugins", "beta", "readme.md"), "v2");
|
|
248
|
+
git(["add", "-A"]);
|
|
249
|
+
git(["commit", "-q", "-m", "republish 1.0.0"]);
|
|
250
|
+
|
|
251
|
+
expect(
|
|
252
|
+
await hasContentDrift({
|
|
253
|
+
marketplace: "probemp",
|
|
254
|
+
pluginName: "beta",
|
|
255
|
+
installedSha: sha,
|
|
256
|
+
installedVersion: "1.0.0",
|
|
257
|
+
}),
|
|
258
|
+
).toBe(true);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
describe("repoHeadSha", () => {
|
|
263
|
+
// The other half of the same bug: nothing in claudeup ever recorded a fresh
|
|
264
|
+
// sha, so the registry could only ever go stale. This is what
|
|
265
|
+
// updateInstalledPluginsRegistry now calls to stamp real provenance onto the
|
|
266
|
+
// files it just copied out of the marketplace clone.
|
|
267
|
+
test("resolves HEAD from a path inside the repo", async () => {
|
|
268
|
+
const sha = head();
|
|
269
|
+
expect(await repoHeadSha(path.join(repo, "plugins", "alpha"))).toBe(sha);
|
|
270
|
+
expect(await repoHeadSha(repo)).toBe(sha);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("returns undefined for a directory that is not in a git repo", async () => {
|
|
274
|
+
// Directory-type marketplaces have no commit to name. Recording a sha
|
|
275
|
+
// from some unrelated clone would assert a provenance that is false.
|
|
276
|
+
const plain = await fs.mkdtemp(path.join(os.tmpdir(), "nogit-"));
|
|
277
|
+
try {
|
|
278
|
+
expect(await repoHeadSha(plain)).toBeUndefined();
|
|
279
|
+
} finally {
|
|
280
|
+
await fs.rm(plain, { recursive: true, force: true });
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("returns undefined for a path that does not exist", async () => {
|
|
285
|
+
expect(
|
|
286
|
+
await repoHeadSha(path.join(configDir, "no", "such", "dir")),
|
|
287
|
+
).toBeUndefined();
|
|
288
|
+
});
|
|
161
289
|
});
|
|
@@ -38,3 +38,87 @@ describe("marketplaces", () => {
|
|
|
38
38
|
expect(names).not.toContain("superpowers");
|
|
39
39
|
});
|
|
40
40
|
});
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The community marketplaces added alongside the Magus channels.
|
|
44
|
+
*
|
|
45
|
+
* `name` is not cosmetic: plugin ids are built as `${plugin.name}@${mpName}`
|
|
46
|
+
* (plugin-manager.ts), so it MUST equal the upstream
|
|
47
|
+
* .claude-plugin/marketplace.json "name" field. A mismatch fails at install
|
|
48
|
+
* time with "not found in marketplace", nowhere near the typo that caused it.
|
|
49
|
+
*
|
|
50
|
+
* Verify against upstream with:
|
|
51
|
+
* gh api repos/<repo>/contents/.claude-plugin/marketplace.json \
|
|
52
|
+
* -H "Accept: application/vnd.github.raw" | jq -r '.name'
|
|
53
|
+
*/
|
|
54
|
+
const COMMUNITY_MARKETPLACES: ReadonlyArray<[name: string, repo: string]> = [
|
|
55
|
+
["mattpocock", "mattpocock/skills"],
|
|
56
|
+
["ecc", "affaan-m/everything-claude-code"],
|
|
57
|
+
["ponytail", "DietrichGebert/ponytail"],
|
|
58
|
+
["addy-agent-skills", "addyosmani/agent-skills"],
|
|
59
|
+
["humanizer", "blader/humanizer"],
|
|
60
|
+
["opendesign", "manalkaff/opendesign"],
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
describe("community marketplaces", () => {
|
|
64
|
+
it.each(COMMUNITY_MARKETPLACES)(
|
|
65
|
+
"%s points at the repo whose manifest declares that name",
|
|
66
|
+
(name, repo) => {
|
|
67
|
+
const mp = defaultMarketplaces.find((m) => m.name === name);
|
|
68
|
+
expect(mp).toBeDefined();
|
|
69
|
+
expect(mp?.source.repo).toBe(repo);
|
|
70
|
+
},
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
it.each(COMMUNITY_MARKETPLACES)(
|
|
74
|
+
"%s is visible by default via the always-show gate",
|
|
75
|
+
(name) => {
|
|
76
|
+
const mp = defaultMarketplaces.find((m) => m.name === name);
|
|
77
|
+
// plugin-manager's gate: configured || official || featured || owned.
|
|
78
|
+
// Without one of these the entry is only a name-to-repo mapping and
|
|
79
|
+
// never appears, which is a silent no-op rather than a failure.
|
|
80
|
+
expect(mp?.official || mp?.featured || mp?.owned).toBe(true);
|
|
81
|
+
},
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
it.each(COMMUNITY_MARKETPLACES)(
|
|
85
|
+
"%s claims neither Anthropic nor MadAppGang ownership",
|
|
86
|
+
(name) => {
|
|
87
|
+
const mp = defaultMarketplaces.find((m) => m.name === name);
|
|
88
|
+
// `official` badges it as Anthropic-managed; `owned` sorts it above
|
|
89
|
+
// everything as a MadAppGang channel. Both would be a false claim.
|
|
90
|
+
expect(mp?.official).toBeUndefined();
|
|
91
|
+
expect(mp?.owned).toBeUndefined();
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
it("introduces no duplicate marketplace name or repo", () => {
|
|
96
|
+
const names = defaultMarketplaces.map((m) => m.name);
|
|
97
|
+
const repos = defaultMarketplaces.map((m) => m.source.repo.toLowerCase());
|
|
98
|
+
|
|
99
|
+
expect(new Set(names).size).toBe(names.length);
|
|
100
|
+
expect(new Set(repos).size).toBe(repos.length);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("surfaces every community marketplace through getAllMarketplaces", () => {
|
|
104
|
+
const names = getAllMarketplaces().map((mp) => mp.name);
|
|
105
|
+
|
|
106
|
+
for (const [name] of COMMUNITY_MARKETPLACES) {
|
|
107
|
+
expect(names).toContain(name);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("keeps the Magus channels sorted above the community ones", () => {
|
|
112
|
+
const order = getAllMarketplaces().map((mp) => mp.name);
|
|
113
|
+
const lastMagus = Math.max(
|
|
114
|
+
order.indexOf("magus"),
|
|
115
|
+
order.indexOf("magus-marketing"),
|
|
116
|
+
order.indexOf("magus-alpha"),
|
|
117
|
+
);
|
|
118
|
+
const firstCommunity = Math.min(
|
|
119
|
+
...COMMUNITY_MARKETPLACES.map(([name]) => order.indexOf(name)),
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
expect(lastMagus).toBeLessThan(firstCommunity);
|
|
123
|
+
});
|
|
124
|
+
});
|
package/src/data/marketplaces.ts
CHANGED
|
@@ -87,6 +87,80 @@ export const defaultMarketplaces: Marketplace[] = [
|
|
|
87
87
|
description: "Curated Claude Code plugins for skills and workflows",
|
|
88
88
|
featured: true,
|
|
89
89
|
},
|
|
90
|
+
// Community marketplaces. Each `name` MUST equal the upstream
|
|
91
|
+
// .claude-plugin/marketplace.json "name" field — plugin ids are built as
|
|
92
|
+
// `${plugin.name}@${marketplaceName}`, so a mismatch here breaks install
|
|
93
|
+
// with a "not found in marketplace" error rather than anything obvious.
|
|
94
|
+
//
|
|
95
|
+
// All carry `featured` because that is the only flag that makes a
|
|
96
|
+
// third-party marketplace visible: the gate in plugin-manager is
|
|
97
|
+
// `configured || official || featured || owned`, and an unflagged entry is
|
|
98
|
+
// just a name→repo mapping for auto-recovery. `official` means Anthropic
|
|
99
|
+
// and `owned` means MadAppGang, so neither applies.
|
|
100
|
+
{
|
|
101
|
+
name: "mattpocock",
|
|
102
|
+
displayName: "Matt Pocock",
|
|
103
|
+
source: {
|
|
104
|
+
source: "github",
|
|
105
|
+
repo: "mattpocock/skills",
|
|
106
|
+
},
|
|
107
|
+
description:
|
|
108
|
+
"Agent skills for real engineering — grilling, spec and ticket flows, TDD, code review, domain modelling",
|
|
109
|
+
featured: true,
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
name: "ecc",
|
|
113
|
+
displayName: "Everything Claude Code",
|
|
114
|
+
source: {
|
|
115
|
+
source: "github",
|
|
116
|
+
repo: "affaan-m/everything-claude-code",
|
|
117
|
+
},
|
|
118
|
+
description:
|
|
119
|
+
"Operator layer for agent harnesses — 68 agents, 286 skills, and command shims",
|
|
120
|
+
featured: true,
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
name: "ponytail",
|
|
124
|
+
displayName: "Ponytail",
|
|
125
|
+
source: {
|
|
126
|
+
source: "github",
|
|
127
|
+
repo: "DietrichGebert/ponytail",
|
|
128
|
+
},
|
|
129
|
+
description:
|
|
130
|
+
"Pushes the agent toward the smallest solution that works — YAGNI, and the standard library before custom code",
|
|
131
|
+
featured: true,
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
name: "addy-agent-skills",
|
|
135
|
+
displayName: "Addy Osmani Skills",
|
|
136
|
+
source: {
|
|
137
|
+
source: "github",
|
|
138
|
+
repo: "addyosmani/agent-skills",
|
|
139
|
+
},
|
|
140
|
+
description: "Production-grade engineering skills for AI coding agents",
|
|
141
|
+
featured: true,
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
name: "humanizer",
|
|
145
|
+
displayName: "Humanizer",
|
|
146
|
+
source: {
|
|
147
|
+
source: "github",
|
|
148
|
+
repo: "blader/humanizer",
|
|
149
|
+
},
|
|
150
|
+
description: "Removes signs of AI-generated writing from text",
|
|
151
|
+
featured: true,
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
name: "opendesign",
|
|
155
|
+
displayName: "OpenDesign",
|
|
156
|
+
source: {
|
|
157
|
+
source: "github",
|
|
158
|
+
repo: "manalkaff/opendesign",
|
|
159
|
+
},
|
|
160
|
+
description:
|
|
161
|
+
"Designs and redesigns frontend UI and marketing graphics, routing to specialist skills per artifact type",
|
|
162
|
+
featured: true,
|
|
163
|
+
},
|
|
90
164
|
{
|
|
91
165
|
name: "claude-code-plugins",
|
|
92
166
|
displayName: "Anthropic Deprecated",
|
|
@@ -13,6 +13,7 @@ import type {
|
|
|
13
13
|
} from "../types/index.js";
|
|
14
14
|
import { parsePluginId } from "../utils/string-utils.js";
|
|
15
15
|
import { inheritablePaths } from "./git-worktree.js";
|
|
16
|
+
import { repoHeadSha } from "./content-drift.js";
|
|
16
17
|
|
|
17
18
|
const CLAUDE_DIR = ".claude";
|
|
18
19
|
const SETTINGS_FILE = "settings.json";
|
|
@@ -1509,19 +1510,23 @@ async function getPluginSourcePath(
|
|
|
1509
1510
|
/**
|
|
1510
1511
|
* Copy plugin files from source to cache
|
|
1511
1512
|
* This ensures the cache is populated with the latest plugin version
|
|
1513
|
+
*
|
|
1514
|
+
* Returns the directory the files were copied FROM, or null if nothing was
|
|
1515
|
+
* copied. The caller needs the source path, not just a success flag: that path
|
|
1516
|
+
* is what identifies the commit the cached files came from.
|
|
1512
1517
|
*/
|
|
1513
1518
|
async function copyPluginToCache(
|
|
1514
1519
|
pluginId: string,
|
|
1515
1520
|
version: string,
|
|
1516
1521
|
marketplace: string,
|
|
1517
|
-
): Promise<
|
|
1522
|
+
): Promise<string | null> {
|
|
1518
1523
|
const { pluginName } = parsePluginId(pluginId) || {
|
|
1519
1524
|
pluginName: pluginId.split("@")[0],
|
|
1520
1525
|
};
|
|
1521
1526
|
|
|
1522
1527
|
const sourcePath = await getPluginSourcePath(pluginName, marketplace);
|
|
1523
1528
|
if (!sourcePath) {
|
|
1524
|
-
return
|
|
1529
|
+
return null;
|
|
1525
1530
|
}
|
|
1526
1531
|
|
|
1527
1532
|
const cachePath = getPluginCachePath(pluginId, version, marketplace);
|
|
@@ -1538,16 +1543,42 @@ async function copyPluginToCache(
|
|
|
1538
1543
|
errorOnExist: false,
|
|
1539
1544
|
});
|
|
1540
1545
|
|
|
1541
|
-
return
|
|
1546
|
+
return sourcePath;
|
|
1542
1547
|
} catch (error) {
|
|
1543
1548
|
console.warn(
|
|
1544
1549
|
`Failed to copy plugin ${pluginId} to cache:`,
|
|
1545
1550
|
error instanceof Error ? error.message : "Unknown error",
|
|
1546
1551
|
);
|
|
1547
|
-
return
|
|
1552
|
+
return null;
|
|
1548
1553
|
}
|
|
1549
1554
|
}
|
|
1550
1555
|
|
|
1556
|
+
/**
|
|
1557
|
+
* The commit a cache copy should be stamped with.
|
|
1558
|
+
*
|
|
1559
|
+
* `gitCommitSha` must describe the files sitting in the cache RIGHT NOW, which
|
|
1560
|
+
* is the single thing content-drift detection reads it for. Two rules:
|
|
1561
|
+
*
|
|
1562
|
+
* - Files were copied → the source repo's HEAD is their provenance. Undefined
|
|
1563
|
+
* is a legitimate answer here: a directory-type marketplace has no commit,
|
|
1564
|
+
* and content-drift stays silent rather than guessing.
|
|
1565
|
+
* - Nothing was copied → the cache is untouched, so whatever sha already
|
|
1566
|
+
* described it still does. Advancing it would claim the cache holds newer
|
|
1567
|
+
* files than it does, hiding real drift.
|
|
1568
|
+
*
|
|
1569
|
+
* The bug this replaces did neither: it preserved the pre-update sha even when
|
|
1570
|
+
* fresh files HAD been copied, so the recorded commit drifted further behind
|
|
1571
|
+
* with every release and drift detection reported a false positive on every
|
|
1572
|
+
* plugin at once.
|
|
1573
|
+
*/
|
|
1574
|
+
export async function resolveCacheProvenance(
|
|
1575
|
+
copiedFrom: string | null,
|
|
1576
|
+
previousSha: string | undefined,
|
|
1577
|
+
): Promise<string | undefined> {
|
|
1578
|
+
if (!copiedFrom) return previousSha;
|
|
1579
|
+
return await repoHeadSha(copiedFrom);
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1551
1582
|
/**
|
|
1552
1583
|
* Read installed_plugins.json registry
|
|
1553
1584
|
*/
|
|
@@ -1632,7 +1663,7 @@ export async function updateInstalledPluginsRegistry(
|
|
|
1632
1663
|
|
|
1633
1664
|
// Copy plugin files from source to cache
|
|
1634
1665
|
// This ensures the cache has the latest plugin version
|
|
1635
|
-
await copyPluginToCache(pluginId, version, marketplace);
|
|
1666
|
+
const copiedFrom = await copyPluginToCache(pluginId, version, marketplace);
|
|
1636
1667
|
|
|
1637
1668
|
const installPath = getPluginCachePath(pluginId, version, marketplace);
|
|
1638
1669
|
const now = new Date().toISOString();
|
|
@@ -1659,10 +1690,12 @@ export async function updateInstalledPluginsRegistry(
|
|
|
1659
1690
|
? registry.plugins[pluginId][existingIndex].installedAt
|
|
1660
1691
|
: now,
|
|
1661
1692
|
lastUpdated: now,
|
|
1662
|
-
gitCommitSha:
|
|
1693
|
+
gitCommitSha: await resolveCacheProvenance(
|
|
1694
|
+
copiedFrom,
|
|
1663
1695
|
existingIndex >= 0
|
|
1664
1696
|
? registry.plugins[pluginId][existingIndex].gitCommitSha
|
|
1665
1697
|
: undefined,
|
|
1698
|
+
),
|
|
1666
1699
|
};
|
|
1667
1700
|
|
|
1668
1701
|
if (existingIndex >= 0) {
|
|
@@ -67,12 +67,33 @@ function git(cwd: string, args: string[]): Promise<{ code: number; out: string }
|
|
|
67
67
|
});
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* HEAD of the git repo containing `dir`, or undefined when there is none.
|
|
72
|
+
*
|
|
73
|
+
* This is what stamps provenance onto a cache copy: `copyPluginToCache` reads
|
|
74
|
+
* the plugin's files out of the marketplace clone, so that clone's HEAD is the
|
|
75
|
+
* commit those files came from. Undefined is a real answer — a directory-type
|
|
76
|
+
* marketplace has no commit to name, and inventing one would assert a
|
|
77
|
+
* provenance that is false.
|
|
78
|
+
*/
|
|
79
|
+
export async function repoHeadSha(dir: string): Promise<string | undefined> {
|
|
80
|
+
const r = await git(dir, ["rev-parse", "HEAD"]);
|
|
81
|
+
if (r.code !== 0) return undefined;
|
|
82
|
+
return /^[0-9a-f]{40}$/.test(r.out) ? r.out : undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
70
85
|
export interface DriftQuery {
|
|
71
86
|
marketplace: string;
|
|
72
87
|
/** Plugin name without the @marketplace suffix. */
|
|
73
88
|
pluginName: string;
|
|
74
89
|
/** `gitCommitSha` from the installed_plugins.json entry. */
|
|
75
90
|
installedSha: string | undefined;
|
|
91
|
+
/**
|
|
92
|
+
* Version currently installed. Required, not optional: the sha alone cannot
|
|
93
|
+
* be trusted, and a caller that omitted this would silently get the old
|
|
94
|
+
* always-drifted behaviour back.
|
|
95
|
+
*/
|
|
96
|
+
installedVersion: string | undefined;
|
|
76
97
|
/**
|
|
77
98
|
* Path of the plugin inside the marketplace repo, from the catalog `source`
|
|
78
99
|
* field (e.g. "./plugins/dev"). Defaults to `plugins/<name>`.
|
|
@@ -80,19 +101,52 @@ export interface DriftQuery {
|
|
|
80
101
|
sourcePath?: string;
|
|
81
102
|
}
|
|
82
103
|
|
|
104
|
+
/** Strip a leading "v" so "v1.2.3" and "1.2.3" compare equal. */
|
|
105
|
+
const normalize = (v: string) => v.trim().replace(/^v/, "");
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The version a plugin's manifest declared at a given commit.
|
|
109
|
+
*
|
|
110
|
+
* Both layouts are tried because both are real: magus lays plugins out as
|
|
111
|
+
* `plugins/<name>/plugin.json`, while Claude Code documents
|
|
112
|
+
* `.claude-plugin/plugin.json`. Reading only one would silence drift detection
|
|
113
|
+
* for every plugin using the other.
|
|
114
|
+
*/
|
|
115
|
+
async function versionAtSha(
|
|
116
|
+
repo: string,
|
|
117
|
+
sha: string,
|
|
118
|
+
rel: string,
|
|
119
|
+
): Promise<string | undefined> {
|
|
120
|
+
for (const manifest of [
|
|
121
|
+
`${rel}/plugin.json`,
|
|
122
|
+
`${rel}/.claude-plugin/plugin.json`,
|
|
123
|
+
]) {
|
|
124
|
+
const r = await git(repo, ["show", `${sha}:${manifest}`]);
|
|
125
|
+
if (r.code !== 0 || !r.out) continue;
|
|
126
|
+
try {
|
|
127
|
+
const v = JSON.parse(r.out)?.version;
|
|
128
|
+
if (typeof v === "string" && v) return v;
|
|
129
|
+
} catch {
|
|
130
|
+
// Unparseable manifest is the same as no manifest: unverifiable.
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
83
136
|
/**
|
|
84
137
|
* True when the plugin's files in the marketplace differ from the commit it was
|
|
85
138
|
* installed from — i.e. a reinstall would deliver different content.
|
|
86
139
|
*
|
|
87
140
|
* Returns false whenever the question cannot be answered honestly: no recorded
|
|
88
|
-
* sha, no clone, git unavailable,
|
|
89
|
-
* (force-push, shallow clone)
|
|
90
|
-
*
|
|
141
|
+
* sha, no clone, git unavailable, the recorded commit is absent locally
|
|
142
|
+
* (force-push, shallow clone), or that commit declares a DIFFERENT version than
|
|
143
|
+
* the one installed. A false negative leaves today's behaviour; a false
|
|
144
|
+
* positive would nag the user to reinstall for no reason.
|
|
91
145
|
*/
|
|
92
146
|
export async function hasContentDrift(q: DriftQuery): Promise<boolean> {
|
|
93
147
|
if (!q.installedSha) return false;
|
|
94
148
|
|
|
95
|
-
const key = `${q.marketplace}\0${q.pluginName}\0${q.installedSha}`;
|
|
149
|
+
const key = `${q.marketplace}\0${q.pluginName}\0${q.installedSha}\0${q.installedVersion}`;
|
|
96
150
|
const hit = cache.get(key);
|
|
97
151
|
if (hit !== undefined) return hit;
|
|
98
152
|
|
|
@@ -106,6 +160,29 @@ export async function hasContentDrift(q: DriftQuery): Promise<boolean> {
|
|
|
106
160
|
return false;
|
|
107
161
|
}
|
|
108
162
|
|
|
163
|
+
// The recorded sha must describe the version actually installed, or the diff
|
|
164
|
+
// answers a different question than the one asked.
|
|
165
|
+
//
|
|
166
|
+
// This is the whole reason the badge fired on every plugin at once:
|
|
167
|
+
// `updateInstalledPluginsRegistry` bumps `version` on an in-place update but
|
|
168
|
+
// carries the pre-update `gitCommitSha` forward, so the sha names whichever
|
|
169
|
+
// commit the plugin was FIRST installed from. Measured 2026-08-22 on one
|
|
170
|
+
// machine: dev recorded 4.3.0 against a sha holding 3.3.0, browser-use 1.4.1
|
|
171
|
+
// against 1.1.3. Diffing across those releases is trivially non-empty, so
|
|
172
|
+
// every plugin rendered "stale — reinstall" while its cached files were in
|
|
173
|
+
// fact byte-identical to the marketplace.
|
|
174
|
+
const shaVersion = q.installedVersion
|
|
175
|
+
? await versionAtSha(repo, q.installedSha, rel)
|
|
176
|
+
: undefined;
|
|
177
|
+
if (
|
|
178
|
+
!q.installedVersion ||
|
|
179
|
+
!shaVersion ||
|
|
180
|
+
normalize(shaVersion) !== normalize(q.installedVersion)
|
|
181
|
+
) {
|
|
182
|
+
cache.set(key, false);
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
|
|
109
186
|
// Exit 0 = no difference, 1 = differs, anything else = could not tell.
|
|
110
187
|
const diff = await git(repo, [
|
|
111
188
|
"diff",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { readFile, writeFile, stat, rename, unlink, open } from 'node:fs/promises';
|
|
17
|
+
import { existsSync } from 'node:fs';
|
|
17
18
|
import { randomUUID } from 'node:crypto';
|
|
18
19
|
import { dirname, basename, join, resolve } from 'node:path';
|
|
19
20
|
import { homedir } from 'node:os';
|
|
@@ -692,7 +693,11 @@ export async function resolveGlobalGitignorePath(): Promise<string | null> {
|
|
|
692
693
|
export async function resolvePluginConventions(
|
|
693
694
|
pluginPath: string,
|
|
694
695
|
): Promise<PluginConventions | null> {
|
|
695
|
-
|
|
696
|
+
// `.claude-plugin/plugin.json` is the location Claude Code reads; a root-level
|
|
697
|
+
// manifest is inert to the runtime but still worth reading for a third-party plugin
|
|
698
|
+
// whose author put it there.
|
|
699
|
+
const nested = join(pluginPath, '.claude-plugin', 'plugin.json');
|
|
700
|
+
const manifestPath = existsSync(nested) ? nested : join(pluginPath, 'plugin.json');
|
|
696
701
|
|
|
697
702
|
try {
|
|
698
703
|
const raw = await readFile(manifestPath, 'utf-8');
|
|
@@ -156,15 +156,19 @@ export function parsePluginRequires(pluginJson: unknown): PluginRequires {
|
|
|
156
156
|
export async function readPluginRequires(
|
|
157
157
|
pluginPath: string,
|
|
158
158
|
): Promise<PluginRequires> {
|
|
159
|
-
|
|
159
|
+
// `.claude-plugin/plugin.json` FIRST — that is the only location Claude Code's
|
|
160
|
+
// runtime loader reads, and the only one its documentation describes. The root
|
|
161
|
+
// fallback stays for third-party plugins whose authors put it there; such a manifest
|
|
162
|
+
// is inert as far as Claude Code is concerned, but claudeup can still read metadata
|
|
163
|
+
// out of it rather than reporting nothing.
|
|
164
|
+
const manifestPath = path.join(pluginPath, ".claude-plugin", "plugin.json");
|
|
160
165
|
try {
|
|
161
166
|
if (await fs.pathExists(manifestPath)) {
|
|
162
167
|
return parsePluginRequires(await fs.readJson(manifestPath));
|
|
163
168
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
return parsePluginRequires(await fs.readJson(nested));
|
|
169
|
+
const rootLevel = path.join(pluginPath, "plugin.json");
|
|
170
|
+
if (await fs.pathExists(rootLevel)) {
|
|
171
|
+
return parsePluginRequires(await fs.readJson(rootLevel));
|
|
168
172
|
}
|
|
169
173
|
} catch {
|
|
170
174
|
// Unreadable/malformed — treat as no requirements.
|