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.
- package/package.json +4 -4
- package/src/__tests__/git-worktree.test.ts +108 -0
- package/src/__tests__/scope-squares.test.tsx +165 -0
- package/src/__tests__/theme-adaptive-colors.test.ts +307 -0
- package/src/__tests__/uppercase-keybindings.test.ts +101 -0
- package/src/__tests__/worktree-registry-resolution.test.ts +107 -0
- package/src/main.tsx +21 -5
- package/src/opentui.d.ts +21 -12
- 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/ui/App.tsx +19 -12
- package/src/ui/adapters/pluginsAdapter.ts +174 -168
- package/src/ui/adapters/settingsAdapter.ts +119 -116
- package/src/ui/adapters/skillsAdapter.ts +203 -196
- package/src/ui/components/CategoryHeader.tsx +9 -8
- package/src/ui/components/EmptyFilterState.tsx +10 -5
- package/src/ui/components/FlagDetailEditor.tsx +0 -0
- package/src/ui/components/ScopeIndicator.tsx +10 -6
- package/src/ui/components/ScrollableList.tsx +3 -2
- package/src/ui/components/SearchInput.tsx +2 -1
- package/src/ui/components/StyledText.tsx +5 -4
- package/src/ui/components/TabBar.tsx +4 -3
- package/src/ui/components/layout/FooterHints.tsx +37 -30
- package/src/ui/components/layout/Panel.tsx +6 -5
- package/src/ui/components/layout/ProgressBar.tsx +7 -6
- package/src/ui/components/layout/ScopeTabs.tsx +6 -5
- package/src/ui/components/layout/ScreenLayout.tsx +27 -24
- package/src/ui/components/layout/index.ts +3 -3
- package/src/ui/components/modals/ConfirmModal.tsx +12 -11
- package/src/ui/components/modals/InputModal.tsx +14 -6
- package/src/ui/components/modals/LoadingModal.tsx +6 -5
- package/src/ui/components/modals/MessageModal.tsx +9 -8
- package/src/ui/components/modals/SelectModal.tsx +11 -7
- package/src/ui/components/modals/VersionMismatchModal.tsx +14 -16
- package/src/ui/components/primitives/ActionHints.tsx +26 -26
- package/src/ui/components/primitives/DetailSection.tsx +13 -12
- package/src/ui/components/primitives/KeyValueLine.tsx +9 -8
- package/src/ui/components/primitives/ListCategoryRow.tsx +25 -27
- package/src/ui/components/primitives/MetaText.tsx +3 -3
- package/src/ui/components/primitives/ScopeDetail.tsx +48 -48
- package/src/ui/components/primitives/ScopeSquares.tsx +47 -22
- package/src/ui/components/primitives/SelectableRow.tsx +22 -16
- package/src/ui/hooks/useGitignoreModal.ts +78 -74
- package/src/ui/registry.ts +11 -11
- package/src/ui/renderers/cliToolRenderers.tsx +260 -203
- package/src/ui/renderers/gitignoreRenderers.tsx +43 -42
- package/src/ui/renderers/mcpRenderers.tsx +121 -117
- package/src/ui/renderers/pluginRenderers.tsx +528 -471
- package/src/ui/renderers/profileRenderers.tsx +346 -300
- package/src/ui/renderers/settingsRenderers.tsx +183 -176
- package/src/ui/renderers/skillRenderers.tsx +410 -326
- package/src/ui/screens/AliasScreen.tsx +1336 -1309
- package/src/ui/screens/CliToolsScreen.tsx +92 -40
- package/src/ui/screens/EnvVarsScreen.tsx +19 -13
- package/src/ui/screens/GitignoreScreen.tsx +510 -493
- package/src/ui/screens/McpRegistryScreen.tsx +28 -21
- package/src/ui/screens/McpScreen.tsx +12 -3
- package/src/ui/screens/PluginsScreen.tsx +17 -7
- package/src/ui/screens/ProfilesScreen.tsx +39 -23
- package/src/ui/screens/SkillsScreen.tsx +832 -688
- package/src/ui/theme-mode.ts +73 -0
- 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
|
-
|
|
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?:
|
|
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?:
|
|
49
|
+
borderColor?: ColorProp;
|
|
41
50
|
title?: string;
|
|
42
51
|
titleAlignment?: "left" | "center" | "right";
|
|
43
52
|
|
|
44
53
|
// Colors
|
|
45
|
-
backgroundColor?:
|
|
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?:
|
|
98
|
-
bg?:
|
|
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?:
|
|
112
|
-
textColor?:
|
|
113
|
-
cursorColor?:
|
|
114
|
-
focusedBackgroundColor?:
|
|
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?:
|
|
154
|
-
backgroundColor?:
|
|
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?:
|
|
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
|
|
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/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=
|
|
242
|
+
<text bg={theme.colors.warning} fg={theme.hints.fg}>
|
|
242
243
|
<strong> UPDATE </strong>
|
|
243
244
|
</text>
|
|
244
|
-
<text fg=
|
|
245
|
+
<text fg={theme.colors.warning}>
|
|
245
246
|
{" "}
|
|
246
247
|
v{result.currentVersion} → v{result.latestVersion}
|
|
247
248
|
</text>
|
|
248
|
-
<text fg=
|
|
249
|
-
<text fg=
|
|
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=
|
|
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=
|
|
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<
|
|
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(
|
|
476
|
-
|
|
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
|
}
|