facult 1.0.1

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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +383 -0
  3. package/bin/facult.cjs +302 -0
  4. package/package.json +78 -0
  5. package/src/adapters/claude-cli.ts +18 -0
  6. package/src/adapters/claude-desktop.ts +15 -0
  7. package/src/adapters/clawdbot.ts +18 -0
  8. package/src/adapters/codex.ts +19 -0
  9. package/src/adapters/cursor.ts +18 -0
  10. package/src/adapters/index.ts +69 -0
  11. package/src/adapters/mcp.ts +270 -0
  12. package/src/adapters/reference.ts +9 -0
  13. package/src/adapters/skills.ts +47 -0
  14. package/src/adapters/types.ts +42 -0
  15. package/src/adapters/version.ts +18 -0
  16. package/src/audit/agent.ts +1071 -0
  17. package/src/audit/index.ts +74 -0
  18. package/src/audit/static.ts +1130 -0
  19. package/src/audit/tui.ts +704 -0
  20. package/src/audit/types.ts +68 -0
  21. package/src/audit/update-index.ts +115 -0
  22. package/src/conflicts.ts +135 -0
  23. package/src/consolidate-conflict-action.ts +57 -0
  24. package/src/consolidate.ts +1637 -0
  25. package/src/enable-disable.ts +349 -0
  26. package/src/index-builder.ts +562 -0
  27. package/src/index.ts +589 -0
  28. package/src/manage.ts +894 -0
  29. package/src/migrate.ts +272 -0
  30. package/src/paths.ts +238 -0
  31. package/src/quarantine.ts +217 -0
  32. package/src/query.ts +186 -0
  33. package/src/remote-manifest-integrity.ts +367 -0
  34. package/src/remote-providers.ts +905 -0
  35. package/src/remote-source-policy.ts +237 -0
  36. package/src/remote-sources.ts +162 -0
  37. package/src/remote-types.ts +136 -0
  38. package/src/remote.ts +1970 -0
  39. package/src/scan.ts +2427 -0
  40. package/src/schema.ts +39 -0
  41. package/src/self-update.ts +408 -0
  42. package/src/snippets-cli.ts +293 -0
  43. package/src/snippets.ts +706 -0
  44. package/src/source-trust.ts +203 -0
  45. package/src/trust-list.ts +232 -0
  46. package/src/trust.ts +170 -0
  47. package/src/tui.ts +118 -0
  48. package/src/util/codex-toml.ts +126 -0
  49. package/src/util/json.ts +32 -0
  50. package/src/util/skills.ts +55 -0
