@rbbtsn0w/adg 0.1.0-alpha.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 (84) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +308 -0
  3. package/bin/adg.ts +758 -0
  4. package/docs/agents-spec.md +132 -0
  5. package/docs/authoring.md +352 -0
  6. package/package.json +50 -0
  7. package/schemas/adg-plugin.schema.json +77 -0
  8. package/schemas/marketplace.schema.json +86 -0
  9. package/schemas/plugin-lock.schema.json +90 -0
  10. package/src/adapters/anthropic.ts +54 -0
  11. package/src/adapters/index.ts +24 -0
  12. package/src/adapters/openai.ts +37 -0
  13. package/src/adapters/reverse.ts +60 -0
  14. package/src/agents/claude.ts +124 -0
  15. package/src/agents/codex.ts +67 -0
  16. package/src/agents/index.ts +12 -0
  17. package/src/agents/registry.ts +30 -0
  18. package/src/agents/types.ts +47 -0
  19. package/src/commands/adapt.ts +36 -0
  20. package/src/commands/import.ts +69 -0
  21. package/src/commands/init.ts +146 -0
  22. package/src/commands/install.ts +411 -0
  23. package/src/commands/link.ts +61 -0
  24. package/src/commands/list.ts +28 -0
  25. package/src/commands/marketplace.ts +198 -0
  26. package/src/commands/migrate.ts +84 -0
  27. package/src/commands/multiselect-skills.ts +137 -0
  28. package/src/commands/remove.ts +136 -0
  29. package/src/commands/select-agents.ts +45 -0
  30. package/src/commands/select-components.ts +66 -0
  31. package/src/commands/select-plugins.ts +28 -0
  32. package/src/commands/select-scope.ts +21 -0
  33. package/src/commands/update.ts +85 -0
  34. package/src/commands/validate.ts +57 -0
  35. package/src/components.ts +90 -0
  36. package/src/deps.ts +64 -0
  37. package/src/fsutil.ts +38 -0
  38. package/src/hash.ts +61 -0
  39. package/src/lock.ts +57 -0
  40. package/src/manifest.ts +113 -0
  41. package/src/marketplace.ts +41 -0
  42. package/src/package.ts +74 -0
  43. package/src/paths.ts +129 -0
  44. package/src/semver.ts +67 -0
  45. package/src/skills.ts +88 -0
  46. package/src/sources.ts +159 -0
  47. package/src/types.ts +140 -0
  48. package/vendor/skills/LICENSE +29 -0
  49. package/vendor/skills/PROVENANCE.md +60 -0
  50. package/vendor/skills/ThirdPartyNoticeText.txt +117 -0
  51. package/vendor/skills/package.json +143 -0
  52. package/vendor/skills/src/add.ts +1999 -0
  53. package/vendor/skills/src/agents.ts +755 -0
  54. package/vendor/skills/src/blob.ts +567 -0
  55. package/vendor/skills/src/cli.ts +387 -0
  56. package/vendor/skills/src/constants.ts +3 -0
  57. package/vendor/skills/src/detect-agent.ts +62 -0
  58. package/vendor/skills/src/find.ts +357 -0
  59. package/vendor/skills/src/frontmatter.ts +16 -0
  60. package/vendor/skills/src/git-tree.ts +36 -0
  61. package/vendor/skills/src/git.ts +277 -0
  62. package/vendor/skills/src/install.ts +91 -0
  63. package/vendor/skills/src/installer.ts +1097 -0
  64. package/vendor/skills/src/list.ts +231 -0
  65. package/vendor/skills/src/local-lock.ts +182 -0
  66. package/vendor/skills/src/plugin-manifest.ts +183 -0
  67. package/vendor/skills/src/prompts/search-multiselect.ts +387 -0
  68. package/vendor/skills/src/providers/index.ts +14 -0
  69. package/vendor/skills/src/providers/registry.ts +51 -0
  70. package/vendor/skills/src/providers/types.ts +97 -0
  71. package/vendor/skills/src/providers/wellknown.ts +804 -0
  72. package/vendor/skills/src/remove.ts +323 -0
  73. package/vendor/skills/src/sanitize.ts +65 -0
  74. package/vendor/skills/src/self-cli.ts +20 -0
  75. package/vendor/skills/src/skill-lock.ts +329 -0
  76. package/vendor/skills/src/skills.ts +316 -0
  77. package/vendor/skills/src/source-parser.ts +438 -0
  78. package/vendor/skills/src/sync.ts +478 -0
  79. package/vendor/skills/src/telemetry.ts +186 -0
  80. package/vendor/skills/src/test-utils.ts +73 -0
  81. package/vendor/skills/src/types.ts +128 -0
  82. package/vendor/skills/src/update-source.ts +90 -0
  83. package/vendor/skills/src/update.ts +749 -0
  84. package/vendor/skills/src/use.ts +675 -0
