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
|
@@ -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);
|
|
@@ -135,6 +135,48 @@ export function isEnabledButNotInstalled(plugin: PluginInfo): boolean {
|
|
|
135
135
|
return enabledSomewhere && !plugin.installedVersion;
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
/**
|
|
139
|
+
* True when a scope genuinely has the plugin installed.
|
|
140
|
+
*
|
|
141
|
+
* `enabled` on its own is not proof. It is the `enabledPlugins` flag out of a
|
|
142
|
+
* settings file, and only claudeup and the user maintain it; `version` comes
|
|
143
|
+
* from `installed_plugins.json` via `overlayRegistryVersions`, which is what
|
|
144
|
+
* Claude Code actually loaded. When the two disagree — flag set, no registry
|
|
145
|
+
* entry — the plugin is in the broken enabled-but-not-installed state that the
|
|
146
|
+
* list already renders as "not installed".
|
|
147
|
+
*
|
|
148
|
+
* Every action that touches the filesystem must use this, not the flag alone.
|
|
149
|
+
* Deciding on the flag is what made the scope keys uninstall a plugin the row
|
|
150
|
+
* had just labelled "not installed".
|
|
151
|
+
*
|
|
152
|
+
* A version of "0.0.0" still counts as installed — Anthropic's official plugins
|
|
153
|
+
* record exactly that — which is why this tests presence, not `isKnownVersion`.
|
|
154
|
+
*/
|
|
155
|
+
export function isInstalledInScope(scope: ScopeStatus | undefined): boolean {
|
|
156
|
+
return !!scope?.enabled && !!scope.version;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* What a scope toggle (`u` / `p` / `l`, or the Enter scope picker) should do.
|
|
161
|
+
*
|
|
162
|
+
* `install` doubles as the repair for enabled-but-not-installed. Measured
|
|
163
|
+
* against Claude Code 2.1.223 in exactly that state — `dev@magus` flagged in
|
|
164
|
+
* `.claude/settings.json` with no registry entry for the project path —
|
|
165
|
+
* `claude plugin install dev@magus --scope project` installed it normally and
|
|
166
|
+
* wrote the registry entry. It does not report "already installed", so this
|
|
167
|
+
* needs no uninstall-first dance (unlike content drift, see repairPlugin).
|
|
168
|
+
*/
|
|
169
|
+
export function resolveScopeAction(
|
|
170
|
+
scope: ScopeStatus | undefined,
|
|
171
|
+
latestVersion: string,
|
|
172
|
+
): "install" | "update" | "uninstall" {
|
|
173
|
+
if (!isInstalledInScope(scope)) return "install";
|
|
174
|
+
const installed = scope?.version;
|
|
175
|
+
const hasUpdate =
|
|
176
|
+
!!installed && latestVersion !== "0.0.0" && installed !== latestVersion;
|
|
177
|
+
return hasUpdate ? "update" : "uninstall";
|
|
178
|
+
}
|
|
179
|
+
|
|
138
180
|
|
|
139
181
|
export async function getAvailablePlugins(
|
|
140
182
|
projectPath?: string,
|
|
@@ -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
|
|
|
@@ -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
|
}
|
|
@@ -16,9 +16,13 @@ import {
|
|
|
16
16
|
import { theme } from "../theme.js";
|
|
17
17
|
import { highlightMatches } from "../../utils/fuzzy-search.js";
|
|
18
18
|
import { getMarketplaceVersion } from "../../services/marketplace-fetcher.js";
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
isEnabledButNotInstalled,
|
|
21
|
+
isInstalledInScope,
|
|
22
|
+
} from "../../services/plugin-manager.js";
|
|
20
23
|
import { isKnownVersion } from "../../services/version-snapshot.js";
|
|
21
24
|
import type { PluginRelease } from "../../types/index.js";
|
|
25
|
+
import type { ScopeStatus } from "../../services/plugin-manager.js";
|
|
22
26
|
|
|
23
27
|
// ─── Category renderers ───────────────────────────────────────────────────────
|
|
24
28
|
|
|
@@ -223,10 +227,14 @@ function pluginRow(item: PluginPluginItem, isSelected: boolean): React.ReactNode
|
|
|
223
227
|
|
|
224
228
|
function pluginDetail(item: PluginPluginItem): React.ReactNode {
|
|
225
229
|
const { plugin } = item;
|
|
230
|
+
// "Installed" has to mean the same thing here as in the list row. Reading the
|
|
231
|
+
// `enabledPlugins` flag alone made this panel print "● Installed" for the
|
|
232
|
+
// exact plugin the row beside it was labelling "not installed".
|
|
226
233
|
const isInstalled =
|
|
227
|
-
plugin.userScope
|
|
228
|
-
plugin.projectScope
|
|
229
|
-
plugin.localScope
|
|
234
|
+
isInstalledInScope(plugin.userScope) ||
|
|
235
|
+
isInstalledInScope(plugin.projectScope) ||
|
|
236
|
+
isInstalledInScope(plugin.localScope);
|
|
237
|
+
const brokenInstall = isEnabledButNotInstalled(plugin);
|
|
230
238
|
|
|
231
239
|
// Orphaned/deprecated plugin
|
|
232
240
|
if (plugin.isOrphaned) {
|
|
@@ -284,10 +292,30 @@ function pluginDetail(item: PluginPluginItem): React.ReactNode {
|
|
|
284
292
|
|
|
285
293
|
{/* Status line */}
|
|
286
294
|
<box marginTop={1}>
|
|
287
|
-
<text
|
|
288
|
-
{
|
|
295
|
+
<text
|
|
296
|
+
fg={
|
|
297
|
+
isInstalled
|
|
298
|
+
? theme.colors.success
|
|
299
|
+
: brokenInstall
|
|
300
|
+
? theme.colors.warning
|
|
301
|
+
: theme.colors.muted
|
|
302
|
+
}
|
|
303
|
+
>
|
|
304
|
+
{isInstalled
|
|
305
|
+
? "● Installed"
|
|
306
|
+
: brokenInstall
|
|
307
|
+
? "○ Not installed — enabled in settings, no files on disk"
|
|
308
|
+
: "○ Not installed"}
|
|
289
309
|
</text>
|
|
290
310
|
</box>
|
|
311
|
+
{brokenInstall ? (
|
|
312
|
+
<box>
|
|
313
|
+
<text fg={theme.colors.muted}>
|
|
314
|
+
Claude Code cannot load it in this state. Press the scope key to
|
|
315
|
+
repair.
|
|
316
|
+
</text>
|
|
317
|
+
</box>
|
|
318
|
+
) : null}
|
|
291
319
|
|
|
292
320
|
{/* Description */}
|
|
293
321
|
<box marginTop={1} marginBottom={1}>
|
|
@@ -331,48 +359,27 @@ function pluginDetail(item: PluginPluginItem): React.ReactNode {
|
|
|
331
359
|
<strong>Scopes:</strong>
|
|
332
360
|
</text>
|
|
333
361
|
<box marginTop={1} flexDirection="column">
|
|
334
|
-
<
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
<
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
</span>
|
|
356
|
-
<span fg={theme.scopes.project}>Project</span>
|
|
357
|
-
<span> team</span>
|
|
358
|
-
{plugin.projectScope?.version ? (
|
|
359
|
-
<span fg={theme.scopes.project}> v{plugin.projectScope.version}</span>
|
|
360
|
-
) : null}
|
|
361
|
-
</text>
|
|
362
|
-
<text>
|
|
363
|
-
<span bg={theme.scopes.local} fg="black">
|
|
364
|
-
{" "}
|
|
365
|
-
l{" "}
|
|
366
|
-
</span>
|
|
367
|
-
<span fg={plugin.localScope?.enabled ? theme.scopes.local : theme.colors.muted}>
|
|
368
|
-
{plugin.localScope?.enabled ? " ● " : " ○ "}
|
|
369
|
-
</span>
|
|
370
|
-
<span fg={theme.scopes.local}>Local</span>
|
|
371
|
-
<span> private</span>
|
|
372
|
-
{plugin.localScope?.version ? (
|
|
373
|
-
<span fg={theme.scopes.local}> v{plugin.localScope.version}</span>
|
|
374
|
-
) : null}
|
|
375
|
-
</text>
|
|
362
|
+
<ScopeLine
|
|
363
|
+
keyHint="u"
|
|
364
|
+
name="User"
|
|
365
|
+
qualifier="global"
|
|
366
|
+
color={theme.scopes.user}
|
|
367
|
+
scope={plugin.userScope}
|
|
368
|
+
/>
|
|
369
|
+
<ScopeLine
|
|
370
|
+
keyHint="p"
|
|
371
|
+
name="Project"
|
|
372
|
+
qualifier="team"
|
|
373
|
+
color={theme.scopes.project}
|
|
374
|
+
scope={plugin.projectScope}
|
|
375
|
+
/>
|
|
376
|
+
<ScopeLine
|
|
377
|
+
keyHint="l"
|
|
378
|
+
name="Local"
|
|
379
|
+
qualifier="private"
|
|
380
|
+
color={theme.scopes.local}
|
|
381
|
+
scope={plugin.localScope}
|
|
382
|
+
/>
|
|
376
383
|
</box>
|
|
377
384
|
</DetailSection>
|
|
378
385
|
|
|
@@ -389,6 +396,47 @@ function pluginDetail(item: PluginPluginItem): React.ReactNode {
|
|
|
389
396
|
);
|
|
390
397
|
}
|
|
391
398
|
|
|
399
|
+
/**
|
|
400
|
+
* One line of the detail panel's scope breakdown.
|
|
401
|
+
*
|
|
402
|
+
* The filled dot means installed — registry-backed, same as everywhere else.
|
|
403
|
+
* A scope that is enabled with nothing installed is called out explicitly
|
|
404
|
+
* rather than shown as a plain empty dot, because those two states need
|
|
405
|
+
* different actions from the user and used to look identical.
|
|
406
|
+
*/
|
|
407
|
+
function ScopeLine({
|
|
408
|
+
keyHint,
|
|
409
|
+
name,
|
|
410
|
+
qualifier,
|
|
411
|
+
color,
|
|
412
|
+
scope,
|
|
413
|
+
}: {
|
|
414
|
+
keyHint: string;
|
|
415
|
+
name: string;
|
|
416
|
+
qualifier: string;
|
|
417
|
+
color: string;
|
|
418
|
+
scope?: ScopeStatus;
|
|
419
|
+
}): React.ReactNode {
|
|
420
|
+
const installed = isInstalledInScope(scope);
|
|
421
|
+
return (
|
|
422
|
+
<text>
|
|
423
|
+
<span bg={color} fg="black">
|
|
424
|
+
{" "}
|
|
425
|
+
{keyHint}{" "}
|
|
426
|
+
</span>
|
|
427
|
+
<span fg={installed ? color : theme.colors.muted}>
|
|
428
|
+
{installed ? " ● " : " ○ "}
|
|
429
|
+
</span>
|
|
430
|
+
<span fg={color}>{name}</span>
|
|
431
|
+
<span> {qualifier}</span>
|
|
432
|
+
{scope?.version ? <span fg={color}> v{scope.version}</span> : null}
|
|
433
|
+
{!installed && scope?.enabled ? (
|
|
434
|
+
<span fg={theme.colors.warning}> enabled, not installed</span>
|
|
435
|
+
) : null}
|
|
436
|
+
</text>
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
|
|
392
440
|
/**
|
|
393
441
|
* Recent release history, newest first.
|
|
394
442
|
*
|