@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.
- package/LICENSE +21 -0
- package/README.md +308 -0
- package/bin/adg.ts +758 -0
- package/docs/agents-spec.md +132 -0
- package/docs/authoring.md +352 -0
- package/package.json +50 -0
- package/schemas/adg-plugin.schema.json +77 -0
- package/schemas/marketplace.schema.json +86 -0
- package/schemas/plugin-lock.schema.json +90 -0
- package/src/adapters/anthropic.ts +54 -0
- package/src/adapters/index.ts +24 -0
- package/src/adapters/openai.ts +37 -0
- package/src/adapters/reverse.ts +60 -0
- package/src/agents/claude.ts +124 -0
- package/src/agents/codex.ts +67 -0
- package/src/agents/index.ts +12 -0
- package/src/agents/registry.ts +30 -0
- package/src/agents/types.ts +47 -0
- package/src/commands/adapt.ts +36 -0
- package/src/commands/import.ts +69 -0
- package/src/commands/init.ts +146 -0
- package/src/commands/install.ts +411 -0
- package/src/commands/link.ts +61 -0
- package/src/commands/list.ts +28 -0
- package/src/commands/marketplace.ts +198 -0
- package/src/commands/migrate.ts +84 -0
- package/src/commands/multiselect-skills.ts +137 -0
- package/src/commands/remove.ts +136 -0
- package/src/commands/select-agents.ts +45 -0
- package/src/commands/select-components.ts +66 -0
- package/src/commands/select-plugins.ts +28 -0
- package/src/commands/select-scope.ts +21 -0
- package/src/commands/update.ts +85 -0
- package/src/commands/validate.ts +57 -0
- package/src/components.ts +90 -0
- package/src/deps.ts +64 -0
- package/src/fsutil.ts +38 -0
- package/src/hash.ts +61 -0
- package/src/lock.ts +57 -0
- package/src/manifest.ts +113 -0
- package/src/marketplace.ts +41 -0
- package/src/package.ts +74 -0
- package/src/paths.ts +129 -0
- package/src/semver.ts +67 -0
- package/src/skills.ts +88 -0
- package/src/sources.ts +159 -0
- package/src/types.ts +140 -0
- package/vendor/skills/LICENSE +29 -0
- package/vendor/skills/PROVENANCE.md +60 -0
- package/vendor/skills/ThirdPartyNoticeText.txt +117 -0
- package/vendor/skills/package.json +143 -0
- package/vendor/skills/src/add.ts +1999 -0
- package/vendor/skills/src/agents.ts +755 -0
- package/vendor/skills/src/blob.ts +567 -0
- package/vendor/skills/src/cli.ts +387 -0
- package/vendor/skills/src/constants.ts +3 -0
- package/vendor/skills/src/detect-agent.ts +62 -0
- package/vendor/skills/src/find.ts +357 -0
- package/vendor/skills/src/frontmatter.ts +16 -0
- package/vendor/skills/src/git-tree.ts +36 -0
- package/vendor/skills/src/git.ts +277 -0
- package/vendor/skills/src/install.ts +91 -0
- package/vendor/skills/src/installer.ts +1097 -0
- package/vendor/skills/src/list.ts +231 -0
- package/vendor/skills/src/local-lock.ts +182 -0
- package/vendor/skills/src/plugin-manifest.ts +183 -0
- package/vendor/skills/src/prompts/search-multiselect.ts +387 -0
- package/vendor/skills/src/providers/index.ts +14 -0
- package/vendor/skills/src/providers/registry.ts +51 -0
- package/vendor/skills/src/providers/types.ts +97 -0
- package/vendor/skills/src/providers/wellknown.ts +804 -0
- package/vendor/skills/src/remove.ts +323 -0
- package/vendor/skills/src/sanitize.ts +65 -0
- package/vendor/skills/src/self-cli.ts +20 -0
- package/vendor/skills/src/skill-lock.ts +329 -0
- package/vendor/skills/src/skills.ts +316 -0
- package/vendor/skills/src/source-parser.ts +438 -0
- package/vendor/skills/src/sync.ts +478 -0
- package/vendor/skills/src/telemetry.ts +186 -0
- package/vendor/skills/src/test-utils.ts +73 -0
- package/vendor/skills/src/types.ts +128 -0
- package/vendor/skills/src/update-source.ts +90 -0
- package/vendor/skills/src/update.ts +749 -0
- 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
|
+
}
|