claudeup 4.35.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudeup",
3
- "version": "4.35.0",
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.35.0",
68
- "claudeup-darwin-x64": "4.35.0",
69
- "claudeup-linux-x64": "4.35.0"
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
+ });
@@ -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
+ });
@@ -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 fall back to the
265
- * user-scope entry, which is what a project without its own install resolves to.
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 target = projectPath ? path.resolve(projectPath) : undefined;
277
- if (target) {
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
- if (pinned) return pinned;
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, projectPath);
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
- projectPath,
636
+ target,
637
+ fallbackPaths,
629
638
  );
630
639
  if (!entry?.gitCommitSha) return;
631
640