@@ -0,0 +1,905 @@
1
+ import type {
2
+ IndexSource,
3
+ LoadManifestHints,
4
+ RemoteIndexManifest,
5
+ RemoteMcpItem,
6
+ RemoteSkillItem,
7
+ } from "./remote-types";
8
+
9
+ const TRAILING_SLASH_RE = /\/+$/;
10
+ const LEADING_AT_RE = /^@/;
11
+ const GITHUB_SOURCE_RE =
12
+ /^https?:\/\/github\.com\/([^/]+)\/([^/#?]+)(?:\/tree\/([^/#?]+)(?:\/(.*))?)?/i;
13
+ const REPO_DOT_GIT_SUFFIX_RE = /\.git$/i;
14
+ const LEADING_SLASH_RE = /^\/+/;
15
+ const OWNER_REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
16
+ const SKILLS_SH_ENTRY_RE =
17
+ /"source":"([^"]+)","skillId":"([^"]+)","name":"([^"]+)"(?:,"description":"([^"]*)")?/g;
18
+
19
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
20
+ return !!v && typeof v === "object" && !Array.isArray(v);
21
+ }
22
+
23
+ function uniqueSorted(values: string[]): string[] {
24
+ return Array.from(new Set(values)).sort();
25
+ }
26
+
27
+ function trimTrailingSlash(v: string): string {
28
+ return v.replace(TRAILING_SLASH_RE, "");
29
+ }
30
+
31
+ function buildUrl(base: string, path: string): string {
32
+ const normalizedBase = trimTrailingSlash(base);
33
+ if (!path) {
34
+ return normalizedBase;
35
+ }
36
+ const normalizedPath = path.startsWith("/") ? path : `/${path}`;
37
+ return `${normalizedBase}${normalizedPath}`;
38
+ }
39
+
40
+ function encodeRefPath(ref: string): string {
41
+ return ref
42
+ .split("/")
43
+ .filter(Boolean)
44
+ .map((part) => encodeURIComponent(part))
45
+ .join("/");
46
+ }
47
+
48
+ function normalizedItemRef(raw: string): string {
49
+ return raw.trim().replace(LEADING_AT_RE, "");
50
+ }
51
+
52
+ function mcpNameFromRef(ref: string): string {
53
+ const parts = ref.split("/").filter(Boolean);
54
+ if (!parts.length) {
55
+ return ref;
56
+ }
57
+ return parts.at(-1) ?? ref;
58
+ }
59
+
60
+ function schemaRequiredEnv(
61
+ rawSchema: unknown
62
+ ): Record<string, string> | undefined {
63
+ if (!isPlainObject(rawSchema)) {
64
+ return undefined;
65
+ }
66
+ const schema = rawSchema as Record<string, unknown>;
67
+ const required = Array.isArray(schema.required)
68
+ ? schema.required
69
+ .filter((v): v is string => typeof v === "string")
70
+ .map((v) => v.trim())
71
+ .filter(Boolean)
72
+ : [];
73
+ if (!required.length) {
74
+ return undefined;
75
+ }
76
+ const properties = isPlainObject(schema.properties)
77
+ ? (schema.properties as Record<string, unknown>)
78
+ : {};
79
+ const env: Record<string, string> = {};
80
+ for (const name of uniqueSorted(required)) {
81
+ const prop = properties[name];
82
+ if (
83
+ isPlainObject(prop) &&
84
+ typeof (prop as Record<string, unknown>).default === "string"
85
+ ) {
86
+ const defaultValue = (prop as Record<string, unknown>).default as string;
87
+ env[name] = defaultValue;
88
+ continue;
89
+ }
90
+ env[name] = "<set-me>";
91
+ }
92
+ return Object.keys(env).length ? env : undefined;
93
+ }
94
+
95
+ function buildPlaceholderMcpDefinition(
96
+ env?: Record<string, string>
97
+ ): Record<string, unknown> {
98
+ const definition: Record<string, unknown> = {
99
+ transport: "stdio",
100
+ command: "<set-command>",
101
+ args: ["<set-args>"],
102
+ };
103
+ if (env && Object.keys(env).length) {
104
+ definition.env = env;
105
+ }
106
+ return definition;
107
+ }
108
+
109
+ function smitherySearchItemToRemoteItem(raw: unknown): RemoteMcpItem | null {
110
+ if (!isPlainObject(raw)) {
111
+ return null;
112
+ }
113
+ const obj = raw as Record<string, unknown>;
114
+ const qualifiedNameRaw =
115
+ typeof obj.qualifiedName === "string" ? obj.qualifiedName : "";
116
+ const id = normalizedItemRef(qualifiedNameRaw);
117
+ if (!id) {
118
+ return null;
119
+ }
120
+ const title =
121
+ typeof obj.displayName === "string" ? obj.displayName : mcpNameFromRef(id);
122
+ const description =
123
+ typeof obj.description === "string" ? obj.description : undefined;
124
+ const sourceUrl = typeof obj.homepage === "string" ? obj.homepage : undefined;
125
+ const tags = uniqueSorted(
126
+ [
127
+ "mcp",
128
+ "smithery",
129
+ obj.verified === true ? "verified" : "",
130
+ obj.remote === true ? "remote-capable" : "",
131
+ ].filter(Boolean)
132
+ );
133
+ return {
134
+ id,
135
+ type: "mcp",
136
+ title,
137
+ description,
138
+ sourceUrl,
139
+ tags,
140
+ mcp: {
141
+ name: mcpNameFromRef(id),
142
+ definition: buildPlaceholderMcpDefinition(),
143
+ },
144
+ };
145
+ }
146
+
147
+ function smitheryDetailToRemoteItem(
148
+ raw: unknown,
149
+ itemIdHint?: string
150
+ ): RemoteMcpItem | null {
151
+ if (!isPlainObject(raw)) {
152
+ return null;
153
+ }
154
+ const obj = raw as Record<string, unknown>;
155
+ const qualifiedNameRaw =
156
+ typeof obj.qualifiedName === "string" ? obj.qualifiedName : "";
157
+ const id = normalizedItemRef(qualifiedNameRaw || itemIdHint || "");
158
+ if (!id) {
159
+ return null;
160
+ }
161
+
162
+ const tags = uniqueSorted(
163
+ [
164
+ "mcp",
165
+ "smithery",
166
+ obj.remote === true ? "remote-capable" : "",
167
+ Array.isArray(obj.tools) ? "has-tools" : "",
168
+ ].filter(Boolean)
169
+ );
170
+
171
+ const connections = Array.isArray(obj.connections) ? obj.connections : [];
172
+ let deploymentUrl =
173
+ typeof obj.deploymentUrl === "string" ? obj.deploymentUrl.trim() : "";
174
+ let envSchema: unknown;
175
+ for (const conn of connections) {
176
+ if (!isPlainObject(conn)) {
177
+ continue;
178
+ }
179
+ const connType = typeof conn.type === "string" ? conn.type : "";
180
+ const connDeploymentUrl =
181
+ typeof conn.deploymentUrl === "string" ? conn.deploymentUrl : "";
182
+ if (!deploymentUrl && connDeploymentUrl) {
183
+ deploymentUrl = connDeploymentUrl;
184
+ }
185
+ if (!envSchema && connType === "http") {
186
+ envSchema = conn.configSchema;
187
+ }
188
+ }
189
+ const requiredEnv = schemaRequiredEnv(envSchema);
190
+
191
+ let definition: Record<string, unknown> =
192
+ buildPlaceholderMcpDefinition(requiredEnv);
193
+ if (deploymentUrl) {
194
+ const normalizedDeployment = trimTrailingSlash(deploymentUrl);
195
+ const mcpUrl = normalizedDeployment.endsWith("/mcp")
196
+ ? normalizedDeployment
197
+ : `${normalizedDeployment}/mcp`;
198
+ definition = {
199
+ transport: "http",
200
+ url: mcpUrl,
201
+ };
202
+ if (requiredEnv && Object.keys(requiredEnv).length) {
203
+ definition.env = requiredEnv;
204
+ }
205
+ }
206
+
207
+ const title =
208
+ typeof obj.displayName === "string" ? obj.displayName : mcpNameFromRef(id);
209
+ const description =
210
+ typeof obj.description === "string" ? obj.description : undefined;
211
+ const sourceUrl = typeof obj.homepage === "string" ? obj.homepage : undefined;
212
+ const version =
213
+ typeof obj.version === "string"
214
+ ? obj.version
215
+ : typeof obj.updatedAt === "string"
216
+ ? obj.updatedAt
217
+ : undefined;
218
+
219
+ return {
220
+ id,
221
+ type: "mcp",
222
+ title,
223
+ description,
224
+ version,
225
+ sourceUrl,
226
+ tags,
227
+ mcp: {
228
+ name: mcpNameFromRef(id),
229
+ definition,
230
+ },
231
+ };
232
+ }
233
+
234
+ async function loadSmitheryManifest(args: {
235
+ source: IndexSource;
236
+ fetchJson: (url: string) => Promise<unknown>;
237
+ hints?: LoadManifestHints;
238
+ }): Promise<RemoteIndexManifest> {
239
+ const baseManifest: RemoteIndexManifest = {
240
+ name: args.source.name,
241
+ url: args.source.url,
242
+ items: [],
243
+ };
244
+ const itemId = args.hints?.itemId?.trim();
245
+ if (itemId) {
246
+ const detailUrl = buildUrl(
247
+ args.source.url,
248
+ `/servers/${encodeRefPath(normalizedItemRef(itemId))}`
249
+ );
250
+ const raw = await args.fetchJson(detailUrl);
251
+ const item = smitheryDetailToRemoteItem(raw, itemId);
252
+ if (!item) {
253
+ return baseManifest;
254
+ }
255
+ return {
256
+ ...baseManifest,
257
+ items: [item],
258
+ };
259
+ }
260
+
261
+ const q = args.hints?.query?.trim() ?? "";
262
+ const searchUrl = new URL(buildUrl(args.source.url, "/servers"));
263
+ searchUrl.searchParams.set("pageSize", "50");
264
+ if (q) {
265
+ searchUrl.searchParams.set("q", q);
266
+ }
267
+
268
+ const raw = await args.fetchJson(searchUrl.toString());
269
+ if (!isPlainObject(raw)) {
270
+ return baseManifest;
271
+ }
272
+ const obj = raw as Record<string, unknown>;
273
+ const servers = Array.isArray(obj.servers) ? obj.servers : [];
274
+ const items = servers
275
+ .map(smitherySearchItemToRemoteItem)
276
+ .filter((v): v is RemoteMcpItem => !!v);
277
+ return {
278
+ ...baseManifest,
279
+ items,
280
+ };
281
+ }
282
+
283
+ function glamaServerToRemoteItem(
284
+ raw: unknown,
285
+ itemIdHint?: string
286
+ ): RemoteMcpItem | null {
287
+ if (!isPlainObject(raw)) {
288
+ return null;
289
+ }
290
+ const obj = raw as Record<string, unknown>;
291
+ const namespace =
292
+ typeof obj.namespace === "string" ? obj.namespace.trim() : "";
293
+ const slug = typeof obj.slug === "string" ? obj.slug.trim() : "";
294
+ const fallbackId = typeof obj.id === "string" ? obj.id.trim() : "";
295
+ const id =
296
+ itemIdHint?.trim() ||
297
+ (namespace && slug ? `${namespace}/${slug}` : fallbackId);
298
+ if (!id) {
299
+ return null;
300
+ }
301
+
302
+ const attributes = Array.isArray(obj.attributes)
303
+ ? obj.attributes
304
+ .filter((v): v is string => typeof v === "string")
305
+ .map((v) => v.trim())
306
+ .filter(Boolean)
307
+ : [];
308
+ const tags = uniqueSorted(["mcp", "glama", ...attributes]);
309
+ const requiredEnv = schemaRequiredEnv(obj.environmentVariablesJsonSchema);
310
+ const definition = buildPlaceholderMcpDefinition(requiredEnv);
311
+ const title = typeof obj.name === "string" ? obj.name : mcpNameFromRef(id);
312
+ const description =
313
+ typeof obj.description === "string" ? obj.description : undefined;
314
+
315
+ let sourceUrl = typeof obj.url === "string" ? obj.url : undefined;
316
+ if (!sourceUrl && isPlainObject(obj.repository)) {
317
+ const repo = obj.repository as Record<string, unknown>;
318
+ sourceUrl = typeof repo.url === "string" ? repo.url : undefined;
319
+ }
320
+
321
+ const version =
322
+ typeof obj.version === "string"
323
+ ? obj.version
324
+ : typeof obj.updatedAt === "string"
325
+ ? obj.updatedAt
326
+ : undefined;
327
+
328
+ return {
329
+ id,
330
+ type: "mcp",
331
+ title,
332
+ description,
333
+ version,
334
+ sourceUrl,
335
+ tags,
336
+ mcp: {
337
+ name: mcpNameFromRef(id),
338
+ definition,
339
+ },
340
+ };
341
+ }
342
+
343
+ async function loadGlamaManifest(args: {
344
+ source: IndexSource;
345
+ fetchJson: (url: string) => Promise<unknown>;
346
+ hints?: LoadManifestHints;
347
+ }): Promise<RemoteIndexManifest> {
348
+ const baseManifest: RemoteIndexManifest = {
349
+ name: args.source.name,
350
+ url: args.source.url,
351
+ items: [],
352
+ };
353
+ const itemId = args.hints?.itemId?.trim();
354
+ if (itemId) {
355
+ const detailUrl = buildUrl(
356
+ args.source.url,
357
+ `/servers/${encodeRefPath(itemId)}`
358
+ );
359
+ const raw = await args.fetchJson(detailUrl);
360
+ const item = glamaServerToRemoteItem(raw, itemId);
361
+ if (!item) {
362
+ return baseManifest;
363
+ }
364
+ return {
365
+ ...baseManifest,
366
+ items: [item],
367
+ };
368
+ }
369
+
370
+ const q = args.hints?.query?.trim() ?? "";
371
+ const searchUrl = new URL(buildUrl(args.source.url, "/servers"));
372
+ searchUrl.searchParams.set("first", "50");
373
+ if (q) {
374
+ searchUrl.searchParams.set("search", q);
375
+ }
376
+ const raw = await args.fetchJson(searchUrl.toString());
377
+ if (!isPlainObject(raw)) {
378
+ return baseManifest;
379
+ }
380
+ const obj = raw as Record<string, unknown>;
381
+ const servers = Array.isArray(obj.servers) ? obj.servers : [];
382
+ const items = servers
383
+ .map((server) => glamaServerToRemoteItem(server))
384
+ .filter((v): v is RemoteMcpItem => !!v);
385
+ return {
386
+ ...baseManifest,
387
+ items,
388
+ };
389
+ }
390
+
391
+ interface SkillsShEntry {
392
+ id: string;
393
+ title: string;
394
+ description?: string;
395
+ sourceUrl?: string;
396
+ }
397
+
398
+ function decodeJsonStringLiteral(raw: string): string {
399
+ try {
400
+ return JSON.parse(`"${raw}"`) as string;
401
+ } catch {
402
+ return raw;
403
+ }
404
+ }
405
+
406
+ function skillNameFromId(id: string): string {
407
+ const base = id.split("/").filter(Boolean).at(-1) ?? id;
408
+ return base
409
+ .trim()
410
+ .toLowerCase()
411
+ .replace(/[^a-z0-9._-]+/g, "-")
412
+ .replace(/^-+|-+$/g, "");
413
+ }
414
+
415
+ function parseSkillsShEntries(html: string): SkillsShEntry[] {
416
+ const normalizedHtml = html.replaceAll('\\"', '"').replaceAll("\\/", "/");
417
+ const out: SkillsShEntry[] = [];
418
+ for (const match of normalizedHtml.matchAll(SKILLS_SH_ENTRY_RE)) {
419
+ const rawSource = match[1] ?? "";
420
+ const rawSkillId = match[2] ?? "";
421
+ const rawName = match[3] ?? "";
422
+ const rawDescription = match[4] ?? "";
423
+
424
+ const sourceUrl = decodeJsonStringLiteral(rawSource).trim();
425
+ const skillId = decodeJsonStringLiteral(rawSkillId).trim();
426
+ const name = decodeJsonStringLiteral(rawName).trim();
427
+ const description = decodeJsonStringLiteral(rawDescription).trim();
428
+
429
+ const id = normalizedItemRef(skillId || sourceUrl);
430
+ if (!id) {
431
+ continue;
432
+ }
433
+ out.push({
434
+ id,
435
+ title: name || skillNameFromId(id),
436
+ description: description || undefined,
437
+ sourceUrl: sourceUrl || undefined,
438
+ });
439
+ }
440
+
441
+ const dedup = new Map<string, SkillsShEntry>();
442
+ for (const entry of out) {
443
+ dedup.set(entry.id, entry);
444
+ }
445
+ return Array.from(dedup.values()).sort((a, b) => a.id.localeCompare(b.id));
446
+ }
447
+
448
+ function githubSourceParts(sourceUrl: string): {
449
+ owner: string;
450
+ repo: string;
451
+ branch?: string;
452
+ pathPrefix?: string;
453
+ } | null {
454
+ const m = sourceUrl.match(GITHUB_SOURCE_RE);
455
+ if (!m) {
456
+ return null;
457
+ }
458
+ const owner = m[1]?.trim();
459
+ const repo = (m[2] ?? "").trim().replace(REPO_DOT_GIT_SUFFIX_RE, "");
460
+ const branch = m[3]?.trim() || undefined;
461
+ const pathPrefix = m[4]?.trim().replace(LEADING_SLASH_RE, "") || undefined;
462
+ if (!(owner && repo)) {
463
+ return null;
464
+ }
465
+ return { owner, repo, branch, pathPrefix };
466
+ }
467
+
468
+ function buildSkillsShRawCandidates(entry: SkillsShEntry): string[] {
469
+ const urls: string[] = [];
470
+ if (
471
+ entry.sourceUrl &&
472
+ entry.sourceUrl.includes("raw.githubusercontent.com") &&
473
+ entry.sourceUrl.endsWith(".md")
474
+ ) {
475
+ urls.push(entry.sourceUrl);
476
+ }
477
+
478
+ if (entry.sourceUrl) {
479
+ const gh = githubSourceParts(entry.sourceUrl);
480
+ if (gh) {
481
+ const path = gh.pathPrefix ? `${gh.pathPrefix}/SKILL.md` : "SKILL.md";
482
+ if (gh.branch) {
483
+ urls.push(
484
+ `https://raw.githubusercontent.com/${gh.owner}/${gh.repo}/${gh.branch}/${path}`
485
+ );
486
+ }
487
+ urls.push(
488
+ `https://raw.githubusercontent.com/${gh.owner}/${gh.repo}/main/${path}`
489
+ );
490
+ urls.push(
491
+ `https://raw.githubusercontent.com/${gh.owner}/${gh.repo}/master/${path}`
492
+ );
493
+ }
494
+ }
495
+
496
+ const idMatch = entry.id.match(OWNER_REPO_RE);
497
+ if (idMatch) {
498
+ const [owner = "", repo = ""] = entry.id.split("/", 2);
499
+ if (!(owner && repo)) {
500
+ return uniqueSorted(urls);
501
+ }
502
+ urls.push(
503
+ `https://raw.githubusercontent.com/${owner}/${repo}/main/SKILL.md`
504
+ );
505
+ urls.push(
506
+ `https://raw.githubusercontent.com/${owner}/${repo}/master/SKILL.md`
507
+ );
508
+ }
509
+
510
+ return uniqueSorted(urls);
511
+ }
512
+
513
+ function skillItemFromEntry(args: {
514
+ entry: SkillsShEntry;
515
+ content?: string;
516
+ }): RemoteSkillItem {
517
+ const skillName = skillNameFromId(args.entry.id) || "skill";
518
+ const tags = uniqueSorted(["skill", "skills.sh"]);
519
+ const content =
520
+ args.content?.trim() ||
521
+ `# ${args.entry.title || "{{name}}"}\n\n${
522
+ args.entry.description ?? "Imported from skills.sh."
523
+ }\n`;
524
+ return {
525
+ id: args.entry.id,
526
+ type: "skill",
527
+ title: args.entry.title,
528
+ description: args.entry.description,
529
+ sourceUrl: args.entry.sourceUrl,
530
+ tags,
531
+ skill: {
532
+ name: skillName,
533
+ files: {
534
+ "SKILL.md": content,
535
+ },
536
+ },
537
+ };
538
+ }
539
+
540
+ async function loadSkillsShManifest(args: {
541
+ source: IndexSource;
542
+ fetchText: (url: string) => Promise<string>;
543
+ hints?: LoadManifestHints;
544
+ }): Promise<RemoteIndexManifest> {
545
+ const baseManifest: RemoteIndexManifest = {
546
+ name: args.source.name,
547
+ url: args.source.url,
548
+ items: [],
549
+ };
550
+
551
+ const itemId = args.hints?.itemId?.trim();
552
+ const q = (itemId || args.hints?.query || "").trim();
553
+ const searchUrl = new URL(buildUrl(args.source.url, "/"));
554
+ if (q) {
555
+ searchUrl.searchParams.set("q", q);
556
+ }
557
+ const html = await args.fetchText(searchUrl.toString());
558
+ const entries = parseSkillsShEntries(html);
559
+
560
+ if (itemId) {
561
+ const targetId = normalizedItemRef(itemId);
562
+ const matched =
563
+ entries.find((entry) => entry.id === targetId) ??
564
+ entries.find((entry) => entry.id.includes(targetId)) ??
565
+ (() => {
566
+ const fallback: SkillsShEntry = {
567
+ id: targetId,
568
+ title: skillNameFromId(targetId) || targetId,
569
+ sourceUrl: targetId.match(OWNER_REPO_RE)
570
+ ? `https://github.com/${targetId}`
571
+ : undefined,
572
+ };
573
+ return fallback;
574
+ })();
575
+
576
+ let content: string | undefined;
577
+ for (const candidate of buildSkillsShRawCandidates(matched)) {
578
+ try {
579
+ const txt = await args.fetchText(candidate);
580
+ if (txt.trim()) {
581
+ content = txt;
582
+ break;
583
+ }
584
+ } catch {
585
+ // Try next source candidate.
586
+ }
587
+ }
588
+
589
+ return {
590
+ ...baseManifest,
591
+ items: [skillItemFromEntry({ entry: matched, content })],
592
+ };
593
+ }
594
+
595
+ return {
596
+ ...baseManifest,
597
+ items: entries.map((entry) => skillItemFromEntry({ entry })),
598
+ };
599
+ }
600
+
601
+ function clawhubVersionValue(raw: Record<string, unknown>): string | undefined {
602
+ if (typeof raw.version === "string" && raw.version.trim()) {
603
+ return raw.version.trim();
604
+ }
605
+ const latest = raw.latestVersion;
606
+ if (typeof latest === "string" && latest.trim()) {
607
+ return latest.trim();
608
+ }
609
+ if (isPlainObject(latest)) {
610
+ const latestObj = latest as Record<string, unknown>;
611
+ if (typeof latestObj.version === "string" && latestObj.version.trim()) {
612
+ return latestObj.version.trim();
613
+ }
614
+ if (typeof latestObj.id === "string" && latestObj.id.trim()) {
615
+ return latestObj.id.trim();
616
+ }
617
+ }
618
+ return undefined;
619
+ }
620
+
621
+ function clawhubEntryToRemoteSkillItem(
622
+ raw: unknown,
623
+ itemIdHint?: string
624
+ ): RemoteSkillItem | null {
625
+ if (!isPlainObject(raw)) {
626
+ return null;
627
+ }
628
+ const obj = raw as Record<string, unknown>;
629
+ const slug = typeof obj.slug === "string" ? obj.slug.trim() : "";
630
+ const id = normalizedItemRef(itemIdHint || slug);
631
+ if (!id) {
632
+ return null;
633
+ }
634
+
635
+ const title =
636
+ typeof obj.name === "string" && obj.name.trim()
637
+ ? obj.name
638
+ : skillNameFromId(id);
639
+ const description =
640
+ typeof obj.description === "string" ? obj.description : undefined;
641
+ const sourceUrl =
642
+ typeof obj.sourceUrl === "string"
643
+ ? obj.sourceUrl
644
+ : typeof obj.repositoryUrl === "string"
645
+ ? obj.repositoryUrl
646
+ : undefined;
647
+ const categories = Array.isArray(obj.categories)
648
+ ? obj.categories
649
+ .filter((v): v is string => typeof v === "string")
650
+ .map((v) => v.trim())
651
+ .filter(Boolean)
652
+ : [];
653
+
654
+ return {
655
+ id,
656
+ type: "skill",
657
+ title,
658
+ description,
659
+ version: clawhubVersionValue(obj),
660
+ sourceUrl,
661
+ tags: uniqueSorted(["skill", "clawhub", ...categories]),
662
+ skill: {
663
+ name: skillNameFromId(id) || id,
664
+ files: {
665
+ "SKILL.md": "# {{name}}\n",
666
+ },
667
+ },
668
+ };
669
+ }
670
+
671
+ function extractClawhubFilePaths(raw: unknown): string[] {
672
+ if (!isPlainObject(raw)) {
673
+ return [];
674
+ }
675
+ const obj = raw as Record<string, unknown>;
676
+ const candidates: unknown[] = [];
677
+ candidates.push(obj.files);
678
+ candidates.push(obj.filePaths);
679
+ candidates.push(obj.paths);
680
+
681
+ const out: string[] = [];
682
+ for (const candidate of candidates) {
683
+ if (!Array.isArray(candidate)) {
684
+ continue;
685
+ }
686
+ for (const entry of candidate) {
687
+ if (typeof entry === "string") {
688
+ out.push(entry);
689
+ continue;
690
+ }
691
+ if (isPlainObject(entry)) {
692
+ const path =
693
+ typeof entry.path === "string"
694
+ ? entry.path
695
+ : typeof entry.filePath === "string"
696
+ ? entry.filePath
697
+ : "";
698
+ if (path) {
699
+ out.push(path);
700
+ }
701
+ }
702
+ }
703
+ }
704
+
705
+ return uniqueSorted(
706
+ out
707
+ .map((path) => path.trim())
708
+ .filter(Boolean)
709
+ .map((path) => path.replace(LEADING_SLASH_RE, ""))
710
+ );
711
+ }
712
+
713
+ function isSafeRelativePath(relPath: string): boolean {
714
+ if (!relPath || relPath.includes("\0")) {
715
+ return false;
716
+ }
717
+ const normalized = relPath.replaceAll("\\", "/");
718
+ const parts = normalized.split("/").filter(Boolean);
719
+ if (!parts.length) {
720
+ return false;
721
+ }
722
+ if (parts.includes(".") || parts.includes("..")) {
723
+ return false;
724
+ }
725
+ return true;
726
+ }
727
+
728
+ async function loadClawhubManifest(args: {
729
+ source: IndexSource;
730
+ fetchJson: (url: string) => Promise<unknown>;
731
+ fetchText: (url: string) => Promise<string>;
732
+ hints?: LoadManifestHints;
733
+ }): Promise<RemoteIndexManifest> {
734
+ const baseManifest: RemoteIndexManifest = {
735
+ name: args.source.name,
736
+ url: args.source.url,
737
+ items: [],
738
+ };
739
+ const itemId = args.hints?.itemId?.trim();
740
+ if (itemId) {
741
+ const detailUrl = buildUrl(
742
+ args.source.url,
743
+ `/skills/${encodeURIComponent(normalizedItemRef(itemId))}`
744
+ );
745
+ const detailRaw = await args.fetchJson(detailUrl);
746
+ const detailItem = clawhubEntryToRemoteSkillItem(detailRaw, itemId);
747
+ if (!detailItem) {
748
+ return baseManifest;
749
+ }
750
+
751
+ const detailObj = isPlainObject(detailRaw)
752
+ ? (detailRaw as Record<string, unknown>)
753
+ : {};
754
+ const version = detailItem.version;
755
+ const fallbackPaths = ["SKILL.md"];
756
+ let filePaths = fallbackPaths;
757
+ if (version) {
758
+ try {
759
+ const versionUrl = buildUrl(
760
+ args.source.url,
761
+ `/skills/${encodeURIComponent(detailItem.id)}/versions/${encodeURIComponent(version)}`
762
+ );
763
+ const versionRaw = await args.fetchJson(versionUrl);
764
+ const extracted = extractClawhubFilePaths(versionRaw);
765
+ if (extracted.length) {
766
+ filePaths = extracted;
767
+ }
768
+ } catch {
769
+ // Keep fallback file list.
770
+ }
771
+ } else {
772
+ const extracted = extractClawhubFilePaths(detailObj);
773
+ if (extracted.length) {
774
+ filePaths = extracted;
775
+ }
776
+ }
777
+
778
+ const files: Record<string, string> = {};
779
+ for (const path of filePaths) {
780
+ if (!isSafeRelativePath(path)) {
781
+ continue;
782
+ }
783
+ const fileUrl = new URL(
784
+ buildUrl(
785
+ args.source.url,
786
+ `/skills/${encodeURIComponent(detailItem.id)}/file`
787
+ )
788
+ );
789
+ fileUrl.searchParams.set("path", path);
790
+ if (version) {
791
+ fileUrl.searchParams.set("version", version);
792
+ }
793
+ try {
794
+ const text = await args.fetchText(fileUrl.toString());
795
+ if (text.trim()) {
796
+ files[path] = text;
797
+ }
798
+ } catch {
799
+ try {
800
+ const raw = await args.fetchJson(fileUrl.toString());
801
+ if (typeof raw === "string" && raw.trim()) {
802
+ files[path] = raw;
803
+ continue;
804
+ }
805
+ if (isPlainObject(raw)) {
806
+ const obj = raw as Record<string, unknown>;
807
+ const content =
808
+ typeof obj.content === "string"
809
+ ? obj.content
810
+ : typeof obj.text === "string"
811
+ ? obj.text
812
+ : "";
813
+ if (content.trim()) {
814
+ files[path] = content;
815
+ }
816
+ }
817
+ } catch {
818
+ // Ignore missing file and continue.
819
+ }
820
+ }
821
+ }
822
+
823
+ if (!Object.keys(files).length) {
824
+ files["SKILL.md"] = `# ${detailItem.title ?? "{{name}}"}\n`;
825
+ }
826
+
827
+ return {
828
+ ...baseManifest,
829
+ items: [
830
+ {
831
+ ...detailItem,
832
+ skill: {
833
+ name: detailItem.skill.name,
834
+ files,
835
+ },
836
+ },
837
+ ],
838
+ };
839
+ }
840
+
841
+ const q = args.hints?.query?.trim() ?? "";
842
+ const searchUrl = new URL(buildUrl(args.source.url, "/skills"));
843
+ searchUrl.searchParams.set("limit", "50");
844
+ if (q) {
845
+ searchUrl.searchParams.set("query", q);
846
+ searchUrl.searchParams.set("q", q);
847
+ }
848
+ const raw = await args.fetchJson(searchUrl.toString());
849
+ const list = Array.isArray(raw)
850
+ ? raw
851
+ : isPlainObject(raw)
852
+ ? Array.isArray((raw as Record<string, unknown>).items)
853
+ ? ((raw as Record<string, unknown>).items as unknown[])
854
+ : Array.isArray((raw as Record<string, unknown>).skills)
855
+ ? ((raw as Record<string, unknown>).skills as unknown[])
856
+ : []
857
+ : [];
858
+
859
+ const items = list
860
+ .map((entry) => clawhubEntryToRemoteSkillItem(entry))
861
+ .filter((v): v is RemoteSkillItem => !!v);
862
+ return {
863
+ ...baseManifest,
864
+ items,
865
+ };
866
+ }
867
+
868
+ export async function loadProviderManifest(args: {
869
+ source: IndexSource;
870
+ fetchJson: (url: string) => Promise<unknown>;
871
+ fetchText: (url: string) => Promise<string>;
872
+ hints?: LoadManifestHints;
873
+ }): Promise<RemoteIndexManifest> {
874
+ if (args.source.kind === "smithery") {
875
+ return await loadSmitheryManifest({
876
+ source: args.source,
877
+ fetchJson: args.fetchJson,
878
+ hints: args.hints,
879
+ });
880
+ }
881
+ if (args.source.kind === "glama") {
882
+ return await loadGlamaManifest({
883
+ source: args.source,
884
+ fetchJson: args.fetchJson,
885
+ hints: args.hints,
886
+ });
887
+ }
888
+ if (args.source.kind === "skills-sh") {
889
+ return await loadSkillsShManifest({
890
+ source: args.source,
891
+ fetchText: args.fetchText,
892
+ hints: args.hints,
893
+ });
894
+ }
895
+ if (args.source.kind === "clawhub") {
896
+ return await loadClawhubManifest({
897
+ source: args.source,
898
+ fetchJson: args.fetchJson,
899
+ fetchText: args.fetchText,
900
+ hints: args.hints,
901
+ });
902
+ }
903
+
904
+ throw new Error(`Unsupported provider source kind: ${args.source.kind}`);
905
+ }