claudeup 4.32.0 → 4.33.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__/mcp-registry.test.ts +158 -0
- package/src/__tests__/scope-action.test.ts +82 -0
- package/src/__tests__/skills-catalog-client.test.ts +48 -0
- package/src/data/mcp-servers.ts +5 -480
- package/src/data/skill-repos.ts +0 -108
- package/src/services/mcp-registry.ts +118 -52
- package/src/services/plugin-manager.ts +42 -0
- package/src/services/skills-manager.ts +5 -49
- package/src/services/skillsmp-client.ts +82 -8
- package/src/ui/renderers/pluginRenderers.tsx +96 -48
- package/src/ui/screens/McpScreen.tsx +13 -5
- package/src/ui/screens/PluginsScreen.tsx +18 -35
- package/src/ui/screens/SkillsScreen.tsx +28 -18
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claudeup",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.33.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.33.0",
|
|
68
|
+
"claudeup-darwin-x64": "4.33.0",
|
|
69
|
+
"claudeup-linux-x64": "4.33.0"
|
|
70
70
|
}
|
|
71
71
|
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
fetchCuratedMcpServers,
|
|
4
|
+
searchMcpServers,
|
|
5
|
+
} from "../services/mcp-registry.js";
|
|
6
|
+
|
|
7
|
+
const realFetch = globalThis.fetch;
|
|
8
|
+
const originalApiUrl = process.env.MCP_REGISTRY_API_URL;
|
|
9
|
+
|
|
10
|
+
const server = {
|
|
11
|
+
name: "io.example/filesystem",
|
|
12
|
+
url: "npm:@example/filesystem",
|
|
13
|
+
short_description: "Filesystem tools",
|
|
14
|
+
version: "1.2.3",
|
|
15
|
+
source_code_url: "https://github.com/example/filesystem",
|
|
16
|
+
package_registry: "npm",
|
|
17
|
+
published_at: "2026-08-01T00:00:00.000Z",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
globalThis.fetch = realFetch;
|
|
22
|
+
if (originalApiUrl === undefined) {
|
|
23
|
+
Reflect.deleteProperty(process.env, "MCP_REGISTRY_API_URL");
|
|
24
|
+
} else {
|
|
25
|
+
process.env.MCP_REGISTRY_API_URL = originalApiUrl;
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("searchMcpServers", () => {
|
|
30
|
+
it("calls the models-index endpoint with encoded normalized parameters", async () => {
|
|
31
|
+
process.env.MCP_REGISTRY_API_URL = "";
|
|
32
|
+
let requestedUrl = "";
|
|
33
|
+
globalThis.fetch = (async (input) => {
|
|
34
|
+
requestedUrl = String(input);
|
|
35
|
+
return Response.json({ servers: [server], next_cursor: "next page" });
|
|
36
|
+
}) as typeof fetch;
|
|
37
|
+
|
|
38
|
+
const result = await searchMcpServers({
|
|
39
|
+
query: "file system & tools",
|
|
40
|
+
limit: 7,
|
|
41
|
+
cursor: "page/2?x=1",
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const url = new URL(requestedUrl);
|
|
45
|
+
expect(`${url.origin}${url.pathname}`).toBe(
|
|
46
|
+
"https://us-central1-claudish-6da10.cloudfunctions.net/mcpRegistry/search",
|
|
47
|
+
);
|
|
48
|
+
expect(Object.fromEntries(url.searchParams)).toEqual({
|
|
49
|
+
q: "file system & tools",
|
|
50
|
+
limit: "7",
|
|
51
|
+
cursor: "page/2?x=1",
|
|
52
|
+
});
|
|
53
|
+
expect(result).toEqual({ servers: [server], next_cursor: "next page" });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("uses MCP_REGISTRY_API_URL and omits an empty query", async () => {
|
|
57
|
+
process.env.MCP_REGISTRY_API_URL = "https://registry.example.test/base/";
|
|
58
|
+
let requestedUrl = "";
|
|
59
|
+
globalThis.fetch = (async (input) => {
|
|
60
|
+
requestedUrl = String(input);
|
|
61
|
+
return Response.json({ servers: [] });
|
|
62
|
+
}) as typeof fetch;
|
|
63
|
+
|
|
64
|
+
await searchMcpServers({ query: "", limit: 4 });
|
|
65
|
+
|
|
66
|
+
expect(requestedUrl).toBe(
|
|
67
|
+
"https://registry.example.test/base/search?limit=4",
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("throws on an HTTP failure", async () => {
|
|
72
|
+
globalThis.fetch = (async () =>
|
|
73
|
+
new Response("unavailable", {
|
|
74
|
+
status: 503,
|
|
75
|
+
statusText: "Service Unavailable",
|
|
76
|
+
})) as typeof fetch;
|
|
77
|
+
|
|
78
|
+
expect(searchMcpServers({ query: "filesystem" })).rejects.toThrow(
|
|
79
|
+
"MCP Registry API error: 503 Service Unavailable",
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("throws when the normalized response or a server is malformed", async () => {
|
|
84
|
+
globalThis.fetch = (async () =>
|
|
85
|
+
Response.json({
|
|
86
|
+
servers: [{ name: "missing-url", short_description: "Broken" }],
|
|
87
|
+
})) as typeof fetch;
|
|
88
|
+
|
|
89
|
+
expect(searchMcpServers()).rejects.toThrow(
|
|
90
|
+
"MCP Registry API returned a malformed response",
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("throws when the response is not valid JSON", async () => {
|
|
95
|
+
globalThis.fetch = (async () =>
|
|
96
|
+
new Response("not json", {
|
|
97
|
+
status: 200,
|
|
98
|
+
headers: { "content-type": "application/json" },
|
|
99
|
+
})) as typeof fetch;
|
|
100
|
+
|
|
101
|
+
expect(searchMcpServers()).rejects.toThrow(
|
|
102
|
+
"MCP Registry API returned a malformed response",
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("passes the caller abort signal to fetch", async () => {
|
|
107
|
+
const controller = new AbortController();
|
|
108
|
+
let receivedSignal: AbortSignal | null | undefined;
|
|
109
|
+
globalThis.fetch = (async (_input, init) => {
|
|
110
|
+
receivedSignal = init?.signal;
|
|
111
|
+
return Response.json({ servers: [] });
|
|
112
|
+
}) as typeof fetch;
|
|
113
|
+
|
|
114
|
+
await searchMcpServers({ signal: controller.signal });
|
|
115
|
+
|
|
116
|
+
expect(receivedSignal).toBe(controller.signal);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("fetchCuratedMcpServers", () => {
|
|
121
|
+
it("loads install-ready configurations from models-index", async () => {
|
|
122
|
+
process.env.MCP_REGISTRY_API_URL = "https://catalog.example.test/";
|
|
123
|
+
let requestedUrl = "";
|
|
124
|
+
globalThis.fetch = (async (input) => {
|
|
125
|
+
requestedUrl = String(input);
|
|
126
|
+
return Response.json({
|
|
127
|
+
schemaVersion: 1,
|
|
128
|
+
version: 2,
|
|
129
|
+
servers: [
|
|
130
|
+
{
|
|
131
|
+
name: "memory",
|
|
132
|
+
description: "Persistent memory",
|
|
133
|
+
command: "npx",
|
|
134
|
+
args: ["-y", "@modelcontextprotocol/server-memory@latest"],
|
|
135
|
+
category: "productivity",
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
});
|
|
139
|
+
}) as typeof fetch;
|
|
140
|
+
|
|
141
|
+
const result = await fetchCuratedMcpServers();
|
|
142
|
+
expect(requestedUrl).toBe("https://catalog.example.test/recommended");
|
|
143
|
+
expect(result).toHaveLength(1);
|
|
144
|
+
expect(result[0].name).toBe("memory");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("rejects malformed install configurations", async () => {
|
|
148
|
+
globalThis.fetch = (async () =>
|
|
149
|
+
Response.json({
|
|
150
|
+
servers: [
|
|
151
|
+
{ name: "missing-command", description: "Broken", category: "ai" },
|
|
152
|
+
],
|
|
153
|
+
})) as typeof fetch;
|
|
154
|
+
expect(fetchCuratedMcpServers()).rejects.toThrow(
|
|
155
|
+
"MCP catalog API returned a malformed response",
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
isInstalledInScope,
|
|
4
|
+
resolveScopeAction,
|
|
5
|
+
} from "../services/plugin-manager.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The scope keys (u / p / l) and the Enter scope picker both decide between
|
|
9
|
+
* install, update and uninstall. That decision used to read `scope.enabled` —
|
|
10
|
+
* the `enabledPlugins` flag from a settings file — and ignore `scope.version`,
|
|
11
|
+
* which is the registry-backed answer to "did Claude Code actually install
|
|
12
|
+
* this".
|
|
13
|
+
*
|
|
14
|
+
* Those two disagree in a real, common state: a plugin listed in
|
|
15
|
+
* `enabledPlugins` with no entry in `installed_plugins.json` for the current
|
|
16
|
+
* project path. The list row renders it "not installed" (isEnabledButNotInstalled),
|
|
17
|
+
* and pressing its scope key ran `claude plugin uninstall`. The user asked to
|
|
18
|
+
* install the thing the UI told them was missing, and it was removed instead.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
describe("isInstalledInScope", () => {
|
|
22
|
+
test("enabled with a registry version is installed", () => {
|
|
23
|
+
expect(isInstalledInScope({ enabled: true, version: "3.0.2" })).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("enabled with NO version is not installed — the broken state", () => {
|
|
27
|
+
// dev@magus, measured: `.claude/settings.json` had "dev@magus": true while
|
|
28
|
+
// installed_plugins.json had no entry resolving for the project path.
|
|
29
|
+
expect(isInstalledInScope({ enabled: true })).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("a version with the flag off is not installed either", () => {
|
|
33
|
+
expect(isInstalledInScope({ enabled: false, version: "3.0.2" })).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("an absent scope is not installed", () => {
|
|
37
|
+
expect(isInstalledInScope(undefined)).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('"0.0.0" counts as installed — Anthropic official plugins record it', () => {
|
|
41
|
+
expect(isInstalledInScope({ enabled: true, version: "0.0.0" })).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("resolveScopeAction", () => {
|
|
46
|
+
test("REGRESSION: enabled but not installed resolves to install, never uninstall", () => {
|
|
47
|
+
// This is the reported bug. Before the fix this returned "uninstall".
|
|
48
|
+
expect(resolveScopeAction({ enabled: true }, "3.0.2")).toBe("install");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("a scope that was never enabled resolves to install", () => {
|
|
52
|
+
expect(resolveScopeAction(undefined, "3.0.2")).toBe("install");
|
|
53
|
+
expect(resolveScopeAction({ enabled: false }, "3.0.2")).toBe("install");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("a healthy install at the latest version resolves to uninstall (toggle off)", () => {
|
|
57
|
+
expect(resolveScopeAction({ enabled: true, version: "3.0.2" }, "3.0.2")).toBe(
|
|
58
|
+
"uninstall",
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("a healthy install behind the catalog resolves to update", () => {
|
|
63
|
+
expect(resolveScopeAction({ enabled: true, version: "2.9.0" }, "3.0.2")).toBe(
|
|
64
|
+
"update",
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('an unknown catalog version ("0.0.0") never fabricates an update', () => {
|
|
69
|
+
// There is nothing to update *to*, so toggling must still mean uninstall.
|
|
70
|
+
expect(resolveScopeAction({ enabled: true, version: "3.0.2" }, "0.0.0")).toBe(
|
|
71
|
+
"uninstall",
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("installed with unknown version resolves to update when a real version is published", () => {
|
|
76
|
+
// "0.0.0" installed against a real catalog version is a genuine upgrade
|
|
77
|
+
// path, not the broken state — it must not be treated as uninstall.
|
|
78
|
+
expect(resolveScopeAction({ enabled: true, version: "0.0.0" }, "3.0.2")).toBe(
|
|
79
|
+
"update",
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { fetchCuratedSkillsCatalog } from "../services/skillsmp-client.js";
|
|
3
|
+
|
|
4
|
+
const realFetch = globalThis.fetch;
|
|
5
|
+
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
globalThis.fetch = realFetch;
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
describe("fetchCuratedSkillsCatalog", () => {
|
|
11
|
+
it("loads the versioned models-index catalog", async () => {
|
|
12
|
+
let requestedUrl = "";
|
|
13
|
+
globalThis.fetch = (async (input) => {
|
|
14
|
+
requestedUrl = String(input);
|
|
15
|
+
return Response.json({
|
|
16
|
+
schemaVersion: 1,
|
|
17
|
+
version: 1,
|
|
18
|
+
generatedAt: "2026-08-07T00:00:00.000Z",
|
|
19
|
+
skills: [
|
|
20
|
+
{
|
|
21
|
+
name: "Find Skills",
|
|
22
|
+
repo: "vercel-labs/skills",
|
|
23
|
+
skillPath: "skills/find-skills",
|
|
24
|
+
description: "Find skills",
|
|
25
|
+
category: "search",
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
skillSets: [],
|
|
29
|
+
});
|
|
30
|
+
}) as typeof fetch;
|
|
31
|
+
|
|
32
|
+
const catalog = await fetchCuratedSkillsCatalog();
|
|
33
|
+
expect(requestedUrl).toBe(
|
|
34
|
+
"https://us-central1-claudish-6da10.cloudfunctions.net/skills/recommended",
|
|
35
|
+
);
|
|
36
|
+
expect(catalog.skills[0].skillPath).toBe("skills/find-skills");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("rejects HTTP and malformed responses", async () => {
|
|
40
|
+
globalThis.fetch = (async () =>
|
|
41
|
+
new Response("down", { status: 503 })) as typeof fetch;
|
|
42
|
+
expect(fetchCuratedSkillsCatalog()).rejects.toThrow("HTTP 503");
|
|
43
|
+
|
|
44
|
+
globalThis.fetch = (async () =>
|
|
45
|
+
Response.json({ skills: [] })) as typeof fetch;
|
|
46
|
+
expect(fetchCuratedSkillsCatalog()).rejects.toThrow("malformed response");
|
|
47
|
+
});
|
|
48
|
+
});
|