claudeup 4.34.0 → 4.35.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/__tests__/git-worktree.test.ts +108 -0
- package/src/__tests__/resolver.test.ts +71 -0
- package/src/__tests__/worktree-registry-resolution.test.ts +107 -0
- package/src/data/marketplaces.ts +13 -0
- package/src/services/claude-settings.ts +37 -7
- package/src/services/git-worktree.ts +129 -0
- package/src/services/plugin-manager.ts +10 -1
- package/src/services/resolver.ts +32 -1
- package/src/ui/screens/PluginsScreen.tsx +73 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claudeup",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.35.1",
|
|
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.35.1",
|
|
68
|
+
"claudeup-darwin-x64": "4.35.1",
|
|
69
|
+
"claudeup-linux-x64": "4.35.1"
|
|
70
70
|
}
|
|
71
71
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterAll,
|
|
3
|
+
beforeAll,
|
|
4
|
+
beforeEach,
|
|
5
|
+
describe,
|
|
6
|
+
expect,
|
|
7
|
+
test,
|
|
8
|
+
} from "bun:test";
|
|
9
|
+
import { execFileSync } from "node:child_process";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import fs from "fs-extra";
|
|
13
|
+
import {
|
|
14
|
+
clearWorktreeCache,
|
|
15
|
+
inheritablePaths,
|
|
16
|
+
} from "../services/git-worktree.js";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Exercised against real repositories rather than a mocked `git`, because the
|
|
20
|
+
* whole point of the function is agreeing with git about what a worktree is.
|
|
21
|
+
* A stub would have happily confirmed a wrong parse of `worktree list`.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
// Isolate from the operator's git config: a global `core.excludesFile`,
|
|
25
|
+
// `init.defaultBranch`, or commit signing would otherwise leak into these repos.
|
|
26
|
+
const GIT_CFG = [
|
|
27
|
+
"-c",
|
|
28
|
+
"user.name=test",
|
|
29
|
+
"-c",
|
|
30
|
+
"user.email=test@example.com",
|
|
31
|
+
"-c",
|
|
32
|
+
"commit.gpgsign=false",
|
|
33
|
+
"-c",
|
|
34
|
+
"init.defaultBranch=main",
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
const git = (cwd: string, ...args: string[]) =>
|
|
38
|
+
execFileSync("git", [...GIT_CFG, ...args], { cwd, encoding: "utf8" }).trim();
|
|
39
|
+
|
|
40
|
+
let base: string;
|
|
41
|
+
let mainTree: string;
|
|
42
|
+
let linked: string;
|
|
43
|
+
|
|
44
|
+
beforeAll(async () => {
|
|
45
|
+
// realpath: on macOS os.tmpdir() is a symlink, and git reports real paths.
|
|
46
|
+
base = await fs.realpath(
|
|
47
|
+
await fs.mkdtemp(path.join(os.tmpdir(), "claudeup-worktree-")),
|
|
48
|
+
);
|
|
49
|
+
mainTree = path.join(base, "repo");
|
|
50
|
+
linked = path.join(base, "linked");
|
|
51
|
+
|
|
52
|
+
await fs.ensureDir(mainTree);
|
|
53
|
+
git(mainTree, "init", "-q");
|
|
54
|
+
await fs.writeFile(path.join(mainTree, "README.md"), "hello\n");
|
|
55
|
+
git(mainTree, "add", "-A");
|
|
56
|
+
git(mainTree, "commit", "-qm", "initial");
|
|
57
|
+
git(mainTree, "worktree", "add", "-q", "-b", "feature", linked);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
afterAll(async () => {
|
|
61
|
+
if (base) await fs.remove(base);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
clearWorktreeCache();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("inheritablePaths", () => {
|
|
69
|
+
test("a linked worktree inherits from the main working tree", async () => {
|
|
70
|
+
expect(await inheritablePaths(linked)).toEqual([mainTree]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("the main working tree inherits nothing — it holds its own rows", async () => {
|
|
74
|
+
expect(await inheritablePaths(mainTree)).toEqual([]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("a subdirectory of a checkout inherits from the repository root", async () => {
|
|
78
|
+
const sub = path.join(mainTree, "packages", "app");
|
|
79
|
+
await fs.ensureDir(sub);
|
|
80
|
+
expect(await inheritablePaths(sub)).toEqual([mainTree]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("a subdirectory of a linked worktree inherits both roots, nearest first", async () => {
|
|
84
|
+
const sub = path.join(linked, "packages", "app");
|
|
85
|
+
await fs.ensureDir(sub);
|
|
86
|
+
expect(await inheritablePaths(sub)).toEqual([linked, mainTree]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("a directory outside any repository inherits nothing", async () => {
|
|
90
|
+
const loose = path.join(base, "not-a-repo");
|
|
91
|
+
await fs.ensureDir(loose);
|
|
92
|
+
// Guard: a stray repo above the temp dir would silently invalidate this.
|
|
93
|
+
const out = await inheritablePaths(loose);
|
|
94
|
+
expect(out.every((p) => !p.startsWith(base))).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("a missing directory yields nothing rather than throwing", async () => {
|
|
98
|
+
expect(await inheritablePaths(path.join(base, "gone"))).toEqual([]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("results are cached per path and cleared on demand", async () => {
|
|
102
|
+
const first = await inheritablePaths(linked);
|
|
103
|
+
expect(await inheritablePaths(linked)).toBe(first); // same array identity
|
|
104
|
+
clearWorktreeCache();
|
|
105
|
+
expect(await inheritablePaths(linked)).not.toBe(first);
|
|
106
|
+
expect(await inheritablePaths(linked)).toEqual([mainTree]);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -216,3 +216,74 @@ describe("resolveAllProfiles — union", () => {
|
|
|
216
216
|
expect(union.conflicts?.some((c) => c.includes("claudish"))).toBe(true);
|
|
217
217
|
});
|
|
218
218
|
});
|
|
219
|
+
|
|
220
|
+
describe("marketplaces derived from plugin ids", () => {
|
|
221
|
+
const noBins = { binResolver: fakeBinResolver({}) };
|
|
222
|
+
|
|
223
|
+
test("a plugin id registers the marketplace it names", async () => {
|
|
224
|
+
const manifest = manifestOf({
|
|
225
|
+
solo: { name: "Solo", plugins: { "dev@magus": "latest" } },
|
|
226
|
+
});
|
|
227
|
+
const c = await resolveProfile(manifest, "solo", noBins);
|
|
228
|
+
expect(c.marketplaces.magus).toEqual({
|
|
229
|
+
source: "github",
|
|
230
|
+
repo: "MadAppGang/magus",
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("extends alone is enough — the failure this fixes", async () => {
|
|
235
|
+
// A profile that only extends a predefined one inherits plugins like
|
|
236
|
+
// dev@magus and previously NO marketplaces, so install resolved every
|
|
237
|
+
// plugin against a marketplace that was never registered.
|
|
238
|
+
const manifest = manifestOf({
|
|
239
|
+
team: { name: "Team", extends: "developer-essentials" },
|
|
240
|
+
});
|
|
241
|
+
const c = await resolveProfile(manifest, "team", noBins);
|
|
242
|
+
expect(Object.keys(c.plugins).length).toBeGreaterThan(0);
|
|
243
|
+
expect(c.marketplaces.magus?.repo).toBe("MadAppGang/magus");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("each distinct channel is derived, not just the first", async () => {
|
|
247
|
+
const manifest = manifestOf({
|
|
248
|
+
mixed: {
|
|
249
|
+
name: "Mixed",
|
|
250
|
+
plugins: {
|
|
251
|
+
"dev@magus": "latest",
|
|
252
|
+
"seo@magus-marketing": "latest",
|
|
253
|
+
"autolinear@magus-alpha": "latest",
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
const c = await resolveProfile(manifest, "mixed", noBins);
|
|
258
|
+
expect(c.marketplaces["magus-marketing"]?.repo).toBe("MadAppGang/magus-marketing");
|
|
259
|
+
expect(c.marketplaces["magus-alpha"]?.repo).toBe("MadAppGang/magus-alpha");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("an explicit declaration is never overridden", async () => {
|
|
263
|
+
const manifest = manifestOf({
|
|
264
|
+
pinned: {
|
|
265
|
+
name: "Pinned",
|
|
266
|
+
marketplaces: { magus: { source: "github", repo: "MadAppGang/magus-fork" } },
|
|
267
|
+
plugins: { "dev@magus": "latest" },
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
const c = await resolveProfile(manifest, "pinned", noBins);
|
|
271
|
+
expect(c.marketplaces.magus?.repo).toBe("MadAppGang/magus-fork");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("an unknown marketplace is left for the manifest to declare", async () => {
|
|
275
|
+
const manifest = manifestOf({
|
|
276
|
+
third: { name: "Third", plugins: { "thing@someones-marketplace": "latest" } },
|
|
277
|
+
});
|
|
278
|
+
const c = await resolveProfile(manifest, "third", noBins);
|
|
279
|
+
expect(c.marketplaces["someones-marketplace"]).toBeUndefined();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("a bare plugin id contributes no marketplace", async () => {
|
|
283
|
+
const manifest = manifestOf({
|
|
284
|
+
bare: { name: "Bare", plugins: { local: "latest" } },
|
|
285
|
+
});
|
|
286
|
+
const c = await resolveProfile(manifest, "bare", noBins);
|
|
287
|
+
expect(Object.keys(c.marketplaces)).toHaveLength(0);
|
|
288
|
+
});
|
|
289
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { pickRegistryEntry } from "../services/claude-settings.js";
|
|
3
|
+
import type { InstalledPluginEntry } from "../types/index.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Regression cover for "a fresh git worktree reports every plugin as not installed".
|
|
7
|
+
*
|
|
8
|
+
* `installed_plugins.json` keys one entry per `projectPath`, and a new worktree is
|
|
9
|
+
* a project path nobody has ever installed into — measured on a real machine:
|
|
10
|
+
* 750 project-scope rows, zero of them for the worktree, and zero user-scope rows
|
|
11
|
+
* for any magus plugin. So `pickRegistryEntry` found nothing, `overlayRegistryVersions`
|
|
12
|
+
* deleted the version as an unbacked claim, and `isInstalledInScope` reported the
|
|
13
|
+
* plugin missing.
|
|
14
|
+
*
|
|
15
|
+
* Claude Code disagreed: it loaded all of them. Its loader is not gated on the
|
|
16
|
+
* current project — a worktree with no rows resolves `installPath` from another
|
|
17
|
+
* project's row and loads from cache (measured, `ai-docs/plugin-system-state-model.md`).
|
|
18
|
+
* So claudeup was offering to install plugins that were already working.
|
|
19
|
+
*
|
|
20
|
+
* A linked worktree is the same project as its main working tree, so the main
|
|
21
|
+
* tree's row is the honest answer for it.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const entry = (
|
|
25
|
+
scope: InstalledPluginEntry["scope"],
|
|
26
|
+
version: string,
|
|
27
|
+
projectPath?: string,
|
|
28
|
+
): InstalledPluginEntry => ({
|
|
29
|
+
scope,
|
|
30
|
+
version,
|
|
31
|
+
projectPath,
|
|
32
|
+
installPath: `/cache/${version}`,
|
|
33
|
+
installedAt: "2026-01-01T00:00:00.000Z",
|
|
34
|
+
lastUpdated: "2026-01-01T00:00:00.000Z",
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const MAIN = "/repo";
|
|
38
|
+
const TREE = "/repo/.claude/worktrees/feature";
|
|
39
|
+
|
|
40
|
+
describe("pickRegistryEntry — worktree fallback", () => {
|
|
41
|
+
test("a worktree with no rows of its own resolves to the main working tree's row", () => {
|
|
42
|
+
const entries = [entry("project", "3.3.0", MAIN)];
|
|
43
|
+
expect(pickRegistryEntry(entries, "project", TREE, [MAIN])?.version).toBe(
|
|
44
|
+
"3.3.0",
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("the worktree's own row still wins over the main tree's row", () => {
|
|
49
|
+
const entries = [
|
|
50
|
+
entry("project", "3.3.0", MAIN),
|
|
51
|
+
entry("project", "4.0.0", TREE),
|
|
52
|
+
];
|
|
53
|
+
expect(pickRegistryEntry(entries, "project", TREE, [MAIN])?.version).toBe(
|
|
54
|
+
"4.0.0",
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("a local-scope row on the main tree satisfies a worktree project lookup", () => {
|
|
59
|
+
const entries = [entry("local", "5.0.0", MAIN)];
|
|
60
|
+
expect(pickRegistryEntry(entries, "project", TREE, [MAIN])?.version).toBe(
|
|
61
|
+
"5.0.0",
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("the main-tree row outranks a user-scope entry", () => {
|
|
66
|
+
// Same project beats "installed globally somewhere". The user entry is the
|
|
67
|
+
// looser fallback and stays available when the main tree has no row.
|
|
68
|
+
const entries = [entry("user", "1.0.0"), entry("project", "3.3.0", MAIN)];
|
|
69
|
+
expect(pickRegistryEntry(entries, "project", TREE, [MAIN])?.version).toBe(
|
|
70
|
+
"3.3.0",
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("the user-scope entry still applies when the main tree has no row either", () => {
|
|
75
|
+
const entries = [
|
|
76
|
+
entry("user", "2.1.2"),
|
|
77
|
+
entry("project", "9.9.9", "/elsewhere"),
|
|
78
|
+
];
|
|
79
|
+
expect(pickRegistryEntry(entries, "project", TREE, [MAIN])?.version).toBe(
|
|
80
|
+
"2.1.2",
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("an unrelated project's row is never inherited", () => {
|
|
85
|
+
// The loader may well resolve one of these, but claudeup must not claim a
|
|
86
|
+
// version it cannot attribute to this project.
|
|
87
|
+
const entries = [entry("project", "9.9.9", "/some/other/project")];
|
|
88
|
+
expect(pickRegistryEntry(entries, "project", TREE, [MAIN])).toBeUndefined();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("fallback paths are compared resolved, not as raw strings", () => {
|
|
92
|
+
const entries = [entry("project", "4.2.0", "/repo/./sub/..")];
|
|
93
|
+
expect(pickRegistryEntry(entries, "project", TREE, [MAIN])?.version).toBe(
|
|
94
|
+
"4.2.0",
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("fallback paths are ignored for a user-scope lookup", () => {
|
|
99
|
+
const entries = [entry("project", "3.3.0", MAIN)];
|
|
100
|
+
expect(pickRegistryEntry(entries, "user", TREE, [MAIN])).toBeUndefined();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("omitting fallback paths preserves the previous behaviour", () => {
|
|
104
|
+
const entries = [entry("project", "3.3.0", MAIN)];
|
|
105
|
+
expect(pickRegistryEntry(entries, "project", TREE)).toBeUndefined();
|
|
106
|
+
});
|
|
107
|
+
});
|
package/src/data/marketplaces.ts
CHANGED
|
@@ -53,6 +53,19 @@ export const defaultMarketplaces: Marketplace[] = [
|
|
|
53
53
|
owned: true, // Ours: always listed even when uninstalled, sorted to the top
|
|
54
54
|
// Not featured: a niche channel, so keep it collapsed by default.
|
|
55
55
|
},
|
|
56
|
+
{
|
|
57
|
+
name: "magus-alpha",
|
|
58
|
+
displayName: "Magus Alpha",
|
|
59
|
+
source: {
|
|
60
|
+
source: "github",
|
|
61
|
+
repo: "MadAppGang/magus-alpha",
|
|
62
|
+
},
|
|
63
|
+
description:
|
|
64
|
+
"Experimental plugins with evolving interfaces — may change or be withdrawn without notice",
|
|
65
|
+
official: false,
|
|
66
|
+
owned: true, // Ours: always listed even when uninstalled, sorted to the top
|
|
67
|
+
// Not featured: experimental, so keep it collapsed by default.
|
|
68
|
+
},
|
|
56
69
|
{
|
|
57
70
|
name: "claude-plugins-official",
|
|
58
71
|
displayName: "Anthropic Official",
|
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
InstalledPluginEntry,
|
|
13
13
|
} from "../types/index.js";
|
|
14
14
|
import { parsePluginId } from "../utils/string-utils.js";
|
|
15
|
+
import { inheritablePaths } from "./git-worktree.js";
|
|
15
16
|
|
|
16
17
|
const CLAUDE_DIR = ".claude";
|
|
17
18
|
const SETTINGS_FILE = "settings.json";
|
|
@@ -261,27 +262,44 @@ export async function getLocalEnabledPlugins(
|
|
|
261
262
|
* The registry is what Claude Code actually loaded, so it wins on conflict.
|
|
262
263
|
*
|
|
263
264
|
* Scope resolution mirrors Claude Code: for a project scope, prefer an entry
|
|
264
|
-
* pinned to that exact projectPath (project or local), then
|
|
265
|
-
*
|
|
265
|
+
* pinned to that exact projectPath (project or local), then any `fallbackPaths`
|
|
266
|
+
* that are the same repository (see git-worktree.ts), then the user-scope entry,
|
|
267
|
+
* which is what a project without its own install resolves to.
|
|
266
268
|
*/
|
|
267
269
|
export function pickRegistryEntry(
|
|
268
270
|
entries: InstalledPluginEntry[] | undefined,
|
|
269
271
|
scope: "user" | "project" | "local",
|
|
270
272
|
projectPath?: string,
|
|
273
|
+
/**
|
|
274
|
+
* Same-repository directories whose rows may be inherited, nearest first —
|
|
275
|
+
* a linked worktree's main working tree, or the root of this checkout.
|
|
276
|
+
*
|
|
277
|
+
* A fresh worktree has no rows of its own, so without this every plugin read
|
|
278
|
+
* as not installed while Claude Code was loading all of them quite happily.
|
|
279
|
+
* These rank above the user-scope entry: "installed in this repository" is a
|
|
280
|
+
* closer answer than "installed globally somewhere".
|
|
281
|
+
*/
|
|
282
|
+
fallbackPaths: string[] = [],
|
|
271
283
|
): InstalledPluginEntry | undefined {
|
|
272
284
|
if (!Array.isArray(entries) || entries.length === 0) return undefined;
|
|
273
285
|
|
|
274
286
|
if (scope === "user") return entries.find((e) => e.scope === "user");
|
|
275
287
|
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
const pinned = entries.find(
|
|
288
|
+
const pinnedTo = (target: string) =>
|
|
289
|
+
entries.find(
|
|
279
290
|
(e) =>
|
|
280
291
|
(e.scope === scope || e.scope === "local") &&
|
|
281
292
|
e.projectPath !== undefined &&
|
|
282
293
|
path.resolve(e.projectPath) === target,
|
|
283
294
|
);
|
|
284
|
-
|
|
295
|
+
|
|
296
|
+
if (projectPath) {
|
|
297
|
+
const own = pinnedTo(path.resolve(projectPath));
|
|
298
|
+
if (own) return own;
|
|
299
|
+
}
|
|
300
|
+
for (const fallback of fallbackPaths) {
|
|
301
|
+
const inherited = pinnedTo(path.resolve(fallback));
|
|
302
|
+
if (inherited) return inherited;
|
|
285
303
|
}
|
|
286
304
|
// A project with no install of its own resolves to the user-scope entry.
|
|
287
305
|
return entries.find((e) => e.scope === "user");
|
|
@@ -299,9 +317,21 @@ async function overlayRegistryVersions(
|
|
|
299
317
|
return base; // registry unreadable — settings-only is still better than nothing
|
|
300
318
|
}
|
|
301
319
|
|
|
320
|
+
// `readSettings(undefined)` already resolves to the current directory, so the
|
|
321
|
+
// registry lookup has to as well. While it did not, the base map described one
|
|
322
|
+
// project while the overlay refused to match any project's rows at all, and every
|
|
323
|
+
// version was deleted as unbacked — which is why the prerunner and the global
|
|
324
|
+
// plugin view, both of which pass no path, saw project-scope plugins as
|
|
325
|
+
// uninstalled everywhere, not only in a worktree.
|
|
326
|
+
const target = scope === "user" ? undefined : (projectPath ?? process.cwd());
|
|
327
|
+
|
|
328
|
+
// Resolved once, not per plugin: this shells out to git, and the loop runs
|
|
329
|
+
// across every key in the registry (76 on a well-used machine) for each scope.
|
|
330
|
+
const fallbackPaths = target ? await inheritablePaths(target) : [];
|
|
331
|
+
|
|
302
332
|
const merged = { ...base };
|
|
303
333
|
for (const [pluginId, entries] of Object.entries(registry.plugins ?? {})) {
|
|
304
|
-
const pick = pickRegistryEntry(entries, scope,
|
|
334
|
+
const pick = pickRegistryEntry(entries, scope, target, fallbackPaths);
|
|
305
335
|
if (pick?.version) {
|
|
306
336
|
merged[pluginId] = pick.version;
|
|
307
337
|
continue;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* git-worktree.ts — which other directories count as "the same project" as this one.
|
|
3
|
+
*
|
|
4
|
+
* `installed_plugins.json` keys one entry per `projectPath`, so a directory that
|
|
5
|
+
* nobody has run `claude plugin install` in has no rows at all. A freshly created
|
|
6
|
+
* git worktree is exactly that: measured on a real machine, 750 project-scope rows
|
|
7
|
+
* across 76 plugin keys and not one of them for the new worktree.
|
|
8
|
+
*
|
|
9
|
+
* Claude Code does not care. Its loader is not gated on the current project — a
|
|
10
|
+
* worktree with zero rows resolves `installPath` from another project's row and
|
|
11
|
+
* loads from cache (measured, `ai-docs/plugin-system-state-model.md`). claudeup
|
|
12
|
+
* mirrored the registry faithfully instead, so it reported every plugin as missing
|
|
13
|
+
* and offered to install things that were already working.
|
|
14
|
+
*
|
|
15
|
+
* The fix is to ask a narrower question than "does the loader find something
|
|
16
|
+
* somewhere": inherit rows only from directories that are the *same repository*.
|
|
17
|
+
* That covers both ways a project path can miss its own rows —
|
|
18
|
+
*
|
|
19
|
+
* 1. a linked worktree, whose main working tree holds the rows
|
|
20
|
+
* 2. a subdirectory of a checkout, where the rows sit at the repo root
|
|
21
|
+
*
|
|
22
|
+
* — under one rule, and never inherits from an unrelated project, which would be
|
|
23
|
+
* claiming a version we cannot attribute.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { spawn } from "node:child_process";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
|
|
29
|
+
function git(
|
|
30
|
+
cwd: string,
|
|
31
|
+
args: string[],
|
|
32
|
+
): Promise<{ code: number; out: string }> {
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
const child = spawn("git", args, {
|
|
35
|
+
cwd,
|
|
36
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
37
|
+
});
|
|
38
|
+
let out = "";
|
|
39
|
+
child.stdout.on("data", (d) => {
|
|
40
|
+
out += String(d);
|
|
41
|
+
});
|
|
42
|
+
// Never let a wedged git block the UI.
|
|
43
|
+
const timer = setTimeout(() => {
|
|
44
|
+
child.kill();
|
|
45
|
+
resolve({ code: -1, out: "" });
|
|
46
|
+
}, 5000);
|
|
47
|
+
timer.unref?.();
|
|
48
|
+
const settle = (code: number, text: string) => {
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
resolve({ code, out: text });
|
|
51
|
+
};
|
|
52
|
+
child.on("error", () => settle(-1, ""));
|
|
53
|
+
child.on("close", (code) => settle(code ?? -1, out.trim()));
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Session cache. Keyed by resolved project path — the repository layout cannot
|
|
59
|
+
* change under a running claudeup in any way worth tracking, and the resolution
|
|
60
|
+
* runs once per plugin per scope otherwise (24+ plugins × 3 scopes of `git`).
|
|
61
|
+
*/
|
|
62
|
+
const cache = new Map<string, string[]>();
|
|
63
|
+
|
|
64
|
+
export function clearWorktreeCache(): void {
|
|
65
|
+
cache.clear();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Directories whose registry rows this project may inherit, nearest first.
|
|
70
|
+
*
|
|
71
|
+
* Returns `[]` — never a guess — when the question cannot be answered honestly:
|
|
72
|
+
* git missing, not a repository, a bare repo with no working tree, or the path
|
|
73
|
+
* already being the repository root. Inheriting nothing restores the previous
|
|
74
|
+
* behaviour for that path, which is the safe direction: a false negative shows
|
|
75
|
+
* an "install" the user does not need, a false positive hides a real one.
|
|
76
|
+
*/
|
|
77
|
+
export async function inheritablePaths(projectPath: string): Promise<string[]> {
|
|
78
|
+
const key = path.resolve(projectPath);
|
|
79
|
+
const cached = cache.get(key);
|
|
80
|
+
if (cached) return cached;
|
|
81
|
+
const { paths, answered } = await resolveInheritable(key);
|
|
82
|
+
// Only cache an answer git actually gave. A timeout or a transient spawn
|
|
83
|
+
// failure would otherwise pin an empty result for the rest of the session,
|
|
84
|
+
// turning one slow moment into a persistent wrong "not installed".
|
|
85
|
+
if (answered) cache.set(key, paths);
|
|
86
|
+
return paths;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function resolveInheritable(
|
|
90
|
+
dir: string,
|
|
91
|
+
): Promise<{ paths: string[]; answered: boolean }> {
|
|
92
|
+
const candidates: string[] = [];
|
|
93
|
+
|
|
94
|
+
// Independent queries, so run them together: sequentially, two wedged git calls
|
|
95
|
+
// would stall the first paint for the full 10s rather than 5.
|
|
96
|
+
const [top, list] = await Promise.all([
|
|
97
|
+
// The root of whichever working tree we are standing in. Covers a subdirectory
|
|
98
|
+
// of a checkout, and is the nearer answer when both apply.
|
|
99
|
+
git(dir, ["rev-parse", "--show-toplevel"]),
|
|
100
|
+
// The main working tree, which `git worktree list` always lists first. This is
|
|
101
|
+
// the row-bearing directory for a linked worktree.
|
|
102
|
+
git(dir, ["worktree", "list", "--porcelain"]),
|
|
103
|
+
]);
|
|
104
|
+
|
|
105
|
+
if (top.code === 0 && top.out) candidates.push(path.resolve(top.out));
|
|
106
|
+
|
|
107
|
+
if (list.code === 0 && list.out) {
|
|
108
|
+
const firstRecord = list.out.split("\n\n")[0].split("\n");
|
|
109
|
+
// A bare repo is listed with a `bare` line and has no working tree to inherit.
|
|
110
|
+
const isBare = firstRecord.some((l) => l.trim() === "bare");
|
|
111
|
+
const line = firstRecord.find((l) => l.startsWith("worktree "));
|
|
112
|
+
if (!isBare && line) {
|
|
113
|
+
candidates.push(path.resolve(line.slice("worktree ".length).trim()));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// "Not a repository" is a real answer worth caching; "git never replied" is not.
|
|
118
|
+
const answered = top.code !== -1 || list.code !== -1;
|
|
119
|
+
|
|
120
|
+
// Drop this project itself and any duplicate, preserving nearest-first order.
|
|
121
|
+
const seen = new Set<string>([dir]);
|
|
122
|
+
const inheritable: string[] = [];
|
|
123
|
+
for (const candidate of candidates) {
|
|
124
|
+
if (seen.has(candidate)) continue;
|
|
125
|
+
seen.add(candidate);
|
|
126
|
+
inheritable.push(candidate);
|
|
127
|
+
}
|
|
128
|
+
return { paths: inheritable, answered };
|
|
129
|
+
}
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
removeFromInstalledPluginsRegistry,
|
|
19
19
|
} from "./claude-settings.js";
|
|
20
20
|
import { hasContentDrift } from "./content-drift.js";
|
|
21
|
+
import { inheritablePaths } from "./git-worktree.js";
|
|
21
22
|
import { defaultMarketplaces } from "../data/marketplaces.js";
|
|
22
23
|
import type {
|
|
23
24
|
InstalledPluginsRegistry,
|
|
@@ -616,6 +617,13 @@ async function annotateContentDrift(
|
|
|
616
617
|
return;
|
|
617
618
|
}
|
|
618
619
|
|
|
620
|
+
// Must match how the version itself was resolved. Without this a worktree
|
|
621
|
+
// inherits an installed version but never its `gitCommitSha`, so drift
|
|
622
|
+
// detection silently switches off for exactly the plugins that just started
|
|
623
|
+
// reporting as installed.
|
|
624
|
+
const target = scope === "user" ? undefined : (projectPath ?? process.cwd());
|
|
625
|
+
const fallbackPaths = target ? await inheritablePaths(target) : [];
|
|
626
|
+
|
|
619
627
|
await Promise.all(
|
|
620
628
|
plugins.map(async (plugin) => {
|
|
621
629
|
if (plugin.hasUpdate || plugin.isOrphaned) return;
|
|
@@ -625,7 +633,8 @@ async function annotateContentDrift(
|
|
|
625
633
|
const entry = pickRegistryEntry(
|
|
626
634
|
registry.plugins[plugin.id],
|
|
627
635
|
scope,
|
|
628
|
-
|
|
636
|
+
target,
|
|
637
|
+
fallbackPaths,
|
|
629
638
|
);
|
|
630
639
|
if (!entry?.gitCommitSha) return;
|
|
631
640
|
|
package/src/services/resolver.ts
CHANGED
|
@@ -17,6 +17,7 @@ import type {
|
|
|
17
17
|
ResolvedClosure,
|
|
18
18
|
} from "../types/index.js";
|
|
19
19
|
import { cliTools } from "../data/cli-tools.js";
|
|
20
|
+
import { getMarketplaceByName } from "../data/marketplaces.js";
|
|
20
21
|
import { PREDEFINED_PROFILES } from "../data/predefined-profiles.js";
|
|
21
22
|
import { resolvePluginBinRequirements } from "./plugin-requires.js";
|
|
22
23
|
|
|
@@ -147,6 +148,36 @@ async function resolveBins(
|
|
|
147
148
|
}
|
|
148
149
|
|
|
149
150
|
/** Resolve a single profile (applying `extends`) into an install closure. */
|
|
151
|
+
/**
|
|
152
|
+
* Add the marketplace every plugin id already names.
|
|
153
|
+
*
|
|
154
|
+
* A plugin id carries its marketplace — `dev@magus`, `seo@magus-marketing` —
|
|
155
|
+
* so requiring the manifest to ALSO declare that marketplace is redundant, and
|
|
156
|
+
* forgetting to is silent: the plugin resolves against a marketplace that was
|
|
157
|
+
* never registered. `extends` made this the default failure, because it
|
|
158
|
+
* inherits plugins from a predefined profile and no marketplaces at all.
|
|
159
|
+
*
|
|
160
|
+
* Only marketplaces claudeup already knows are derived, since a repo is needed
|
|
161
|
+
* to register one. An unknown suffix is left alone for the manifest to declare.
|
|
162
|
+
* An explicit entry always wins: deriving must never override a pin.
|
|
163
|
+
*/
|
|
164
|
+
function withDerivedMarketplaces(
|
|
165
|
+
declared: ProfileManifestEntry["marketplaces"],
|
|
166
|
+
plugins: Record<string, string>,
|
|
167
|
+
): NonNullable<ProfileManifestEntry["marketplaces"]> {
|
|
168
|
+
const out = { ...(declared ?? {}) };
|
|
169
|
+
for (const id of Object.keys(plugins)) {
|
|
170
|
+
const at = id.lastIndexOf("@");
|
|
171
|
+
if (at <= 0) continue;
|
|
172
|
+
const name = id.slice(at + 1);
|
|
173
|
+
if (out[name]) continue;
|
|
174
|
+
const known = getMarketplaceByName(name);
|
|
175
|
+
if (!known?.source.repo) continue;
|
|
176
|
+
out[name] = { source: "github", repo: known.source.repo };
|
|
177
|
+
}
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
|
|
150
181
|
export async function resolveProfile(
|
|
151
182
|
manifest: ProfileManifest,
|
|
152
183
|
profileId: string,
|
|
@@ -161,7 +192,7 @@ export async function resolveProfile(
|
|
|
161
192
|
const bins = await resolveBins(entry, Object.keys(plugins), binResolver);
|
|
162
193
|
|
|
163
194
|
return {
|
|
164
|
-
marketplaces:
|
|
195
|
+
marketplaces: withDerivedMarketplaces(entry.marketplaces, plugins),
|
|
165
196
|
plugins,
|
|
166
197
|
mcpServers: { ...(entry.mcpServers ?? {}) },
|
|
167
198
|
bins,
|
|
@@ -6,7 +6,7 @@ import { ScreenLayout } from "../components/layout/index.js";
|
|
|
6
6
|
import { ScrollableList } from "../components/ScrollableList.js";
|
|
7
7
|
import { EmptyFilterState } from "../components/EmptyFilterState.js";
|
|
8
8
|
import { fuzzyFilter } from "../../utils/fuzzy-search.js";
|
|
9
|
-
import { getAllMarketplaces } from "../../data/marketplaces.js";
|
|
9
|
+
import { defaultMarketplaces, getAllMarketplaces } from "../../data/marketplaces.js";
|
|
10
10
|
import { clearContentDriftCache } from "../../services/content-drift.js";
|
|
11
11
|
import {
|
|
12
12
|
diffVersions,
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
} from "../../services/claude-settings.js";
|
|
34
34
|
import { saveProfile } from "../../services/profiles.js";
|
|
35
35
|
import {
|
|
36
|
+
addMarketplace as cliAddMarketplace,
|
|
36
37
|
installPlugin as cliInstallPlugin,
|
|
37
38
|
repairPlugin as cliRepairPlugin,
|
|
38
39
|
uninstallPlugin as cliUninstallPlugin,
|
|
@@ -387,16 +388,60 @@ export function PluginsScreen() {
|
|
|
387
388
|
}
|
|
388
389
|
};
|
|
389
390
|
|
|
391
|
+
/**
|
|
392
|
+
* Add a marketplace.
|
|
393
|
+
*
|
|
394
|
+
* The known ones are offered as a menu and added here — that covers every
|
|
395
|
+
* marketplace claudeup ships with, including magus, so a first run does not
|
|
396
|
+
* have to leave the tool. An arbitrary owner/repo still needs the terminal:
|
|
397
|
+
* there is no text-input modal, only confirm/message/select.
|
|
398
|
+
*/
|
|
390
399
|
const handleShowAddMarketplaceInstructions = async () => {
|
|
400
|
+
const installed = new Set(
|
|
401
|
+
pluginsState.marketplaces.status === "success"
|
|
402
|
+
? pluginsState.marketplaces.data.map((m) => m.name)
|
|
403
|
+
: [],
|
|
404
|
+
);
|
|
405
|
+
const addable = defaultMarketplaces.filter(
|
|
406
|
+
(m) => !installed.has(m.name) && !m.deprecated && m.source.repo,
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
if (addable.length > 0) {
|
|
410
|
+
const choice = await modal.select(
|
|
411
|
+
"Add Marketplace",
|
|
412
|
+
"Pick one to add now, or choose Other for a repo not listed.",
|
|
413
|
+
[
|
|
414
|
+
...addable.map((m) => ({
|
|
415
|
+
label: m.displayName || m.name,
|
|
416
|
+
value: m.source.repo as string,
|
|
417
|
+
description: m.description,
|
|
418
|
+
})),
|
|
419
|
+
{ label: "Other…", value: "", description: "Any owner/repo" },
|
|
420
|
+
],
|
|
421
|
+
);
|
|
422
|
+
if (choice === null) return;
|
|
423
|
+
if (choice) {
|
|
424
|
+
try {
|
|
425
|
+
await cliAddMarketplace(choice);
|
|
426
|
+
await handleRefresh();
|
|
427
|
+
} catch (e) {
|
|
428
|
+
await modal.message(
|
|
429
|
+
"Could not add marketplace",
|
|
430
|
+
`${(e as Error).message}\n\nRun it yourself if this persists:\n\n` +
|
|
431
|
+
` claude plugin marketplace add ${choice}`,
|
|
432
|
+
"error",
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
391
439
|
await modal.message(
|
|
392
440
|
"Add Marketplace",
|
|
393
|
-
"
|
|
441
|
+
"For a marketplace that is not listed, run this in your terminal:\n\n" +
|
|
394
442
|
" claude plugin marketplace add owner/repo\n\n" +
|
|
395
|
-
"Examples:\n" +
|
|
396
|
-
" claude plugin marketplace add MadAppGang/magus\n" +
|
|
397
|
-
" claude plugin marketplace add anthropics/claude-plugins-official\n\n" +
|
|
398
443
|
"Auto-update is enabled by default for new marketplaces.\n\n" +
|
|
399
|
-
"After adding, refresh claudeup with 'r' to see
|
|
444
|
+
"After adding, refresh claudeup with 'r' to see it.",
|
|
400
445
|
"info",
|
|
401
446
|
);
|
|
402
447
|
};
|
|
@@ -593,14 +638,30 @@ export function PluginsScreen() {
|
|
|
593
638
|
);
|
|
594
639
|
}
|
|
595
640
|
} else {
|
|
596
|
-
|
|
641
|
+
// Add it here rather than printing the command for the user to run.
|
|
642
|
+
// Telling someone to leave the tool, paste a command, come back and
|
|
643
|
+
// press 'r' is a step claudeup can just take: it already knows the
|
|
644
|
+
// repo, and addMarketplace() is the same call they would make.
|
|
645
|
+
const repo = mp.source.repo || mp.name;
|
|
646
|
+
const wantAdd = await modal.confirm(
|
|
597
647
|
`Add ${mp.displayName}?`,
|
|
598
|
-
`
|
|
599
|
-
`
|
|
600
|
-
`Auto-update is enabled by default.\n\n` +
|
|
601
|
-
`After adding, refresh claudeup with 'r' to see it.`,
|
|
602
|
-
"info",
|
|
648
|
+
`Clone ${repo} and register it as a marketplace?\n\n` +
|
|
649
|
+
`Auto-update is enabled by default.`,
|
|
603
650
|
);
|
|
651
|
+
if (wantAdd) {
|
|
652
|
+
try {
|
|
653
|
+
await cliAddMarketplace(repo);
|
|
654
|
+
await handleRefresh();
|
|
655
|
+
} catch (e) {
|
|
656
|
+
await modal.message(
|
|
657
|
+
"Could not add marketplace",
|
|
658
|
+
`${(e as Error).message}\n\n` +
|
|
659
|
+
`Run it yourself if this persists:\n\n` +
|
|
660
|
+
` claude plugin marketplace add ${repo}`,
|
|
661
|
+
"error",
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
604
665
|
}
|
|
605
666
|
} else if (item.kind === "plugin") {
|
|
606
667
|
const plugin = item.plugin;
|