@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,804 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { gunzipSync, inflateRawSync } from 'node:zlib';
|
|
3
|
+
import { parseFrontmatter } from '../frontmatter.ts';
|
|
4
|
+
import { sanitizeMetadata } from '../sanitize.ts';
|
|
5
|
+
import type { HostProvider, ProviderMatch, RemoteSkill } from './types.ts';
|
|
6
|
+
|
|
7
|
+
const DISCOVERY_SCHEMA_V2 = 'https://schemas.agentskills.io/discovery/0.2.0/schema.json';
|
|
8
|
+
const MAX_ARCHIVE_UNPACKED_BYTES = 50 * 1024 * 1024;
|
|
9
|
+
const MAX_ARCHIVE_FILES = 1000;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Legacy index.json structure for well-known skills.
|
|
13
|
+
* This is the pre-0.2.0 format used by existing publishers.
|
|
14
|
+
*/
|
|
15
|
+
export interface WellKnownIndexV1 {
|
|
16
|
+
skills: WellKnownSkillEntryV1[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Represents a legacy skill entry in index.json.
|
|
21
|
+
*/
|
|
22
|
+
export interface WellKnownSkillEntryV1 {
|
|
23
|
+
/** Skill identifier. Must match the directory name. */
|
|
24
|
+
name: string;
|
|
25
|
+
/** Brief description of what the skill does. */
|
|
26
|
+
description: string;
|
|
27
|
+
/** Array of all files in the skill directory. */
|
|
28
|
+
files: string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Current v0.2.0 well-known discovery index.
|
|
33
|
+
*/
|
|
34
|
+
export interface WellKnownIndexV2 {
|
|
35
|
+
$schema: typeof DISCOVERY_SCHEMA_V2;
|
|
36
|
+
skills: WellKnownSkillEntryV2[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Represents a v0.2.0 skill artifact entry in index.json.
|
|
41
|
+
*/
|
|
42
|
+
export interface WellKnownSkillEntryV2 {
|
|
43
|
+
name: string;
|
|
44
|
+
type: 'skill-md' | 'archive';
|
|
45
|
+
description: string;
|
|
46
|
+
url: string;
|
|
47
|
+
digest: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type WellKnownIndex = WellKnownIndexV1 | WellKnownIndexV2;
|
|
51
|
+
export type WellKnownSkillEntry = WellKnownSkillEntryV1 | WellKnownSkillEntryV2;
|
|
52
|
+
export type WellKnownFileContent = string | Uint8Array;
|
|
53
|
+
|
|
54
|
+
type NormalizedWellKnownEntry =
|
|
55
|
+
| {
|
|
56
|
+
version: '0.1.0';
|
|
57
|
+
name: string;
|
|
58
|
+
description: string;
|
|
59
|
+
files: string[];
|
|
60
|
+
baseUrl: string;
|
|
61
|
+
wellKnownPath: string;
|
|
62
|
+
indexEntry: WellKnownSkillEntryV1;
|
|
63
|
+
}
|
|
64
|
+
| {
|
|
65
|
+
version: '0.2.0';
|
|
66
|
+
name: string;
|
|
67
|
+
description: string;
|
|
68
|
+
type: 'skill-md' | 'archive';
|
|
69
|
+
artifactUrl: string;
|
|
70
|
+
digest: string;
|
|
71
|
+
indexEntry: WellKnownSkillEntryV2;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Represents a skill with all installable files fetched from a well-known endpoint.
|
|
76
|
+
*/
|
|
77
|
+
export interface WellKnownSkill extends RemoteSkill {
|
|
78
|
+
/** All files in the skill, keyed by relative path */
|
|
79
|
+
files: Map<string, WellKnownFileContent>;
|
|
80
|
+
/** The entry from index.json */
|
|
81
|
+
indexEntry: WellKnownSkillEntry;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Well-known skills provider using RFC 8615 well-known URIs.
|
|
86
|
+
*
|
|
87
|
+
* Supports both:
|
|
88
|
+
* - v0.2.0: $schema + type/url/digest single-artifact model
|
|
89
|
+
* - legacy/v0.1.0: name/description/files directory model
|
|
90
|
+
*
|
|
91
|
+
* Organizations can publish skills at:
|
|
92
|
+
* https://example.com/.well-known/agent-skills/ (preferred)
|
|
93
|
+
* https://example.com/.well-known/skills/ (legacy fallback)
|
|
94
|
+
*
|
|
95
|
+
* The provider first checks /.well-known/agent-skills/index.json,
|
|
96
|
+
* then falls back to /.well-known/skills/index.json. For compatibility with
|
|
97
|
+
* existing publishers, it also preserves the historical path-relative probing
|
|
98
|
+
* behavior for URLs such as https://example.com/docs.
|
|
99
|
+
*/
|
|
100
|
+
export class WellKnownProvider implements HostProvider {
|
|
101
|
+
readonly id = 'well-known';
|
|
102
|
+
readonly displayName = 'Well-Known Skills';
|
|
103
|
+
|
|
104
|
+
private readonly WELL_KNOWN_PATHS = ['.well-known/agent-skills', '.well-known/skills'] as const;
|
|
105
|
+
private readonly INDEX_FILE = 'index.json';
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Check if a URL could be a well-known skills endpoint.
|
|
109
|
+
* This is a fallback provider - it matches any HTTP(S) URL that is not
|
|
110
|
+
* a recognized pattern (GitHub, GitLab, owner/repo shorthand, etc.)
|
|
111
|
+
*/
|
|
112
|
+
match(url: string): ProviderMatch {
|
|
113
|
+
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
|
114
|
+
return { matches: false };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const parsed = new URL(url);
|
|
119
|
+
const excludedHosts = ['github.com', 'gitlab.com', 'huggingface.co'];
|
|
120
|
+
if (excludedHosts.includes(parsed.hostname)) {
|
|
121
|
+
return { matches: false };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
matches: true,
|
|
126
|
+
sourceIdentifier: `wellknown/${parsed.hostname}`,
|
|
127
|
+
};
|
|
128
|
+
} catch {
|
|
129
|
+
return { matches: false };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Fetch the skills index from a well-known endpoint.
|
|
135
|
+
* Tries /.well-known/agent-skills/index.json first, then falls back to
|
|
136
|
+
* /.well-known/skills/index.json. For each path, tries path-relative
|
|
137
|
+
* first, then root .well-known.
|
|
138
|
+
*/
|
|
139
|
+
async fetchIndex(baseUrl: string): Promise<{
|
|
140
|
+
index: WellKnownIndex;
|
|
141
|
+
entries: NormalizedWellKnownEntry[];
|
|
142
|
+
resolvedBaseUrl: string;
|
|
143
|
+
resolvedWellKnownPath: string;
|
|
144
|
+
indexUrl: string;
|
|
145
|
+
} | null> {
|
|
146
|
+
const candidates = await this.fetchIndexCandidates(baseUrl);
|
|
147
|
+
return candidates[0] ?? null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private async fetchIndexCandidates(baseUrl: string): Promise<
|
|
151
|
+
Array<{
|
|
152
|
+
index: WellKnownIndex;
|
|
153
|
+
entries: NormalizedWellKnownEntry[];
|
|
154
|
+
resolvedBaseUrl: string;
|
|
155
|
+
resolvedWellKnownPath: string;
|
|
156
|
+
indexUrl: string;
|
|
157
|
+
}>
|
|
158
|
+
> {
|
|
159
|
+
try {
|
|
160
|
+
const parsed = new URL(baseUrl);
|
|
161
|
+
const basePath = parsed.pathname.replace(/\/$/, '');
|
|
162
|
+
|
|
163
|
+
const urlsToTry: Array<{
|
|
164
|
+
indexUrl: string;
|
|
165
|
+
baseUrl: string;
|
|
166
|
+
wellKnownPath: string;
|
|
167
|
+
}> = [];
|
|
168
|
+
|
|
169
|
+
for (const wellKnownPath of this.WELL_KNOWN_PATHS) {
|
|
170
|
+
urlsToTry.push({
|
|
171
|
+
indexUrl: `${parsed.protocol}//${parsed.host}${basePath}/${wellKnownPath}/${this.INDEX_FILE}`,
|
|
172
|
+
baseUrl: `${parsed.protocol}//${parsed.host}${basePath}`,
|
|
173
|
+
wellKnownPath,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
if (basePath && basePath !== '') {
|
|
177
|
+
urlsToTry.push({
|
|
178
|
+
indexUrl: `${parsed.protocol}//${parsed.host}/${wellKnownPath}/${this.INDEX_FILE}`,
|
|
179
|
+
baseUrl: `${parsed.protocol}//${parsed.host}`,
|
|
180
|
+
wellKnownPath,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const candidates: Array<{
|
|
186
|
+
index: WellKnownIndex;
|
|
187
|
+
entries: NormalizedWellKnownEntry[];
|
|
188
|
+
resolvedBaseUrl: string;
|
|
189
|
+
resolvedWellKnownPath: string;
|
|
190
|
+
indexUrl: string;
|
|
191
|
+
}> = [];
|
|
192
|
+
|
|
193
|
+
for (const { indexUrl, baseUrl: resolvedBase, wellKnownPath } of urlsToTry) {
|
|
194
|
+
try {
|
|
195
|
+
const response = await fetch(indexUrl);
|
|
196
|
+
if (!response.ok) continue;
|
|
197
|
+
|
|
198
|
+
const rawIndex = (await response.json()) as unknown;
|
|
199
|
+
const normalized = this.normalizeIndex(rawIndex, indexUrl, wellKnownPath);
|
|
200
|
+
if (!normalized) continue;
|
|
201
|
+
|
|
202
|
+
candidates.push({
|
|
203
|
+
index: normalized.index,
|
|
204
|
+
entries: normalized.entries,
|
|
205
|
+
resolvedBaseUrl: resolvedBase,
|
|
206
|
+
resolvedWellKnownPath: wellKnownPath,
|
|
207
|
+
indexUrl,
|
|
208
|
+
});
|
|
209
|
+
} catch {
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return candidates;
|
|
215
|
+
} catch {
|
|
216
|
+
return [];
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private normalizeIndex(
|
|
221
|
+
rawIndex: unknown,
|
|
222
|
+
indexUrl: string,
|
|
223
|
+
resolvedWellKnownPath: string
|
|
224
|
+
): { index: WellKnownIndex; entries: NormalizedWellKnownEntry[] } | null {
|
|
225
|
+
if (!rawIndex || typeof rawIndex !== 'object') return null;
|
|
226
|
+
|
|
227
|
+
const record = rawIndex as Record<string, unknown>;
|
|
228
|
+
if (!Array.isArray(record.skills)) return null;
|
|
229
|
+
|
|
230
|
+
const schema = record.$schema;
|
|
231
|
+
|
|
232
|
+
if (schema === DISCOVERY_SCHEMA_V2) {
|
|
233
|
+
const entries: NormalizedWellKnownEntry[] = [];
|
|
234
|
+
const v2Entries: WellKnownSkillEntryV2[] = [];
|
|
235
|
+
|
|
236
|
+
for (const entry of record.skills) {
|
|
237
|
+
if (!this.isValidSkillEntryV2(entry)) continue;
|
|
238
|
+
|
|
239
|
+
const artifactUrl = new URL(entry.url, indexUrl).toString();
|
|
240
|
+
entries.push({
|
|
241
|
+
version: '0.2.0',
|
|
242
|
+
name: entry.name,
|
|
243
|
+
description: entry.description,
|
|
244
|
+
type: entry.type,
|
|
245
|
+
artifactUrl,
|
|
246
|
+
digest: entry.digest,
|
|
247
|
+
indexEntry: entry,
|
|
248
|
+
});
|
|
249
|
+
v2Entries.push(entry);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (entries.length === 0) return null;
|
|
253
|
+
return { index: { $schema: DISCOVERY_SCHEMA_V2, skills: v2Entries }, entries };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Per the v0.2.0 draft, an absent $schema means legacy/v0.1.0.
|
|
257
|
+
// Unknown schemas are not processed because the shape may have changed incompatibly.
|
|
258
|
+
if (schema !== undefined) return null;
|
|
259
|
+
|
|
260
|
+
const v1Entries: WellKnownSkillEntryV1[] = [];
|
|
261
|
+
const entries: NormalizedWellKnownEntry[] = [];
|
|
262
|
+
|
|
263
|
+
// Preserve legacy all-or-nothing validation behavior for the old files[] format.
|
|
264
|
+
for (const entry of record.skills) {
|
|
265
|
+
if (!this.isValidSkillEntryV1(entry)) return null;
|
|
266
|
+
v1Entries.push(entry);
|
|
267
|
+
entries.push({
|
|
268
|
+
version: '0.1.0',
|
|
269
|
+
name: entry.name,
|
|
270
|
+
description: entry.description,
|
|
271
|
+
files: entry.files,
|
|
272
|
+
baseUrl: this.getLegacySkillBaseUrl(indexUrl, resolvedWellKnownPath),
|
|
273
|
+
wellKnownPath: resolvedWellKnownPath,
|
|
274
|
+
indexEntry: entry,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return { index: { skills: v1Entries }, entries };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private getLegacySkillBaseUrl(indexUrl: string, wellKnownPath: string): string {
|
|
282
|
+
const parsed = new URL(indexUrl);
|
|
283
|
+
const marker = `/${wellKnownPath}/${this.INDEX_FILE}`;
|
|
284
|
+
return `${parsed.protocol}//${parsed.host}${parsed.pathname.slice(0, -marker.length)}`;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private isValidSkillName(name: unknown): name is string {
|
|
288
|
+
if (typeof name !== 'string') return false;
|
|
289
|
+
if (name.length < 1 || name.length > 64) return false;
|
|
290
|
+
if (!/^[a-z0-9-]+$/.test(name)) return false;
|
|
291
|
+
if (name.startsWith('-') || name.endsWith('-')) return false;
|
|
292
|
+
if (name.includes('--')) return false;
|
|
293
|
+
return true;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private isSafeLegacyFilePath(filePath: unknown): filePath is string {
|
|
297
|
+
if (typeof filePath !== 'string' || filePath.length === 0) return false;
|
|
298
|
+
// Preserve existing stricter legacy behavior: reject absolute paths, Windows absolute-ish
|
|
299
|
+
// paths, and any occurrence of "..".
|
|
300
|
+
if (filePath.startsWith('/') || filePath.startsWith('\\') || filePath.includes('..')) {
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
if (filePath.includes('\0')) return false;
|
|
304
|
+
return true;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Validate a legacy skill entry from the index. */
|
|
308
|
+
private isValidSkillEntryV1(entry: unknown): entry is WellKnownSkillEntryV1 {
|
|
309
|
+
if (!entry || typeof entry !== 'object') return false;
|
|
310
|
+
|
|
311
|
+
const e = entry as Record<string, unknown>;
|
|
312
|
+
if (!this.isValidSkillName(e.name)) return false;
|
|
313
|
+
if (typeof e.description !== 'string' || !e.description) return false;
|
|
314
|
+
if (!Array.isArray(e.files) || e.files.length === 0) return false;
|
|
315
|
+
|
|
316
|
+
for (const file of e.files) {
|
|
317
|
+
if (!this.isSafeLegacyFilePath(file)) return false;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const hasSkillMd = e.files.some((f) => typeof f === 'string' && f.toLowerCase() === 'skill.md');
|
|
321
|
+
return hasSkillMd;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Validate a v0.2.0 skill entry from the index. */
|
|
325
|
+
private isValidSkillEntryV2(entry: unknown): entry is WellKnownSkillEntryV2 {
|
|
326
|
+
if (!entry || typeof entry !== 'object') return false;
|
|
327
|
+
|
|
328
|
+
const e = entry as Record<string, unknown>;
|
|
329
|
+
if (!this.isValidSkillName(e.name)) return false;
|
|
330
|
+
if (typeof e.description !== 'string' || !e.description || e.description.length > 1024) {
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
if (e.type !== 'skill-md' && e.type !== 'archive') return false;
|
|
334
|
+
if (typeof e.url !== 'string' || !e.url) return false;
|
|
335
|
+
if (typeof e.digest !== 'string' || !/^sha256:[a-f0-9]{64}$/.test(e.digest)) return false;
|
|
336
|
+
|
|
337
|
+
try {
|
|
338
|
+
// Ensure URL is resolvable as either absolute or relative.
|
|
339
|
+
new URL(e.url, 'https://example.com/.well-known/agent-skills/index.json');
|
|
340
|
+
} catch {
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return true;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Fetch a single skill from a well-known endpoint. */
|
|
348
|
+
async fetchSkill(url: string): Promise<RemoteSkill | null> {
|
|
349
|
+
try {
|
|
350
|
+
const parsed = new URL(url);
|
|
351
|
+
const candidates = await this.fetchIndexCandidates(url);
|
|
352
|
+
|
|
353
|
+
for (const result of candidates) {
|
|
354
|
+
const { entries } = result;
|
|
355
|
+
let skillName: string | null = null;
|
|
356
|
+
|
|
357
|
+
const pathMatch = parsed.pathname.match(
|
|
358
|
+
/\/.well-known\/(?:agent-skills|skills)\/([^/]+)\/?$/
|
|
359
|
+
);
|
|
360
|
+
if (pathMatch && pathMatch[1] && pathMatch[1] !== 'index.json') {
|
|
361
|
+
skillName = pathMatch[1];
|
|
362
|
+
} else if (entries.length === 1) {
|
|
363
|
+
skillName = entries[0]!.name;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (!skillName) continue;
|
|
367
|
+
|
|
368
|
+
const skillEntry = entries.find((s) => s.name === skillName);
|
|
369
|
+
if (!skillEntry) continue;
|
|
370
|
+
|
|
371
|
+
const skill = await this.fetchSkillByEntry(skillEntry);
|
|
372
|
+
if (skill) return skill;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return null;
|
|
376
|
+
} catch {
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Fetch a skill by its normalized index entry. Kept public for tests and
|
|
383
|
+
* internal add flow; callers with legacy arguments are also supported.
|
|
384
|
+
*/
|
|
385
|
+
async fetchSkillByEntry(
|
|
386
|
+
baseUrlOrEntry: string | NormalizedWellKnownEntry,
|
|
387
|
+
legacyEntry?: WellKnownSkillEntryV1,
|
|
388
|
+
legacyWellKnownPath?: string
|
|
389
|
+
): Promise<WellKnownSkill | null> {
|
|
390
|
+
if (typeof baseUrlOrEntry === 'string') {
|
|
391
|
+
if (!legacyEntry) return null;
|
|
392
|
+
return this.fetchLegacySkillByEntry({
|
|
393
|
+
version: '0.1.0',
|
|
394
|
+
name: legacyEntry.name,
|
|
395
|
+
description: legacyEntry.description,
|
|
396
|
+
files: legacyEntry.files,
|
|
397
|
+
baseUrl: baseUrlOrEntry,
|
|
398
|
+
wellKnownPath: legacyWellKnownPath ?? this.WELL_KNOWN_PATHS[0],
|
|
399
|
+
indexEntry: legacyEntry,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (baseUrlOrEntry.version === '0.1.0') {
|
|
404
|
+
return this.fetchLegacySkillByEntry(baseUrlOrEntry);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return this.fetchArtifactSkillByEntry(baseUrlOrEntry);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
private async fetchLegacySkillByEntry(
|
|
411
|
+
entry: Extract<NormalizedWellKnownEntry, { version: '0.1.0' }>
|
|
412
|
+
) {
|
|
413
|
+
try {
|
|
414
|
+
const skillBaseUrl = `${entry.baseUrl.replace(/\/$/, '')}/${entry.wellKnownPath}/${entry.name}`;
|
|
415
|
+
const skillMdUrl = `${skillBaseUrl}/SKILL.md`;
|
|
416
|
+
const response = await fetch(skillMdUrl);
|
|
417
|
+
if (!response.ok) return null;
|
|
418
|
+
|
|
419
|
+
const content = await response.text();
|
|
420
|
+
const { data } = parseFrontmatter(content);
|
|
421
|
+
if (typeof data.name !== 'string' || typeof data.description !== 'string') return null;
|
|
422
|
+
|
|
423
|
+
const files = new Map<string, WellKnownFileContent>();
|
|
424
|
+
files.set('SKILL.md', content);
|
|
425
|
+
|
|
426
|
+
const otherFiles = entry.files.filter((f) => f.toLowerCase() !== 'skill.md');
|
|
427
|
+
const filePromises = otherFiles.map(async (filePath) => {
|
|
428
|
+
try {
|
|
429
|
+
const fileUrl = `${skillBaseUrl}/${filePath}`;
|
|
430
|
+
const fileResponse = await fetch(fileUrl);
|
|
431
|
+
if (fileResponse.ok) {
|
|
432
|
+
const fileContent = await fileResponse.arrayBuffer();
|
|
433
|
+
return { path: filePath, content: new Uint8Array(fileContent) };
|
|
434
|
+
}
|
|
435
|
+
} catch {
|
|
436
|
+
// Ignore individual file fetch errors to preserve legacy behavior.
|
|
437
|
+
}
|
|
438
|
+
return null;
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
const fileResults = await Promise.all(filePromises);
|
|
442
|
+
for (const result of fileResults) {
|
|
443
|
+
if (result) files.set(result.path, result.content);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return this.createSkill({
|
|
447
|
+
name: data.name,
|
|
448
|
+
description: data.description,
|
|
449
|
+
content,
|
|
450
|
+
installName: entry.name,
|
|
451
|
+
sourceUrl: skillMdUrl,
|
|
452
|
+
metadata: data.metadata,
|
|
453
|
+
files,
|
|
454
|
+
indexEntry: entry.indexEntry,
|
|
455
|
+
});
|
|
456
|
+
} catch {
|
|
457
|
+
return null;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
private async fetchArtifactSkillByEntry(
|
|
462
|
+
entry: Extract<NormalizedWellKnownEntry, { version: '0.2.0' }>
|
|
463
|
+
) {
|
|
464
|
+
try {
|
|
465
|
+
const response = await fetch(entry.artifactUrl);
|
|
466
|
+
if (!response.ok) return null;
|
|
467
|
+
|
|
468
|
+
const contentType = response.headers.get('content-type') ?? '';
|
|
469
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
470
|
+
if (this.computeDigest(bytes) !== entry.digest) return null;
|
|
471
|
+
|
|
472
|
+
if (entry.type === 'skill-md') {
|
|
473
|
+
const content = new TextDecoder().decode(bytes);
|
|
474
|
+
const { data } = parseFrontmatter(content);
|
|
475
|
+
if (typeof data.name !== 'string' || typeof data.description !== 'string') return null;
|
|
476
|
+
|
|
477
|
+
const files = new Map<string, WellKnownFileContent>();
|
|
478
|
+
files.set('SKILL.md', content);
|
|
479
|
+
|
|
480
|
+
return this.createSkill({
|
|
481
|
+
name: data.name,
|
|
482
|
+
description: data.description,
|
|
483
|
+
content,
|
|
484
|
+
installName: entry.name,
|
|
485
|
+
sourceUrl: entry.artifactUrl,
|
|
486
|
+
metadata: data.metadata,
|
|
487
|
+
files,
|
|
488
|
+
indexEntry: entry.indexEntry,
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const files = this.extractArchive(bytes, entry.artifactUrl, contentType);
|
|
493
|
+
const skillMdBytes = files.get('SKILL.md');
|
|
494
|
+
if (!skillMdBytes) return null;
|
|
495
|
+
|
|
496
|
+
const content =
|
|
497
|
+
typeof skillMdBytes === 'string' ? skillMdBytes : new TextDecoder().decode(skillMdBytes);
|
|
498
|
+
files.set('SKILL.md', content);
|
|
499
|
+
|
|
500
|
+
const { data } = parseFrontmatter(content);
|
|
501
|
+
if (typeof data.name !== 'string' || typeof data.description !== 'string') return null;
|
|
502
|
+
|
|
503
|
+
return this.createSkill({
|
|
504
|
+
name: data.name,
|
|
505
|
+
description: data.description,
|
|
506
|
+
content,
|
|
507
|
+
installName: entry.name,
|
|
508
|
+
sourceUrl: entry.artifactUrl,
|
|
509
|
+
metadata: data.metadata,
|
|
510
|
+
files,
|
|
511
|
+
indexEntry: entry.indexEntry,
|
|
512
|
+
});
|
|
513
|
+
} catch {
|
|
514
|
+
return null;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
private createSkill(input: {
|
|
519
|
+
name: string;
|
|
520
|
+
description: string;
|
|
521
|
+
content: string;
|
|
522
|
+
installName: string;
|
|
523
|
+
sourceUrl: string;
|
|
524
|
+
metadata: unknown;
|
|
525
|
+
files: Map<string, WellKnownFileContent>;
|
|
526
|
+
indexEntry: WellKnownSkillEntry;
|
|
527
|
+
}): WellKnownSkill {
|
|
528
|
+
return {
|
|
529
|
+
name: sanitizeMetadata(input.name),
|
|
530
|
+
description: sanitizeMetadata(input.description),
|
|
531
|
+
content: input.content,
|
|
532
|
+
installName: input.installName,
|
|
533
|
+
sourceUrl: input.sourceUrl,
|
|
534
|
+
metadata:
|
|
535
|
+
input.metadata && typeof input.metadata === 'object'
|
|
536
|
+
? (input.metadata as Record<string, unknown>)
|
|
537
|
+
: undefined,
|
|
538
|
+
files: input.files,
|
|
539
|
+
indexEntry: input.indexEntry,
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** Fetch all skills from a well-known endpoint. */
|
|
544
|
+
async fetchAllSkills(url: string): Promise<WellKnownSkill[]> {
|
|
545
|
+
try {
|
|
546
|
+
const candidates = await this.fetchIndexCandidates(url);
|
|
547
|
+
|
|
548
|
+
for (const result of candidates) {
|
|
549
|
+
const skillPromises = result.entries.map((entry) => this.fetchSkillByEntry(entry));
|
|
550
|
+
const results = await Promise.all(skillPromises);
|
|
551
|
+
const skills = results.filter(
|
|
552
|
+
(s: WellKnownSkill | null): s is WellKnownSkill => s !== null
|
|
553
|
+
);
|
|
554
|
+
if (skills.length > 0) return skills;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
return [];
|
|
558
|
+
} catch {
|
|
559
|
+
return [];
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
private computeDigest(bytes: Uint8Array): string {
|
|
564
|
+
return `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
private extractArchive(
|
|
568
|
+
bytes: Uint8Array,
|
|
569
|
+
artifactUrl: string,
|
|
570
|
+
contentType: string
|
|
571
|
+
): Map<string, WellKnownFileContent> {
|
|
572
|
+
if (this.isZipArchive(bytes, artifactUrl, contentType)) {
|
|
573
|
+
return this.extractZip(bytes);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
if (this.isTarGzArchive(bytes, artifactUrl, contentType)) {
|
|
577
|
+
return this.extractTarGz(bytes);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
throw new Error('Unsupported archive format');
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
private isZipArchive(bytes: Uint8Array, artifactUrl: string, contentType: string): boolean {
|
|
584
|
+
return (
|
|
585
|
+
contentType.includes('application/zip') ||
|
|
586
|
+
artifactUrl.toLowerCase().endsWith('.zip') ||
|
|
587
|
+
(bytes[0] === 0x50 && bytes[1] === 0x4b)
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
private isTarGzArchive(bytes: Uint8Array, artifactUrl: string, contentType: string): boolean {
|
|
592
|
+
const lower = artifactUrl.toLowerCase();
|
|
593
|
+
return (
|
|
594
|
+
contentType.includes('application/gzip') ||
|
|
595
|
+
contentType.includes('application/x-gzip') ||
|
|
596
|
+
lower.endsWith('.tar.gz') ||
|
|
597
|
+
lower.endsWith('.tgz') ||
|
|
598
|
+
(bytes[0] === 0x1f && bytes[1] === 0x8b)
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
private normalizeArchivePath(rawPath: string): string | null {
|
|
603
|
+
if (!rawPath || rawPath.includes('\0')) return null;
|
|
604
|
+
if (rawPath.startsWith('/') || rawPath.startsWith('\\')) return null;
|
|
605
|
+
if (/^[A-Za-z]:/.test(rawPath)) return null;
|
|
606
|
+
if (rawPath.includes('\\')) return null;
|
|
607
|
+
|
|
608
|
+
const parts = rawPath.split('/').filter(Boolean);
|
|
609
|
+
if (parts.length === 0) return null;
|
|
610
|
+
if (parts.some((part) => part === '.' || part === '..')) return null;
|
|
611
|
+
|
|
612
|
+
return parts.join('/');
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
private addArchiveFile(
|
|
616
|
+
files: Map<string, WellKnownFileContent>,
|
|
617
|
+
path: string,
|
|
618
|
+
content: Uint8Array,
|
|
619
|
+
runningTotal: { bytes: number }
|
|
620
|
+
) {
|
|
621
|
+
const normalizedPath = this.normalizeArchivePath(path);
|
|
622
|
+
if (!normalizedPath) throw new Error(`Unsafe archive path: ${path}`);
|
|
623
|
+
|
|
624
|
+
runningTotal.bytes += content.byteLength;
|
|
625
|
+
if (runningTotal.bytes > MAX_ARCHIVE_UNPACKED_BYTES) {
|
|
626
|
+
throw new Error('Archive exceeds maximum unpacked size');
|
|
627
|
+
}
|
|
628
|
+
if (files.size >= MAX_ARCHIVE_FILES) {
|
|
629
|
+
throw new Error('Archive contains too many files');
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
files.set(normalizedPath, content);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
private extractTarGz(bytes: Uint8Array): Map<string, WellKnownFileContent> {
|
|
636
|
+
const tar = gunzipSync(Buffer.from(bytes));
|
|
637
|
+
const files = new Map<string, WellKnownFileContent>();
|
|
638
|
+
const runningTotal = { bytes: 0 };
|
|
639
|
+
let offset = 0;
|
|
640
|
+
|
|
641
|
+
while (offset + 512 <= tar.length) {
|
|
642
|
+
const header = tar.subarray(offset, offset + 512);
|
|
643
|
+
if (header.every((byte) => byte === 0)) break;
|
|
644
|
+
|
|
645
|
+
const name = this.readTarString(header, 0, 100);
|
|
646
|
+
const sizeText = this.readTarString(header, 124, 12).trim();
|
|
647
|
+
const typeFlag = header[156];
|
|
648
|
+
const prefix = this.readTarString(header, 345, 155);
|
|
649
|
+
const path = prefix ? `${prefix}/${name}` : name;
|
|
650
|
+
const size = Number.parseInt(sizeText || '0', 8);
|
|
651
|
+
|
|
652
|
+
if (!Number.isFinite(size) || size < 0) throw new Error('Invalid tar entry size');
|
|
653
|
+
offset += 512;
|
|
654
|
+
|
|
655
|
+
// Reject symlinks and hard links. Skip directories and metadata entries.
|
|
656
|
+
if (typeFlag === 0x32 || typeFlag === 0x31) {
|
|
657
|
+
throw new Error('Archive links are not supported');
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const isFile = typeFlag === 0 || typeFlag === 0x30;
|
|
661
|
+
if (isFile) {
|
|
662
|
+
const content = tar.subarray(offset, offset + size);
|
|
663
|
+
this.addArchiveFile(files, path, new Uint8Array(content), runningTotal);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
offset += Math.ceil(size / 512) * 512;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if (!files.has('SKILL.md')) throw new Error('Archive missing root SKILL.md');
|
|
670
|
+
return files;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
private readTarString(buffer: Uint8Array, offset: number, length: number): string {
|
|
674
|
+
const slice = buffer.subarray(offset, offset + length);
|
|
675
|
+
const nul = slice.indexOf(0);
|
|
676
|
+
return new TextDecoder().decode(nul >= 0 ? slice.subarray(0, nul) : slice);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
private extractZip(bytes: Uint8Array): Map<string, WellKnownFileContent> {
|
|
680
|
+
const buffer = Buffer.from(bytes);
|
|
681
|
+
const eocdOffset = this.findZipEndOfCentralDirectory(buffer);
|
|
682
|
+
if (eocdOffset < 0) throw new Error('Invalid zip archive');
|
|
683
|
+
|
|
684
|
+
const totalEntries = buffer.readUInt16LE(eocdOffset + 10);
|
|
685
|
+
const centralDirectoryOffset = buffer.readUInt32LE(eocdOffset + 16);
|
|
686
|
+
const files = new Map<string, WellKnownFileContent>();
|
|
687
|
+
const runningTotal = { bytes: 0 };
|
|
688
|
+
let offset = centralDirectoryOffset;
|
|
689
|
+
|
|
690
|
+
for (let i = 0; i < totalEntries; i++) {
|
|
691
|
+
if (buffer.readUInt32LE(offset) !== 0x02014b50) throw new Error('Invalid zip directory');
|
|
692
|
+
|
|
693
|
+
const flags = buffer.readUInt16LE(offset + 8);
|
|
694
|
+
const method = buffer.readUInt16LE(offset + 10);
|
|
695
|
+
const compressedSize = buffer.readUInt32LE(offset + 20);
|
|
696
|
+
const uncompressedSize = buffer.readUInt32LE(offset + 24);
|
|
697
|
+
const fileNameLength = buffer.readUInt16LE(offset + 28);
|
|
698
|
+
const extraLength = buffer.readUInt16LE(offset + 30);
|
|
699
|
+
const commentLength = buffer.readUInt16LE(offset + 32);
|
|
700
|
+
const externalAttributes = buffer.readUInt32LE(offset + 38);
|
|
701
|
+
const localHeaderOffset = buffer.readUInt32LE(offset + 42);
|
|
702
|
+
const nameStart = offset + 46;
|
|
703
|
+
const rawName = buffer.subarray(nameStart, nameStart + fileNameLength);
|
|
704
|
+
const fileName = new TextDecoder(flags & 0x800 ? 'utf-8' : undefined).decode(rawName);
|
|
705
|
+
|
|
706
|
+
offset = nameStart + fileNameLength + extraLength + commentLength;
|
|
707
|
+
|
|
708
|
+
// Directories are allowed but not installed as files.
|
|
709
|
+
if (fileName.endsWith('/')) continue;
|
|
710
|
+
|
|
711
|
+
// Reject encrypted entries, symlinks, and hard links. ZIP external attributes store
|
|
712
|
+
// POSIX mode bits in the upper 16 bits for common UNIX-producing tools.
|
|
713
|
+
if (flags & 0x1) throw new Error('Encrypted zip entries are not supported');
|
|
714
|
+
const unixMode = externalAttributes >>> 16;
|
|
715
|
+
const fileType = unixMode & 0o170000;
|
|
716
|
+
if (fileType === 0o120000 || fileType === 0o10000) {
|
|
717
|
+
throw new Error('Archive links are not supported');
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
if (buffer.readUInt32LE(localHeaderOffset) !== 0x04034b50) {
|
|
721
|
+
throw new Error('Invalid zip local header');
|
|
722
|
+
}
|
|
723
|
+
const localFileNameLength = buffer.readUInt16LE(localHeaderOffset + 26);
|
|
724
|
+
const localExtraLength = buffer.readUInt16LE(localHeaderOffset + 28);
|
|
725
|
+
const dataStart = localHeaderOffset + 30 + localFileNameLength + localExtraLength;
|
|
726
|
+
const compressed = buffer.subarray(dataStart, dataStart + compressedSize);
|
|
727
|
+
|
|
728
|
+
let content: Buffer;
|
|
729
|
+
if (method === 0) {
|
|
730
|
+
content = compressed;
|
|
731
|
+
} else if (method === 8) {
|
|
732
|
+
content = inflateRawSync(compressed);
|
|
733
|
+
} else {
|
|
734
|
+
throw new Error(`Unsupported zip compression method: ${method}`);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
if (content.byteLength !== uncompressedSize) {
|
|
738
|
+
throw new Error('Zip entry size mismatch');
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
this.addArchiveFile(files, fileName, new Uint8Array(content), runningTotal);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
if (!files.has('SKILL.md')) throw new Error('Archive missing root SKILL.md');
|
|
745
|
+
return files;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
private findZipEndOfCentralDirectory(buffer: Buffer): number {
|
|
749
|
+
const minOffset = Math.max(0, buffer.length - 0xffff - 22);
|
|
750
|
+
for (let offset = buffer.length - 22; offset >= minOffset; offset--) {
|
|
751
|
+
if (buffer.readUInt32LE(offset) === 0x06054b50) return offset;
|
|
752
|
+
}
|
|
753
|
+
return -1;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Convert a user-facing URL to a skill URL.
|
|
758
|
+
* For well-known, this extracts the base domain and constructs the proper path.
|
|
759
|
+
* Uses agent-skills as the primary path for new URLs.
|
|
760
|
+
*/
|
|
761
|
+
toRawUrl(url: string): string {
|
|
762
|
+
try {
|
|
763
|
+
const parsed = new URL(url);
|
|
764
|
+
if (url.toLowerCase().endsWith('/skill.md')) {
|
|
765
|
+
return url;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const primaryPath = this.WELL_KNOWN_PATHS[0];
|
|
769
|
+
const pathMatch = parsed.pathname.match(
|
|
770
|
+
/\/.well-known\/(?:agent-skills|skills)\/([^/]+)\/?$/
|
|
771
|
+
);
|
|
772
|
+
if (pathMatch && pathMatch[1]) {
|
|
773
|
+
const basePath = parsed.pathname.replace(/\/.well-known\/(?:agent-skills|skills)\/.*$/, '');
|
|
774
|
+
return `${parsed.protocol}//${parsed.host}${basePath}/${primaryPath}/${pathMatch[1]}/SKILL.md`;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
const basePath = parsed.pathname.replace(/\/$/, '');
|
|
778
|
+
return `${parsed.protocol}//${parsed.host}${basePath}/${primaryPath}/${this.INDEX_FILE}`;
|
|
779
|
+
} catch {
|
|
780
|
+
return url;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* Get the source identifier for telemetry/storage.
|
|
786
|
+
* Returns the full hostname with www. stripped.
|
|
787
|
+
*/
|
|
788
|
+
getSourceIdentifier(url: string): string {
|
|
789
|
+
try {
|
|
790
|
+
const parsed = new URL(url);
|
|
791
|
+
return parsed.hostname.replace(/^www\./, '');
|
|
792
|
+
} catch {
|
|
793
|
+
return 'unknown';
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/** Check if a URL has a well-known skills index. */
|
|
798
|
+
async hasSkillsIndex(url: string): Promise<boolean> {
|
|
799
|
+
const result = await this.fetchIndex(url);
|
|
800
|
+
return result !== null;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
export const wellKnownProvider = new WellKnownProvider();
|