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
|
@@ -1,31 +1,107 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
McpRegistryResponse,
|
|
3
|
+
McpRegistryServer,
|
|
4
|
+
McpServer,
|
|
5
|
+
} from "../types/index.js";
|
|
2
6
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
const MCP_REGISTRY_API = "https://registry.modelcontextprotocol.io/v0";
|
|
7
|
+
const DEFAULT_MCP_REGISTRY_API =
|
|
8
|
+
"https://us-central1-claudish-6da10.cloudfunctions.net/mcpRegistry";
|
|
6
9
|
|
|
7
10
|
export interface SearchOptions {
|
|
8
11
|
query?: string;
|
|
9
12
|
limit?: number;
|
|
10
13
|
cursor?: string;
|
|
14
|
+
signal?: AbortSignal;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function getMcpRegistryApiUrl(): string {
|
|
18
|
+
return (process.env.MCP_REGISTRY_API_URL || DEFAULT_MCP_REGISTRY_API).replace(
|
|
19
|
+
/\/+$/,
|
|
20
|
+
"",
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isOptionalString(value: unknown): value is string | undefined {
|
|
25
|
+
return value === undefined || typeof value === "string";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isMcpRegistryServer(value: unknown): value is McpRegistryServer {
|
|
29
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
30
|
+
|
|
31
|
+
const server = value as Record<string, unknown>;
|
|
32
|
+
return (
|
|
33
|
+
typeof server.name === "string" &&
|
|
34
|
+
server.name.length > 0 &&
|
|
35
|
+
typeof server.url === "string" &&
|
|
36
|
+
server.url.length > 0 &&
|
|
37
|
+
typeof server.short_description === "string" &&
|
|
38
|
+
isOptionalString(server.version) &&
|
|
39
|
+
isOptionalString(server.source_code_url) &&
|
|
40
|
+
isOptionalString(server.package_registry) &&
|
|
41
|
+
isOptionalString(server.published_at)
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isMcpServer(value: unknown): value is McpServer {
|
|
46
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
47
|
+
const server = value as Record<string, unknown>;
|
|
48
|
+
const isHttp = server.type === "http";
|
|
49
|
+
return (
|
|
50
|
+
typeof server.name === "string" &&
|
|
51
|
+
server.name.length > 0 &&
|
|
52
|
+
typeof server.description === "string" &&
|
|
53
|
+
typeof server.category === "string" &&
|
|
54
|
+
(isHttp
|
|
55
|
+
? typeof server.url === "string" && server.url.length > 0
|
|
56
|
+
: typeof server.command === "string" && server.command.length > 0) &&
|
|
57
|
+
(server.args === undefined ||
|
|
58
|
+
(Array.isArray(server.args) &&
|
|
59
|
+
server.args.every((arg) => typeof arg === "string"))) &&
|
|
60
|
+
(server.env === undefined ||
|
|
61
|
+
(typeof server.env === "object" && !Array.isArray(server.env))) &&
|
|
62
|
+
(server.configFields === undefined || Array.isArray(server.configFields))
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function parseMcpRegistryResponse(value: unknown): McpRegistryResponse {
|
|
67
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
68
|
+
throw new Error("MCP Registry API returned a malformed response");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const response = value as Record<string, unknown>;
|
|
72
|
+
if (
|
|
73
|
+
!Array.isArray(response.servers) ||
|
|
74
|
+
!response.servers.every(isMcpRegistryServer) ||
|
|
75
|
+
!isOptionalString(response.next_cursor)
|
|
76
|
+
) {
|
|
77
|
+
throw new Error("MCP Registry API returned a malformed response");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
servers: response.servers,
|
|
82
|
+
...(response.next_cursor === undefined
|
|
83
|
+
? {}
|
|
84
|
+
: { next_cursor: response.next_cursor }),
|
|
85
|
+
};
|
|
11
86
|
}
|
|
12
87
|
|
|
13
88
|
export async function searchMcpServers(
|
|
14
89
|
options: SearchOptions = {},
|
|
15
90
|
): Promise<McpRegistryResponse> {
|
|
16
|
-
const { query = "", limit = 20, cursor } = options;
|
|
91
|
+
const { query = "", limit = 20, cursor, signal } = options;
|
|
17
92
|
|
|
18
93
|
const params = new URLSearchParams();
|
|
19
|
-
if (query) params.set("
|
|
94
|
+
if (query) params.set("q", query);
|
|
20
95
|
params.set("limit", String(limit));
|
|
21
96
|
if (cursor) params.set("cursor", cursor);
|
|
22
97
|
|
|
23
|
-
const url = `${
|
|
98
|
+
const url = `${getMcpRegistryApiUrl()}/search?${params.toString()}`;
|
|
24
99
|
|
|
25
100
|
const response = await fetch(url, {
|
|
26
101
|
headers: {
|
|
27
102
|
Accept: "application/json",
|
|
28
103
|
},
|
|
104
|
+
signal,
|
|
29
105
|
});
|
|
30
106
|
|
|
31
107
|
if (!response.ok) {
|
|
@@ -34,61 +110,51 @@ export async function searchMcpServers(
|
|
|
34
110
|
);
|
|
35
111
|
}
|
|
36
112
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
.map((item: any) => {
|
|
44
|
-
const server = item.server || item; // Handle both nested and flat structures
|
|
45
|
-
const meta = item._meta?.["io.modelcontextprotocol.registry/official"];
|
|
46
|
-
|
|
47
|
-
// Get URL from remotes (HTTP) or construct from packages
|
|
48
|
-
let url = "";
|
|
49
|
-
if (server.remotes?.length > 0) {
|
|
50
|
-
url = server.remotes[0].url;
|
|
51
|
-
} else if (server.packages?.length > 0) {
|
|
52
|
-
// For package-based servers, use the package identifier as reference
|
|
53
|
-
const pkg = server.packages[0];
|
|
54
|
-
url = `${pkg.registryType}:${pkg.identifier}`;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
return {
|
|
58
|
-
name: server.name,
|
|
59
|
-
url,
|
|
60
|
-
short_description: server.description || "No description",
|
|
61
|
-
version: server.version,
|
|
62
|
-
source_code_url: server.repository?.url,
|
|
63
|
-
package_registry: server.packages?.[0]?.registryType,
|
|
64
|
-
published_at: meta?.publishedAt,
|
|
65
|
-
};
|
|
66
|
-
})
|
|
67
|
-
// Filter out servers without name or URL
|
|
68
|
-
.filter((s: McpRegistryServer) => s.name && s.url)
|
|
69
|
-
// Sort by publish date (newest first)
|
|
70
|
-
.sort((a: McpRegistryServer, b: McpRegistryServer) => {
|
|
71
|
-
if (!a.published_at) return 1;
|
|
72
|
-
if (!b.published_at) return -1;
|
|
73
|
-
return (
|
|
74
|
-
new Date(b.published_at).getTime() - new Date(a.published_at).getTime()
|
|
75
|
-
);
|
|
76
|
-
});
|
|
113
|
+
let data: unknown;
|
|
114
|
+
try {
|
|
115
|
+
data = await response.json();
|
|
116
|
+
} catch {
|
|
117
|
+
throw new Error("MCP Registry API returned a malformed response");
|
|
118
|
+
}
|
|
77
119
|
|
|
78
|
-
return
|
|
79
|
-
servers,
|
|
80
|
-
next_cursor: data.metadata?.nextCursor || data.next_cursor,
|
|
81
|
-
};
|
|
120
|
+
return parseMcpRegistryResponse(data);
|
|
82
121
|
}
|
|
83
122
|
|
|
84
123
|
export async function getPopularServers(
|
|
85
124
|
limit = 20,
|
|
86
125
|
): Promise<McpRegistryServer[]> {
|
|
87
126
|
const response = await searchMcpServers({ limit });
|
|
88
|
-
// Already sorted by publish date in searchMcpServers
|
|
89
127
|
return response.servers;
|
|
90
128
|
}
|
|
91
129
|
|
|
130
|
+
/** Fetch the install-ready catalog. This is separate from dynamic Registry search. */
|
|
131
|
+
export async function fetchCuratedMcpServers(
|
|
132
|
+
signal?: AbortSignal,
|
|
133
|
+
): Promise<McpServer[]> {
|
|
134
|
+
const response = await fetch(`${getMcpRegistryApiUrl()}/recommended`, {
|
|
135
|
+
headers: { Accept: "application/json" },
|
|
136
|
+
signal,
|
|
137
|
+
});
|
|
138
|
+
if (!response.ok) {
|
|
139
|
+
throw new Error(`MCP catalog API error: ${response.status}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
let data: unknown;
|
|
143
|
+
try {
|
|
144
|
+
data = await response.json();
|
|
145
|
+
} catch {
|
|
146
|
+
throw new Error("MCP catalog API returned a malformed response");
|
|
147
|
+
}
|
|
148
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
|
149
|
+
throw new Error("MCP catalog API returned a malformed response");
|
|
150
|
+
}
|
|
151
|
+
const servers = (data as Record<string, unknown>).servers;
|
|
152
|
+
if (!Array.isArray(servers) || !servers.every(isMcpServer)) {
|
|
153
|
+
throw new Error("MCP catalog API returned a malformed response");
|
|
154
|
+
}
|
|
155
|
+
return servers;
|
|
156
|
+
}
|
|
157
|
+
|
|
92
158
|
export function formatDate(dateStr?: string): string {
|
|
93
159
|
if (!dateStr) return "";
|
|
94
160
|
const date = new Date(dateStr);
|
|
@@ -7,7 +7,7 @@ import type {
|
|
|
7
7
|
SkillFrontmatter,
|
|
8
8
|
GitTreeResponse,
|
|
9
9
|
} from "../types/index.js";
|
|
10
|
-
import {
|
|
10
|
+
import { classifyStarReliability, type RecommendedSkill } from "../data/skill-repos.js";
|
|
11
11
|
|
|
12
12
|
const SKILLS_API_BASE =
|
|
13
13
|
"https://us-central1-claudish-6da10.cloudfunctions.net/skills";
|
|
@@ -286,6 +286,7 @@ export async function fetchPopularSkills(limit = 30): Promise<SkillInfo[]> {
|
|
|
286
286
|
export async function fetchAvailableSkills(
|
|
287
287
|
_repos: SkillSource[],
|
|
288
288
|
projectPath?: string,
|
|
289
|
+
recommended: RecommendedSkill[] = [],
|
|
289
290
|
): Promise<SkillInfo[]> {
|
|
290
291
|
const userInstalled = await getInstalledSkillNames("user");
|
|
291
292
|
const projectInstalled = await getInstalledSkillNames("project", projectPath);
|
|
@@ -306,8 +307,8 @@ export async function fetchAvailableSkills(
|
|
|
306
307
|
return { ...skill, installed, installedScope };
|
|
307
308
|
};
|
|
308
309
|
|
|
309
|
-
// 1. Recommended skills from
|
|
310
|
-
const recommendedSkills: SkillInfo[] =
|
|
310
|
+
// 1. Recommended skills from the versioned models-index catalog.
|
|
311
|
+
const recommendedSkills: SkillInfo[] = recommended.map((rec) => {
|
|
311
312
|
const source: SkillSource = {
|
|
312
313
|
label: rec.repo,
|
|
313
314
|
repo: rec.repo,
|
|
@@ -334,52 +335,7 @@ export async function fetchAvailableSkills(
|
|
|
334
335
|
const popular = await fetchPopularSkills(30);
|
|
335
336
|
const popularSkills = popular.map((s) => markInstalled({ ...s, isRecommended: false }));
|
|
336
337
|
|
|
337
|
-
// 3.
|
|
338
|
-
const starsCachePath = path.join(os.homedir(), ".claude", "skill-stars-cache.json");
|
|
339
|
-
let starsCache: Record<string, { stars: number; fetchedAt: string }> = {};
|
|
340
|
-
try { starsCache = await fs.readJson(starsCachePath); } catch { /* no cache yet */ }
|
|
341
|
-
|
|
342
|
-
const uniqueRepos = [...new Set(recommendedSkills.map((s) => s.source.repo))];
|
|
343
|
-
const repoStars = new Map<string, number>();
|
|
344
|
-
const cacheMaxAge = 24 * 60 * 60 * 1000; // 24 hours
|
|
345
|
-
let cacheUpdated = false;
|
|
346
|
-
|
|
347
|
-
for (const repo of uniqueRepos) {
|
|
348
|
-
const cached = starsCache[repo];
|
|
349
|
-
if (cached && Date.now() - new Date(cached.fetchedAt).getTime() < cacheMaxAge) {
|
|
350
|
-
repoStars.set(repo, cached.stars);
|
|
351
|
-
continue;
|
|
352
|
-
}
|
|
353
|
-
// Try fetching from GitHub (may be rate limited)
|
|
354
|
-
try {
|
|
355
|
-
const res = await fetch(`https://api.github.com/repos/${repo}`, {
|
|
356
|
-
headers: { Accept: "application/vnd.github+json" },
|
|
357
|
-
signal: AbortSignal.timeout(5000),
|
|
358
|
-
});
|
|
359
|
-
if (res.ok) {
|
|
360
|
-
const data = (await res.json()) as { stargazers_count?: number };
|
|
361
|
-
if (data.stargazers_count) {
|
|
362
|
-
repoStars.set(repo, data.stargazers_count);
|
|
363
|
-
starsCache[repo] = { stars: data.stargazers_count, fetchedAt: new Date().toISOString() };
|
|
364
|
-
cacheUpdated = true;
|
|
365
|
-
}
|
|
366
|
-
} else if (cached) {
|
|
367
|
-
// Rate limited but have stale cache — use it
|
|
368
|
-
repoStars.set(repo, cached.stars);
|
|
369
|
-
}
|
|
370
|
-
} catch {
|
|
371
|
-
if (cached) repoStars.set(repo, cached.stars);
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
if (cacheUpdated) {
|
|
375
|
-
try { await fs.writeJson(starsCachePath, starsCache); } catch { /* ignore */ }
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
for (const rec of recommendedSkills) {
|
|
379
|
-
rec.stars = repoStars.get(rec.source.repo) || rec.stars || undefined;
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
// 4. Combine: recommended first, then popular (dedup by name)
|
|
338
|
+
// 3. Combine: recommended first, then popular (dedup by name)
|
|
383
339
|
const seen = new Set<string>(recommendedSkills.map((s) => s.name));
|
|
384
340
|
const deduped = popularSkills.filter((s) => !seen.has(s.name));
|
|
385
341
|
|
|
@@ -388,11 +344,69 @@ export async function fetchAvailableSkills(
|
|
|
388
344
|
|
|
389
345
|
// ─── Install / Uninstall ──────────────────────────────────────────────────────
|
|
390
346
|
|
|
347
|
+
/** Hard ceilings so one pathological repo cannot fill a user's disk. */
|
|
348
|
+
const MAX_SKILL_FILES = 200;
|
|
349
|
+
const MAX_SKILL_BYTES = 20 * 1024 * 1024;
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Pick the files that belong to one skill out of a whole-repo tree listing.
|
|
353
|
+
*
|
|
354
|
+
* Returns paths relative to the skill directory, so `skills/x/refs/a.md`
|
|
355
|
+
* under `skills/x` becomes `refs/a.md`.
|
|
356
|
+
*
|
|
357
|
+
* The trailing slash on the prefix is load-bearing: without it `skills/audit`
|
|
358
|
+
* also matches `skills/audit-website/...`, and the user gets a second skill's
|
|
359
|
+
* files dumped into the first one's directory.
|
|
360
|
+
*/
|
|
361
|
+
export function selectSkillFiles(
|
|
362
|
+
treePaths: { path: string; type: string }[],
|
|
363
|
+
repoPath: string,
|
|
364
|
+
): string[] {
|
|
365
|
+
const prefix = `${repoPath.replace(/\/+$/, "")}/`;
|
|
366
|
+
return treePaths
|
|
367
|
+
.filter((e) => e.type === "blob" && e.path.startsWith(prefix))
|
|
368
|
+
.map((e) => e.path.slice(prefix.length))
|
|
369
|
+
.filter((rel) => rel.length > 0);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Resolve one relative path inside the install directory, or return null.
|
|
374
|
+
*
|
|
375
|
+
* These paths come from a remote repository, so they are untrusted input to a
|
|
376
|
+
* filesystem write. Anything that escapes the install directory — `..`
|
|
377
|
+
* segments, an absolute path — is dropped rather than written outside the
|
|
378
|
+
* skill folder.
|
|
379
|
+
*/
|
|
380
|
+
export function resolveSkillFilePath(
|
|
381
|
+
installDir: string,
|
|
382
|
+
relativePath: string,
|
|
383
|
+
): string | null {
|
|
384
|
+
if (!relativePath || path.isAbsolute(relativePath)) return null;
|
|
385
|
+
const resolvedDir = path.resolve(installDir);
|
|
386
|
+
const target = path.resolve(resolvedDir, relativePath);
|
|
387
|
+
if (target !== resolvedDir && !target.startsWith(resolvedDir + path.sep)) {
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
return target;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** What an install actually delivered — surfaced so the UI can be honest. */
|
|
394
|
+
export interface InstallResult {
|
|
395
|
+
/** Number of files written, always ≥ 1 (SKILL.md). */
|
|
396
|
+
fileCount: number;
|
|
397
|
+
/**
|
|
398
|
+
* Set when only SKILL.md could be written for a skill that has more files,
|
|
399
|
+
* e.g. the GitHub tree call was rate-limited. The skill is installed but
|
|
400
|
+
* incomplete, and the caller should say so.
|
|
401
|
+
*/
|
|
402
|
+
degraded?: string;
|
|
403
|
+
}
|
|
404
|
+
|
|
391
405
|
export async function installSkill(
|
|
392
406
|
skill: SkillInfo,
|
|
393
407
|
scope: "user" | "project",
|
|
394
408
|
projectPath?: string,
|
|
395
|
-
): Promise<
|
|
409
|
+
): Promise<InstallResult> {
|
|
396
410
|
// Try multiple URL patterns — repos structure SKILL.md differently
|
|
397
411
|
const repo = skill.source.repo;
|
|
398
412
|
const repoPath = skill.repoPath.replace(/\/SKILL\.md$/, "");
|
|
@@ -436,6 +450,74 @@ export async function installSkill(
|
|
|
436
450
|
|
|
437
451
|
await fs.ensureDir(installDir);
|
|
438
452
|
await fs.writeFile(path.join(installDir, "SKILL.md"), content, "utf8");
|
|
453
|
+
|
|
454
|
+
// SKILL.md alone is usually not a working skill. Skills ship scripts,
|
|
455
|
+
// references and assets beside it and link to them by relative path, so
|
|
456
|
+
// writing only SKILL.md leaves dangling links — measured: audit-website
|
|
457
|
+
// references `references/OUTPUT-FORMAT.md`, systematic-debugging ships
|
|
458
|
+
// `find-polluter.sh` and 9 more. Fetch the rest of the directory.
|
|
459
|
+
let extras: string[] = [];
|
|
460
|
+
try {
|
|
461
|
+
const tree = await fetchGitTree(repo);
|
|
462
|
+
extras = selectSkillFiles(tree.tree, repoPath).filter(
|
|
463
|
+
(rel) => rel !== "SKILL.md",
|
|
464
|
+
);
|
|
465
|
+
} catch (error) {
|
|
466
|
+
// The tree call is the rate-limited one. A skill with only SKILL.md is
|
|
467
|
+
// still correct, so report the shortfall instead of failing the install.
|
|
468
|
+
return {
|
|
469
|
+
fileCount: 1,
|
|
470
|
+
degraded:
|
|
471
|
+
error instanceof Error && /rate limit/i.test(error.message)
|
|
472
|
+
? "GitHub rate limit — supporting files not fetched. Set GITHUB_TOKEN and reinstall."
|
|
473
|
+
: "supporting files could not be listed; SKILL.md only",
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (extras.length === 0) return { fileCount: 1 };
|
|
478
|
+
|
|
479
|
+
const truncated = extras.length > MAX_SKILL_FILES;
|
|
480
|
+
const selected = truncated ? extras.slice(0, MAX_SKILL_FILES) : extras;
|
|
481
|
+
|
|
482
|
+
let written = 1;
|
|
483
|
+
let bytes = content.length;
|
|
484
|
+
let budgetHit = false;
|
|
485
|
+
|
|
486
|
+
for (const rel of selected) {
|
|
487
|
+
if (bytes >= MAX_SKILL_BYTES) {
|
|
488
|
+
budgetHit = true;
|
|
489
|
+
break;
|
|
490
|
+
}
|
|
491
|
+
const target = resolveSkillFilePath(installDir, rel);
|
|
492
|
+
if (!target) continue; // escapes the install dir — never write it
|
|
493
|
+
try {
|
|
494
|
+
const res = await fetch(
|
|
495
|
+
`https://raw.githubusercontent.com/${repo}/HEAD/${repoPath}/${rel}`,
|
|
496
|
+
{ signal: AbortSignal.timeout(15000) },
|
|
497
|
+
);
|
|
498
|
+
if (!res.ok) continue;
|
|
499
|
+
// Buffer, not text: skills ship binary assets (.png, .svg) and
|
|
500
|
+
// decoding those as UTF-8 corrupts them.
|
|
501
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
502
|
+
await fs.ensureDir(path.dirname(target));
|
|
503
|
+
await fs.writeFile(target, buf);
|
|
504
|
+
written++;
|
|
505
|
+
bytes += buf.length;
|
|
506
|
+
} catch {
|
|
507
|
+
// One unreachable asset should not abandon a half-written skill.
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
let degraded: string | undefined;
|
|
512
|
+
if (budgetHit) {
|
|
513
|
+
degraded = `stopped at ${MAX_SKILL_BYTES / 1024 / 1024}MB — skill is larger than the install budget`;
|
|
514
|
+
} else if (truncated) {
|
|
515
|
+
degraded = `only the first ${MAX_SKILL_FILES} of ${extras.length + 1} files were installed`;
|
|
516
|
+
} else if (written < selected.length + 1) {
|
|
517
|
+
degraded = `${selected.length + 1 - written} of ${selected.length + 1} files could not be fetched`;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
return { fileCount: written, degraded };
|
|
439
521
|
}
|
|
440
522
|
|
|
441
523
|
export async function uninstallSkill(
|
|
@@ -455,11 +537,20 @@ export async function uninstallSkill(
|
|
|
455
537
|
|
|
456
538
|
const skillMdPath = path.join(installDir, "SKILL.md");
|
|
457
539
|
|
|
540
|
+
// Remove the whole skill directory, not just SKILL.md. Installs now write
|
|
541
|
+
// supporting files (scripts, references, assets); deleting SKILL.md alone
|
|
542
|
+
// left those orphaned, and the follow-up `rmdir` then failed silently on the
|
|
543
|
+
// non-empty directory — so the skill vanished from the UI while its files
|
|
544
|
+
// stayed on disk forever.
|
|
545
|
+
//
|
|
546
|
+
// Gated on SKILL.md existing so this only ever deletes a directory that is
|
|
547
|
+
// actually an installed skill.
|
|
458
548
|
if (await fs.pathExists(skillMdPath)) {
|
|
459
|
-
await fs.remove(
|
|
549
|
+
await fs.remove(installDir);
|
|
550
|
+
return;
|
|
460
551
|
}
|
|
461
552
|
|
|
462
|
-
//
|
|
553
|
+
// No SKILL.md: at most an empty leftover directory. Remove it only if empty.
|
|
463
554
|
try {
|
|
464
555
|
await fs.rmdir(installDir);
|
|
465
556
|
} catch {
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
// Deployed `skills` Gen2 Cloud Function (project claudish-6da10, us-central1).
|
|
12
12
|
// Firebase keeps this cloudfunctions.net alias stable for the function.
|
|
13
13
|
const SKILLS_API_BASE =
|
|
14
|
-
process.env.SKILLS_API_URL ||
|
|
14
|
+
process.env.SKILLS_API_URL ||
|
|
15
|
+
"https://us-central1-claudish-6da10.cloudfunctions.net/skills";
|
|
15
16
|
|
|
16
17
|
export interface SkillSearchResult {
|
|
17
18
|
name: string;
|
|
@@ -31,6 +32,54 @@ export interface RepoSkillResult {
|
|
|
31
32
|
description?: string;
|
|
32
33
|
}
|
|
33
34
|
|
|
35
|
+
export interface CuratedSkillSetResult {
|
|
36
|
+
name: string;
|
|
37
|
+
repo: string;
|
|
38
|
+
description: string;
|
|
39
|
+
icon: string;
|
|
40
|
+
stars?: number;
|
|
41
|
+
skillCount?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface CuratedSkillResult extends SkillSearchResult {
|
|
45
|
+
description: string;
|
|
46
|
+
category: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface CuratedSkillsCatalogResult {
|
|
50
|
+
schemaVersion: number;
|
|
51
|
+
version: number;
|
|
52
|
+
generatedAt: string;
|
|
53
|
+
skills: CuratedSkillResult[];
|
|
54
|
+
skillSets: CuratedSkillSetResult[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isCuratedSkill(value: unknown): value is CuratedSkillResult {
|
|
58
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
59
|
+
const skill = value as Record<string, unknown>;
|
|
60
|
+
return (
|
|
61
|
+
typeof skill.name === "string" &&
|
|
62
|
+
typeof skill.repo === "string" &&
|
|
63
|
+
typeof skill.skillPath === "string" &&
|
|
64
|
+
typeof skill.description === "string" &&
|
|
65
|
+
typeof skill.category === "string" &&
|
|
66
|
+
(skill.stars === undefined || typeof skill.stars === "number")
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isCuratedSkillSet(value: unknown): value is CuratedSkillSetResult {
|
|
71
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
72
|
+
const set = value as Record<string, unknown>;
|
|
73
|
+
return (
|
|
74
|
+
typeof set.name === "string" &&
|
|
75
|
+
typeof set.repo === "string" &&
|
|
76
|
+
typeof set.description === "string" &&
|
|
77
|
+
typeof set.icon === "string" &&
|
|
78
|
+
(set.stars === undefined || typeof set.stars === "number") &&
|
|
79
|
+
(set.skillCount === undefined || typeof set.skillCount === "number")
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
34
83
|
/**
|
|
35
84
|
* Thrown when the skills API is unreachable or returns a non-OK status.
|
|
36
85
|
* Lets callers distinguish "search service is down" from "no skills matched",
|
|
@@ -106,14 +155,39 @@ export async function fetchRepoSkills(
|
|
|
106
155
|
* Get recommended skills list (curated, server-side)
|
|
107
156
|
*/
|
|
108
157
|
export async function fetchRecommendedSkills(): Promise<SkillSearchResult[]> {
|
|
158
|
+
return (await fetchCuratedSkillsCatalog()).skills;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Load the versioned install-ready catalog and skill-set definitions. */
|
|
162
|
+
export async function fetchCuratedSkillsCatalog(): Promise<CuratedSkillsCatalogResult> {
|
|
163
|
+
let res: Response;
|
|
109
164
|
try {
|
|
110
|
-
|
|
111
|
-
signal: AbortSignal.timeout(
|
|
165
|
+
res = await fetch(`${SKILLS_API_BASE}/recommended`, {
|
|
166
|
+
signal: AbortSignal.timeout(10000),
|
|
112
167
|
});
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
168
|
+
} catch (cause) {
|
|
169
|
+
throw new SkillsApiError(
|
|
170
|
+
`Curated skills request failed: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
if (!res.ok) {
|
|
174
|
+
throw new SkillsApiError(
|
|
175
|
+
`Curated skills returned HTTP ${res.status}`,
|
|
176
|
+
res.status,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const data = (await res.json()) as Partial<CuratedSkillsCatalogResult>;
|
|
181
|
+
if (
|
|
182
|
+
typeof data.schemaVersion !== "number" ||
|
|
183
|
+
typeof data.version !== "number" ||
|
|
184
|
+
typeof data.generatedAt !== "string" ||
|
|
185
|
+
!Array.isArray(data.skills) ||
|
|
186
|
+
!data.skills.every(isCuratedSkill) ||
|
|
187
|
+
!Array.isArray(data.skillSets) ||
|
|
188
|
+
!data.skillSets.every(isCuratedSkillSet)
|
|
189
|
+
) {
|
|
190
|
+
throw new SkillsApiError("Curated skills returned a malformed response");
|
|
118
191
|
}
|
|
192
|
+
return data as CuratedSkillsCatalogResult;
|
|
119
193
|
}
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
getCategoryDisplayName,
|
|
10
10
|
categoryOrder,
|
|
11
11
|
} from "../../data/mcp-servers.js";
|
|
12
|
+
import { fetchCuratedMcpServers } from "../../services/mcp-registry.js";
|
|
12
13
|
import {
|
|
13
14
|
addMcpServer,
|
|
14
15
|
removeMcpServer,
|
|
@@ -33,7 +34,10 @@ export function McpScreen() {
|
|
|
33
34
|
const fetchData = useCallback(async () => {
|
|
34
35
|
dispatch({ type: "MCP_DATA_LOADING" });
|
|
35
36
|
try {
|
|
36
|
-
const
|
|
37
|
+
const curatedServers = await fetchCuratedMcpServers(
|
|
38
|
+
AbortSignal.timeout(10_000),
|
|
39
|
+
);
|
|
40
|
+
const serversByCategory = getMcpServersByCategory(curatedServers);
|
|
37
41
|
const installedServers = await getInstalledMcpServers(state.projectPath);
|
|
38
42
|
const enabledServers = await getEnabledMcpServers(state.projectPath);
|
|
39
43
|
|
|
@@ -70,7 +74,7 @@ export function McpScreen() {
|
|
|
70
74
|
const allListItems = useMemo((): McpListItem[] => {
|
|
71
75
|
if (mcp.servers.status !== "success") return [];
|
|
72
76
|
|
|
73
|
-
const serversByCategory = getMcpServersByCategory();
|
|
77
|
+
const serversByCategory = getMcpServersByCategory(mcp.servers.data);
|
|
74
78
|
const items: McpListItem[] = [];
|
|
75
79
|
|
|
76
80
|
for (const category of categoryOrder) {
|
|
@@ -86,7 +90,7 @@ export function McpScreen() {
|
|
|
86
90
|
}
|
|
87
91
|
|
|
88
92
|
return items;
|
|
89
|
-
}, [mcp.servers
|
|
93
|
+
}, [mcp.servers, mcp.installedServers]);
|
|
90
94
|
|
|
91
95
|
useKeyboard((event) => {
|
|
92
96
|
if (state.isSearching || state.modal) return;
|
|
@@ -142,10 +146,14 @@ export function McpScreen() {
|
|
|
142
146
|
let config: McpServerConfig;
|
|
143
147
|
|
|
144
148
|
if (server.type === "http") {
|
|
145
|
-
|
|
149
|
+
if (!server.url) throw new Error(`${server.name} is missing its HTTP URL`);
|
|
150
|
+
config = { type: "http", url: server.url };
|
|
146
151
|
} else {
|
|
152
|
+
if (!server.command) {
|
|
153
|
+
throw new Error(`${server.name} is missing its launch command`);
|
|
154
|
+
}
|
|
147
155
|
config = {
|
|
148
|
-
command: server.command
|
|
156
|
+
command: server.command,
|
|
149
157
|
args: server.args ? [...server.args] : undefined,
|
|
150
158
|
env: server.env ? { ...server.env } : undefined,
|
|
151
159
|
};
|