claudeup 4.32.1 → 4.34.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__/content-drift.test.ts +10 -1
- package/src/__tests__/gitignore-detector.test.ts +13 -1
- package/src/__tests__/mcp-registry.test.ts +158 -0
- package/src/__tests__/skill-install-files.test.ts +128 -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 +6 -108
- package/src/services/mcp-registry.ts +118 -52
- package/src/services/skills-manager.ts +143 -52
- package/src/services/skillsmp-client.ts +82 -8
- package/src/ui/screens/McpScreen.tsx +13 -5
- package/src/ui/screens/SkillsScreen.tsx +51 -21
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claudeup",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.34.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.34.0",
|
|
68
|
+
"claudeup-darwin-x64": "4.34.0",
|
|
69
|
+
"claudeup-linux-x64": "4.34.0"
|
|
70
70
|
}
|
|
71
71
|
}
|
|
@@ -20,8 +20,17 @@ let configDir: string;
|
|
|
20
20
|
let prevConfigDir: string | undefined;
|
|
21
21
|
let repo: string;
|
|
22
22
|
|
|
23
|
+
// commit.gpgsign=false isolates the developer's git config. With signing on
|
|
24
|
+
// (1Password's SSH signer, for instance) every commit here calls that signer,
|
|
25
|
+
// and because this helper ignores the exit status a failure is silent: no
|
|
26
|
+
// commit is created, so drift is never detected and the assertions fail with a
|
|
27
|
+
// bare `false` that points nowhere near the real cause. CI has no signing
|
|
28
|
+
// config, so this only ever broke locally.
|
|
23
29
|
const git = (args: string[]) =>
|
|
24
|
-
spawnSync("git",
|
|
30
|
+
spawnSync("git", ["-c", "commit.gpgsign=false", ...args], {
|
|
31
|
+
cwd: repo,
|
|
32
|
+
encoding: "utf-8",
|
|
33
|
+
});
|
|
25
34
|
|
|
26
35
|
const head = () => git(["rev-parse", "HEAD"]).stdout.trim();
|
|
27
36
|
|
|
@@ -9,7 +9,19 @@ import type { ResolvedManifest } from "../types/gitignore";
|
|
|
9
9
|
function git(cwd: string, ...args: string[]): string {
|
|
10
10
|
// core.excludesFile=/dev/null bypasses the developer's global gitignore
|
|
11
11
|
// so the test environment doesn't leak into the result.
|
|
12
|
-
|
|
12
|
+
//
|
|
13
|
+
// commit.gpgsign=false is the same isolation, one step further: a developer
|
|
14
|
+
// with signed commits makes every `git commit` here call their signer. With
|
|
15
|
+
// 1Password's SSH agent that means a prompt or an agent error, so these tests
|
|
16
|
+
// failed or timed out locally for reasons that have nothing to do with the
|
|
17
|
+
// code under test. CI has no signing config, so it only ever broke locally.
|
|
18
|
+
const r = spawnSync("git", [
|
|
19
|
+
"-c",
|
|
20
|
+
"core.excludesFile=/dev/null",
|
|
21
|
+
"-c",
|
|
22
|
+
"commit.gpgsign=false",
|
|
23
|
+
...args,
|
|
24
|
+
], {
|
|
13
25
|
cwd,
|
|
14
26
|
encoding: "utf8",
|
|
15
27
|
});
|
|
@@ -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,128 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
resolveSkillFilePath,
|
|
5
|
+
selectSkillFiles,
|
|
6
|
+
} from "../services/skills-manager.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Installing a skill used to write exactly one file, SKILL.md, and drop
|
|
10
|
+
* everything beside it. Measured against the real repos: audit-website lost
|
|
11
|
+
* `references/OUTPUT-FORMAT.md` while its SKILL.md still linked to it,
|
|
12
|
+
* systematic-debugging lost `find-polluter.sh` and 9 more, and
|
|
13
|
+
* remotion-best-practices lost 136 files. The installed skill looked fine and
|
|
14
|
+
* had dangling links.
|
|
15
|
+
*
|
|
16
|
+
* These cover the two pieces of that fix that are pure: choosing which files
|
|
17
|
+
* belong to a skill, and refusing to write outside its directory.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const blob = (p: string) => ({ path: p, type: "blob" });
|
|
21
|
+
const tree = (p: string) => ({ path: p, type: "tree" });
|
|
22
|
+
|
|
23
|
+
describe("selectSkillFiles", () => {
|
|
24
|
+
test("returns paths relative to the skill directory", () => {
|
|
25
|
+
expect(
|
|
26
|
+
selectSkillFiles(
|
|
27
|
+
[
|
|
28
|
+
blob("skills/audit-website/SKILL.md"),
|
|
29
|
+
blob("skills/audit-website/references/OUTPUT-FORMAT.md"),
|
|
30
|
+
blob("skills/audit-website/assets/icon-small.svg"),
|
|
31
|
+
],
|
|
32
|
+
"skills/audit-website",
|
|
33
|
+
),
|
|
34
|
+
).toEqual(["SKILL.md", "references/OUTPUT-FORMAT.md", "assets/icon-small.svg"]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("REGRESSION: a sibling with a shared name prefix is excluded", () => {
|
|
38
|
+
// Without the trailing slash on the prefix, "skills/audit" also matches
|
|
39
|
+
// "skills/audit-website/..." and dumps a second skill's files into the
|
|
40
|
+
// first one's directory.
|
|
41
|
+
expect(
|
|
42
|
+
selectSkillFiles(
|
|
43
|
+
[
|
|
44
|
+
blob("skills/audit/SKILL.md"),
|
|
45
|
+
blob("skills/audit-website/SKILL.md"),
|
|
46
|
+
blob("skills/audit-website/references/OUTPUT-FORMAT.md"),
|
|
47
|
+
],
|
|
48
|
+
"skills/audit",
|
|
49
|
+
),
|
|
50
|
+
).toEqual(["SKILL.md"]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("tree entries are skipped — only blobs are downloadable", () => {
|
|
54
|
+
expect(
|
|
55
|
+
selectSkillFiles(
|
|
56
|
+
[
|
|
57
|
+
tree("skills/x/references"),
|
|
58
|
+
blob("skills/x/SKILL.md"),
|
|
59
|
+
blob("skills/x/references/a.md"),
|
|
60
|
+
],
|
|
61
|
+
"skills/x",
|
|
62
|
+
),
|
|
63
|
+
).toEqual(["SKILL.md", "references/a.md"]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("files outside the skill directory are excluded", () => {
|
|
67
|
+
expect(
|
|
68
|
+
selectSkillFiles(
|
|
69
|
+
[blob("README.md"), blob("other/SKILL.md"), blob("skills/x/SKILL.md")],
|
|
70
|
+
"skills/x",
|
|
71
|
+
),
|
|
72
|
+
).toEqual(["SKILL.md"]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("a trailing slash on the skill path does not break matching", () => {
|
|
76
|
+
expect(
|
|
77
|
+
selectSkillFiles([blob("skills/x/SKILL.md")], "skills/x/"),
|
|
78
|
+
).toEqual(["SKILL.md"]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("a skill directory with no files yields nothing", () => {
|
|
82
|
+
expect(selectSkillFiles([blob("skills/other/SKILL.md")], "skills/x")).toEqual([]);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("resolveSkillFilePath", () => {
|
|
87
|
+
const dir = path.join(path.sep, "tmp", "skills", "demo");
|
|
88
|
+
|
|
89
|
+
test("resolves a plain relative path inside the install directory", () => {
|
|
90
|
+
expect(resolveSkillFilePath(dir, "SKILL.md")).toBe(
|
|
91
|
+
path.join(dir, "SKILL.md"),
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("resolves a nested path", () => {
|
|
96
|
+
expect(resolveSkillFilePath(dir, "references/OUTPUT-FORMAT.md")).toBe(
|
|
97
|
+
path.join(dir, "references", "OUTPUT-FORMAT.md"),
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("SECURITY: rejects paths escaping via ..", () => {
|
|
102
|
+
// These paths come from a remote repository and are untrusted input to a
|
|
103
|
+
// filesystem write.
|
|
104
|
+
expect(resolveSkillFilePath(dir, "../evil.md")).toBeNull();
|
|
105
|
+
expect(resolveSkillFilePath(dir, "../../.bashrc")).toBeNull();
|
|
106
|
+
expect(resolveSkillFilePath(dir, "refs/../../../evil.md")).toBeNull();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("SECURITY: rejects absolute paths", () => {
|
|
110
|
+
expect(resolveSkillFilePath(dir, path.join(path.sep, "etc", "passwd"))).toBeNull();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("rejects an empty path", () => {
|
|
114
|
+
expect(resolveSkillFilePath(dir, "")).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("a .. that stays inside the directory is allowed", () => {
|
|
118
|
+
expect(resolveSkillFilePath(dir, "refs/../SKILL.md")).toBe(
|
|
119
|
+
path.join(dir, "SKILL.md"),
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("REGRESSION: a sibling directory sharing the name prefix is rejected", () => {
|
|
124
|
+
// path.startsWith without a separator would accept /tmp/skills/demo-evil
|
|
125
|
+
// as being inside /tmp/skills/demo.
|
|
126
|
+
expect(resolveSkillFilePath(dir, "../demo-evil/x.md")).toBeNull();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -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
|
+
});
|