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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudeup",
3
- "version": "4.32.1",
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.32.1",
68
- "claudeup-darwin-x64": "4.32.1",
69
- "claudeup-linux-x64": "4.32.1"
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", args, { cwd: repo, encoding: "utf-8" });
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
- const r = spawnSync("git", ["-c", "core.excludesFile=/dev/null", ...args], {
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
+ });