claudeup 4.22.0 → 4.24.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__/enabled-not-installed.test.ts +87 -0
- package/src/__tests__/marketplace-badge.test.ts +91 -0
- package/src/__tests__/resolver.test.ts +2 -2
- package/src/data/predefined-profiles.ts +1 -1
- package/src/services/plugin-manager.ts +24 -0
- package/src/ui/adapters/pluginsAdapter.ts +13 -0
- package/src/ui/renderers/pluginRenderers.tsx +14 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claudeup",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.24.0",
|
|
4
4
|
"description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/main.tsx",
|
|
@@ -64,8 +64,8 @@
|
|
|
64
64
|
"typescript": "^5.6.3"
|
|
65
65
|
},
|
|
66
66
|
"optionalDependencies": {
|
|
67
|
-
"claudeup-darwin-arm64": "4.
|
|
68
|
-
"claudeup-darwin-x64": "4.
|
|
69
|
-
"claudeup-linux-x64": "4.
|
|
67
|
+
"claudeup-darwin-arm64": "4.24.0",
|
|
68
|
+
"claudeup-darwin-x64": "4.24.0",
|
|
69
|
+
"claudeup-linux-x64": "4.24.0"
|
|
70
70
|
}
|
|
71
71
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { isEnabledButNotInstalled } from "../services/plugin-manager.js";
|
|
3
|
+
import type { PluginInfo } from "../services/plugin-manager.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* "Enabled but not installed" is a broken state that previously rendered
|
|
7
|
+
* identically to a healthy install: a green scope square and no version.
|
|
8
|
+
*
|
|
9
|
+
* It is also invisible to every update path, because the check is
|
|
10
|
+
* `installedVersion && compare(latest, installedVersion) > 0` — with no
|
|
11
|
+
* installed version that is false, so neither the update list nor the
|
|
12
|
+
* prerunner's auto-update will ever touch it.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const plugin = (over: Partial<PluginInfo>): PluginInfo =>
|
|
16
|
+
({
|
|
17
|
+
id: "x@magus",
|
|
18
|
+
name: "x",
|
|
19
|
+
version: "1.0.0",
|
|
20
|
+
description: "",
|
|
21
|
+
marketplace: "magus",
|
|
22
|
+
marketplaceDisplay: "Magus",
|
|
23
|
+
enabled: true,
|
|
24
|
+
...over,
|
|
25
|
+
}) as PluginInfo;
|
|
26
|
+
|
|
27
|
+
describe("isEnabledButNotInstalled", () => {
|
|
28
|
+
test("enabled in project scope with no installed version is flagged", () => {
|
|
29
|
+
// The real case: terminal/code-analysis are in this project's enabledPlugins
|
|
30
|
+
// but have no registry entry for it and none at user scope.
|
|
31
|
+
expect(
|
|
32
|
+
isEnabledButNotInstalled(
|
|
33
|
+
plugin({ projectScope: { enabled: true }, installedVersion: undefined }),
|
|
34
|
+
),
|
|
35
|
+
).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("a healthy install is not flagged", () => {
|
|
39
|
+
expect(
|
|
40
|
+
isEnabledButNotInstalled(
|
|
41
|
+
plugin({ projectScope: { enabled: true }, installedVersion: "1.0.0" }),
|
|
42
|
+
),
|
|
43
|
+
).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('installedVersion "0.0.0" means installed-with-unknown-version, not missing', () => {
|
|
47
|
+
// Anthropic's official plugins record exactly this. Treating it as missing
|
|
48
|
+
// would mislabel every one of them as broken.
|
|
49
|
+
expect(
|
|
50
|
+
isEnabledButNotInstalled(
|
|
51
|
+
plugin({ userScope: { enabled: true }, installedVersion: "0.0.0" }),
|
|
52
|
+
),
|
|
53
|
+
).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("not enabled anywhere is not flagged — it is simply available", () => {
|
|
57
|
+
expect(isEnabledButNotInstalled(plugin({ installedVersion: undefined }))).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("orphaned plugins are excluded — they have their own 'deprecated' state", () => {
|
|
61
|
+
expect(
|
|
62
|
+
isEnabledButNotInstalled(
|
|
63
|
+
plugin({
|
|
64
|
+
isOrphaned: true,
|
|
65
|
+
projectScope: { enabled: true },
|
|
66
|
+
installedVersion: undefined,
|
|
67
|
+
}),
|
|
68
|
+
),
|
|
69
|
+
).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("any enabled scope counts, not just project", () => {
|
|
73
|
+
for (const scope of ["userScope", "projectScope", "localScope"] as const) {
|
|
74
|
+
expect(
|
|
75
|
+
isEnabledButNotInstalled(plugin({ [scope]: { enabled: true } })),
|
|
76
|
+
).toBe(true);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("a scope present but disabled does not count as enabled", () => {
|
|
81
|
+
expect(
|
|
82
|
+
isEnabledButNotInstalled(
|
|
83
|
+
plugin({ projectScope: { enabled: false }, installedVersion: undefined }),
|
|
84
|
+
),
|
|
85
|
+
).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { buildPluginBrowserItems } from "../ui/adapters/pluginsAdapter.js";
|
|
3
|
+
import type { Marketplace } from "../types/index.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The marketplace category badge is the line users read on every claudeup launch,
|
|
7
|
+
* and it had no coverage. The MadAppGang byline must not leak onto anyone else's
|
|
8
|
+
* marketplace, and the Official/Deprecated markers must still win.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const mp = (over: Partial<Marketplace> & { name: string }): Marketplace => ({
|
|
12
|
+
displayName: over.displayName ?? over.name,
|
|
13
|
+
source: { source: "github", repo: `someone/${over.name}` },
|
|
14
|
+
description: "",
|
|
15
|
+
...over,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
/** Badge for a marketplace when it has plugins and is expanded. */
|
|
19
|
+
function badgeFor(marketplace: Marketplace): string | undefined {
|
|
20
|
+
const items = buildPluginBrowserItems({
|
|
21
|
+
marketplaces: [marketplace],
|
|
22
|
+
plugins: [
|
|
23
|
+
{
|
|
24
|
+
id: `demo@${marketplace.name}`,
|
|
25
|
+
name: "demo",
|
|
26
|
+
marketplace: marketplace.name,
|
|
27
|
+
// Only the fields the adapter groups on matter here.
|
|
28
|
+
} as never,
|
|
29
|
+
],
|
|
30
|
+
collapsedMarketplaces: new Set(),
|
|
31
|
+
});
|
|
32
|
+
const category = items.find((i) => i.kind === "category");
|
|
33
|
+
return category && "badge" in category ? category.badge : undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("marketplace category badge", () => {
|
|
37
|
+
test("MadAppGang marketplaces carry the byline instead of a generic label", () => {
|
|
38
|
+
for (const name of ["magus", "magus-marketing", "magus-alpha"]) {
|
|
39
|
+
const badge = badgeFor(
|
|
40
|
+
mp({ name, source: { source: "github", repo: `MadAppGang/${name}` } }),
|
|
41
|
+
);
|
|
42
|
+
expect(badge).toBe("made with ❤️ by MadAppGang");
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("owner match is case-insensitive", () => {
|
|
47
|
+
// The npm/GitHub casing of the org has varied in config; don't depend on it.
|
|
48
|
+
const badge = badgeFor(
|
|
49
|
+
mp({ name: "magus", source: { source: "github", repo: "madappgang/magus" } }),
|
|
50
|
+
);
|
|
51
|
+
expect(badge).toBe("made with ❤️ by MadAppGang");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("someone else's marketplace does NOT claim the byline", () => {
|
|
55
|
+
const badge = badgeFor(
|
|
56
|
+
mp({
|
|
57
|
+
name: "superpowers-marketplace",
|
|
58
|
+
source: { source: "github", repo: "obra/superpowers-marketplace" },
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
expect(badge).toBe("✓ Added");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("a repo merely containing 'madappgang' downstream does not match", () => {
|
|
65
|
+
// Guards against a substring check — only the owner segment counts.
|
|
66
|
+
const badge = badgeFor(
|
|
67
|
+
mp({ name: "impostor", source: { source: "github", repo: "evil/madappgang-magus" } }),
|
|
68
|
+
);
|
|
69
|
+
expect(badge).toBe("✓ Added");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("Official and Deprecated still take precedence", () => {
|
|
73
|
+
expect(
|
|
74
|
+
badgeFor(
|
|
75
|
+
mp({
|
|
76
|
+
name: "claude-plugins-official",
|
|
77
|
+
source: { source: "github", repo: "anthropics/claude-plugins-official" },
|
|
78
|
+
}),
|
|
79
|
+
),
|
|
80
|
+
).toBe("★ Official");
|
|
81
|
+
|
|
82
|
+
expect(
|
|
83
|
+
badgeFor(
|
|
84
|
+
mp({
|
|
85
|
+
name: "claude-code-plugins",
|
|
86
|
+
source: { source: "github", repo: "anthropics/claude-code" },
|
|
87
|
+
}),
|
|
88
|
+
),
|
|
89
|
+
).toBe("⚠ Deprecated");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -86,10 +86,10 @@ describe("resolveExtends", () => {
|
|
|
86
86
|
extends: "growth-marketer",
|
|
87
87
|
};
|
|
88
88
|
const merged = resolveExtends(entry);
|
|
89
|
-
// seo/
|
|
89
|
+
// seo/image-generate/video-editing ship on the magus-marketing channel and
|
|
90
90
|
// pin it explicitly — they must NOT be rewritten to @magus.
|
|
91
91
|
expect(merged.plugins!["seo@magus-marketing"]).toBe("latest");
|
|
92
|
-
expect(merged.plugins!["
|
|
92
|
+
expect(merged.plugins!["image-generate@magus-marketing"]).toBe("latest");
|
|
93
93
|
expect(merged.plugins!["video-editing@magus-marketing"]).toBe("latest");
|
|
94
94
|
expect(merged.plugins!["seo@magus"]).toBeUndefined();
|
|
95
95
|
// Bare names still default to @magus.
|
|
@@ -201,7 +201,7 @@ export const PREDEFINED_PROFILES: PredefinedProfile[] = [
|
|
|
201
201
|
"statusline",
|
|
202
202
|
"seo@magus-marketing",
|
|
203
203
|
"browser-use",
|
|
204
|
-
"
|
|
204
|
+
"image-generate@magus-marketing",
|
|
205
205
|
"video-editing@magus-marketing",
|
|
206
206
|
],
|
|
207
207
|
anthropicPlugins: [
|
|
@@ -74,6 +74,30 @@ export interface PluginInfo {
|
|
|
74
74
|
isOrphaned?: boolean;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/**
|
|
78
|
+
* True when a plugin is enabled in some scope but no installed version resolves
|
|
79
|
+
* anywhere — it is listed in `enabledPlugins` but was never actually installed.
|
|
80
|
+
*
|
|
81
|
+
* This is a broken state, not a healthy one: Claude Code will not load the
|
|
82
|
+
* plugin, and because the update check is `installedVersion && compare(...)`,
|
|
83
|
+
* it is also invisible to both the update list and the prerunner's auto-update.
|
|
84
|
+
* It previously rendered identically to a working install.
|
|
85
|
+
*
|
|
86
|
+
* `isOrphaned` is a different failure — installed, but no longer offered by any
|
|
87
|
+
* marketplace — and is rendered as "deprecated" already.
|
|
88
|
+
*
|
|
89
|
+
* "0.0.0" means installed-with-unknown-version (Anthropic's official plugins
|
|
90
|
+
* record exactly that), so it must NOT count as missing.
|
|
91
|
+
*/
|
|
92
|
+
export function isEnabledButNotInstalled(plugin: PluginInfo): boolean {
|
|
93
|
+
if (plugin.isOrphaned) return false;
|
|
94
|
+
const enabledSomewhere =
|
|
95
|
+
!!plugin.userScope?.enabled ||
|
|
96
|
+
!!plugin.projectScope?.enabled ||
|
|
97
|
+
!!plugin.localScope?.enabled;
|
|
98
|
+
return enabledSomewhere && !plugin.installedVersion;
|
|
99
|
+
}
|
|
100
|
+
|
|
77
101
|
|
|
78
102
|
export async function getAvailablePlugins(
|
|
79
103
|
projectPath?: string,
|
|
@@ -65,9 +65,22 @@ function categoryStyling(
|
|
|
65
65
|
if (mp.official) {
|
|
66
66
|
return { tone: "yellow", badge: "★ Official" };
|
|
67
67
|
}
|
|
68
|
+
if (isMadAppGang(mp)) {
|
|
69
|
+
return { tone: "green", badge: "made with ❤️ by MadAppGang" };
|
|
70
|
+
}
|
|
68
71
|
return { tone: "green", badge: "✓ Added" };
|
|
69
72
|
}
|
|
70
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Our own marketplaces (magus, magus-marketing, magus-alpha) get the byline
|
|
76
|
+
* instead of the generic "Added". Matched on the repo owner rather than a list
|
|
77
|
+
* of names, so a new channel picks it up without a code change. Same idiom the
|
|
78
|
+
* marketplace sort uses to float MadAppGang entries to the top.
|
|
79
|
+
*/
|
|
80
|
+
function isMadAppGang(mp: Marketplace): boolean {
|
|
81
|
+
return (mp.source.repo ?? "").toLowerCase().startsWith("madappgang/");
|
|
82
|
+
}
|
|
83
|
+
|
|
71
84
|
/**
|
|
72
85
|
* Builds the flat list of items for the PluginsScreen list panel.
|
|
73
86
|
* Extracted from PluginsScreen so it can be tested independently.
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
} from "../components/primitives/index.js";
|
|
16
16
|
import { theme } from "../theme.js";
|
|
17
17
|
import { highlightMatches } from "../../utils/fuzzy-search.js";
|
|
18
|
+
import { isEnabledButNotInstalled } from "../../services/plugin-manager.js";
|
|
18
19
|
|
|
19
20
|
// ─── Category renderers ───────────────────────────────────────────────────────
|
|
20
21
|
|
|
@@ -113,10 +114,16 @@ function pluginRow(item: PluginPluginItem, isSelected: boolean): React.ReactNode
|
|
|
113
114
|
const hasLocal = !!plugin.localScope?.enabled;
|
|
114
115
|
const hasAnyScope = hasUser || hasProject || hasLocal;
|
|
115
116
|
|
|
117
|
+
// Enabled somewhere but nothing actually installed — a broken state that used
|
|
118
|
+
// to render exactly like a healthy install.
|
|
119
|
+
const notInstalled = isEnabledButNotInstalled(plugin);
|
|
120
|
+
|
|
116
121
|
// Build version string — only show if plugin is installed in at least one scope
|
|
117
122
|
let versionStr = "";
|
|
118
123
|
if (plugin.isOrphaned) {
|
|
119
124
|
versionStr = " deprecated";
|
|
125
|
+
} else if (notInstalled) {
|
|
126
|
+
versionStr = " not installed";
|
|
120
127
|
} else if (hasAnyScope && plugin.installedVersion && plugin.installedVersion !== "0.0.0") {
|
|
121
128
|
versionStr = ` v${plugin.installedVersion}`;
|
|
122
129
|
if (plugin.hasUpdate && plugin.version) {
|
|
@@ -158,8 +165,13 @@ function pluginRow(item: PluginPluginItem, isSelected: boolean): React.ReactNode
|
|
|
158
165
|
<span> </span>
|
|
159
166
|
<ScopeSquares user={hasUser} project={hasProject} local={hasLocal} />
|
|
160
167
|
<span> </span>
|
|
161
|
-
<span fg={hasAnyScope ? theme.colors.text : theme.colors.muted}>
|
|
162
|
-
|
|
168
|
+
<span fg={hasAnyScope && !notInstalled ? theme.colors.text : theme.colors.muted}>
|
|
169
|
+
{displayName}
|
|
170
|
+
</span>
|
|
171
|
+
<MetaText
|
|
172
|
+
text={versionStr}
|
|
173
|
+
tone={notInstalled || plugin.hasUpdate ? "warning" : "muted"}
|
|
174
|
+
/>
|
|
163
175
|
</text>
|
|
164
176
|
);
|
|
165
177
|
}
|