claudeup 4.35.0 → 4.36.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.
Files changed (63) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/git-worktree.test.ts +108 -0
  3. package/src/__tests__/scope-squares.test.tsx +165 -0
  4. package/src/__tests__/theme-adaptive-colors.test.ts +307 -0
  5. package/src/__tests__/uppercase-keybindings.test.ts +101 -0
  6. package/src/__tests__/worktree-registry-resolution.test.ts +107 -0
  7. package/src/main.tsx +21 -5
  8. package/src/opentui.d.ts +21 -12
  9. package/src/services/claude-settings.ts +37 -7
  10. package/src/services/git-worktree.ts +129 -0
  11. package/src/services/plugin-manager.ts +10 -1
  12. package/src/ui/App.tsx +19 -12
  13. package/src/ui/adapters/pluginsAdapter.ts +174 -168
  14. package/src/ui/adapters/settingsAdapter.ts +119 -116
  15. package/src/ui/adapters/skillsAdapter.ts +203 -196
  16. package/src/ui/components/CategoryHeader.tsx +9 -8
  17. package/src/ui/components/EmptyFilterState.tsx +10 -5
  18. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  19. package/src/ui/components/ScopeIndicator.tsx +10 -6
  20. package/src/ui/components/ScrollableList.tsx +3 -2
  21. package/src/ui/components/SearchInput.tsx +2 -1
  22. package/src/ui/components/StyledText.tsx +5 -4
  23. package/src/ui/components/TabBar.tsx +4 -3
  24. package/src/ui/components/layout/FooterHints.tsx +37 -30
  25. package/src/ui/components/layout/Panel.tsx +6 -5
  26. package/src/ui/components/layout/ProgressBar.tsx +7 -6
  27. package/src/ui/components/layout/ScopeTabs.tsx +6 -5
  28. package/src/ui/components/layout/ScreenLayout.tsx +27 -24
  29. package/src/ui/components/layout/index.ts +3 -3
  30. package/src/ui/components/modals/ConfirmModal.tsx +12 -11
  31. package/src/ui/components/modals/InputModal.tsx +14 -6
  32. package/src/ui/components/modals/LoadingModal.tsx +6 -5
  33. package/src/ui/components/modals/MessageModal.tsx +9 -8
  34. package/src/ui/components/modals/SelectModal.tsx +11 -7
  35. package/src/ui/components/modals/VersionMismatchModal.tsx +14 -16
  36. package/src/ui/components/primitives/ActionHints.tsx +26 -26
  37. package/src/ui/components/primitives/DetailSection.tsx +13 -12
  38. package/src/ui/components/primitives/KeyValueLine.tsx +9 -8
  39. package/src/ui/components/primitives/ListCategoryRow.tsx +25 -27
  40. package/src/ui/components/primitives/MetaText.tsx +3 -3
  41. package/src/ui/components/primitives/ScopeDetail.tsx +48 -48
  42. package/src/ui/components/primitives/ScopeSquares.tsx +47 -22
  43. package/src/ui/components/primitives/SelectableRow.tsx +22 -16
  44. package/src/ui/hooks/useGitignoreModal.ts +78 -74
  45. package/src/ui/registry.ts +11 -11
  46. package/src/ui/renderers/cliToolRenderers.tsx +260 -203
  47. package/src/ui/renderers/gitignoreRenderers.tsx +43 -42
  48. package/src/ui/renderers/mcpRenderers.tsx +121 -117
  49. package/src/ui/renderers/pluginRenderers.tsx +528 -471
  50. package/src/ui/renderers/profileRenderers.tsx +346 -300
  51. package/src/ui/renderers/settingsRenderers.tsx +183 -176
  52. package/src/ui/renderers/skillRenderers.tsx +410 -326
  53. package/src/ui/screens/AliasScreen.tsx +1336 -1309
  54. package/src/ui/screens/CliToolsScreen.tsx +92 -40
  55. package/src/ui/screens/EnvVarsScreen.tsx +19 -13
  56. package/src/ui/screens/GitignoreScreen.tsx +510 -493
  57. package/src/ui/screens/McpRegistryScreen.tsx +28 -21
  58. package/src/ui/screens/McpScreen.tsx +12 -3
  59. package/src/ui/screens/PluginsScreen.tsx +17 -7
  60. package/src/ui/screens/ProfilesScreen.tsx +39 -23
  61. package/src/ui/screens/SkillsScreen.tsx +832 -688
  62. package/src/ui/theme-mode.ts +73 -0
  63. package/src/ui/theme.ts +147 -53
