claudeup 4.32.1 → 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.
@@ -1,31 +1,107 @@
1
- import type { McpRegistryServer, McpRegistryResponse } from "../types/index.js";
1
+ import type {
2
+ McpRegistryResponse,
3
+ McpRegistryServer,
4
+ McpServer,
5
+ } from "../types/index.js";
2
6
 
3
- // Official Model Context Protocol Registry
4
- // API Docs: https://registry.modelcontextprotocol.io
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("query", query);
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 = `${MCP_REGISTRY_API}/servers?${params.toString()}`;
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
- const data: any = await response.json();
38
-
39
- // The official registry returns data in a nested format:
40
- // { servers: [{ server: {...}, _meta: {...} }] }
41
- // Convert it to match our expected McpRegistryResponse format
42
- const servers: McpRegistryServer[] = (data.servers || [])
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 { RECOMMENDED_SKILLS, classifyStarReliability } from "../data/skill-repos.js";
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 RECOMMENDED_SKILLS constant (no API call)
310
- const recommendedSkills: SkillInfo[] = RECOMMENDED_SKILLS.map((rec) => {
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. Enrich recommended skills with GitHub repo stars (cached to disk)
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
 
@@ -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 || "https://us-central1-claudish-6da10.cloudfunctions.net/skills";
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
- const res = await fetch(`${SKILLS_API_BASE}/recommended`, {
111
- signal: AbortSignal.timeout(5000),
165
+ res = await fetch(`${SKILLS_API_BASE}/recommended`, {
166
+ signal: AbortSignal.timeout(10000),
112
167
  });
113
- if (!res.ok) return [];
114
- const data = (await res.json()) as { skills: SkillSearchResult[] };
115
- return data.skills || [];
116
- } catch {
117
- return [];
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 serversByCategory = getMcpServersByCategory();
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.status, mcp.installedServers]);
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
- config = { type: "http", url: server.url! };
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
  };
@@ -15,8 +15,8 @@ import {
15
15
  installSkill,
16
16
  uninstallSkill,
17
17
  } from "../../services/skills-manager.js";
18
- import { searchSkills } from "../../services/skillsmp-client.js";
19
- import { DEFAULT_SKILL_REPOS, RECOMMENDED_SKILLS, RECOMMENDED_SKILL_SETS, classifyStarReliability } from "../../data/skill-repos.js";
18
+ import { fetchCuratedSkillsCatalog, searchSkills } from "../../services/skillsmp-client.js";
19
+ import { DEFAULT_SKILL_REPOS, classifyStarReliability, type RecommendedSkill } from "../../data/skill-repos.js";
20
20
  import type { SkillInfo, SkillSetInfo, SkillSource } from "../../types/index.js";
21
21
  import { buildSkillBrowserItems } from "../adapters/skillsAdapter.js";
22
22
  import type { SkillBrowserItem } from "../adapters/skillsAdapter.js";
@@ -28,6 +28,9 @@ export function SkillsScreen() {
28
28
  const { skills: skillsState } = state;
29
29
  const modal = useModal();
30
30
  const dimensions = useDimensions();
31
+ const [curatedSkills, setCuratedSkills] = useState<RecommendedSkill[]>([]);
32
+ const [skillSets, setSkillSets] = useState<SkillSetInfo[]>([]);
33
+ const [expandedSets, setExpandedSets] = useState<Set<string>>(new Set());
31
34
 
32
35
  const isSearchActive =
33
36
  state.isSearching &&
@@ -39,9 +42,30 @@ export function SkillsScreen() {
39
42
  const fetchData = useCallback(async () => {
40
43
  dispatch({ type: "SKILLS_DATA_LOADING" });
41
44
  try {
45
+ const catalog = await fetchCuratedSkillsCatalog();
46
+ setCuratedSkills(catalog.skills);
47
+ setSkillSets((previous) =>
48
+ catalog.skillSets.map((skillSet) => {
49
+ const existing = previous.find((item) => item.repo === skillSet.repo);
50
+ return existing
51
+ ? { ...existing, ...skillSet, id: skillSet.repo }
52
+ : {
53
+ id: skillSet.repo,
54
+ name: skillSet.name,
55
+ description: skillSet.description,
56
+ repo: skillSet.repo,
57
+ icon: skillSet.icon,
58
+ stars: skillSet.stars,
59
+ skills: [],
60
+ loaded: false,
61
+ loading: false,
62
+ };
63
+ }),
64
+ );
42
65
  const skills = await fetchAvailableSkills(
43
66
  DEFAULT_SKILL_REPOS,
44
67
  state.projectPath,
68
+ catalog.skills,
45
69
  );
46
70
  dispatch({ type: "SKILLS_DATA_SUCCESS", skills });
47
71
  } catch (error) {
@@ -159,20 +183,6 @@ export function SkillsScreen() {
159
183
 
160
184
  // ── Skill Sets state ──────────────────────────────────────────────────────
161
185
 
162
- const [skillSets, setSkillSets] = useState<SkillSetInfo[]>(() =>
163
- RECOMMENDED_SKILL_SETS.map((rs) => ({
164
- id: rs.repo,
165
- name: rs.name,
166
- description: rs.description,
167
- repo: rs.repo,
168
- icon: rs.icon,
169
- stars: rs.stars,
170
- skills: [],
171
- loaded: false,
172
- loading: false,
173
- })),
174
- );
175
- const [expandedSets, setExpandedSets] = useState<Set<string>>(new Set());
176
186
 
177
187
  // Re-mark installed status on child skills when disk state changes
178
188
  useEffect(() => {
@@ -282,7 +292,7 @@ export function SkillsScreen() {
282
292
  // ── Derived data ──────────────────────────────────────────────────────────
283
293
 
284
294
  const staticRecommended = useMemo((): SkillInfo[] => {
285
- return RECOMMENDED_SKILLS.map((r) => {
295
+ return curatedSkills.map((r) => {
286
296
  const slug = r.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
287
297
  const isUser = installedFromDisk.user.has(slug) || installedFromDisk.user.has(r.name);
288
298
  const isProj = installedFromDisk.project.has(slug) || installedFromDisk.project.has(r.name);
@@ -302,7 +312,7 @@ export function SkillsScreen() {
302
312
  starReliability: classifyStarReliability(r.repo, r.stars),
303
313
  };
304
314
  });
305
- }, [installedFromDisk]);
315
+ }, [curatedSkills, installedFromDisk]);
306
316
 
307
317
  const mergedRecommended = useMemo((): SkillInfo[] => {
308
318
  if (skillsState.skills.status !== "success") return staticRecommended;