claudeup 3.8.0 → 3.10.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 +1 -1
- package/src/data/skill-repos.js +86 -0
- package/src/data/skill-repos.ts +97 -0
- package/src/services/skills-manager.js +307 -0
- package/src/services/skills-manager.ts +401 -0
- package/src/services/skillsmp-client.js +67 -0
- package/src/services/skillsmp-client.ts +89 -0
- package/src/types/index.ts +71 -1
- package/src/ui/App.js +14 -8
- package/src/ui/App.tsx +13 -7
- package/src/ui/components/EmptyFilterState.js +4 -0
- package/src/ui/components/EmptyFilterState.tsx +27 -0
- package/src/ui/components/TabBar.js +4 -3
- package/src/ui/components/TabBar.tsx +4 -3
- package/src/ui/screens/PluginsScreen.js +17 -35
- package/src/ui/screens/PluginsScreen.tsx +25 -43
- package/src/ui/screens/SkillsScreen.js +430 -0
- package/src/ui/screens/SkillsScreen.tsx +727 -0
- package/src/ui/screens/index.js +1 -0
- package/src/ui/screens/index.ts +1 -0
- package/src/ui/state/reducer.js +88 -0
- package/src/ui/state/reducer.ts +99 -0
- package/src/ui/state/types.ts +27 -2
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import fs from "fs-extra";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import type {
|
|
5
|
+
SkillSource,
|
|
6
|
+
SkillInfo,
|
|
7
|
+
SkillFrontmatter,
|
|
8
|
+
GitTreeResponse,
|
|
9
|
+
} from "../types/index.js";
|
|
10
|
+
import { RECOMMENDED_SKILLS } from "../data/skill-repos.js";
|
|
11
|
+
|
|
12
|
+
const SKILLS_API_BASE =
|
|
13
|
+
"https://us-central1-claudish-6da10.cloudfunctions.net/skills";
|
|
14
|
+
|
|
15
|
+
// ─── In-process cache ─────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
interface TreeCacheEntry {
|
|
18
|
+
etag: string;
|
|
19
|
+
tree: GitTreeResponse;
|
|
20
|
+
fetchedAt: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const treeCache = new Map<string, TreeCacheEntry>();
|
|
24
|
+
|
|
25
|
+
// ─── Path helpers ──────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
export function getUserSkillsDir(): string {
|
|
28
|
+
return path.join(os.homedir(), ".claude", "skills");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function getProjectSkillsDir(projectPath?: string): string {
|
|
32
|
+
return path.join(projectPath ?? process.cwd(), ".claude", "skills");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ─── GitHub Tree API ──────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
async function fetchGitTree(repo: string): Promise<GitTreeResponse> {
|
|
38
|
+
const cached = treeCache.get(repo);
|
|
39
|
+
|
|
40
|
+
const url = `https://api.github.com/repos/${repo}/git/trees/HEAD?recursive=1`;
|
|
41
|
+
const headers: Record<string, string> = {
|
|
42
|
+
Accept: "application/vnd.github+json",
|
|
43
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
if (cached?.etag) {
|
|
47
|
+
headers["If-None-Match"] = cached.etag;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const token =
|
|
51
|
+
process.env.GITHUB_TOKEN || process.env.GITHUB_PERSONAL_ACCESS_TOKEN;
|
|
52
|
+
if (token) {
|
|
53
|
+
headers.Authorization = `Bearer ${token}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const response = await fetch(url, {
|
|
57
|
+
headers,
|
|
58
|
+
signal: AbortSignal.timeout(10000),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
if (response.status === 304 && cached) {
|
|
62
|
+
return cached.tree;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (response.status === 403 || response.status === 429) {
|
|
66
|
+
const resetHeader = response.headers.get("X-RateLimit-Reset");
|
|
67
|
+
const resetTime = resetHeader
|
|
68
|
+
? new Date(Number(resetHeader) * 1000).toLocaleTimeString()
|
|
69
|
+
: "unknown";
|
|
70
|
+
throw new Error(
|
|
71
|
+
`GitHub API rate limit exceeded. Resets at ${resetTime}. Set GITHUB_TOKEN to increase limits.`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (!response.ok) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
`GitHub API error: ${response.status} ${response.statusText}`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const etag = response.headers.get("ETag") || "";
|
|
82
|
+
const tree = (await response.json()) as GitTreeResponse;
|
|
83
|
+
|
|
84
|
+
treeCache.set(repo, { etag, tree, fetchedAt: Date.now() });
|
|
85
|
+
return tree;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ─── Frontmatter parser ───────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
function parseYamlFrontmatter(content: string): Partial<SkillFrontmatter> {
|
|
91
|
+
const frontmatterMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
92
|
+
if (!frontmatterMatch) return {};
|
|
93
|
+
|
|
94
|
+
const yaml = frontmatterMatch[1];
|
|
95
|
+
const result: Record<string, unknown> = {};
|
|
96
|
+
|
|
97
|
+
for (const line of yaml.split("\n")) {
|
|
98
|
+
const colonIdx = line.indexOf(":");
|
|
99
|
+
if (colonIdx === -1) continue;
|
|
100
|
+
|
|
101
|
+
const key = line.slice(0, colonIdx).trim();
|
|
102
|
+
const rawValue = line.slice(colonIdx + 1).trim();
|
|
103
|
+
|
|
104
|
+
if (!key) continue;
|
|
105
|
+
|
|
106
|
+
// Handle arrays (simple inline: [a, b, c])
|
|
107
|
+
if (rawValue.startsWith("[") && rawValue.endsWith("]")) {
|
|
108
|
+
const items = rawValue
|
|
109
|
+
.slice(1, -1)
|
|
110
|
+
.split(",")
|
|
111
|
+
.map((s) => s.trim().replace(/^["']|["']$/g, ""))
|
|
112
|
+
.filter(Boolean);
|
|
113
|
+
result[key] = items;
|
|
114
|
+
} else {
|
|
115
|
+
// Strip quotes
|
|
116
|
+
result[key] = rawValue.replace(/^["']|["']$/g, "");
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return result as Partial<SkillFrontmatter>;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function fetchSkillFrontmatter(
|
|
124
|
+
skill: SkillInfo,
|
|
125
|
+
): Promise<SkillFrontmatter> {
|
|
126
|
+
const url = `https://raw.githubusercontent.com/${skill.source.repo}/HEAD/${skill.repoPath}`;
|
|
127
|
+
|
|
128
|
+
const response = await fetch(url, {
|
|
129
|
+
signal: AbortSignal.timeout(10000),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
if (!response.ok) {
|
|
133
|
+
return {
|
|
134
|
+
name: skill.name,
|
|
135
|
+
description: "(no description)",
|
|
136
|
+
category: "general",
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const content = await response.text();
|
|
141
|
+
const parsed = parseYamlFrontmatter(content);
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
name: parsed.name || skill.name,
|
|
145
|
+
description: parsed.description || "(no description)",
|
|
146
|
+
category: parsed.category,
|
|
147
|
+
author: parsed.author,
|
|
148
|
+
version: parsed.version,
|
|
149
|
+
tags: parsed.tags,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ─── Check installation ───────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
export async function getInstalledSkillNames(
|
|
156
|
+
scope: "user" | "project",
|
|
157
|
+
projectPath?: string,
|
|
158
|
+
): Promise<Set<string>> {
|
|
159
|
+
const dir =
|
|
160
|
+
scope === "user"
|
|
161
|
+
? getUserSkillsDir()
|
|
162
|
+
: getProjectSkillsDir(projectPath);
|
|
163
|
+
|
|
164
|
+
const installed = new Set<string>();
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
if (!(await fs.pathExists(dir))) return installed;
|
|
168
|
+
const entries = await fs.readdir(dir);
|
|
169
|
+
for (const entry of entries) {
|
|
170
|
+
const skillMd = path.join(dir, entry, "SKILL.md");
|
|
171
|
+
if (await fs.pathExists(skillMd)) {
|
|
172
|
+
installed.add(entry);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
} catch {
|
|
176
|
+
// ignore
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return installed;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ─── Firebase Skills API ──────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
185
|
+
function mapApiSkillToSkillInfo(raw: any): SkillInfo {
|
|
186
|
+
const repo = (raw.repo as string) || "unknown";
|
|
187
|
+
const skillPath = (raw.skillPath as string) || (raw.name as string) || "";
|
|
188
|
+
const source: SkillSource = {
|
|
189
|
+
label: repo,
|
|
190
|
+
repo,
|
|
191
|
+
skillsPath: "",
|
|
192
|
+
};
|
|
193
|
+
return {
|
|
194
|
+
id: `${repo}/${skillPath}`,
|
|
195
|
+
name: (raw.name as string) || skillPath,
|
|
196
|
+
description: (raw.description as string) || "",
|
|
197
|
+
source,
|
|
198
|
+
repoPath: skillPath ? `${skillPath}/SKILL.md` : "SKILL.md",
|
|
199
|
+
gitBlobSha: "",
|
|
200
|
+
frontmatter: null,
|
|
201
|
+
installed: false,
|
|
202
|
+
installedScope: null,
|
|
203
|
+
hasUpdate: false,
|
|
204
|
+
stars: typeof raw.stars === "number" ? raw.stars : undefined,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export async function fetchPopularSkills(limit = 30): Promise<SkillInfo[]> {
|
|
209
|
+
try {
|
|
210
|
+
const res = await fetch(
|
|
211
|
+
`${SKILLS_API_BASE}/search?q=development&limit=${limit}&sortBy=stars`,
|
|
212
|
+
{ signal: AbortSignal.timeout(10000) },
|
|
213
|
+
);
|
|
214
|
+
if (!res.ok) return [];
|
|
215
|
+
const data = (await res.json()) as { skills?: unknown[] };
|
|
216
|
+
return (data.skills || []).map(mapApiSkillToSkillInfo);
|
|
217
|
+
} catch {
|
|
218
|
+
return [];
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ─── Fetch available skills ───────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
export async function fetchAvailableSkills(
|
|
225
|
+
_repos: SkillSource[],
|
|
226
|
+
projectPath?: string,
|
|
227
|
+
): Promise<SkillInfo[]> {
|
|
228
|
+
const userInstalled = await getInstalledSkillNames("user");
|
|
229
|
+
const projectInstalled = await getInstalledSkillNames("project", projectPath);
|
|
230
|
+
|
|
231
|
+
const markInstalled = (skill: SkillInfo): SkillInfo => {
|
|
232
|
+
const isUserInstalled = userInstalled.has(skill.name);
|
|
233
|
+
const isProjInstalled = projectInstalled.has(skill.name);
|
|
234
|
+
const installed = isUserInstalled || isProjInstalled;
|
|
235
|
+
const installedScope: "user" | "project" | null = isProjInstalled
|
|
236
|
+
? "project"
|
|
237
|
+
: isUserInstalled
|
|
238
|
+
? "user"
|
|
239
|
+
: null;
|
|
240
|
+
return { ...skill, installed, installedScope };
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
// 1. Recommended skills from RECOMMENDED_SKILLS constant (no API call)
|
|
244
|
+
const recommendedSkills: SkillInfo[] = RECOMMENDED_SKILLS.map((rec) => {
|
|
245
|
+
const source: SkillSource = {
|
|
246
|
+
label: rec.repo,
|
|
247
|
+
repo: rec.repo,
|
|
248
|
+
skillsPath: "",
|
|
249
|
+
};
|
|
250
|
+
const skill: SkillInfo = {
|
|
251
|
+
id: `${rec.repo}/${rec.skillPath}`,
|
|
252
|
+
name: rec.name,
|
|
253
|
+
description: rec.description,
|
|
254
|
+
source,
|
|
255
|
+
repoPath: `${rec.skillPath}/SKILL.md`,
|
|
256
|
+
gitBlobSha: "",
|
|
257
|
+
frontmatter: null,
|
|
258
|
+
installed: false,
|
|
259
|
+
installedScope: null,
|
|
260
|
+
hasUpdate: false,
|
|
261
|
+
isRecommended: true,
|
|
262
|
+
};
|
|
263
|
+
return markInstalled(skill);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// 2. Fetch popular skills from Firebase API
|
|
267
|
+
const popular = await fetchPopularSkills(30);
|
|
268
|
+
const popularSkills = popular.map((s) => markInstalled({ ...s, isRecommended: false }));
|
|
269
|
+
|
|
270
|
+
// 3. Enrich recommended skills with GitHub repo stars
|
|
271
|
+
// Fetch stars for each unique repo (typically ~7 repos, parallel)
|
|
272
|
+
const uniqueRepos = [...new Set(recommendedSkills.map((s) => s.source.repo))];
|
|
273
|
+
const repoStars = new Map<string, number>();
|
|
274
|
+
try {
|
|
275
|
+
const starResults = await Promise.allSettled(
|
|
276
|
+
uniqueRepos.map(async (repo) => {
|
|
277
|
+
const res = await fetch(`https://api.github.com/repos/${repo}`, {
|
|
278
|
+
headers: { Accept: "application/vnd.github+json" },
|
|
279
|
+
signal: AbortSignal.timeout(5000),
|
|
280
|
+
});
|
|
281
|
+
if (!res.ok) return;
|
|
282
|
+
const data = (await res.json()) as { stargazers_count?: number };
|
|
283
|
+
if (data.stargazers_count) repoStars.set(repo, data.stargazers_count);
|
|
284
|
+
}),
|
|
285
|
+
);
|
|
286
|
+
} catch {
|
|
287
|
+
// Non-fatal — stars are cosmetic
|
|
288
|
+
}
|
|
289
|
+
for (const rec of recommendedSkills) {
|
|
290
|
+
rec.stars = repoStars.get(rec.source.repo) || undefined;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// 4. Combine: recommended first, then popular (dedup by name)
|
|
294
|
+
const seen = new Set<string>(recommendedSkills.map((s) => s.name));
|
|
295
|
+
const deduped = popularSkills.filter((s) => !seen.has(s.name));
|
|
296
|
+
|
|
297
|
+
return [...recommendedSkills, ...deduped];
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ─── Install / Uninstall ──────────────────────────────────────────────────────
|
|
301
|
+
|
|
302
|
+
export async function installSkill(
|
|
303
|
+
skill: SkillInfo,
|
|
304
|
+
scope: "user" | "project",
|
|
305
|
+
projectPath?: string,
|
|
306
|
+
): Promise<void> {
|
|
307
|
+
// Try multiple URL patterns — repos structure SKILL.md differently
|
|
308
|
+
const repo = skill.source.repo;
|
|
309
|
+
const repoPath = skill.repoPath.replace(/\/SKILL\.md$/, "");
|
|
310
|
+
const candidates = [
|
|
311
|
+
`https://raw.githubusercontent.com/${repo}/HEAD/${repoPath}/SKILL.md`,
|
|
312
|
+
`https://raw.githubusercontent.com/${repo}/main/${repoPath}/SKILL.md`,
|
|
313
|
+
`https://raw.githubusercontent.com/${repo}/master/${repoPath}/SKILL.md`,
|
|
314
|
+
`https://raw.githubusercontent.com/${repo}/HEAD/SKILL.md`,
|
|
315
|
+
`https://raw.githubusercontent.com/${repo}/main/SKILL.md`,
|
|
316
|
+
];
|
|
317
|
+
|
|
318
|
+
let content: string | null = null;
|
|
319
|
+
for (const url of candidates) {
|
|
320
|
+
try {
|
|
321
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(8000) });
|
|
322
|
+
if (response.ok) {
|
|
323
|
+
content = await response.text();
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
} catch {
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (!content) {
|
|
332
|
+
throw new Error(
|
|
333
|
+
`Failed to fetch skill: SKILL.md not found in ${repo}/${repoPath}`,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const installDir =
|
|
338
|
+
scope === "user"
|
|
339
|
+
? path.join(getUserSkillsDir(), skill.name)
|
|
340
|
+
: path.join(getProjectSkillsDir(projectPath), skill.name);
|
|
341
|
+
|
|
342
|
+
await fs.ensureDir(installDir);
|
|
343
|
+
await fs.writeFile(path.join(installDir, "SKILL.md"), content, "utf8");
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export async function uninstallSkill(
|
|
347
|
+
skillName: string,
|
|
348
|
+
scope: "user" | "project",
|
|
349
|
+
projectPath?: string,
|
|
350
|
+
): Promise<void> {
|
|
351
|
+
const installDir =
|
|
352
|
+
scope === "user"
|
|
353
|
+
? path.join(getUserSkillsDir(), skillName)
|
|
354
|
+
: path.join(getProjectSkillsDir(projectPath), skillName);
|
|
355
|
+
|
|
356
|
+
const skillMdPath = path.join(installDir, "SKILL.md");
|
|
357
|
+
|
|
358
|
+
if (await fs.pathExists(skillMdPath)) {
|
|
359
|
+
await fs.remove(skillMdPath);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Try to remove directory if empty
|
|
363
|
+
try {
|
|
364
|
+
await fs.rmdir(installDir);
|
|
365
|
+
} catch {
|
|
366
|
+
// Ignore if not empty or doesn't exist
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ─── Check installed skills from file system ──────────────────────────────────
|
|
371
|
+
|
|
372
|
+
export async function getInstalledSkillsFromFs(
|
|
373
|
+
projectPath?: string,
|
|
374
|
+
): Promise<{ name: string; scope: "user" | "project" }[]> {
|
|
375
|
+
const result: { name: string; scope: "user" | "project" }[] = [];
|
|
376
|
+
|
|
377
|
+
const userDir = getUserSkillsDir();
|
|
378
|
+
const projDir = getProjectSkillsDir(projectPath);
|
|
379
|
+
|
|
380
|
+
const scopedDirs: Array<[string, "user" | "project"]> = [
|
|
381
|
+
[userDir, "user"],
|
|
382
|
+
[projDir, "project"],
|
|
383
|
+
];
|
|
384
|
+
|
|
385
|
+
for (const [dir, scope] of scopedDirs) {
|
|
386
|
+
try {
|
|
387
|
+
if (!(await fs.pathExists(dir))) continue;
|
|
388
|
+
const entries = await fs.readdir(dir);
|
|
389
|
+
for (const entry of entries) {
|
|
390
|
+
const skillMd = path.join(dir, entry, "SKILL.md");
|
|
391
|
+
if (await fs.pathExists(skillMd)) {
|
|
392
|
+
result.push({ name: entry, scope });
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
} catch {
|
|
396
|
+
// ignore
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return result;
|
|
401
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skills API client — calls our Firebase aggregator function
|
|
3
|
+
* instead of hitting SkillsMP/GitHub APIs directly.
|
|
4
|
+
*
|
|
5
|
+
* The Firebase function handles:
|
|
6
|
+
* - SkillsMP keyword + AI search (with server-side API key)
|
|
7
|
+
* - GitHub Tree API with ETag caching
|
|
8
|
+
* - Recommended skills list
|
|
9
|
+
*/
|
|
10
|
+
// TODO: Update to production URL after Firebase deploy
|
|
11
|
+
const SKILLS_API_BASE = process.env.SKILLS_API_URL || "https://us-central1-claudish-6da10.cloudfunctions.net/skills";
|
|
12
|
+
/**
|
|
13
|
+
* Search skills across all sources (SkillsMP keyword + AI search)
|
|
14
|
+
*/
|
|
15
|
+
export async function searchSkills(query, options) {
|
|
16
|
+
const params = new URLSearchParams({ q: query });
|
|
17
|
+
if (options?.page)
|
|
18
|
+
params.set("page", String(options.page));
|
|
19
|
+
if (options?.limit)
|
|
20
|
+
params.set("limit", String(options.limit));
|
|
21
|
+
try {
|
|
22
|
+
const res = await fetch(`${SKILLS_API_BASE}/search?${params}`, {
|
|
23
|
+
signal: AbortSignal.timeout(12000),
|
|
24
|
+
});
|
|
25
|
+
if (!res.ok)
|
|
26
|
+
return [];
|
|
27
|
+
const data = (await res.json());
|
|
28
|
+
return data.skills || [];
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Fetch skills from a specific GitHub repo (via our proxy)
|
|
36
|
+
*/
|
|
37
|
+
export async function fetchRepoSkills(owner, repo) {
|
|
38
|
+
try {
|
|
39
|
+
const res = await fetch(`${SKILLS_API_BASE}/repo/${owner}/${repo}`, {
|
|
40
|
+
signal: AbortSignal.timeout(10000),
|
|
41
|
+
});
|
|
42
|
+
if (!res.ok)
|
|
43
|
+
return [];
|
|
44
|
+
const data = (await res.json());
|
|
45
|
+
return data.skills || [];
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Get recommended skills list (curated, server-side)
|
|
53
|
+
*/
|
|
54
|
+
export async function fetchRecommendedSkills() {
|
|
55
|
+
try {
|
|
56
|
+
const res = await fetch(`${SKILLS_API_BASE}/recommended`, {
|
|
57
|
+
signal: AbortSignal.timeout(5000),
|
|
58
|
+
});
|
|
59
|
+
if (!res.ok)
|
|
60
|
+
return [];
|
|
61
|
+
const data = (await res.json());
|
|
62
|
+
return data.skills || [];
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skills API client — calls our Firebase aggregator function
|
|
3
|
+
* instead of hitting SkillsMP/GitHub APIs directly.
|
|
4
|
+
*
|
|
5
|
+
* The Firebase function handles:
|
|
6
|
+
* - SkillsMP keyword + AI search (with server-side API key)
|
|
7
|
+
* - GitHub Tree API with ETag caching
|
|
8
|
+
* - Recommended skills list
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// TODO: Update to production URL after Firebase deploy
|
|
12
|
+
const SKILLS_API_BASE =
|
|
13
|
+
process.env.SKILLS_API_URL || "https://us-central1-claudish-6da10.cloudfunctions.net/skills";
|
|
14
|
+
|
|
15
|
+
export interface SkillSearchResult {
|
|
16
|
+
name: string;
|
|
17
|
+
displayName?: string;
|
|
18
|
+
repo: string;
|
|
19
|
+
skillPath: string;
|
|
20
|
+
description?: string;
|
|
21
|
+
source?: string;
|
|
22
|
+
stars?: number;
|
|
23
|
+
installCommand?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface RepoSkillResult {
|
|
27
|
+
name: string;
|
|
28
|
+
skillPath: string;
|
|
29
|
+
treeSha?: string;
|
|
30
|
+
description?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Search skills across all sources (SkillsMP keyword + AI search)
|
|
35
|
+
*/
|
|
36
|
+
export async function searchSkills(
|
|
37
|
+
query: string,
|
|
38
|
+
options?: { page?: number; limit?: number },
|
|
39
|
+
): Promise<SkillSearchResult[]> {
|
|
40
|
+
const params = new URLSearchParams({ q: query });
|
|
41
|
+
if (options?.page) params.set("page", String(options.page));
|
|
42
|
+
if (options?.limit) params.set("limit", String(options.limit));
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const res = await fetch(`${SKILLS_API_BASE}/search?${params}`, {
|
|
46
|
+
signal: AbortSignal.timeout(12000),
|
|
47
|
+
});
|
|
48
|
+
if (!res.ok) return [];
|
|
49
|
+
const data = (await res.json()) as { skills: SkillSearchResult[] };
|
|
50
|
+
return data.skills || [];
|
|
51
|
+
} catch {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Fetch skills from a specific GitHub repo (via our proxy)
|
|
58
|
+
*/
|
|
59
|
+
export async function fetchRepoSkills(
|
|
60
|
+
owner: string,
|
|
61
|
+
repo: string,
|
|
62
|
+
): Promise<RepoSkillResult[]> {
|
|
63
|
+
try {
|
|
64
|
+
const res = await fetch(`${SKILLS_API_BASE}/repo/${owner}/${repo}`, {
|
|
65
|
+
signal: AbortSignal.timeout(10000),
|
|
66
|
+
});
|
|
67
|
+
if (!res.ok) return [];
|
|
68
|
+
const data = (await res.json()) as { skills: RepoSkillResult[] };
|
|
69
|
+
return data.skills || [];
|
|
70
|
+
} catch {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Get recommended skills list (curated, server-side)
|
|
77
|
+
*/
|
|
78
|
+
export async function fetchRecommendedSkills(): Promise<SkillSearchResult[]> {
|
|
79
|
+
try {
|
|
80
|
+
const res = await fetch(`${SKILLS_API_BASE}/recommended`, {
|
|
81
|
+
signal: AbortSignal.timeout(5000),
|
|
82
|
+
});
|
|
83
|
+
if (!res.ok) return [];
|
|
84
|
+
const data = (await res.json()) as { skills: SkillSearchResult[] };
|
|
85
|
+
return data.skills || [];
|
|
86
|
+
} catch {
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
}
|
package/src/types/index.ts
CHANGED
|
@@ -116,7 +116,77 @@ export type Screen =
|
|
|
116
116
|
| "plugins"
|
|
117
117
|
| "statusline"
|
|
118
118
|
| "cli-tools"
|
|
119
|
-
| "env-vars"
|
|
119
|
+
| "env-vars"
|
|
120
|
+
| "skills";
|
|
121
|
+
|
|
122
|
+
// ─── Skill Types ──────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
/** A configured skill repository (entry in skill-repos.ts) */
|
|
125
|
+
export interface SkillSource {
|
|
126
|
+
/** Human-readable label, e.g. "vercel-labs/agent-skills" */
|
|
127
|
+
label: string;
|
|
128
|
+
/** GitHub owner/repo, e.g. "vercel-labs/agent-skills" */
|
|
129
|
+
repo: string;
|
|
130
|
+
/** Sub-path within the repo where skills live, e.g. "skills" */
|
|
131
|
+
skillsPath: string;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** YAML frontmatter extracted from a SKILL.md file */
|
|
135
|
+
export interface SkillFrontmatter {
|
|
136
|
+
name: string;
|
|
137
|
+
description: string;
|
|
138
|
+
category?: string;
|
|
139
|
+
author?: string;
|
|
140
|
+
version?: string;
|
|
141
|
+
tags?: string[];
|
|
142
|
+
[key: string]: unknown;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** A skill entry from the available-skills list (may or may not be installed) */
|
|
146
|
+
export interface SkillInfo {
|
|
147
|
+
/** Unique key: "{owner}/{repo}/{path}" */
|
|
148
|
+
id: string;
|
|
149
|
+
/** Slug used as directory name: last segment of path */
|
|
150
|
+
name: string;
|
|
151
|
+
/** Source repo this skill comes from */
|
|
152
|
+
source: SkillSource;
|
|
153
|
+
/** Relative path within repo, e.g. "skills/researcher/SKILL.md" */
|
|
154
|
+
repoPath: string;
|
|
155
|
+
/** Git blob SHA from Tree API response */
|
|
156
|
+
gitBlobSha: string;
|
|
157
|
+
/** Parsed frontmatter (null if not yet fetched) */
|
|
158
|
+
frontmatter: SkillFrontmatter | null;
|
|
159
|
+
/** Whether the skill is installed (either scope) */
|
|
160
|
+
installed: boolean;
|
|
161
|
+
/** Scope of the installed copy */
|
|
162
|
+
installedScope: "user" | "project" | null;
|
|
163
|
+
/** True when lock file SHA differs from Tree API SHA */
|
|
164
|
+
hasUpdate: boolean;
|
|
165
|
+
/** Whether this is a recommended skill */
|
|
166
|
+
isRecommended?: boolean;
|
|
167
|
+
/** GitHub star count from the skills API */
|
|
168
|
+
stars?: number;
|
|
169
|
+
/** Short description (from API, before frontmatter is loaded) */
|
|
170
|
+
description?: string;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ─── GitHub Tree API types ────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
export interface GitTreeItem {
|
|
176
|
+
path: string;
|
|
177
|
+
mode: string;
|
|
178
|
+
type: "blob" | "tree";
|
|
179
|
+
sha: string;
|
|
180
|
+
size?: number;
|
|
181
|
+
url: string;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface GitTreeResponse {
|
|
185
|
+
sha: string;
|
|
186
|
+
url: string;
|
|
187
|
+
tree: GitTreeItem[];
|
|
188
|
+
truncated: boolean;
|
|
189
|
+
}
|
|
120
190
|
|
|
121
191
|
// MCP Registry Types (registry.modelcontextprotocol.io)
|
|
122
192
|
export interface McpRegistryServer {
|
package/src/ui/App.js
CHANGED
|
@@ -5,7 +5,7 @@ import fs from "node:fs";
|
|
|
5
5
|
import { AppProvider, useApp, useNavigation, useModal, } from "./state/AppContext.js";
|
|
6
6
|
import { DimensionsProvider, useDimensions, } from "./state/DimensionsContext.js";
|
|
7
7
|
import { ModalContainer } from "./components/modals/index.js";
|
|
8
|
-
import { PluginsScreen, McpScreen, McpRegistryScreen, SettingsScreen, CliToolsScreen, ModelSelectorScreen, ProfilesScreen, } from "./screens/index.js";
|
|
8
|
+
import { PluginsScreen, McpScreen, McpRegistryScreen, SettingsScreen, CliToolsScreen, ModelSelectorScreen, ProfilesScreen, SkillsScreen, } from "./screens/index.js";
|
|
9
9
|
import { repairAllMarketplaces } from "../services/local-marketplace.js";
|
|
10
10
|
import { migrateMarketplaceRename } from "../services/claude-settings.js";
|
|
11
11
|
import { checkForUpdates, getCurrentVersion, } from "../services/version-check.js";
|
|
@@ -34,6 +34,8 @@ function Router() {
|
|
|
34
34
|
return _jsx(ModelSelectorScreen, {});
|
|
35
35
|
case "profiles":
|
|
36
36
|
return _jsx(ProfilesScreen, {});
|
|
37
|
+
case "skills":
|
|
38
|
+
return _jsx(SkillsScreen, {});
|
|
37
39
|
default:
|
|
38
40
|
return _jsx(PluginsScreen, {});
|
|
39
41
|
}
|
|
@@ -84,26 +86,30 @@ function GlobalKeyHandler({ onDebugToggle, onExit, }) {
|
|
|
84
86
|
"cli-tools",
|
|
85
87
|
"model-selector",
|
|
86
88
|
"profiles",
|
|
89
|
+
"skills",
|
|
87
90
|
].includes(state.currentRoute.screen);
|
|
88
91
|
if (isTopLevel) {
|
|
89
92
|
if (input === "1")
|
|
90
93
|
navigateToScreen("plugins");
|
|
91
94
|
else if (input === "2")
|
|
92
|
-
navigateToScreen("
|
|
95
|
+
navigateToScreen("skills");
|
|
93
96
|
else if (input === "3")
|
|
94
|
-
navigateToScreen("
|
|
97
|
+
navigateToScreen("mcp");
|
|
95
98
|
else if (input === "4")
|
|
96
|
-
navigateToScreen("
|
|
99
|
+
navigateToScreen("settings");
|
|
97
100
|
else if (input === "5")
|
|
98
101
|
navigateToScreen("profiles");
|
|
102
|
+
else if (input === "6")
|
|
103
|
+
navigateToScreen("cli-tools");
|
|
99
104
|
// Tab navigation cycling
|
|
100
105
|
if (key.tab) {
|
|
101
106
|
const screens = [
|
|
102
107
|
"plugins",
|
|
108
|
+
"skills",
|
|
103
109
|
"mcp",
|
|
104
110
|
"settings",
|
|
105
|
-
"cli-tools",
|
|
106
111
|
"profiles",
|
|
112
|
+
"cli-tools",
|
|
107
113
|
];
|
|
108
114
|
const currentIndex = screens.indexOf(state.currentRoute.screen);
|
|
109
115
|
if (currentIndex !== -1) {
|
|
@@ -138,9 +144,9 @@ function GlobalKeyHandler({ onDebugToggle, onExit, }) {
|
|
|
138
144
|
? This help
|
|
139
145
|
|
|
140
146
|
Quick Navigation
|
|
141
|
-
1 Plugins 4
|
|
142
|
-
2
|
|
143
|
-
3
|
|
147
|
+
1 Plugins 4 Settings
|
|
148
|
+
2 Skills 5 Profiles
|
|
149
|
+
3 MCP Servers 6 CLI Tools
|
|
144
150
|
|
|
145
151
|
Plugin Actions
|
|
146
152
|
u Update d Uninstall
|