@skillctl/registry 0.2.0
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/dist/fetch/concurrency.d.ts +2 -0
- package/dist/fetch/concurrency.d.ts.map +1 -0
- package/dist/fetch/concurrency.js +32 -0
- package/dist/fetch/concurrency.js.map +1 -0
- package/dist/fetch/https.d.ts +2 -0
- package/dist/fetch/https.d.ts.map +1 -0
- package/dist/fetch/https.js +24 -0
- package/dist/fetch/https.js.map +1 -0
- package/dist/fetch/tarball.d.ts +4 -0
- package/dist/fetch/tarball.d.ts.map +1 -0
- package/dist/fetch/tarball.js +28 -0
- package/dist/fetch/tarball.js.map +1 -0
- package/dist/frontmatter.d.ts +5 -0
- package/dist/frontmatter.d.ts.map +1 -0
- package/dist/frontmatter.js +31 -0
- package/dist/frontmatter.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/locate-skill.d.ts +7 -0
- package/dist/locate-skill.d.ts.map +1 -0
- package/dist/locate-skill.js +112 -0
- package/dist/locate-skill.js.map +1 -0
- package/dist/manager.d.ts +23 -0
- package/dist/manager.d.ts.map +1 -0
- package/dist/manager.js +139 -0
- package/dist/manager.js.map +1 -0
- package/dist/names.d.ts +2 -0
- package/dist/names.d.ts.map +1 -0
- package/dist/names.js +2 -0
- package/dist/names.js.map +1 -0
- package/dist/registry.d.ts +137 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +758 -0
- package/dist/registry.js.map +1 -0
- package/dist/sources/github.d.ts +12 -0
- package/dist/sources/github.d.ts.map +1 -0
- package/dist/sources/github.js +102 -0
- package/dist/sources/github.js.map +1 -0
- package/dist/sources/local.d.ts +12 -0
- package/dist/sources/local.d.ts.map +1 -0
- package/dist/sources/local.js +39 -0
- package/dist/sources/local.js.map +1 -0
- package/dist/sources/npm.d.ts +12 -0
- package/dist/sources/npm.d.ts.map +1 -0
- package/dist/sources/npm.js +89 -0
- package/dist/sources/npm.js.map +1 -0
- package/dist/sources/skills-sh.d.ts +12 -0
- package/dist/sources/skills-sh.d.ts.map +1 -0
- package/dist/sources/skills-sh.js +27 -0
- package/dist/sources/skills-sh.js.map +1 -0
- package/dist/test/registry-resolution.test.d.ts +3 -0
- package/dist/test/registry-resolution.test.d.ts.map +1 -0
- package/dist/test/registry-resolution.test.js +101 -0
- package/dist/test/registry-resolution.test.js.map +1 -0
- package/package.json +50 -0
package/dist/registry.js
ADDED
|
@@ -0,0 +1,758 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry Manager + Sources for PR4.
|
|
3
|
+
*
|
|
4
|
+
* Supports: github, local (file:), skills.sh (shorthand alias), basic npm.
|
|
5
|
+
* Full resolution, materialize to canonical store, integrity (sha256 tree).
|
|
6
|
+
* Integrates with manifest/lock from PR3 (load/save, makeLockEntry).
|
|
7
|
+
*
|
|
8
|
+
* npm resolution algorithm (per design Issue 4 / appendix):
|
|
9
|
+
* 1. Parse spec → pkg, versionRange (supports npm:pkg, npm:pkg@ver, npm:@scope/pkg@^range).
|
|
10
|
+
* 2. Resolve best version: query registry.npmjs.org for versions, use semver to pick maxSatisfying (or 'latest' if no range).
|
|
11
|
+
* 3. Fetch package metadata for the version; get dist.tarball and dist.shasum (or integrity).
|
|
12
|
+
* 4. Download tarball (https), verify npm tarball shasum (sha1 for shasum field; note modern npm uses sha512 in integrity, we support both for basic).
|
|
13
|
+
* 5. Extract tarball to temp dir (using 'tar' pkg).
|
|
14
|
+
* 6. Locate skill dir inside extracted:
|
|
15
|
+
* - package.json "agentSkills" field? (string | string[] paths) or "skills" field.
|
|
16
|
+
* - or conventional ./skills/ subdir (if contains SKILL.md)
|
|
17
|
+
* - or root if SKILL.md present at top
|
|
18
|
+
* - fallback: glob ** /SKILL.md limited depth (1-2), pick first containing one.
|
|
19
|
+
* 7. From located skill dir: parse SKILL.md frontmatter for 'name' (canonicalize to lower-hyphen), validate basic.
|
|
20
|
+
* 8. Materialize: atomic copy of the skill dir contents to canonical/<name>/ (or the dir itself), compute tree sha256 integrity via core.
|
|
21
|
+
* 9. Record provenance: {type: 'npm', tarballHash: 'sha1:xxx' or npm integrity, subpath? }
|
|
22
|
+
* Also store resolved tarball info in resolved string.
|
|
23
|
+
* 10. Update lock with makeLockEntry + save; optionally update manifest.
|
|
24
|
+
*
|
|
25
|
+
* GitHub/local/skills.sh similar: resolve to (url/ref/subpath), fetch/extract/copy to temp, locate skill (prefer subpath or find SKILL.md), materialize, sha256 tree.
|
|
26
|
+
* Shorthands in resolve (e.g. owner/repo mapped to github:).
|
|
27
|
+
*
|
|
28
|
+
* Checksum (integrity) always written to lock entry.
|
|
29
|
+
*
|
|
30
|
+
* Uses core: loadConfig (for store), computeDirIntegrity, ensureDir, writeFileAtomic (indirect).
|
|
31
|
+
* No adapters. Basic add only.
|
|
32
|
+
*
|
|
33
|
+
* Sources implement (extend core RegistrySource for MVP):
|
|
34
|
+
*/
|
|
35
|
+
import { readFile, writeFile, rm, cp, readdir, stat } from 'node:fs/promises';
|
|
36
|
+
import { join, dirname, resolve as pathResolve, basename, sep } from 'node:path';
|
|
37
|
+
import { createHash } from 'node:crypto';
|
|
38
|
+
import { tmpdir } from 'node:os';
|
|
39
|
+
import https from 'node:https';
|
|
40
|
+
import * as tar from 'tar';
|
|
41
|
+
import semver from 'semver';
|
|
42
|
+
import { loadConfig, ensureDir, computeDirIntegrity, getCachedSkill, putCachedSkill, getCachedDownload, putCachedDownload, ensureCacheDir, } from '@skillctl/core';
|
|
43
|
+
import { loadManifest, saveManifest } from '@skillctl/manifest';
|
|
44
|
+
import { loadLockfile, saveLockfile, createEmptyLockfile, addOrUpdateEntry, makeLockEntry } from '@skillctl/lockfile';
|
|
45
|
+
// PR12: limited parallel + content addressable cache support
|
|
46
|
+
const MAX_PARALLEL = Math.max(1, Math.min(16, parseInt(process.env.SKILLCTL_PARALLEL || '6', 10) || 6));
|
|
47
|
+
/**
|
|
48
|
+
* Simple concurrency limiter for network fetches (PR12 perf).
|
|
49
|
+
*/
|
|
50
|
+
function createConcurrencyLimiter(maxConcurrent) {
|
|
51
|
+
let active = 0;
|
|
52
|
+
const queue = [];
|
|
53
|
+
return (fn) => {
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
const run = async () => {
|
|
56
|
+
active++;
|
|
57
|
+
try {
|
|
58
|
+
const res = await fn();
|
|
59
|
+
resolve(res);
|
|
60
|
+
}
|
|
61
|
+
catch (e) {
|
|
62
|
+
reject(e);
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
active--;
|
|
66
|
+
if (queue.length > 0) {
|
|
67
|
+
const next = queue.shift();
|
|
68
|
+
next();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
if (active < maxConcurrent) {
|
|
73
|
+
run();
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
queue.push(run);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
const fetchLimiter = createConcurrencyLimiter(MAX_PARALLEL);
|
|
82
|
+
async function limitedFetch(fn) {
|
|
83
|
+
return fetchLimiter(fn);
|
|
84
|
+
}
|
|
85
|
+
// --- Helpers: parse frontmatter from SKILL.md (basic, no full yaml dep) ---
|
|
86
|
+
export function parseSkillFrontmatter(skillDir) {
|
|
87
|
+
// Try SKILL.md or skill.md
|
|
88
|
+
const candidates = ['SKILL.md', 'skill.md', 'SKILL.markdown'];
|
|
89
|
+
for (const f of candidates) {
|
|
90
|
+
const p = join(skillDir, f);
|
|
91
|
+
try {
|
|
92
|
+
// sync not, but top level read ok
|
|
93
|
+
// use sync? no, we'll make async version below for use
|
|
94
|
+
}
|
|
95
|
+
catch { }
|
|
96
|
+
}
|
|
97
|
+
return {};
|
|
98
|
+
}
|
|
99
|
+
export async function parseSkillFrontmatterAsync(skillDir) {
|
|
100
|
+
const candidates = ['SKILL.md', 'skill.md'];
|
|
101
|
+
for (const f of candidates) {
|
|
102
|
+
const p = join(skillDir, f);
|
|
103
|
+
try {
|
|
104
|
+
const content = await readFile(p, 'utf8');
|
|
105
|
+
const match = content.match(/^---\s*[\r\n]+([\s\S]*?)[\r\n]+---/);
|
|
106
|
+
if (match && match[1]) {
|
|
107
|
+
const yamlLike = match[1];
|
|
108
|
+
// very basic key: value parse for name (supports quoted)
|
|
109
|
+
const nameMatch = yamlLike.match(/(?:^|\n)\s*name:\s*["']?([^"'\n#]+)["']?/i);
|
|
110
|
+
if (nameMatch) {
|
|
111
|
+
return { name: nameMatch[1].trim(), description: extractDesc(yamlLike) };
|
|
112
|
+
}
|
|
113
|
+
// fallback try description only
|
|
114
|
+
}
|
|
115
|
+
// also allow name in first heading or loose
|
|
116
|
+
const looseName = content.match(/name:\s*["']?([^"'\n]+)/i);
|
|
117
|
+
if (looseName)
|
|
118
|
+
return { name: looseName[1].trim() };
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// continue
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// fallback: use dir basename as name (will be normalized)
|
|
125
|
+
return { name: basename(skillDir) };
|
|
126
|
+
}
|
|
127
|
+
function extractDesc(yaml) {
|
|
128
|
+
const m = yaml.match(/(?:^|\n)\s*description:\s*["']?([^"'\n]+)/i);
|
|
129
|
+
return m ? m[1].trim() : undefined;
|
|
130
|
+
}
|
|
131
|
+
// Canonical name: lowercase, hyphen normalized (per design)
|
|
132
|
+
export function canonicalizeName(raw) {
|
|
133
|
+
return raw.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
134
|
+
}
|
|
135
|
+
// Copy dir tree (node cp recursive)
|
|
136
|
+
async function copyDir(src, dest) {
|
|
137
|
+
await ensureDir(dest);
|
|
138
|
+
await cp(src, dest, { recursive: true, force: true });
|
|
139
|
+
}
|
|
140
|
+
// Compute sha1 for npm shasum verify (npm legacy)
|
|
141
|
+
function computeSha1(buf) {
|
|
142
|
+
return createHash('sha1').update(buf).digest('hex');
|
|
143
|
+
}
|
|
144
|
+
// Simple https get buffer (for tarball + json metadata)
|
|
145
|
+
function httpsGet(url, headers = {}) {
|
|
146
|
+
return new Promise((resolve, reject) => {
|
|
147
|
+
const req = https.get(url, { headers: { 'User-Agent': 'skillctl/0.0.1', ...headers } }, (res) => {
|
|
148
|
+
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
149
|
+
// follow redirect once
|
|
150
|
+
httpsGet(res.headers.location, headers).then(resolve).catch(reject);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (res.statusCode !== 200) {
|
|
154
|
+
reject(new Error(`HTTP ${res.statusCode} for ${url}`));
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const chunks = [];
|
|
158
|
+
res.on('data', (c) => chunks.push(c));
|
|
159
|
+
res.on('end', () => resolve(Buffer.concat(chunks)));
|
|
160
|
+
});
|
|
161
|
+
req.on('error', reject);
|
|
162
|
+
req.setTimeout(30000, () => { req.destroy(); reject(new Error('timeout')); });
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
// --- Source implementations ---
|
|
166
|
+
export class LocalSource {
|
|
167
|
+
id = 'local';
|
|
168
|
+
match(spec) {
|
|
169
|
+
return spec.startsWith('file:') || spec.startsWith('local:') || spec.startsWith('./') || spec.startsWith('../') || /^[a-zA-Z]:\\/.test(spec) || spec.startsWith('/');
|
|
170
|
+
}
|
|
171
|
+
async resolve(spec, options) {
|
|
172
|
+
let localPath = spec;
|
|
173
|
+
if (spec.startsWith('file:'))
|
|
174
|
+
localPath = spec.slice(5);
|
|
175
|
+
if (spec.startsWith('local:'))
|
|
176
|
+
localPath = spec.slice(6);
|
|
177
|
+
// resolve relative to cwd for now
|
|
178
|
+
const abs = pathResolve(process.cwd(), localPath);
|
|
179
|
+
const nameGuess = basename(abs);
|
|
180
|
+
const resolved = {
|
|
181
|
+
name: canonicalizeName(nameGuess),
|
|
182
|
+
resolved: `local:${abs}`,
|
|
183
|
+
sourceType: 'local',
|
|
184
|
+
localPath: abs,
|
|
185
|
+
originalSpec: spec,
|
|
186
|
+
};
|
|
187
|
+
return resolved;
|
|
188
|
+
}
|
|
189
|
+
async fetch(resolved, dest) {
|
|
190
|
+
const r = resolved;
|
|
191
|
+
if (!r.localPath)
|
|
192
|
+
throw new Error('Invalid local resolved');
|
|
193
|
+
await ensureDir(dest);
|
|
194
|
+
// copy the local dir contents or the dir? assume spec points to skill dir root (containing SKILL.md)
|
|
195
|
+
await copyDir(r.localPath, dest);
|
|
196
|
+
const integrity = await computeDirIntegrity(dest);
|
|
197
|
+
return { integrity };
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
export class GitHubSource {
|
|
201
|
+
id = 'github';
|
|
202
|
+
match(spec) {
|
|
203
|
+
return spec.startsWith('github:') ||
|
|
204
|
+
/^https?:\/\/github\.com\//.test(spec) ||
|
|
205
|
+
/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+/.test(spec); // owner/repo shorthand
|
|
206
|
+
}
|
|
207
|
+
async resolve(spec, options) {
|
|
208
|
+
let ownerRepo = spec;
|
|
209
|
+
let ref = options?.ref || 'HEAD';
|
|
210
|
+
let subpath;
|
|
211
|
+
if (spec.startsWith('github:'))
|
|
212
|
+
ownerRepo = spec.slice(7);
|
|
213
|
+
if (spec.startsWith('https://github.com/'))
|
|
214
|
+
ownerRepo = spec.replace(/^https?:\/\/github\.com\//, '');
|
|
215
|
+
// parse #ref or @ref or /sub
|
|
216
|
+
const hashIdx = ownerRepo.indexOf('#');
|
|
217
|
+
if (hashIdx !== -1) {
|
|
218
|
+
const after = ownerRepo.slice(hashIdx + 1);
|
|
219
|
+
ownerRepo = ownerRepo.slice(0, hashIdx);
|
|
220
|
+
if (after.includes('/')) {
|
|
221
|
+
// e.g. owner/repo#sub/path or owner/repo#ref/sub
|
|
222
|
+
const parts = after.split('/');
|
|
223
|
+
if (parts[0].match(/^[0-9a-f]{7,40}$/i) || parts[0] === 'HEAD') {
|
|
224
|
+
ref = parts[0];
|
|
225
|
+
subpath = parts.slice(1).join('/');
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
ref = 'HEAD';
|
|
229
|
+
subpath = after;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
ref = after || ref;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
// @ref syntax
|
|
237
|
+
const atIdx = ownerRepo.indexOf('@');
|
|
238
|
+
if (atIdx !== -1 && !ownerRepo.includes('/@')) {
|
|
239
|
+
ref = ownerRepo.slice(atIdx + 1) || ref;
|
|
240
|
+
ownerRepo = ownerRepo.slice(0, atIdx);
|
|
241
|
+
}
|
|
242
|
+
// subpath like owner/repo/skills/foo
|
|
243
|
+
if (ownerRepo.includes('/') && ownerRepo.split('/').length > 2) {
|
|
244
|
+
const segs = ownerRepo.split('/');
|
|
245
|
+
ownerRepo = segs.slice(0, 2).join('/');
|
|
246
|
+
subpath = segs.slice(2).join('/') + (subpath ? '/' + subpath : '');
|
|
247
|
+
}
|
|
248
|
+
const [owner, repo] = ownerRepo.split('/');
|
|
249
|
+
if (!owner || !repo)
|
|
250
|
+
throw new Error(`Invalid github spec: ${spec}`);
|
|
251
|
+
const nameGuess = subpath ? basename(subpath) : repo;
|
|
252
|
+
const resolvedStr = `github:${owner}/${repo}@${ref}${subpath ? '/' + subpath : ''}`;
|
|
253
|
+
const r = {
|
|
254
|
+
name: canonicalizeName(nameGuess),
|
|
255
|
+
resolved: resolvedStr,
|
|
256
|
+
sourceType: 'github',
|
|
257
|
+
gitUrl: `https://github.com/${owner}/${repo}.git`,
|
|
258
|
+
ref,
|
|
259
|
+
subpath,
|
|
260
|
+
originalSpec: spec,
|
|
261
|
+
};
|
|
262
|
+
return r;
|
|
263
|
+
}
|
|
264
|
+
async fetch(resolved, dest) {
|
|
265
|
+
const r = resolved;
|
|
266
|
+
if (!r.gitUrl)
|
|
267
|
+
throw new Error('bad github resolved');
|
|
268
|
+
// Use GitHub tarball API for shallow fetch (no git dep)
|
|
269
|
+
const ref = r.ref || 'HEAD';
|
|
270
|
+
const tarballUrl = `https://api.github.com/repos/${r.gitUrl.split('/').slice(-2, -1)[0] || ''}/${basename(r.gitUrl).replace('.git', '')}/tarball/${ref}`;
|
|
271
|
+
// fix: parse owner/repo from gitUrl
|
|
272
|
+
const match = r.gitUrl.match(/github.com\/([^/]+)\/([^/]+?)(?:\.git)?$/);
|
|
273
|
+
if (!match)
|
|
274
|
+
throw new Error('cannot parse github url');
|
|
275
|
+
const owner = match[1];
|
|
276
|
+
const repo = match[2];
|
|
277
|
+
const url = `https://api.github.com/repos/${owner}/${repo}/tarball/${encodeURIComponent(ref)}`;
|
|
278
|
+
// PR12: try download cache for github tarball (keyed by ref+repo for stability)
|
|
279
|
+
const ghDlKey = `gh-${owner}-${repo}-${encodeURIComponent(ref).slice(0, 32)}`;
|
|
280
|
+
let buf;
|
|
281
|
+
const cachedGh = await getCachedDownload(ghDlKey);
|
|
282
|
+
if (cachedGh) {
|
|
283
|
+
buf = await readFile(cachedGh);
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
buf = await httpsGet(url, { Accept: 'application/vnd.github.v3.raw' });
|
|
287
|
+
const tmpGhTar = join(tmpdir(), `gh-dl-${Date.now()}.tgz`);
|
|
288
|
+
await writeFile(tmpGhTar, buf);
|
|
289
|
+
await putCachedDownload(ghDlKey, tmpGhTar).catch(() => { });
|
|
290
|
+
await rm(tmpGhTar, { force: true }).catch(() => { });
|
|
291
|
+
}
|
|
292
|
+
// GitHub tarball has a top level dir like owner-repo-sha/
|
|
293
|
+
const tmpExtract = join(tmpdir(), `skillctl-gh-${Date.now()}`);
|
|
294
|
+
await ensureDir(tmpExtract);
|
|
295
|
+
// write temp tar
|
|
296
|
+
const tarTmp = `${tmpExtract}.tar.gz`;
|
|
297
|
+
await writeFile(tarTmp, buf);
|
|
298
|
+
await tar.extract({ file: tarTmp, cwd: tmpExtract, strip: 1 }); // strip the top wrapper dir
|
|
299
|
+
await rm(tarTmp, { force: true });
|
|
300
|
+
// if subpath, drill down
|
|
301
|
+
let sourceDir = tmpExtract;
|
|
302
|
+
if (r.subpath) {
|
|
303
|
+
const candidate = join(tmpExtract, r.subpath);
|
|
304
|
+
try {
|
|
305
|
+
const st = await stat(candidate);
|
|
306
|
+
if (st.isDirectory())
|
|
307
|
+
sourceDir = candidate;
|
|
308
|
+
}
|
|
309
|
+
catch { }
|
|
310
|
+
}
|
|
311
|
+
// locate skill if not exact
|
|
312
|
+
const located = await this.locateSkillDir(sourceDir);
|
|
313
|
+
await ensureDir(dest);
|
|
314
|
+
await copyDir(located, dest);
|
|
315
|
+
// cleanup
|
|
316
|
+
await rm(tmpExtract, { recursive: true, force: true });
|
|
317
|
+
const integrity = await computeDirIntegrity(dest);
|
|
318
|
+
return { integrity };
|
|
319
|
+
}
|
|
320
|
+
async locateSkillDir(dir) {
|
|
321
|
+
// if has SKILL.md directly, use it; else look for skills/ or first sub with SKILL
|
|
322
|
+
const has = await hasSkillMd(dir);
|
|
323
|
+
if (has)
|
|
324
|
+
return dir;
|
|
325
|
+
const skillsSub = join(dir, 'skills');
|
|
326
|
+
if (await hasSkillMd(skillsSub))
|
|
327
|
+
return skillsSub;
|
|
328
|
+
// limited glob
|
|
329
|
+
try {
|
|
330
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
331
|
+
for (const e of entries) {
|
|
332
|
+
if (e.isDirectory() && !e.name.startsWith('.')) {
|
|
333
|
+
const sub = join(dir, e.name);
|
|
334
|
+
if (await hasSkillMd(sub))
|
|
335
|
+
return sub;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
catch { }
|
|
340
|
+
return dir; // fallback
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
async function hasSkillMd(d) {
|
|
344
|
+
try {
|
|
345
|
+
const st = await stat(join(d, 'SKILL.md'));
|
|
346
|
+
return st.isFile();
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
try {
|
|
350
|
+
const st2 = await stat(join(d, 'skill.md'));
|
|
351
|
+
return st2.isFile();
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
return false;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
export class SkillsShSource {
|
|
359
|
+
id = 'skills.sh';
|
|
360
|
+
match(spec) {
|
|
361
|
+
return spec.startsWith('skills.sh/') || spec.startsWith('npx-skills/');
|
|
362
|
+
}
|
|
363
|
+
async resolve(spec, options) {
|
|
364
|
+
let inner = spec.replace(/^skills\.sh\//, '').replace(/^npx-skills\//, '');
|
|
365
|
+
// alias to github style; many skills.sh are github mirrors or owner/repo
|
|
366
|
+
// for basic impl (per PR4): support 'skills.sh/owner/repo' or 'skills.sh/skillname' (name-only)
|
|
367
|
+
if (!inner.includes('/')) {
|
|
368
|
+
// name only shorthand (common for skills.sh); construct resolved without full gh parse
|
|
369
|
+
const ref = options?.ref || 'HEAD';
|
|
370
|
+
const r = {
|
|
371
|
+
name: canonicalizeName(inner),
|
|
372
|
+
resolved: `skills.sh/${inner}@${ref}`,
|
|
373
|
+
sourceType: 'skills.sh',
|
|
374
|
+
ref,
|
|
375
|
+
originalSpec: spec,
|
|
376
|
+
};
|
|
377
|
+
return r;
|
|
378
|
+
}
|
|
379
|
+
// owner/repo form -> delegate parse to gh for subpath etc
|
|
380
|
+
const ghSpec = `github:${inner}`;
|
|
381
|
+
const gh = new GitHubSource();
|
|
382
|
+
const resolvedGh = await gh.resolve(ghSpec, options);
|
|
383
|
+
const r = {
|
|
384
|
+
...resolvedGh,
|
|
385
|
+
sourceType: 'skills.sh',
|
|
386
|
+
resolved: resolvedGh.resolved.replace('github:', 'skills.sh/'),
|
|
387
|
+
originalSpec: spec,
|
|
388
|
+
};
|
|
389
|
+
return r;
|
|
390
|
+
}
|
|
391
|
+
async fetch(resolved, dest) {
|
|
392
|
+
// delegate fetch to github logic but keep provenance
|
|
393
|
+
const gh = new GitHubSource();
|
|
394
|
+
// reconstruct
|
|
395
|
+
const r = resolved;
|
|
396
|
+
// fetch will work since gitUrl etc present
|
|
397
|
+
return gh.fetch(resolved, dest);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* NpmSource - basic npm support.
|
|
402
|
+
* See class comments for full documented algorithm.
|
|
403
|
+
*/
|
|
404
|
+
export class NpmSource {
|
|
405
|
+
id = 'npm';
|
|
406
|
+
match(spec) {
|
|
407
|
+
// Strict: only 'npm:' prefix for basic impl (shorthands for github handled by gh + fallback).
|
|
408
|
+
// Supports npm:pkg, npm:pkg@ver, npm:@scope/pkg@range per design.
|
|
409
|
+
return spec.startsWith('npm:');
|
|
410
|
+
}
|
|
411
|
+
async resolve(spec, options) {
|
|
412
|
+
let pkg = spec;
|
|
413
|
+
let range = 'latest';
|
|
414
|
+
if (spec.startsWith('npm:'))
|
|
415
|
+
pkg = spec.slice(4);
|
|
416
|
+
// parse @ver
|
|
417
|
+
const at = pkg.lastIndexOf('@');
|
|
418
|
+
if (at > 0 && !pkg.slice(0, at).endsWith('@') /* not scope */) { // handle @scope/pkg@ver
|
|
419
|
+
range = pkg.slice(at + 1) || 'latest';
|
|
420
|
+
pkg = pkg.slice(0, at);
|
|
421
|
+
}
|
|
422
|
+
if (options?.ref)
|
|
423
|
+
range = options.ref;
|
|
424
|
+
// fetch metadata
|
|
425
|
+
const metaUrl = `https://registry.npmjs.org/${encodeURIComponent(pkg)}`;
|
|
426
|
+
let metaBuf;
|
|
427
|
+
try {
|
|
428
|
+
metaBuf = await httpsGet(metaUrl, { Accept: 'application/json' });
|
|
429
|
+
}
|
|
430
|
+
catch (e) {
|
|
431
|
+
throw new Error(`npm registry fetch failed for ${pkg}: ${e.message}`);
|
|
432
|
+
}
|
|
433
|
+
const meta = JSON.parse(metaBuf.toString('utf8'));
|
|
434
|
+
if (meta.error)
|
|
435
|
+
throw new Error(`npm error: ${meta.error}`);
|
|
436
|
+
let version;
|
|
437
|
+
if (range === 'latest' || !semver.validRange(range)) {
|
|
438
|
+
version = meta['dist-tags']?.latest || Object.keys(meta.versions || {}).pop();
|
|
439
|
+
}
|
|
440
|
+
else {
|
|
441
|
+
const versions = Object.keys(meta.versions || {});
|
|
442
|
+
const best = semver.maxSatisfying(versions, range);
|
|
443
|
+
if (!best)
|
|
444
|
+
throw new Error(`No version satisfying ${range} for ${pkg}`);
|
|
445
|
+
version = best;
|
|
446
|
+
}
|
|
447
|
+
const pkgInfo = meta.versions[version];
|
|
448
|
+
if (!pkgInfo)
|
|
449
|
+
throw new Error(`Version ${version} not in metadata`);
|
|
450
|
+
const tarballUrl = pkgInfo.dist.tarball;
|
|
451
|
+
const tarballShasum = pkgInfo.dist.shasum; // sha1
|
|
452
|
+
const tarballIntegrity = pkgInfo.dist.integrity; // e.g. sha512-...
|
|
453
|
+
const resolvedStr = `npm:${pkg}@${version}`;
|
|
454
|
+
const r = {
|
|
455
|
+
name: canonicalizeName(pkg.split('/').pop() || pkg),
|
|
456
|
+
resolved: resolvedStr,
|
|
457
|
+
sourceType: 'npm',
|
|
458
|
+
tarballUrl,
|
|
459
|
+
tarballHash: tarballShasum || tarballIntegrity,
|
|
460
|
+
ref: version,
|
|
461
|
+
originalSpec: spec,
|
|
462
|
+
};
|
|
463
|
+
return r;
|
|
464
|
+
}
|
|
465
|
+
async fetch(resolved, dest) {
|
|
466
|
+
const r = resolved;
|
|
467
|
+
if (!r.tarballUrl)
|
|
468
|
+
throw new Error('Invalid npm resolved, no tarballUrl');
|
|
469
|
+
// PR12: download cache by tarballHash or url-derived key (content addressable downloads)
|
|
470
|
+
let tarBuf;
|
|
471
|
+
const dlKey = r.tarballHash ? `npm-${r.tarballHash}` : `npm-${createHash('sha256').update(r.tarballUrl).digest('hex').slice(0, 16)}`;
|
|
472
|
+
const cachedTarPath = await getCachedDownload(dlKey);
|
|
473
|
+
if (cachedTarPath) {
|
|
474
|
+
tarBuf = await readFile(cachedTarPath);
|
|
475
|
+
}
|
|
476
|
+
else {
|
|
477
|
+
tarBuf = await httpsGet(r.tarballUrl);
|
|
478
|
+
// write to temp then cache
|
|
479
|
+
const tmpTar = join(tmpdir(), `dl-${Date.now()}.tgz`);
|
|
480
|
+
await writeFile(tmpTar, tarBuf);
|
|
481
|
+
await putCachedDownload(dlKey, tmpTar).catch(() => { });
|
|
482
|
+
await rm(tmpTar, { force: true }).catch(() => { });
|
|
483
|
+
}
|
|
484
|
+
// 4b. verify (basic shasum if sha1)
|
|
485
|
+
if (r.tarballHash && r.tarballHash.length === 40) {
|
|
486
|
+
const got = computeSha1(tarBuf);
|
|
487
|
+
if (got !== r.tarballHash) {
|
|
488
|
+
throw new Error(`npm tarball integrity mismatch: expected ${r.tarballHash} got ${got}`);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
// note: for sha512 we could verify but skip detailed for basic (use core integrity later)
|
|
492
|
+
// 5. extract
|
|
493
|
+
const tmpBase = join(tmpdir(), `skillctl-npm-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
494
|
+
await ensureDir(tmpBase);
|
|
495
|
+
const tarFile = join(tmpBase, 'pkg.tgz');
|
|
496
|
+
await writeFile(tarFile, tarBuf);
|
|
497
|
+
await tar.extract({ file: tarFile, cwd: tmpBase, strip: 1 }); // package/ wrapper typical for npm
|
|
498
|
+
await rm(tarFile, { force: true }).catch(() => { });
|
|
499
|
+
// 6. locate skill dir
|
|
500
|
+
const located = await this.locateNpmSkillDir(tmpBase);
|
|
501
|
+
// 7. parse name
|
|
502
|
+
const front = await parseSkillFrontmatterAsync(located);
|
|
503
|
+
let skillName = front.name ? canonicalizeName(front.name) : r.name;
|
|
504
|
+
// 8. materialize copy to dest (dest will be the target canonical temp)
|
|
505
|
+
await ensureDir(dest);
|
|
506
|
+
await copyDir(located, dest);
|
|
507
|
+
// cleanup temp
|
|
508
|
+
await rm(tmpBase, { recursive: true, force: true }).catch(() => { });
|
|
509
|
+
const treeIntegrity = await computeDirIntegrity(dest);
|
|
510
|
+
// return tree, caller may record tarball separately
|
|
511
|
+
return { integrity: treeIntegrity };
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Locate skill dir logic per design:
|
|
515
|
+
* 1. package.json "agentSkills" or "skills" field (string or array of paths relative)
|
|
516
|
+
* 2. conventional ./skills/<name> or root ./skills/
|
|
517
|
+
* 3. root if SKILL.md
|
|
518
|
+
* 4. limited glob ** /SKILL.md (depth limited)
|
|
519
|
+
*/
|
|
520
|
+
async locateNpmSkillDir(extractedRoot) {
|
|
521
|
+
// try package.json hints
|
|
522
|
+
try {
|
|
523
|
+
const pkgJsonPath = join(extractedRoot, 'package.json');
|
|
524
|
+
const pkgRaw = await readFile(pkgJsonPath, 'utf8');
|
|
525
|
+
const pkg = JSON.parse(pkgRaw);
|
|
526
|
+
const hints = [];
|
|
527
|
+
if (pkg.agentSkills) {
|
|
528
|
+
if (typeof pkg.agentSkills === 'string')
|
|
529
|
+
hints.push(pkg.agentSkills);
|
|
530
|
+
else if (Array.isArray(pkg.agentSkills.skills))
|
|
531
|
+
hints.push(...pkg.agentSkills.skills);
|
|
532
|
+
else if (Array.isArray(pkg.agentSkills))
|
|
533
|
+
hints.push(...pkg.agentSkills);
|
|
534
|
+
}
|
|
535
|
+
if (pkg.skills) {
|
|
536
|
+
if (typeof pkg.skills === 'string')
|
|
537
|
+
hints.push(pkg.skills);
|
|
538
|
+
else if (Array.isArray(pkg.skills))
|
|
539
|
+
hints.push(...pkg.skills);
|
|
540
|
+
}
|
|
541
|
+
for (const h of hints) {
|
|
542
|
+
const cand = pathResolve(extractedRoot, h);
|
|
543
|
+
if (await hasSkillMd(cand) || await hasSkillMd(join(cand, '..'))) {
|
|
544
|
+
const st = await stat(cand).catch(() => null);
|
|
545
|
+
if (st?.isDirectory())
|
|
546
|
+
return cand;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
catch { /* no pkg or bad */ }
|
|
551
|
+
// conventional skills/
|
|
552
|
+
const skillsDir = join(extractedRoot, 'skills');
|
|
553
|
+
if (await hasSkillMd(skillsDir))
|
|
554
|
+
return skillsDir;
|
|
555
|
+
// single skill inside skills/ subdir?
|
|
556
|
+
try {
|
|
557
|
+
const subs = await readdir(skillsDir, { withFileTypes: true });
|
|
558
|
+
for (const s of subs) {
|
|
559
|
+
if (s.isDirectory()) {
|
|
560
|
+
const subp = join(skillsDir, s.name);
|
|
561
|
+
if (await hasSkillMd(subp))
|
|
562
|
+
return subp;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
catch { }
|
|
567
|
+
// root?
|
|
568
|
+
if (await hasSkillMd(extractedRoot))
|
|
569
|
+
return extractedRoot;
|
|
570
|
+
// limited glob depth 2
|
|
571
|
+
const found = await findFirstSkillMd(extractedRoot, 2);
|
|
572
|
+
if (found)
|
|
573
|
+
return dirname(found);
|
|
574
|
+
return extractedRoot;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
async function findFirstSkillMd(dir, maxDepth, depth = 0) {
|
|
578
|
+
if (depth > maxDepth)
|
|
579
|
+
return null;
|
|
580
|
+
try {
|
|
581
|
+
const ents = await readdir(dir, { withFileTypes: true });
|
|
582
|
+
for (const e of ents) {
|
|
583
|
+
const p = join(dir, e.name);
|
|
584
|
+
if (e.isFile() && (e.name === 'SKILL.md' || e.name === 'skill.md'))
|
|
585
|
+
return p;
|
|
586
|
+
if (e.isDirectory() && !e.name.startsWith('.')) {
|
|
587
|
+
const rec = await findFirstSkillMd(p, maxDepth, depth + 1);
|
|
588
|
+
if (rec)
|
|
589
|
+
return rec;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
catch { }
|
|
594
|
+
return null;
|
|
595
|
+
}
|
|
596
|
+
// --- Registry Manager ---
|
|
597
|
+
export class RegistryManager {
|
|
598
|
+
sources = [];
|
|
599
|
+
constructor() {
|
|
600
|
+
this.registerDefaultSources();
|
|
601
|
+
}
|
|
602
|
+
register(source) {
|
|
603
|
+
this.sources.push(source);
|
|
604
|
+
}
|
|
605
|
+
registerDefaultSources() {
|
|
606
|
+
// order matters: specific prefix matchers before loose shorthands
|
|
607
|
+
this.register(new NpmSource());
|
|
608
|
+
this.register(new SkillsShSource());
|
|
609
|
+
this.register(new GitHubSource());
|
|
610
|
+
this.register(new LocalSource());
|
|
611
|
+
}
|
|
612
|
+
async resolve(spec, options) {
|
|
613
|
+
for (const src of this.sources) {
|
|
614
|
+
if (src.match(spec)) {
|
|
615
|
+
const res = await src.resolve(spec, options);
|
|
616
|
+
// attach original for downstream
|
|
617
|
+
res.originalSpec = spec;
|
|
618
|
+
return res;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
// fallback try as local or github shorthand
|
|
622
|
+
if (spec.includes('/') && !spec.includes(':')) {
|
|
623
|
+
const gh = new GitHubSource();
|
|
624
|
+
if (gh.match(spec))
|
|
625
|
+
return gh.resolve(spec, options);
|
|
626
|
+
}
|
|
627
|
+
throw new Error(`No registry source matched spec: ${spec}. Supported: github:, npm:, skills.sh/, file:, ./local`);
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Materialize resolved source to canonical store.
|
|
631
|
+
* Returns final canonicalPath + tree integrity.
|
|
632
|
+
* Uses atomic-ish: copy to .tmp then rename.
|
|
633
|
+
*/
|
|
634
|
+
async materialize(resolved, options) {
|
|
635
|
+
const config = await loadConfig();
|
|
636
|
+
const store = config.store;
|
|
637
|
+
await ensureDir(store);
|
|
638
|
+
const name = options?.name || resolved.name;
|
|
639
|
+
const canonicalName = canonicalizeName(name);
|
|
640
|
+
const target = join(store, canonicalName);
|
|
641
|
+
// temp dir for fetch/extract
|
|
642
|
+
const tmpDest = join(tmpdir(), `skillctl-mat-${canonicalName}-${Date.now()}`);
|
|
643
|
+
await ensureDir(tmpDest);
|
|
644
|
+
let fetchIntegrity;
|
|
645
|
+
try {
|
|
646
|
+
// find source to fetch? or use generic - for simplicity call matching source's fetch
|
|
647
|
+
const source = this.sources.find(s => s.match(resolved.originalSpec || '')) || this.sources.find(s => s.id === resolved.sourceType);
|
|
648
|
+
if (!source)
|
|
649
|
+
throw new Error('no source for materialize');
|
|
650
|
+
const resFetch = await limitedFetch(() => source.fetch(resolved, tmpDest));
|
|
651
|
+
fetchIntegrity = resFetch.integrity;
|
|
652
|
+
}
|
|
653
|
+
catch (err) {
|
|
654
|
+
await rm(tmpDest, { recursive: true, force: true }).catch(() => { });
|
|
655
|
+
throw err;
|
|
656
|
+
}
|
|
657
|
+
// compute (should match)
|
|
658
|
+
const treeIntegrity = await computeDirIntegrity(tmpDest);
|
|
659
|
+
// PR12: content-addressable cache by integrity (skip re-extract work for identical trees)
|
|
660
|
+
await ensureCacheDir().catch(() => { });
|
|
661
|
+
const cached = await getCachedSkill(treeIntegrity);
|
|
662
|
+
let sourceForTarget = tmpDest;
|
|
663
|
+
if (cached) {
|
|
664
|
+
sourceForTarget = cached;
|
|
665
|
+
// cleanup the fresh tmp since we have cached copy
|
|
666
|
+
await rm(tmpDest, { recursive: true, force: true }).catch(() => { });
|
|
667
|
+
}
|
|
668
|
+
else {
|
|
669
|
+
// populate cache for future
|
|
670
|
+
await putCachedSkill(treeIntegrity, tmpDest).catch(() => { });
|
|
671
|
+
}
|
|
672
|
+
// atomic move/copy to target: if exists, for basic we overwrite (later PRs use versioned)
|
|
673
|
+
if (await exists(target)) {
|
|
674
|
+
await rm(target, { recursive: true, force: true });
|
|
675
|
+
}
|
|
676
|
+
// rename tmp (or cached) to target (cross volume may fail, fallback copy+rm)
|
|
677
|
+
try {
|
|
678
|
+
if (sourceForTarget === tmpDest) {
|
|
679
|
+
await (await import('node:fs/promises')).rename(sourceForTarget, target);
|
|
680
|
+
}
|
|
681
|
+
else {
|
|
682
|
+
await copyDir(sourceForTarget, target);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
catch {
|
|
686
|
+
await copyDir(sourceForTarget, target);
|
|
687
|
+
if (sourceForTarget === tmpDest) {
|
|
688
|
+
await rm(sourceForTarget, { recursive: true, force: true });
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
return {
|
|
692
|
+
canonicalPath: target,
|
|
693
|
+
integrity: treeIntegrity,
|
|
694
|
+
sourceType: resolved.sourceType,
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* Basic `add` : parse spec, resolve, materialize to canonical, update lock (and manifest if present).
|
|
699
|
+
* Returns the created/updated LockfileEntry.
|
|
700
|
+
* Integrates manifest/lock per PR3/4 spec.
|
|
701
|
+
*/
|
|
702
|
+
async add(spec, opts = {}) {
|
|
703
|
+
const cwd = opts.cwd || process.cwd();
|
|
704
|
+
const resolved = await this.resolve(spec);
|
|
705
|
+
// materialize
|
|
706
|
+
const mat = await this.materialize(resolved);
|
|
707
|
+
// provenance construction
|
|
708
|
+
const prov = {
|
|
709
|
+
type: resolved.sourceType === 'skills.sh' ? 'skills.sh' : resolved.sourceType,
|
|
710
|
+
subpath: resolved.subpath,
|
|
711
|
+
};
|
|
712
|
+
if (resolved.sourceType === 'github' || resolved.sourceType === 'skills.sh') {
|
|
713
|
+
prov.commit = resolved.ref; // approx; real would be commit sha
|
|
714
|
+
}
|
|
715
|
+
if (resolved.sourceType === 'npm') {
|
|
716
|
+
prov.tarballHash = resolved.tarballHash;
|
|
717
|
+
}
|
|
718
|
+
const entry = makeLockEntry(mat.canonicalPath.split(sep).pop(), // name from final
|
|
719
|
+
resolved.originalSpec || spec, resolved.resolved, mat.integrity, mat.canonicalPath, prov);
|
|
720
|
+
// update lock
|
|
721
|
+
let lock = await loadLockfile(cwd) || createEmptyLockfile();
|
|
722
|
+
lock = addOrUpdateEntry(lock, entry.name, entry);
|
|
723
|
+
await saveLockfile(lock, cwd);
|
|
724
|
+
// optionally update manifest (basic, if present add to deps with normalized specifier)
|
|
725
|
+
if (opts.updateManifest) {
|
|
726
|
+
let manifest = await loadManifest(cwd);
|
|
727
|
+
if (manifest) {
|
|
728
|
+
if (!manifest.agentSkills)
|
|
729
|
+
manifest.agentSkills = { dependencies: {}, devDependencies: {} };
|
|
730
|
+
if (!manifest.agentSkills.dependencies)
|
|
731
|
+
manifest.agentSkills.dependencies = {};
|
|
732
|
+
// normalize specifier if shorthand
|
|
733
|
+
let normSpec = spec;
|
|
734
|
+
if (!/^(github:|npm:|skills\.sh\/|file:)/.test(spec)) {
|
|
735
|
+
if (resolved.sourceType === 'github')
|
|
736
|
+
normSpec = `github:${spec}`;
|
|
737
|
+
else if (resolved.sourceType === 'npm')
|
|
738
|
+
normSpec = `npm:${spec}`;
|
|
739
|
+
}
|
|
740
|
+
manifest.agentSkills.dependencies[entry.name] = normSpec;
|
|
741
|
+
await saveManifest(manifest, cwd);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
return entry;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
async function exists(p) {
|
|
748
|
+
try {
|
|
749
|
+
await stat(p);
|
|
750
|
+
return true;
|
|
751
|
+
}
|
|
752
|
+
catch {
|
|
753
|
+
return false;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
// Export for tests + usage
|
|
757
|
+
// (ResolvedSourceInternal, canonicalizeName, parseSkillFrontmatterAsync already exported via declaration keywords)
|
|
758
|
+
//# sourceMappingURL=registry.js.map
|