@@ -0,0 +1,101 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import path from "node:path";
3
+ import { parseKeypress } from "@opentui/core";
4
+ import fs from "fs-extra";
5
+
6
+ /**
7
+ * A shifted key never arrives as an uppercase `name`.
8
+ *
9
+ * OpenTUI reports Shift+U as `{name: "u", shift: true, sequence: "U"}`, so
10
+ * `event.name === "U"` is unreachable. That is not merely dead code: in
11
+ * PluginsScreen the dead branch sat BELOW `event.name === "u"`, so pressing
12
+ * Shift+U — advertised in the footer as "update" — fell through to the plain "u"
13
+ * branch and installed the plugin at user scope instead. Reported from the real
14
+ * UI: "U instead of update install user scope".
15
+ *
16
+ * The same shape was in McpRegistryScreen (`"R"` for refresh), where nothing
17
+ * shadowed it, so refresh silently did nothing at all.
18
+ */
19
+
20
+ const UI_DIR = path.join(import.meta.dir, "..", "ui");
21
+
22
+ async function uiSourceFiles(dir: string): Promise<string[]> {
23
+ const out: string[] = [];
24
+ for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
25
+ const full = path.join(dir, entry.name);
26
+ if (entry.isDirectory()) out.push(...(await uiSourceFiles(full)));
27
+ else if (/\.tsx?$/.test(entry.name)) out.push(full);
28
+ }
29
+ return out;
30
+ }
31
+
32
+ const isComment = (line: string) => {
33
+ const t = line.trim();
34
+ return t.startsWith("//") || t.startsWith("*") || t.startsWith("/*");
35
+ };
36
+
37
+ describe("shifted keys parse to a lowercase name plus a flag", () => {
38
+ // Pin the platform behaviour the rule depends on. If OpenTUI ever changed
39
+ // this, the guard below would be enforcing a rule that no longer holds.
40
+ test("Shift+letter keeps the unshifted name and sets shift", () => {
41
+ for (const [raw, name] of [
42
+ ["U", "u"],
43
+ ["A", "a"],
44
+ ["R", "r"],
45
+ ] as const) {
46
+ const k = parseKeypress(raw);
47
+ expect({ raw, name: k?.name, shift: k?.shift }).toEqual({
48
+ raw,
49
+ name,
50
+ shift: true,
51
+ });
52
+ }
53
+ });
54
+
55
+ test("an unshifted letter reports shift false", () => {
56
+ const k = parseKeypress("u");
57
+ expect({ name: k?.name, shift: k?.shift }).toEqual({
58
+ name: "u",
59
+ shift: false,
60
+ });
61
+ });
62
+ });
63
+
64
+ describe("no binding compares name to an uppercase letter", () => {
65
+ test("uppercase name comparisons are absent from the UI", async () => {
66
+ const files = await uiSourceFiles(UI_DIR);
67
+ expect(files.length).toBeGreaterThan(10); // guard: the walk found real files
68
+
69
+ const offenders: string[] = [];
70
+ for (const file of files) {
71
+ const text = await fs.readFile(file, "utf8");
72
+ text.split("\n").forEach((line, i) => {
73
+ if (isComment(line)) return;
74
+ if (/\bname\s*===\s*"[A-Z]"/.test(line)) {
75
+ offenders.push(
76
+ `${path.relative(UI_DIR, file)}:${i + 1} ${line.trim()}`,
77
+ );
78
+ }
79
+ });
80
+ }
81
+ expect(offenders).toEqual([]);
82
+ });
83
+
84
+ test("an uppercase binding is reachable only via the shift flag", async () => {
85
+ // The positive half: Shift+U must actually route to update. Asserting only
86
+ // the absence of `=== "U"` would also pass if someone deleted the binding.
87
+ const src = await fs.readFile(
88
+ path.join(UI_DIR, "screens", "PluginsScreen.tsx"),
89
+ "utf8",
90
+ );
91
+ const update = src.indexOf('event.name === "u" && event.shift');
92
+ const userScope = src.indexOf(
93
+ 'event.name === "u") handleScopeToggle("user")',
94
+ );
95
+ expect(update).toBeGreaterThan(-1);
96
+ expect(userScope).toBeGreaterThan(-1);
97
+ // Order matters: the shifted branch has to be tested first, or the plain
98
+ // "u" branch swallows it again — which is exactly the original bug.
99
+ expect(update).toBeLessThan(userScope);
100
+ });
101
+ });
@@ -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/main.tsx CHANGED
@@ -1,12 +1,13 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
- import { createCliRenderer } from "@opentui/core";
3
+ import { RGBA, createCliRenderer } from "@opentui/core";
4
4
  import { createRoot } from "@opentui/react";
5
- import { App } from "./ui/App.js";
6
- import { route } from "./cli/router.js";
7
5
  // Static import so `bun build --compile` embeds package.json into the binary;
8
6
  // a dynamic require("../package.json") is not resolvable inside the bunfs root.
9
7
  import pkg from "../package.json";