@@ -0,0 +1,567 @@
1
+ /**
2
+ * Blob-based skill download utilities.
3
+ *
4
+ * Enables fast skill installation by fetching pre-built skill snapshots
5
+ * from the skills.sh download API instead of cloning git repos.
6
+ *
7
+ * Flow:
8
+ * 1. GitHub Trees API → discover SKILL.md locations
9
+ * 2. raw.githubusercontent.com → fetch frontmatter to get skill names
10
+ * 3. skills.sh/api/download → fetch full file contents from cached blob
11
+ */
12
+
13
+ import { parseFrontmatter } from './frontmatter.ts';
14
+ import { sanitizeMetadata } from './sanitize.ts';
15
+ import type { Skill } from './types.ts';
16
+
17
+ // ─── Types ───
18
+
19
+ export interface SkillSnapshotFile {
20
+ path: string;
21
+ contents: string;
22
+ }
23
+
24
+ export interface SkillDownloadResponse {
25
+ files: SkillSnapshotFile[];
26
+ hash: string; // skillsComputedHash
27
+ }
28
+
29
+ /**
30
+ * A skill resolved from blob storage, carrying file contents in memory
31
+ * instead of referencing a directory on disk.
32
+ */
33
+ export interface BlobSkill extends Skill {
34
+ /** Files from the blob snapshot */
35
+ files: SkillSnapshotFile[];
36
+ /** skillsComputedHash from the blob snapshot */
37
+ snapshotHash: string;
38
+ /** Path of the SKILL.md within the repo (e.g., "skills/react-best-practices/SKILL.md") */
39
+ repoPath: string;
40
+ }
41
+
42
+ // ─── Constants ───
43
+
44
+ const DOWNLOAD_BASE_URL = process.env.SKILLS_DOWNLOAD_URL || 'https://skills.sh';
45
+
46
+ // Repos that self-host their downloads on the blob fast-path
47
+ export const BLOB_ALLOWED_REPOS: Record<string, { downloadUrl: (slug: string) => string }> = {
48
+ 'zapier/connectors': {
49
+ downloadUrl: (slug) =>
50
+ `https://connectors-skills.zapier.com/download/${encodeURIComponent(slug)}/snapshot.json`,
51
+ },
52
+ };
53
+
54
+ /** Timeout for individual HTTP fetches (ms) */
55
+ const FETCH_TIMEOUT = 10_000;
56
+
57
+ // ─── Slug computation ───
58
+
59
+ /**
60
+ * Convert a skill name to a URL-safe slug.
61
+ * Must match the server-side toSkillSlug() exactly.
62
+ */
63
+ export function toSkillSlug(name: string): string {
64
+ return name
65
+ .toLowerCase()
66
+ .replace(/[\s_]+/g, '-')
67
+ .replace(/[^a-z0-9-]/g, '')
68
+ .replace(/-+/g, '-')
69
+ .replace(/^-|-$/g, '');
70
+ }
71
+
72
+ // ─── GitHub Trees API ───
73
+
74
+ export interface TreeEntry {
75
+ path: string;
76
+ type: 'blob' | 'tree';
77
+ sha: string;
78
+ size?: number;
79
+ }
80
+
81
+ export interface RepoTree {
82
+ sha: string;
83
+ branch: string;
84
+ tree: TreeEntry[];
85
+ }
86
+
87
+ /**
88
+ * Within-process memo: once we've discovered that the GitHub API has
89
+ * rate-limited this IP, subsequent calls skip straight to the auth fallback
90
+ * instead of burning round trips on requests guaranteed to 403.
91
+ */
92
+ let _rateLimitedThisSession = false;
93
+
94
+ /** For tests only. */
95
+ export function resetRepoTreeAuthState(): void {
96
+ _rateLimitedThisSession = false;
97
+ }
98
+
99
+ interface BranchFetchResult {
100
+ tree: RepoTree | null;
101
+ rateLimited: boolean;
102
+ /**
103
+ * ADG patch: the unauthenticated request got 401/403/404 — a private or
104
+ * access-controlled repo looks identical to "not found" to an anonymous
105
+ * caller, so a token may unlock it. Distinct from `rateLimited`.
106
+ */
107
+ authMayHelp: boolean;
108
+ }
109
+
110
+ async function fetchTreeBranch(
111
+ ownerRepo: string,
112
+ branch: string,
113
+ token: string | null
114
+ ): Promise<BranchFetchResult> {
115
+ try {
116
+ const url = `https://api.github.com/repos/${ownerRepo}/git/trees/${encodeURIComponent(branch)}?recursive=1`;
117
+ const headers: Record<string, string> = {
118
+ Accept: 'application/vnd.github.v3+json',
119
+ 'User-Agent': 'skills-cli',
120
+ };
121
+ if (token) {
122
+ headers['Authorization'] = `Bearer ${token}`;
123
+ }
124
+
125
+ const response = await fetch(url, {
126
+ headers,
127
+ signal: AbortSignal.timeout(FETCH_TIMEOUT),
128
+ });
129
+
130
+ if (response.ok) {
131
+ const data = (await response.json()) as {
132
+ sha: string;
133
+ tree: TreeEntry[];
134
+ };
135
+ return {
136
+ tree: { sha: data.sha, branch, tree: data.tree },
137
+ rateLimited: false,
138
+ authMayHelp: false,
139
+ };
140
+ }
141
+
142
+ // GitHub signals rate-limit with 403 + X-RateLimit-Remaining: 0.
143
+ const rateLimited =
144
+ response.status === 403 && response.headers.get('x-ratelimit-remaining') === '0';
145
+ // ADG patch: a 401/403/404 from an UNauthenticated request may just mean the
146
+ // repo is private — retry with a token before giving up. GitHub returns 404
147
+ // (not 403) for private repos to anonymous callers to avoid leaking existence.
148
+ const authMayHelp =
149
+ !rateLimited &&
150
+ (response.status === 401 || response.status === 403 || response.status === 404);
151
+ return { tree: null, rateLimited, authMayHelp };
152
+ } catch {
153
+ return { tree: null, rateLimited: false, authMayHelp: false };
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Fetch the full recursive tree for a GitHub repo.
159
+ * Returns the tree data including all entries, or null on failure.
160
+ * Tries branches in order: ref (if specified), then main, then master.
161
+ *
162
+ * Authentication is lazy: by default the call goes out unauthenticated,
163
+ * which is enough for the vast majority of users (60 req/hr per IP).
164
+ * Only if GitHub responds with a rate-limit 403 do we ask the optional
165
+ * `getToken` callback for a token and retry. This avoids invoking
166
+ * `gh auth token` on every install, which corporate endpoint security
167
+ * tools flag as suspicious credential extraction. See issue #523.
168
+ */
169
+ export async function fetchRepoTree(
170
+ ownerRepo: string,
171
+ ref?: string,
172
+ getToken?: () => string | null
173
+ ): Promise<RepoTree | null> {
174
+ const branches = ref ? [ref] : ['HEAD', 'main', 'master'];
175
+
176
+ // Fast path: once we've seen a rate limit in this process, don't bother
177
+ // retrying unauth on subsequent calls. Go straight to auth.
178
+ if (_rateLimitedThisSession && getToken) {
179
+ const token = getToken();
180
+ if (!token) return null;
181
+ for (const branch of branches) {
182
+ const result = await fetchTreeBranch(ownerRepo, branch, token);
183
+ if (result.tree) return result.tree;
184
+ }
185
+ return null;
186
+ }
187
+
188
+ // First pass: unauthenticated.
189
+ let rateLimited = false;
190
+ let authMayHelp = false;
191
+ for (const branch of branches) {
192
+ const result = await fetchTreeBranch(ownerRepo, branch, null);
193
+ if (result.tree) return result.tree;
194
+ if (result.rateLimited) {
195
+ // All branches share the same rate-limit bucket on this IP, so it's
196
+ // pointless to keep trying other branches in this pass.
197
+ rateLimited = true;
198
+ break;
199
+ }
200
+ // ADG patch: remember a possible-private signal, but keep trying the other
201
+ // candidate branches unauthenticated first (the repo may just lack `main`).
202
+ if (result.authMayHelp) authMayHelp = true;
203
+ }
204
+
205
+ // ADG patch: fall back to an authenticated retry on EITHER a rate limit or a
206
+ // private/forbidden response. Upstream only retried on rate limit, so private
207
+ // repos never used the token and always failed.
208
+ if ((!rateLimited && !authMayHelp) || !getToken) return null;
209
+
210
+ // Only a rate limit is a process-wide condition worth short-circuiting later.
211
+ if (rateLimited) _rateLimitedThisSession = true;
212
+ const token = getToken();
213
+ if (!token) return null;
214
+
215
+ for (const branch of branches) {
216
+ const result = await fetchTreeBranch(ownerRepo, branch, token);
217
+ if (result.tree) return result.tree;
218
+ }
219
+ return null;
220
+ }
221
+
222
+ /**
223
+ * Extract the folder hash (tree SHA) for a specific skill path from a repo tree.
224
+ * This replaces the per-skill GitHub API call previously done in fetchSkillFolderHash().
225
+ */
226
+ export function getSkillFolderHashFromTree(tree: RepoTree, skillPath: string): string | null {
227
+ let folderPath = skillPath.replace(/\\/g, '/');
228
+
229
+ // Remove SKILL.md suffix to get folder path (case-insensitive)
230
+ if (folderPath.toLowerCase().endsWith('/skill.md')) {
231
+ folderPath = folderPath.slice(0, -9);
232
+ } else if (folderPath.toLowerCase().endsWith('skill.md')) {
233
+ folderPath = folderPath.slice(0, -8);
234
+ }
235
+ if (folderPath.endsWith('/')) {
236
+ folderPath = folderPath.slice(0, -1);
237
+ }
238
+
239
+ // Root-level skill
240
+ if (!folderPath) {
241
+ return tree.sha;
242
+ }
243
+
244
+ const entry = tree.tree.find((e) => e.type === 'tree' && e.path === folderPath);
245
+ return entry?.sha ?? null;
246
+ }
247
+
248
+ // ─── Skill discovery from tree ───
249
+
250
+ /** Known directories where SKILL.md files are commonly found (relative to repo root) */
251
+ const PRIORITY_PREFIXES = [
252
+ '',
253
+ 'skills/',
254
+ 'skills/.curated/',
255
+ 'skills/.experimental/',
256
+ 'skills/.system/',
257
+ '.agents/skills/',
258
+ '.claude/skills/',
259
+ '.cline/skills/',
260
+ '.codebuddy/skills/',
261
+ '.codex/skills/',
262
+ '.commandcode/skills/',
263
+ '.continue/skills/',
264
+ '.github/skills/',
265
+ '.goose/skills/',
266
+ '.iflow/skills/',
267
+ '.junie/skills/',
268
+ '.kilocode/skills/',
269
+ '.kiro/skills/',
270
+ '.mux/skills/',
271
+ '.neovate/skills/',
272
+ '.opencode/skills/',
273
+ '.openhands/skills/',
274
+ '.pi/skills/',
275
+ '.qoder/skills/',
276
+ '.roo/skills/',
277
+ '.trae/skills/',
278
+ '.windsurf/skills/',
279
+ '.zencoder/skills/',
280
+ ];
281
+
282
+ /**
283
+ * Find all SKILL.md file paths in a repo tree.
284
+ * Applies the same priority directory logic as discoverSkills().
285
+ * If subpath is set, only searches within that subtree.
286
+ */
287
+ export function findSkillMdPaths(tree: RepoTree, subpath?: string): string[] {
288
+ // Find all blob entries that are SKILL.md files (case-insensitive)
289
+ const allSkillMds = tree.tree
290
+ .filter((e) => e.type === 'blob' && e.path.toLowerCase().endsWith('skill.md'))
291
+ .map((e) => e.path);
292
+
293
+ // Apply subpath filter
294
+ const prefix = subpath ? (subpath.endsWith('/') ? subpath : subpath + '/') : '';
295
+ const filtered = prefix
296
+ ? allSkillMds.filter((p) => p.startsWith(prefix) || p === prefix + 'SKILL.md')
297
+ : allSkillMds;
298
+
299
+ if (filtered.length === 0) return [];
300
+
301
+ // Check priority directories first (same order as discoverSkills).
302
+ // Non-root prefixes also accept depth-2 paths so the blob fast path stays
303
+ // in sync with the on-disk walk's catalog-layout discovery.
304
+ const priorityResults: string[] = [];
305
+ const seen = new Set<string>();
306
+ // Mirror of SKIP_DIRS at the top of src/skills.ts. Kept local to avoid
307
+ // a cross-file import; if these ever drift, update both.
308
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '__pycache__']);
309
+ const lowerSkillMdSet = new Set(filtered.map((p) => p.toLowerCase()));
310
+
311
+ for (const priorityPrefix of PRIORITY_PREFIXES) {
312
+ const fullPrefix = prefix + priorityPrefix;
313
+ const isContainer = priorityPrefix !== '';
314
+
315
+ for (const skillMd of filtered) {
316
+ // Check if this SKILL.md is directly inside the priority dir (one level deep)
317
+ if (!skillMd.startsWith(fullPrefix)) continue;
318
+ const rest = skillMd.slice(fullPrefix.length);
319
+
320
+ // Direct SKILL.md in the priority dir (e.g., "skills/SKILL.md")
321
+ if (rest.toLowerCase() === 'skill.md') {
322
+ if (!seen.has(skillMd)) {
323
+ priorityResults.push(skillMd);
324
+ seen.add(skillMd);
325
+ }
326
+ continue;
327
+ }
328
+
329
+ // SKILL.md one level deep (e.g., "skills/react-best-practices/SKILL.md")
330
+ const parts = rest.split('/');
331
+ if (parts.length === 2 && parts[1]!.toLowerCase() === 'skill.md') {
332
+ if (!seen.has(skillMd)) {
333
+ priorityResults.push(skillMd);
334
+ seen.add(skillMd);
335
+ }
336
+ continue;
337
+ }
338
+
339
+ // SKILL.md two levels deep under a known container prefix
340
+ // (e.g., "skills/<category>/<skill>/SKILL.md"). Skip if the parent
341
+ // child dir already has its own SKILL.md (no descent past), or if
342
+ // any path segment is an ignored directory.
343
+ if (
344
+ isContainer &&
345
+ parts.length === 3 &&
346
+ parts[2]!.toLowerCase() === 'skill.md' &&
347
+ !SKIP_DIRS.has(parts[0]!) &&
348
+ !SKIP_DIRS.has(parts[1]!)
349
+ ) {
350
+ const parentSkillMd = `${fullPrefix}${parts[0]}/SKILL.md`.toLowerCase();
351
+ if (!lowerSkillMdSet.has(parentSkillMd) && !seen.has(skillMd)) {
352
+ priorityResults.push(skillMd);
353
+ seen.add(skillMd);
354
+ }
355
+ }
356
+ }
357
+ }
358
+
359
+ // If we found skills in priority dirs, return those
360
+ if (priorityResults.length > 0) return priorityResults;
361
+
362
+ // Fallback: return all SKILL.md files found (limited to 5 levels deep)
363
+ return filtered.filter((p) => {
364
+ const depth = p.split('/').length;
365
+ return depth <= 6; // 5 levels + the SKILL.md file itself
366
+ });
367
+ }
368
+
369
+ // ─── Fetching skill content ───
370
+
371
+ /**
372
+ * Fetch a single SKILL.md from raw.githubusercontent.com to get frontmatter.
373
+ * Returns the raw content string, or null on failure.
374
+ */
375
+ async function fetchSkillMdContent(
376
+ ownerRepo: string,
377
+ branch: string,
378
+ skillMdPath: string
379
+ ): Promise<string | null> {
380
+ try {
381
+ const url = `https://raw.githubusercontent.com/${ownerRepo}/${branch}/${skillMdPath}`;
382
+ const response = await fetch(url, {
383
+ signal: AbortSignal.timeout(FETCH_TIMEOUT),
384
+ });
385
+ if (!response.ok) return null;
386
+ return await response.text();
387
+ } catch {
388
+ return null;
389
+ }
390
+ }
391
+
392
+ /**
393
+ * Fetch a skill's full file contents from the skills.sh download API.
394
+ * Returns the files array and content hash, or null on failure.
395
+ */
396
+ async function fetchSkillDownload(
397
+ source: string,
398
+ slug: string
399
+ ): Promise<SkillDownloadResponse | null> {
400
+ try {
401
+ const [owner, repo] = source.split('/');
402
+ const defaultUrl = `${DOWNLOAD_BASE_URL}/api/download/${encodeURIComponent(owner!)}/${encodeURIComponent(repo!)}/${encodeURIComponent(slug)}`;
403
+ // Self-hosted repos build their own URL; otherwise fall back to the default.
404
+ const selfHosted = BLOB_ALLOWED_REPOS[source.toLowerCase()]?.downloadUrl(slug);
405
+ const url = selfHosted ?? defaultUrl;
406
+ const response = await fetch(url, {
407
+ signal: AbortSignal.timeout(FETCH_TIMEOUT),
408
+ });
409
+ if (!response.ok) return null;
410
+ return (await response.json()) as SkillDownloadResponse;
411
+ } catch {
412
+ return null;
413
+ }
414
+ }
415
+
416
+ // ─── Main entry point ───
417
+
418
+ export interface BlobInstallResult {
419
+ skills: BlobSkill[];
420
+ tree: RepoTree;
421
+ }
422
+
423
+ /**
424
+ * Attempt to resolve skills from blob storage instead of cloning.
425
+ *
426
+ * Steps:
427
+ * 1. Fetch repo tree from GitHub Trees API
428
+ * 2. Discover SKILL.md paths from the tree
429
+ * 3. Fetch SKILL.md content from raw.githubusercontent.com (for frontmatter/name)
430
+ * 4. Compute slugs and fetch full snapshots from skills.sh download API
431
+ *
432
+ * Returns the resolved BlobSkills + tree data on success, or null on any failure
433
+ * (the caller should fall back to git clone).
434
+ *
435
+ * @param ownerRepo - e.g., "vercel-labs/agent-skills"
436
+ * @param options - subpath, skillFilter, ref, token
437
+ */
438
+ export async function tryBlobInstall(
439
+ ownerRepo: string,
440
+ options: {
441
+ subpath?: string;
442
+ skillFilter?: string;
443
+ ref?: string;
444
+ getToken?: () => string | null;
445
+ includeInternal?: boolean;
446
+ } = {}
447
+ ): Promise<BlobInstallResult | null> {
448
+ // 1. Fetch the full repo tree
449
+ const tree = await fetchRepoTree(ownerRepo, options.ref, options.getToken);
450
+ if (!tree) return null;
451
+
452
+ // 2. Discover SKILL.md paths in the tree
453
+ let skillMdPaths = findSkillMdPaths(tree, options.subpath);
454
+ if (skillMdPaths.length === 0) return null;
455
+
456
+ // 3. If a skill filter is set (owner/repo@skill-name), try to narrow down
457
+ if (options.skillFilter) {
458
+ const filterSlug = toSkillSlug(options.skillFilter);
459
+ const filtered = skillMdPaths.filter((p) => {
460
+ // Match by folder name — e.g., "skills/react-best-practices/SKILL.md"
461
+ const parts = p.split('/');
462
+ if (parts.length < 2) return false;
463
+ const folderName = parts[parts.length - 2]!;
464
+ return toSkillSlug(folderName) === filterSlug;
465
+ });
466
+ if (filtered.length > 0) {
467
+ skillMdPaths = filtered;
468
+ }
469
+ // If no match by folder name, we'll try matching by frontmatter name below
470
+ }
471
+
472
+ // 4. Fetch SKILL.md content from raw.githubusercontent.com in parallel
473
+ const mdFetches = await Promise.all(
474
+ skillMdPaths.map(async (mdPath) => {
475
+ const content = await fetchSkillMdContent(ownerRepo, tree.branch, mdPath);
476
+ return { mdPath, content };
477
+ })
478
+ );
479
+
480
+ // Parse frontmatter to get skill names
481
+ const parsedSkills: Array<{
482
+ mdPath: string;
483
+ name: string;
484
+ description: string;
485
+ content: string;
486
+ slug: string;
487
+ metadata?: Record<string, unknown>;
488
+ }> = [];
489
+
490
+ for (const { mdPath, content } of mdFetches) {
491
+ if (!content) continue;
492
+
493
+ const { data } = parseFrontmatter(content);
494
+ if (!data.name || !data.description) continue;
495
+ if (typeof data.name !== 'string' || typeof data.description !== 'string') continue;
496
+
497
+ // Skip internal skills unless explicitly requested
498
+ const isInternal = (data.metadata as Record<string, unknown>)?.internal === true;
499
+ if (isInternal && !options.includeInternal) continue;
500
+
501
+ const safeName = sanitizeMetadata(data.name);
502
+ const safeDescription = sanitizeMetadata(data.description);
503
+
504
+ parsedSkills.push({
505
+ mdPath,
506
+ name: safeName,
507
+ description: safeDescription,
508
+ content,
509
+ slug: toSkillSlug(safeName),
510
+ metadata: data.metadata as Record<string, unknown> | undefined,
511
+ });
512
+ }
513
+
514
+ if (parsedSkills.length === 0) return null;
515
+
516
+ // Apply skill filter by name if not already filtered by folder name
517
+ let filteredSkills = parsedSkills;
518
+ if (options.skillFilter) {
519
+ const filterSlug = toSkillSlug(options.skillFilter);
520
+ const nameFiltered = parsedSkills.filter((s) => s.slug === filterSlug);
521
+ if (nameFiltered.length > 0) {
522
+ filteredSkills = nameFiltered;
523
+ }
524
+ // If still no match, let the caller fall back to clone where
525
+ // filterSkills() does fuzzy matching
526
+ if (filteredSkills.length === 0) return null;
527
+ }
528
+
529
+ // 5. Fetch full snapshots from skills.sh download API in parallel
530
+ const source = ownerRepo.toLowerCase();
531
+ const downloads = await Promise.all(
532
+ filteredSkills.map(async (skill) => {
533
+ const download = await fetchSkillDownload(source, skill.slug);
534
+ return { skill, download };
535
+ })
536
+ );
537
+
538
+ // If ANY download failed, fall back to clone — we don't do partial blob installs
539
+ const allSucceeded = downloads.every((d) => d.download !== null);
540
+ if (!allSucceeded) return null;
541
+
542
+ // 6. Convert to BlobSkill objects
543
+ const blobSkills: BlobSkill[] = downloads.map(({ skill, download }) => {
544
+ // Compute the folder path from the SKILL.md path (e.g., "skills/react-best-practices")
545
+ const mdPathLower = skill.mdPath.toLowerCase();
546
+ const folderPath = mdPathLower.endsWith('/skill.md')
547
+ ? skill.mdPath.slice(0, -9)
548
+ : mdPathLower === 'skill.md'
549
+ ? ''
550
+ : skill.mdPath.slice(0, -(1 + 'SKILL.md'.length));
551
+
552
+ return {
553
+ name: skill.name,
554
+ description: skill.description,
555
+ // BlobSkills don't have a disk path — set to empty string.
556
+ // The installer uses the files array directly.
557
+ path: '',
558
+ rawContent: skill.content,
559
+ metadata: skill.metadata,
560
+ files: download!.files,
561
+ snapshotHash: download!.hash,
562
+ repoPath: skill.mdPath,
563
+ };
564
+ });
565
+
566
+ return { skills: blobSkills, tree };
567
+ }