@rbbtsn0w/adg 0.1.0-alpha.1 → 0.1.0-beta.2

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