8
+ import { route } from "./cli/router.js";
9
+ import { App } from "./ui/App.js";
10
+ import { setThemeMode } from "./ui/theme-mode.js";
10
11
 
11
12
  export const VERSION = (pkg as { version: string }).version;
12
13
 
@@ -24,8 +25,23 @@ async function main(): Promise<void> {
24
25
  process.exit(outcome.exitCode ?? 0);
25
26
  }
26
27
 
27
- // Create OpenTUI renderer (handles alternate screen buffer automatically)
28
- const renderer = await createCliRenderer();
28
+ // Create OpenTUI renderer (handles alternate screen buffer automatically).
29
+ //
30
+ // The background is the terminal's own, not a colour of ours: painting an
31
+ // absolute fill would box the UI into whatever theme we guessed. OpenTUI's
32
+ // built-in default for unstyled cells is truecolor white, which is why every
33
+ // element now names an adaptive colour explicitly — see src/ui/theme.ts.
34
+ const renderer = await createCliRenderer({
35
+ backgroundColor: RGBA.defaultBackground(),
36
+ });
37
+
38
+ // Ask the terminal whether it is light or dark, once. Only the disabled-row
39
+ // tint needs this — see src/ui/theme-mode.ts for why that one case cannot be
40
+ // solved the way the rest of the palette is. Bounded wait: a terminal that
41
+ // ignores the OSC query must not delay first paint, and `null` simply means
42
+ // no tint.
43
+ setThemeMode(await renderer.waitForThemeMode(250).catch(() => null));
44
+
29
45
  const root = createRoot(renderer);
30
46
 
31
47
  // Cleanup function to restore terminal
package/src/opentui.d.ts CHANGED
@@ -8,6 +8,15 @@
8
8
  */
9
9
 
10
10
  import type { ReactNode } from "react";
11
+ import type { RGBA } from "@opentui/core";
12
+
13
+ /**
14
+ * What OpenTUI actually accepts for a colour: a CSS string OR an RGBA, which is
15
+ * how adaptive colours are expressed (RGBA.defaultForeground(), RGBA.fromIndex()).
16
+ * These declarations previously said `string`, which made the terminal-resolved
17
+ * forms a type error and quietly pushed every call site towards absolute hex.
18
+ */
19
+ type ColorProp = string | RGBA;
11
20
 
12
21
  declare global {
13
22
  namespace JSX {
@@ -28,7 +37,7 @@ declare global {
28
37
  u: { children?: ReactNode };
29
38
  dim: { children?: ReactNode };
30
39
  a: { href?: string; children?: ReactNode };
31
- span: { fg?: string; bg?: string; children?: ReactNode };
40
+ span: { fg?: ColorProp; bg?: ColorProp; children?: ReactNode };
32
41
  }
33
42
  }
34
43
  }
@@ -37,12 +46,12 @@ interface BaseBoxProps {
37
46
  // Borders
38
47
  border?: boolean;
39
48
  borderStyle?: "single" | "double" | "rounded" | "bold";
40
- borderColor?: string;
49
+ borderColor?: ColorProp;
41
50
  title?: string;
42
51
  titleAlignment?: "left" | "center" | "right";
43
52
 
44
53
  // Colors
45
- backgroundColor?: string;
54
+ backgroundColor?: ColorProp;
46
55
 
47
56
  // Layout (Flexbox)
48
57
  flexDirection?: "row" | "column";
@@ -94,8 +103,8 @@ interface BoxProps extends BaseBoxProps {
94
103
 
95
104
  interface TextProps {
96
105
  content?: string;
97
- fg?: string;
98
- bg?: string;
106
+ fg?: ColorProp;
107
+ bg?: ColorProp;
99
108
  selectable?: boolean;
100
109
  children?: ReactNode;
101
110
  }
@@ -108,10 +117,10 @@ interface InputProps {
108
117
  placeholder?: string;
109
118
  focused?: boolean;
110
119
  width?: number;
111
- backgroundColor?: string;
112
- textColor?: string;
113
- cursorColor?: string;
114
- focusedBackgroundColor?: string;
120
+ backgroundColor?: ColorProp;
121
+ textColor?: ColorProp;
122
+ cursorColor?: ColorProp;
123
+ focusedBackgroundColor?: ColorProp;
115
124
  }
116
125
 
117
126
  interface SelectOption {
@@ -150,8 +159,8 @@ interface ScrollboxProps {
150
159
  scrollbarOptions?: {
151
160
  showArrows?: boolean;
152
161
  trackOptions?: {
153
- foregroundColor?: string;
154
- backgroundColor?: string;
162
+ foregroundColor?: ColorProp;
163
+ backgroundColor?: ColorProp;
155
164
  };
156
165
  };
157
166
  };
@@ -161,7 +170,7 @@ interface ScrollboxProps {
161
170
  interface AsciiFontProps {
162
171
  text: string;
163
172
  font?: "tiny" | "block" | "slick" | "shade";
164
- color?: string;
173
+ color?: ColorProp;
165
174
  }
166
175
 
167
176
  interface CodeProps {
@@ -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
 
package/src/ui/App.tsx CHANGED
@@ -49,6 +49,7 @@ import {
49
49
  } from "../services/version-check.js";
50
50
  import { useKeyboardHandler } from "./hooks/useKeyboardHandler.js";
51
51
  import { ProgressBar } from "./components/layout/ProgressBar.js";
52
+ import { theme } from "./theme.js";
52
53
 
53
54
  export const VERSION = getCurrentVersion();
54
55
 
@@ -238,15 +239,15 @@ function UpdateBanner({ result }: { result: VersionCheckResult }) {
238
239
 
239
240
  return (
240
241
  <box paddingLeft={1} paddingRight={1}>
241
- <text bg="yellow" fg="black">
242
+ <text bg={theme.colors.warning} fg={theme.hints.fg}>
242
243
  <strong> UPDATE </strong>
243
244
  </text>
244
- <text fg="yellow">
245
+ <text fg={theme.colors.warning}>
245
246
  {" "}
246
247
  v{result.currentVersion} → v{result.latestVersion}
247
248
  </text>
248
- <text fg="gray"> Run: </text>
249
- <text fg="cyan">claudeup update</text>
249
+ <text fg={theme.colors.muted}> Run: </text>
250
+ <text fg={theme.colors.info}>claudeup update</text>
250
251
  </box>
251
252
  );
252
253
  }
@@ -299,12 +300,12 @@ function AppContentInner({
299
300
  {updateInfo?.updateAvailable && <UpdateBanner result={updateInfo} />}
300
301
  {recoveryReport && (
301
302
  <box paddingLeft={1} paddingRight={1}>
302
- <text fg="green">✓ Fixed: {recoveryReport}</text>
303
+ <text fg={theme.colors.success}>✓ Fixed: {recoveryReport}</text>
303
304
  </box>
304
305
  )}
305
306
  {showDebug && (
306
307
  <box paddingLeft={1} paddingRight={1}>
307
- <text fg="#888888">
308
+ <text fg={theme.colors.muted}>
308
309
  DEBUG: {dimensions.terminalWidth}x{dimensions.terminalHeight} |
309
310
  content={dimensions.contentHeight} | screen=
310
311
  {state.currentRoute.screen}
@@ -340,7 +341,9 @@ function AppContent({ onExit }: AppContentProps) {
340
341
  const [showDebug, setShowDebug] = useState(false);
341
342
  const [updateInfo, setUpdateInfo] = useState<VersionCheckResult | null>(null);
342
343
  const [recoveryReport, setRecoveryReport] = useState<string | null>(null);
343
- const [mismatchData, setMismatchData] = useState<VersionMismatchInfo[] | null>(null);
344
+ const [mismatchData, setMismatchData] = useState<
345
+ VersionMismatchInfo[] | null
346
+ >(null);
344
347
  const mismatchModal = useMismatchModal();
345
348
  const [gitignoreData, setGitignoreData] = useState<{
346
349
  violationCount: number;
@@ -472,8 +475,13 @@ function AppContent({ onExit }: AppContentProps) {
472
475
  const result = await checkGitignore(process.cwd());
473
476
  if (result.violationCount > 0) {
474
477
  const s = await loadGitignoreState(process.cwd());
475
- const safeCount = s.violations.filter((v) => v.severity === "safe").length;
476
- setGitignoreData({ violationCount: result.violationCount, safeCount });
478
+ const safeCount = s.violations.filter(
479
+ (v) => v.severity === "safe",
480
+ ).length;
481
+ setGitignoreData({
482
+ violationCount: result.violationCount,
483
+ safeCount,
484
+ });
477
485
  }
478
486
  } catch {
479
487
  // non-fatal
@@ -491,8 +499,7 @@ function AppContent({ onExit }: AppContentProps) {
491
499
  }, [dispatch]);
492
500
 
493
501
  // Count transient banners for dimension calculation
494
- const transientBannerCount =
495
- recoveryReport ? 1 : 0;
502
+ const transientBannerCount = recoveryReport ? 1 : 0;
496
503
 
497
504
  return (
498
505
  <DimensionsProvider
@@ -507,7 +514,7 @@ function AppContent({ onExit }: AppContentProps) {
507
514
  updateInfo={updateInfo}
508
515
  onExit={onExit}
509
516
  recoveryReport={recoveryReport}
510
- />
517
+ />
511
518
  </DimensionsProvider>
512
519
  );
513
520
  }