skillscat 0.1.0 → 0.1.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/dist/commands/add.d.ts.map +1 -1
- package/dist/index.js +1272 -168
- package/dist/utils/api/background-submit.d.ts +8 -0
- package/dist/utils/api/background-submit.d.ts.map +1 -0
- package/dist/utils/api/registry.d.ts +19 -0
- package/dist/utils/api/registry.d.ts.map +1 -1
- package/dist/utils/config/config.d.ts +2 -0
- package/dist/utils/config/config.d.ts.map +1 -1
- package/dist/utils/core/github-request.d.ts +14 -0
- package/dist/utils/core/github-request.d.ts.map +1 -0
- package/dist/utils/source/git.d.ts +26 -2
- package/dist/utils/source/git.d.ts.map +1 -1
- package/dist/utils/source/source.d.ts +8 -0
- package/dist/utils/source/source.d.ts.map +1 -1
- package/dist/utils/storage/github-cache.d.ts +12 -0
- package/dist/utils/storage/github-cache.d.ts.map +1 -0
- package/package.json +5 -4
package/dist/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { Command } from 'commander';
|
|
3
3
|
import pc from 'picocolors';
|
|
4
4
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, unlinkSync, readdirSync, rmSync, realpathSync, statSync } from 'node:fs';
|
|
5
|
-
import { join, dirname, resolve, relative, isAbsolute } from 'node:path';
|
|
5
|
+
import { join, dirname, posix, resolve, relative, isAbsolute } from 'node:path';
|
|
6
6
|
import { createHash, randomBytes } from 'node:crypto';
|
|
7
7
|
import os, { platform, homedir, hostname, release } from 'node:os';
|
|
8
8
|
import * as readline from 'node:readline';
|
|
@@ -23,16 +23,42 @@ function parseSource(source) {
|
|
|
23
23
|
repo: shorthandMatch[2]
|
|
24
24
|
};
|
|
25
25
|
}
|
|
26
|
-
// GitHub URL: https://github.com/owner/repo or with tree/
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
26
|
+
// GitHub URL: https://github.com/owner/repo or with tree/blob refs
|
|
27
|
+
try {
|
|
28
|
+
const url = new URL(source);
|
|
29
|
+
const host = url.hostname.toLowerCase();
|
|
30
|
+
if (host === 'github.com' || host === 'www.github.com') {
|
|
31
|
+
const parts = url.pathname.split('/').filter(Boolean).map(decodeURIComponent);
|
|
32
|
+
if (parts.length >= 2) {
|
|
33
|
+
const owner = parts[0];
|
|
34
|
+
const repo = parts[1].replace(/\.git$/, '');
|
|
35
|
+
const route = parts[2];
|
|
36
|
+
if (route === 'tree' || route === 'blob') {
|
|
37
|
+
const branch = parts[3];
|
|
38
|
+
const path = parts.slice(4).join('/');
|
|
39
|
+
return {
|
|
40
|
+
platform: 'github',
|
|
41
|
+
owner,
|
|
42
|
+
repo,
|
|
43
|
+
branch,
|
|
44
|
+
path: path || undefined,
|
|
45
|
+
refKind: route,
|
|
46
|
+
hasExplicitRef: true,
|
|
47
|
+
originalInput: source
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
platform: 'github',
|
|
52
|
+
owner,
|
|
53
|
+
repo,
|
|
54
|
+
path: parts.slice(2).join('/') || undefined,
|
|
55
|
+
originalInput: source
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// Not a valid URL, continue to other formats.
|
|
36
62
|
}
|
|
37
63
|
// GitLab URL: https://gitlab.com/owner/repo or with -/tree/branch/path
|
|
38
64
|
const gitlabMatch = source.match(/gitlab\.com\/(.+?)(?:\/-\/tree\/([^\/]+))?(?:\/(.+))?$/);
|
|
@@ -236,9 +262,24 @@ function deleteSetting(key) {
|
|
|
236
262
|
function getRegistryUrl() {
|
|
237
263
|
return getSetting('registry') || DEFAULT_REGISTRY_URL$1;
|
|
238
264
|
}
|
|
265
|
+
function normalizeRegistryUrlForCompare(url) {
|
|
266
|
+
try {
|
|
267
|
+
const parsed = new URL(url);
|
|
268
|
+
parsed.hash = '';
|
|
269
|
+
parsed.search = '';
|
|
270
|
+
parsed.pathname = parsed.pathname.replace(/\/+$/, '') || '/';
|
|
271
|
+
return parsed.toString();
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
return url.replace(/\/+$/, '');
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
function isDefaultRegistry() {
|
|
278
|
+
return normalizeRegistryUrlForCompare(getRegistryUrl()) === normalizeRegistryUrlForCompare(DEFAULT_REGISTRY_URL$1);
|
|
279
|
+
}
|
|
239
280
|
|
|
240
281
|
const MAX_CACHE_ITEMS = 100;
|
|
241
|
-
const PRUNE_PERCENTAGE = 0.2;
|
|
282
|
+
const PRUNE_PERCENTAGE$1 = 0.2;
|
|
242
283
|
/**
|
|
243
284
|
* Get the skills cache directory
|
|
244
285
|
*/
|
|
@@ -364,7 +405,7 @@ function pruneCache(maxItems = MAX_CACHE_ITEMS) {
|
|
|
364
405
|
return (index.skills[a]?.lastAccessedAt || 0) - (index.skills[b]?.lastAccessedAt || 0);
|
|
365
406
|
});
|
|
366
407
|
// Remove oldest PRUNE_PERCENTAGE
|
|
367
|
-
const toRemove = Math.ceil(keys.length * PRUNE_PERCENTAGE);
|
|
408
|
+
const toRemove = Math.ceil(keys.length * PRUNE_PERCENTAGE$1);
|
|
368
409
|
const keysToRemove = sorted.slice(0, toRemove);
|
|
369
410
|
const skillsDir = getSkillsCacheDir();
|
|
370
411
|
for (const key of keysToRemove) {
|
|
@@ -386,23 +427,413 @@ function pruneCache(maxItems = MAX_CACHE_ITEMS) {
|
|
|
386
427
|
}
|
|
387
428
|
}
|
|
388
429
|
|
|
430
|
+
const GITHUB_CACHE_VERSION = 1;
|
|
431
|
+
const TREE_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
432
|
+
const MAX_TREE_CACHE_ITEMS = 100;
|
|
433
|
+
const MAX_BLOB_CACHE_ITEMS = 1000;
|
|
434
|
+
const PRUNE_PERCENTAGE = 0.2;
|
|
435
|
+
function getGitHubCacheDir() {
|
|
436
|
+
return join(getCacheDir(), 'github');
|
|
437
|
+
}
|
|
438
|
+
function getTreeCacheDir() {
|
|
439
|
+
return join(getGitHubCacheDir(), 'trees');
|
|
440
|
+
}
|
|
441
|
+
function getBlobCacheDir() {
|
|
442
|
+
return join(getGitHubCacheDir(), 'blobs');
|
|
443
|
+
}
|
|
444
|
+
function ensureDir(dir) {
|
|
445
|
+
if (!existsSync(dir)) {
|
|
446
|
+
mkdirSync(dir, { recursive: true });
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
function ensureGitHubCacheDirs() {
|
|
450
|
+
ensureDir(getTreeCacheDir());
|
|
451
|
+
ensureDir(getBlobCacheDir());
|
|
452
|
+
}
|
|
453
|
+
function toCacheFileName(prefix, key) {
|
|
454
|
+
const digest = createHash('sha256').update(key).digest('hex');
|
|
455
|
+
return `${prefix}-${digest}.json`;
|
|
456
|
+
}
|
|
457
|
+
function readJsonFile(filePath) {
|
|
458
|
+
try {
|
|
459
|
+
if (!existsSync(filePath))
|
|
460
|
+
return null;
|
|
461
|
+
return JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
462
|
+
}
|
|
463
|
+
catch {
|
|
464
|
+
return null;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
function writeJsonFile(filePath, value) {
|
|
468
|
+
ensureDir(dirname(filePath));
|
|
469
|
+
writeFileSync(filePath, JSON.stringify(value));
|
|
470
|
+
}
|
|
471
|
+
function hasValidCacheRecordBase(record) {
|
|
472
|
+
return !!record
|
|
473
|
+
&& record.version === GITHUB_CACHE_VERSION
|
|
474
|
+
&& typeof record.cachedAt === 'number'
|
|
475
|
+
&& typeof record.lastAccessedAt === 'number';
|
|
476
|
+
}
|
|
477
|
+
function isValidTreeCacheRecord(record) {
|
|
478
|
+
if (!record || !hasValidCacheRecordBase(record)) {
|
|
479
|
+
return false;
|
|
480
|
+
}
|
|
481
|
+
const treeRecord = record;
|
|
482
|
+
return Array.isArray(treeRecord.tree);
|
|
483
|
+
}
|
|
484
|
+
function isValidBlobCacheRecord(record) {
|
|
485
|
+
if (!record || !hasValidCacheRecordBase(record)) {
|
|
486
|
+
return false;
|
|
487
|
+
}
|
|
488
|
+
const blobRecord = record;
|
|
489
|
+
return typeof blobRecord.dataBase64 === 'string';
|
|
490
|
+
}
|
|
491
|
+
function touchCacheRecord(filePath, record) {
|
|
492
|
+
try {
|
|
493
|
+
record.lastAccessedAt = Date.now();
|
|
494
|
+
writeFileSync(filePath, JSON.stringify(record));
|
|
495
|
+
}
|
|
496
|
+
catch {
|
|
497
|
+
// Best effort only
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
function pruneCacheDir(dir, maxItems) {
|
|
501
|
+
try {
|
|
502
|
+
ensureDir(dir);
|
|
503
|
+
const files = readdirSync(dir).filter((name) => name.endsWith('.json'));
|
|
504
|
+
if (files.length <= maxItems)
|
|
505
|
+
return;
|
|
506
|
+
const sortable = [];
|
|
507
|
+
for (const file of files) {
|
|
508
|
+
const record = readJsonFile(join(dir, file));
|
|
509
|
+
sortable.push({
|
|
510
|
+
file,
|
|
511
|
+
lastAccessedAt: hasValidCacheRecordBase(record) ? record.lastAccessedAt : 0,
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
sortable.sort((a, b) => a.lastAccessedAt - b.lastAccessedAt);
|
|
515
|
+
const toRemove = Math.max(1, Math.ceil(files.length * PRUNE_PERCENTAGE));
|
|
516
|
+
for (const item of sortable.slice(0, toRemove)) {
|
|
517
|
+
try {
|
|
518
|
+
unlinkSync(join(dir, item.file));
|
|
519
|
+
}
|
|
520
|
+
catch {
|
|
521
|
+
// ignore individual file failures
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
catch {
|
|
526
|
+
// ignore prune errors
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
function getTreeCachePath(owner, repo, ref) {
|
|
530
|
+
const key = `${owner}/${repo}@${ref}`;
|
|
531
|
+
return join(getTreeCacheDir(), toCacheFileName('tree', key));
|
|
532
|
+
}
|
|
533
|
+
function getBlobCachePath(sha) {
|
|
534
|
+
return join(getBlobCacheDir(), toCacheFileName('blob', sha));
|
|
535
|
+
}
|
|
536
|
+
function getCachedGitHubTree(owner, repo, ref) {
|
|
537
|
+
try {
|
|
538
|
+
const filePath = getTreeCachePath(owner, repo, ref);
|
|
539
|
+
const record = readJsonFile(filePath);
|
|
540
|
+
if (!isValidTreeCacheRecord(record)) {
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
if (Date.now() - record.cachedAt > TREE_CACHE_TTL_MS) {
|
|
544
|
+
return null;
|
|
545
|
+
}
|
|
546
|
+
touchCacheRecord(filePath, record);
|
|
547
|
+
return record.tree.map((item) => ({ ...item }));
|
|
548
|
+
}
|
|
549
|
+
catch {
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
function cacheGitHubTree(owner, repo, ref, tree) {
|
|
554
|
+
try {
|
|
555
|
+
ensureGitHubCacheDirs();
|
|
556
|
+
const now = Date.now();
|
|
557
|
+
const filePath = getTreeCachePath(owner, repo, ref);
|
|
558
|
+
const record = {
|
|
559
|
+
version: GITHUB_CACHE_VERSION,
|
|
560
|
+
cachedAt: now,
|
|
561
|
+
lastAccessedAt: now,
|
|
562
|
+
tree: tree.map((item) => ({ ...item })),
|
|
563
|
+
};
|
|
564
|
+
writeJsonFile(filePath, record);
|
|
565
|
+
pruneCacheDir(getTreeCacheDir(), MAX_TREE_CACHE_ITEMS);
|
|
566
|
+
}
|
|
567
|
+
catch {
|
|
568
|
+
// ignore cache write errors
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
function getCachedGitHubBlob(sha) {
|
|
572
|
+
try {
|
|
573
|
+
const filePath = getBlobCachePath(sha);
|
|
574
|
+
const record = readJsonFile(filePath);
|
|
575
|
+
if (!isValidBlobCacheRecord(record)) {
|
|
576
|
+
return null;
|
|
577
|
+
}
|
|
578
|
+
touchCacheRecord(filePath, record);
|
|
579
|
+
return Buffer.from(record.dataBase64, 'base64');
|
|
580
|
+
}
|
|
581
|
+
catch {
|
|
582
|
+
return null;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
function cacheGitHubBlob(sha, bytes) {
|
|
586
|
+
try {
|
|
587
|
+
ensureGitHubCacheDirs();
|
|
588
|
+
const now = Date.now();
|
|
589
|
+
const filePath = getBlobCachePath(sha);
|
|
590
|
+
const record = {
|
|
591
|
+
version: GITHUB_CACHE_VERSION,
|
|
592
|
+
cachedAt: now,
|
|
593
|
+
lastAccessedAt: now,
|
|
594
|
+
dataBase64: Buffer.from(bytes).toString('base64'),
|
|
595
|
+
};
|
|
596
|
+
writeJsonFile(filePath, record);
|
|
597
|
+
pruneCacheDir(getBlobCacheDir(), MAX_BLOB_CACHE_ITEMS);
|
|
598
|
+
}
|
|
599
|
+
catch {
|
|
600
|
+
// ignore cache write errors
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const DEFAULT_API_VERSION = '2022-11-28';
|
|
605
|
+
const DEFAULT_USER_AGENT = 'skillscat-cli/1.0';
|
|
606
|
+
const DEFAULT_MAX_RETRIES = 3;
|
|
607
|
+
const DEFAULT_RETRYABLE_STATUSES = new Set([408, 429, 500, 502, 503, 504]);
|
|
608
|
+
function sleep(ms) {
|
|
609
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
610
|
+
}
|
|
611
|
+
function parseRetryAfterMs(headers) {
|
|
612
|
+
const retryAfter = headers.get('retry-after');
|
|
613
|
+
if (retryAfter) {
|
|
614
|
+
const seconds = Number(retryAfter);
|
|
615
|
+
if (Number.isFinite(seconds) && seconds >= 0) {
|
|
616
|
+
return seconds * 1000;
|
|
617
|
+
}
|
|
618
|
+
const dateMs = Date.parse(retryAfter);
|
|
619
|
+
if (!Number.isNaN(dateMs)) {
|
|
620
|
+
return Math.max(0, dateMs - Date.now());
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
const reset = headers.get('x-ratelimit-reset');
|
|
624
|
+
if (reset) {
|
|
625
|
+
const epochSeconds = Number(reset);
|
|
626
|
+
if (Number.isFinite(epochSeconds) && epochSeconds > 0) {
|
|
627
|
+
return Math.max(0, epochSeconds * 1000 - Date.now());
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
return null;
|
|
631
|
+
}
|
|
632
|
+
function isRateLimited(response) {
|
|
633
|
+
if (response.status === 429)
|
|
634
|
+
return true;
|
|
635
|
+
if (response.status !== 403)
|
|
636
|
+
return false;
|
|
637
|
+
if (response.headers.get('x-ratelimit-remaining') === '0')
|
|
638
|
+
return true;
|
|
639
|
+
return response.headers.has('retry-after');
|
|
640
|
+
}
|
|
641
|
+
function getBackoffDelayMs(attempt, maxDelayMs) {
|
|
642
|
+
const exponential = Math.min(maxDelayMs, 500 * (2 ** attempt));
|
|
643
|
+
const jitter = Math.floor(Math.random() * 250);
|
|
644
|
+
return Math.min(maxDelayMs, exponential + jitter);
|
|
645
|
+
}
|
|
646
|
+
function getUrlHost(url) {
|
|
647
|
+
try {
|
|
648
|
+
return new URL(url).hostname.toLowerCase();
|
|
649
|
+
}
|
|
650
|
+
catch {
|
|
651
|
+
return null;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
function isGitHubDomain(host) {
|
|
655
|
+
if (!host)
|
|
656
|
+
return false;
|
|
657
|
+
return host === 'github.com'
|
|
658
|
+
|| host.endsWith('.github.com')
|
|
659
|
+
|| host === 'githubusercontent.com'
|
|
660
|
+
|| host.endsWith('.githubusercontent.com');
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Unified GitHub request helper with retry on rate limits / transient errors.
|
|
664
|
+
*/
|
|
665
|
+
async function githubRequest(url, options = {}) {
|
|
666
|
+
const { token, headers: extraHeaders, apiVersion = DEFAULT_API_VERSION, userAgent = DEFAULT_USER_AGENT, maxRetries = DEFAULT_MAX_RETRIES, retryableStatuses, maxDelayMs = 30_000, ...requestInit } = options;
|
|
667
|
+
const host = getUrlHost(url);
|
|
668
|
+
const githubDomain = isGitHubDomain(host);
|
|
669
|
+
const isApiHost = host === 'api.github.com';
|
|
670
|
+
const headers = new Headers(extraHeaders ?? {});
|
|
671
|
+
if (!headers.has('Accept'))
|
|
672
|
+
headers.set('Accept', 'application/vnd.github+json');
|
|
673
|
+
if (isApiHost && apiVersion && !headers.has('X-GitHub-Api-Version')) {
|
|
674
|
+
headers.set('X-GitHub-Api-Version', apiVersion);
|
|
675
|
+
}
|
|
676
|
+
if (!headers.has('User-Agent'))
|
|
677
|
+
headers.set('User-Agent', userAgent);
|
|
678
|
+
if (githubDomain && token && !headers.has('Authorization')) {
|
|
679
|
+
headers.set('Authorization', `Bearer ${token}`);
|
|
680
|
+
}
|
|
681
|
+
const statuses = new Set(retryableStatuses ?? Array.from(DEFAULT_RETRYABLE_STATUSES));
|
|
682
|
+
let lastError;
|
|
683
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
684
|
+
try {
|
|
685
|
+
const response = await fetch(url, {
|
|
686
|
+
...requestInit,
|
|
687
|
+
headers,
|
|
688
|
+
});
|
|
689
|
+
const shouldRetry = statuses.has(response.status) || isRateLimited(response);
|
|
690
|
+
if (attempt < maxRetries && shouldRetry) {
|
|
691
|
+
const retryAfterMs = parseRetryAfterMs(response.headers);
|
|
692
|
+
const delayMs = Math.min(maxDelayMs, retryAfterMs ?? getBackoffDelayMs(attempt, maxDelayMs));
|
|
693
|
+
await sleep(delayMs);
|
|
694
|
+
continue;
|
|
695
|
+
}
|
|
696
|
+
return response;
|
|
697
|
+
}
|
|
698
|
+
catch (err) {
|
|
699
|
+
lastError = err;
|
|
700
|
+
if (attempt >= maxRetries)
|
|
701
|
+
throw err;
|
|
702
|
+
await sleep(getBackoffDelayMs(attempt, maxDelayMs));
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
if (lastError instanceof Error)
|
|
706
|
+
throw lastError;
|
|
707
|
+
throw new Error('GitHub request failed');
|
|
708
|
+
}
|
|
709
|
+
|
|
389
710
|
const GITHUB_API$1 = 'https://api.github.com';
|
|
390
711
|
const GITLAB_API = 'https://gitlab.com/api/v4';
|
|
712
|
+
const MAX_GITHUB_SYMLINK_DEPTH = 8;
|
|
713
|
+
class CachedGitHubRepoSnapshot {
|
|
714
|
+
owner;
|
|
715
|
+
repo;
|
|
716
|
+
explicitBranch;
|
|
717
|
+
repoInfoPromise;
|
|
718
|
+
branchPromise;
|
|
719
|
+
treePromise;
|
|
720
|
+
pathMapPromise;
|
|
721
|
+
blobBytesBySha = new Map();
|
|
722
|
+
fileBytesByPath = new Map();
|
|
723
|
+
constructor(owner, repo, branch) {
|
|
724
|
+
this.owner = owner;
|
|
725
|
+
this.repo = repo;
|
|
726
|
+
this.explicitBranch = branch;
|
|
727
|
+
}
|
|
728
|
+
matches(source) {
|
|
729
|
+
return source.platform === 'github'
|
|
730
|
+
&& source.owner === this.owner
|
|
731
|
+
&& source.repo === this.repo
|
|
732
|
+
&& source.branch === this.explicitBranch;
|
|
733
|
+
}
|
|
734
|
+
getBranch() {
|
|
735
|
+
if (!this.branchPromise) {
|
|
736
|
+
this.branchPromise = this.explicitBranch
|
|
737
|
+
? Promise.resolve(this.explicitBranch)
|
|
738
|
+
: this.getRepoInfo().then(info => info.default_branch);
|
|
739
|
+
}
|
|
740
|
+
return this.branchPromise;
|
|
741
|
+
}
|
|
742
|
+
getTree() {
|
|
743
|
+
if (!this.treePromise) {
|
|
744
|
+
this.treePromise = this.getBranch().then(branch => fetchGitHubTree(this.owner, this.repo, branch));
|
|
745
|
+
}
|
|
746
|
+
return this.treePromise;
|
|
747
|
+
}
|
|
748
|
+
getPathMap() {
|
|
749
|
+
if (!this.pathMapPromise) {
|
|
750
|
+
this.pathMapPromise = this.getTree().then((tree) => {
|
|
751
|
+
const map = new Map();
|
|
752
|
+
for (const item of tree) {
|
|
753
|
+
map.set(normalizeRepoPath(item.path), item);
|
|
754
|
+
}
|
|
755
|
+
return map;
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
return this.pathMapPromise;
|
|
759
|
+
}
|
|
760
|
+
async getTreeItem(path) {
|
|
761
|
+
const pathMap = await this.getPathMap();
|
|
762
|
+
return pathMap.get(normalizeRepoPath(path)) ?? null;
|
|
763
|
+
}
|
|
764
|
+
getBlobBytesBySha(sha) {
|
|
765
|
+
return getOrCreate(this.blobBytesBySha, sha, () => fetchGitHubBlobBytesBySha(this.owner, this.repo, sha));
|
|
766
|
+
}
|
|
767
|
+
getFileBytes(path, item) {
|
|
768
|
+
const normalizedPath = normalizeRepoPath(path);
|
|
769
|
+
const cacheKey = item?.sha ? `sha:${item.sha}` : `path:${normalizedPath}`;
|
|
770
|
+
return getOrCreate(this.fileBytesByPath, cacheKey, async () => {
|
|
771
|
+
if (item?.sha && item.mode !== '120000') {
|
|
772
|
+
try {
|
|
773
|
+
return await this.getBlobBytesBySha(item.sha);
|
|
774
|
+
}
|
|
775
|
+
catch {
|
|
776
|
+
const branch = await this.getBranch();
|
|
777
|
+
const file = await fetchGitHubFileBytes(this.owner, this.repo, normalizedPath, branch);
|
|
778
|
+
if (file.sha && file.sha !== item.sha) {
|
|
779
|
+
throw new Error(`GitHub file changed during fetch: ${normalizedPath}`);
|
|
780
|
+
}
|
|
781
|
+
return file.bytes;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
const content = await fetchGitHubFileBytes(this.owner, this.repo, normalizedPath, await this.getBranch());
|
|
785
|
+
return content.bytes;
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
async getFileText(path, item) {
|
|
789
|
+
const bytes = await this.getFileBytes(path, item);
|
|
790
|
+
return bytes.toString('utf-8');
|
|
791
|
+
}
|
|
792
|
+
getRepoInfo() {
|
|
793
|
+
if (!this.repoInfoPromise) {
|
|
794
|
+
this.repoInfoPromise = fetchGitHubRepoInfo(this.owner, this.repo);
|
|
795
|
+
}
|
|
796
|
+
return this.repoInfoPromise;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
function getOrCreate(map, key, factory) {
|
|
800
|
+
const existing = map.get(key);
|
|
801
|
+
if (existing) {
|
|
802
|
+
return existing;
|
|
803
|
+
}
|
|
804
|
+
const created = factory().catch((err) => {
|
|
805
|
+
map.delete(key);
|
|
806
|
+
throw err;
|
|
807
|
+
});
|
|
808
|
+
map.set(key, created);
|
|
809
|
+
return created;
|
|
810
|
+
}
|
|
811
|
+
function createGitHubRepoSnapshot(source) {
|
|
812
|
+
if (source.platform !== 'github') {
|
|
813
|
+
return null;
|
|
814
|
+
}
|
|
815
|
+
return new CachedGitHubRepoSnapshot(source.owner, source.repo, source.branch);
|
|
816
|
+
}
|
|
817
|
+
function getMatchingGitHubSnapshot(source, snapshot) {
|
|
818
|
+
if (!snapshot)
|
|
819
|
+
return null;
|
|
820
|
+
return snapshot.matches(source) ? snapshot : null;
|
|
821
|
+
}
|
|
391
822
|
/**
|
|
392
823
|
* Get default branch for a GitHub repo
|
|
393
824
|
*/
|
|
394
825
|
async function getGitHubDefaultBranch(owner, repo) {
|
|
395
|
-
const
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
826
|
+
const info = await fetchGitHubRepoInfo(owner, repo);
|
|
827
|
+
return info.default_branch;
|
|
828
|
+
}
|
|
829
|
+
async function fetchGitHubRepoInfo(owner, repo) {
|
|
830
|
+
const response = await githubRequest(`${GITHUB_API$1}/repos/${owner}/${repo}`, {
|
|
831
|
+
userAgent: 'skillscat-cli/1.0',
|
|
400
832
|
});
|
|
401
833
|
if (!response.ok) {
|
|
402
834
|
throw new Error(`Repository not found: ${owner}/${repo}`);
|
|
403
835
|
}
|
|
404
|
-
|
|
405
|
-
return data.default_branch;
|
|
836
|
+
return await response.json();
|
|
406
837
|
}
|
|
407
838
|
/**
|
|
408
839
|
* Get default branch for a GitLab repo
|
|
@@ -422,57 +853,75 @@ async function getGitLabDefaultBranch(owner, repo) {
|
|
|
422
853
|
* Fetch repository tree from GitHub
|
|
423
854
|
*/
|
|
424
855
|
async function fetchGitHubTree(owner, repo, branch) {
|
|
425
|
-
const
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
856
|
+
const cachedTree = getCachedGitHubTree(owner, repo, branch);
|
|
857
|
+
if (cachedTree) {
|
|
858
|
+
return cachedTree;
|
|
859
|
+
}
|
|
860
|
+
const response = await githubRequest(`${GITHUB_API$1}/repos/${owner}/${repo}/git/trees/${branch}?recursive=1`, {
|
|
861
|
+
userAgent: 'skillscat-cli/1.0',
|
|
430
862
|
});
|
|
431
863
|
if (!response.ok) {
|
|
432
864
|
throw new Error(`Failed to fetch repository tree`);
|
|
433
865
|
}
|
|
434
866
|
const data = await response.json();
|
|
867
|
+
if (data.truncated) {
|
|
868
|
+
throw new Error(`Repository tree is too large to inspect via GitHub API (truncated response): ${owner}/${repo}. ` +
|
|
869
|
+
'Try installing a specific path instead.');
|
|
870
|
+
}
|
|
871
|
+
cacheGitHubTree(owner, repo, branch, data.tree);
|
|
435
872
|
return data.tree;
|
|
436
873
|
}
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
:
|
|
444
|
-
const response = await fetch(url, {
|
|
445
|
-
headers: {
|
|
446
|
-
'Accept': 'application/vnd.github+json',
|
|
447
|
-
'User-Agent': 'skillscat-cli/1.0'
|
|
448
|
-
}
|
|
874
|
+
async function fetchGitHubBlobBytesBySha(owner, repo, sha) {
|
|
875
|
+
const cachedBlob = getCachedGitHubBlob(sha);
|
|
876
|
+
if (cachedBlob) {
|
|
877
|
+
return cachedBlob;
|
|
878
|
+
}
|
|
879
|
+
const response = await githubRequest(`${GITHUB_API$1}/repos/${owner}/${repo}/git/blobs/${sha}`, {
|
|
880
|
+
userAgent: 'skillscat-cli/1.0',
|
|
449
881
|
});
|
|
450
882
|
if (!response.ok) {
|
|
451
|
-
throw new Error(`
|
|
883
|
+
throw new Error(`Failed to fetch blob: ${sha}`);
|
|
452
884
|
}
|
|
453
885
|
const data = await response.json();
|
|
454
|
-
if (data.encoding
|
|
455
|
-
|
|
886
|
+
if (data.encoding !== 'base64' || !data.content) {
|
|
887
|
+
throw new Error(`Unexpected blob encoding for ${sha}`);
|
|
456
888
|
}
|
|
457
|
-
|
|
889
|
+
const decoded = Buffer.from(data.content.replace(/\n/g, ''), 'base64');
|
|
890
|
+
cacheGitHubBlob(sha, decoded);
|
|
891
|
+
return decoded;
|
|
458
892
|
}
|
|
459
|
-
|
|
460
|
-
* Get file SHA from GitHub (for update checking)
|
|
461
|
-
*/
|
|
462
|
-
async function getGitHubFileSha(owner, repo, path, ref) {
|
|
893
|
+
async function fetchGitHubFileBytes(owner, repo, path, ref) {
|
|
463
894
|
const url = ref
|
|
464
895
|
? `${GITHUB_API$1}/repos/${owner}/${repo}/contents/${path}?ref=${ref}`
|
|
465
896
|
: `${GITHUB_API$1}/repos/${owner}/${repo}/contents/${path}`;
|
|
466
|
-
const response = await
|
|
467
|
-
|
|
468
|
-
'Accept': 'application/vnd.github+json',
|
|
469
|
-
'User-Agent': 'skillscat-cli/1.0'
|
|
470
|
-
}
|
|
897
|
+
const response = await githubRequest(url, {
|
|
898
|
+
userAgent: 'skillscat-cli/1.0',
|
|
471
899
|
});
|
|
472
|
-
if (!response.ok)
|
|
473
|
-
|
|
900
|
+
if (!response.ok) {
|
|
901
|
+
if (response.status === 404) {
|
|
902
|
+
throw new Error(`File not found: ${path}`);
|
|
903
|
+
}
|
|
904
|
+
throw new Error(`Failed to fetch file from GitHub (${response.status}): ${path}`);
|
|
905
|
+
}
|
|
474
906
|
const data = await response.json();
|
|
475
|
-
|
|
907
|
+
const contentType = data.type ?? 'file';
|
|
908
|
+
if (contentType !== 'file' && contentType !== 'symlink') {
|
|
909
|
+
throw new Error(`Unexpected GitHub content type for ${path}: ${String(data.type)}`);
|
|
910
|
+
}
|
|
911
|
+
if (data.encoding !== 'base64' || !data.content) {
|
|
912
|
+
throw new Error(`Unexpected file encoding: ${data.encoding}`);
|
|
913
|
+
}
|
|
914
|
+
return {
|
|
915
|
+
bytes: Buffer.from(data.content.replace(/\n/g, ''), 'base64'),
|
|
916
|
+
sha: data.sha,
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
async function fetchGitHubFileContent(owner, repo, path, ref) {
|
|
920
|
+
const file = await fetchGitHubFileBytes(owner, repo, path, ref);
|
|
921
|
+
return {
|
|
922
|
+
content: file.bytes.toString('utf-8'),
|
|
923
|
+
sha: file.sha,
|
|
924
|
+
};
|
|
476
925
|
}
|
|
477
926
|
/**
|
|
478
927
|
* Fetch file content from GitLab
|
|
@@ -530,44 +979,66 @@ async function fetchGitLabTree(owner, repo, branch) {
|
|
|
530
979
|
/**
|
|
531
980
|
* Discover skills in a repository
|
|
532
981
|
*/
|
|
533
|
-
async function discoverSkills(source) {
|
|
982
|
+
async function discoverSkills(source, options) {
|
|
534
983
|
const { platform, owner, repo, branch: sourceBranch, path: sourcePath } = source;
|
|
535
984
|
const skills = [];
|
|
985
|
+
const matchedSnapshot = platform === 'github'
|
|
986
|
+
? getMatchingGitHubSnapshot(source, options?.githubSnapshot)
|
|
987
|
+
: null;
|
|
988
|
+
const githubSnapshot = platform === 'github'
|
|
989
|
+
? (matchedSnapshot ?? (!sourcePath ? createGitHubRepoSnapshot(source) : null))
|
|
990
|
+
: null;
|
|
536
991
|
try {
|
|
537
992
|
// Get default branch if not specified
|
|
538
993
|
const branch = sourceBranch || (platform === 'github'
|
|
539
|
-
? await getGitHubDefaultBranch(owner, repo)
|
|
994
|
+
? await (githubSnapshot?.getBranch() ?? getGitHubDefaultBranch(owner, repo))
|
|
540
995
|
: await getGitLabDefaultBranch(owner, repo));
|
|
541
996
|
// If a specific path is provided, check only that path
|
|
542
997
|
if (sourcePath) {
|
|
543
998
|
const skillPath = sourcePath.endsWith('SKILL.md') ? sourcePath : `${sourcePath}/SKILL.md`;
|
|
544
999
|
try {
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
1000
|
+
let content;
|
|
1001
|
+
let sha;
|
|
1002
|
+
if (platform === 'github') {
|
|
1003
|
+
const treeItem = await githubSnapshot?.getTreeItem(skillPath) ?? null;
|
|
1004
|
+
if (treeItem?.type === 'blob') {
|
|
1005
|
+
content = await githubSnapshot.getFileText(skillPath, treeItem);
|
|
1006
|
+
sha = treeItem.sha || undefined;
|
|
1007
|
+
}
|
|
1008
|
+
else {
|
|
1009
|
+
const file = await fetchGitHubFileContent(owner, repo, skillPath, branch);
|
|
1010
|
+
content = file.content;
|
|
1011
|
+
sha = file.sha || undefined;
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
else {
|
|
1015
|
+
content = await fetchGitLabFile(owner, repo, skillPath, branch);
|
|
1016
|
+
}
|
|
548
1017
|
const metadata = parseSkillFrontmatter(content);
|
|
549
1018
|
if (metadata) {
|
|
550
|
-
const sha = platform === 'github'
|
|
551
|
-
? await getGitHubFileSha(owner, repo, skillPath, branch)
|
|
552
|
-
: undefined;
|
|
553
1019
|
skills.push({
|
|
554
1020
|
name: metadata.name,
|
|
555
1021
|
description: metadata.description,
|
|
556
1022
|
path: skillPath,
|
|
557
1023
|
content,
|
|
558
|
-
sha
|
|
1024
|
+
sha,
|
|
559
1025
|
contentHash: calculateContentHash(content)
|
|
560
1026
|
});
|
|
561
1027
|
}
|
|
562
1028
|
}
|
|
563
|
-
catch {
|
|
564
|
-
|
|
1029
|
+
catch (err) {
|
|
1030
|
+
if (isSourcePathNotFoundError(err)) {
|
|
1031
|
+
// Skill not found at path
|
|
1032
|
+
}
|
|
1033
|
+
else {
|
|
1034
|
+
throw err;
|
|
1035
|
+
}
|
|
565
1036
|
}
|
|
566
1037
|
return skills;
|
|
567
1038
|
}
|
|
568
1039
|
// Fetch repository tree
|
|
569
1040
|
const tree = platform === 'github'
|
|
570
|
-
? await fetchGitHubTree(owner, repo, branch)
|
|
1041
|
+
? await (githubSnapshot?.getTree() ?? fetchGitHubTree(owner, repo, branch))
|
|
571
1042
|
: await fetchGitLabTree(owner, repo, branch);
|
|
572
1043
|
// Find all SKILL.md files
|
|
573
1044
|
const skillFiles = tree.filter(item => item.path.endsWith('SKILL.md') &&
|
|
@@ -591,7 +1062,7 @@ async function discoverSkills(source) {
|
|
|
591
1062
|
for (const file of skillFiles) {
|
|
592
1063
|
try {
|
|
593
1064
|
const content = platform === 'github'
|
|
594
|
-
? await
|
|
1065
|
+
? await githubSnapshot.getFileText(file.path, file)
|
|
595
1066
|
: await fetchGitLabFile(owner, repo, file.path, branch);
|
|
596
1067
|
const metadata = parseSkillFrontmatter(content);
|
|
597
1068
|
if (metadata) {
|
|
@@ -615,6 +1086,153 @@ async function discoverSkills(source) {
|
|
|
615
1086
|
throw error;
|
|
616
1087
|
}
|
|
617
1088
|
}
|
|
1089
|
+
async function fetchSkillCompanionFilesWithOptions(source, skillFilePath, options) {
|
|
1090
|
+
if (!skillFilePath || !skillFilePath.endsWith('SKILL.md')) {
|
|
1091
|
+
return [];
|
|
1092
|
+
}
|
|
1093
|
+
if (source.platform !== 'github') {
|
|
1094
|
+
return [];
|
|
1095
|
+
}
|
|
1096
|
+
const snapshot = getMatchingGitHubSnapshot(source, options?.githubSnapshot) ?? createGitHubRepoSnapshot(source);
|
|
1097
|
+
if (!snapshot) {
|
|
1098
|
+
throw new Error('Failed to initialize GitHub repository snapshot');
|
|
1099
|
+
}
|
|
1100
|
+
return fetchGitHubSkillCompanionFiles(source, skillFilePath, snapshot);
|
|
1101
|
+
}
|
|
1102
|
+
async function fetchGitHubSkillCompanionFiles(source, skillFilePath, snapshot) {
|
|
1103
|
+
const tree = await snapshot.getTree();
|
|
1104
|
+
const normalizedSkillFilePath = normalizeRepoPath(skillFilePath);
|
|
1105
|
+
const skillDir = getRepoDirPath(normalizedSkillFilePath);
|
|
1106
|
+
const nestedSkillDirs = getNestedSkillDirectories(tree, normalizedSkillFilePath);
|
|
1107
|
+
const pathMap = await snapshot.getPathMap();
|
|
1108
|
+
const files = [];
|
|
1109
|
+
for (const item of tree) {
|
|
1110
|
+
const repoPath = normalizeRepoPath(item.path);
|
|
1111
|
+
if (item.type !== 'blob')
|
|
1112
|
+
continue;
|
|
1113
|
+
if (repoPath === normalizedSkillFilePath)
|
|
1114
|
+
continue;
|
|
1115
|
+
if (!isPathWithinDirectory(repoPath, skillDir))
|
|
1116
|
+
continue;
|
|
1117
|
+
if (isPathInNestedSkillDirectory(repoPath, nestedSkillDirs))
|
|
1118
|
+
continue;
|
|
1119
|
+
const relativePath = toRelativeSkillPath(repoPath, skillDir);
|
|
1120
|
+
if (!relativePath)
|
|
1121
|
+
continue;
|
|
1122
|
+
const content = await resolveGitHubBlobOrSymlinkContent({
|
|
1123
|
+
snapshot,
|
|
1124
|
+
item,
|
|
1125
|
+
currentPath: repoPath,
|
|
1126
|
+
pathMap,
|
|
1127
|
+
depth: 0,
|
|
1128
|
+
visited: new Set(),
|
|
1129
|
+
});
|
|
1130
|
+
if (!content)
|
|
1131
|
+
continue;
|
|
1132
|
+
files.push({ path: relativePath, content });
|
|
1133
|
+
}
|
|
1134
|
+
files.sort((a, b) => a.path.localeCompare(b.path));
|
|
1135
|
+
return files;
|
|
1136
|
+
}
|
|
1137
|
+
async function resolveGitHubBlobOrSymlinkContent({ snapshot, item, currentPath, pathMap, depth, visited, }) {
|
|
1138
|
+
const symlinkMode = item.mode === '120000';
|
|
1139
|
+
if (!symlinkMode) {
|
|
1140
|
+
if (!item.sha)
|
|
1141
|
+
return null;
|
|
1142
|
+
return snapshot.getFileBytes(currentPath, item);
|
|
1143
|
+
}
|
|
1144
|
+
if (depth >= MAX_GITHUB_SYMLINK_DEPTH) {
|
|
1145
|
+
throw new Error(`Symlink resolution depth exceeded for ${currentPath}`);
|
|
1146
|
+
}
|
|
1147
|
+
if (visited.has(currentPath)) {
|
|
1148
|
+
throw new Error(`Symlink loop detected at ${currentPath}`);
|
|
1149
|
+
}
|
|
1150
|
+
const nextVisited = new Set(visited);
|
|
1151
|
+
nextVisited.add(currentPath);
|
|
1152
|
+
const targetBlob = await snapshot.getBlobBytesBySha(item.sha);
|
|
1153
|
+
const targetPathRaw = targetBlob.toString('utf-8').replace(/\r?\n$/, '');
|
|
1154
|
+
const resolvedTargetPath = resolveRepoSymlinkTarget(currentPath, targetPathRaw);
|
|
1155
|
+
if (!resolvedTargetPath) {
|
|
1156
|
+
return null;
|
|
1157
|
+
}
|
|
1158
|
+
const targetItem = pathMap.get(resolvedTargetPath);
|
|
1159
|
+
if (!targetItem || targetItem.type !== 'blob') {
|
|
1160
|
+
return null;
|
|
1161
|
+
}
|
|
1162
|
+
return resolveGitHubBlobOrSymlinkContent({
|
|
1163
|
+
snapshot,
|
|
1164
|
+
item: targetItem,
|
|
1165
|
+
currentPath: resolvedTargetPath,
|
|
1166
|
+
pathMap,
|
|
1167
|
+
depth: depth + 1,
|
|
1168
|
+
visited: nextVisited,
|
|
1169
|
+
});
|
|
1170
|
+
}
|
|
1171
|
+
function getNestedSkillDirectories(tree, currentSkillFilePath) {
|
|
1172
|
+
const currentSkillDir = getRepoDirPath(currentSkillFilePath);
|
|
1173
|
+
const nested = new Set();
|
|
1174
|
+
for (const item of tree) {
|
|
1175
|
+
const itemPath = normalizeRepoPath(item.path);
|
|
1176
|
+
if (item.type !== 'blob' || !itemPath.endsWith('/SKILL.md'))
|
|
1177
|
+
continue;
|
|
1178
|
+
if (itemPath === currentSkillFilePath)
|
|
1179
|
+
continue;
|
|
1180
|
+
const dir = getRepoDirPath(itemPath);
|
|
1181
|
+
if (dir === currentSkillDir)
|
|
1182
|
+
continue;
|
|
1183
|
+
if (!isPathWithinDirectory(dir, currentSkillDir))
|
|
1184
|
+
continue;
|
|
1185
|
+
nested.add(dir);
|
|
1186
|
+
}
|
|
1187
|
+
return Array.from(nested);
|
|
1188
|
+
}
|
|
1189
|
+
function isPathInNestedSkillDirectory(path, nestedSkillDirs) {
|
|
1190
|
+
for (const nestedDir of nestedSkillDirs) {
|
|
1191
|
+
if (isPathWithinDirectory(path, nestedDir)) {
|
|
1192
|
+
return true;
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
return false;
|
|
1196
|
+
}
|
|
1197
|
+
function normalizeRepoPath(path) {
|
|
1198
|
+
const normalized = path.replace(/^\/+|\/+$/g, '');
|
|
1199
|
+
if (!normalized)
|
|
1200
|
+
return '';
|
|
1201
|
+
return posix.normalize(normalized).replace(/^\.\//, '');
|
|
1202
|
+
}
|
|
1203
|
+
function getRepoDirPath(filePath) {
|
|
1204
|
+
const dir = posix.dirname(filePath);
|
|
1205
|
+
return dir === '.' ? '' : dir;
|
|
1206
|
+
}
|
|
1207
|
+
function isPathWithinDirectory(path, dir) {
|
|
1208
|
+
if (!dir)
|
|
1209
|
+
return true;
|
|
1210
|
+
return path === dir || path.startsWith(`${dir}/`);
|
|
1211
|
+
}
|
|
1212
|
+
function toRelativeSkillPath(repoPath, skillDir) {
|
|
1213
|
+
if (!skillDir)
|
|
1214
|
+
return repoPath;
|
|
1215
|
+
if (!repoPath.startsWith(`${skillDir}/`))
|
|
1216
|
+
return '';
|
|
1217
|
+
return repoPath.slice(skillDir.length + 1);
|
|
1218
|
+
}
|
|
1219
|
+
function resolveRepoSymlinkTarget(currentPath, targetPath) {
|
|
1220
|
+
if (!targetPath)
|
|
1221
|
+
return null;
|
|
1222
|
+
const normalizedInput = targetPath.replace(/\\/g, '/');
|
|
1223
|
+
if (normalizedInput.startsWith('/'))
|
|
1224
|
+
return null;
|
|
1225
|
+
const currentDir = getRepoDirPath(currentPath);
|
|
1226
|
+
const resolved = normalizeRepoPath(posix.join(currentDir, normalizedInput));
|
|
1227
|
+
if (!resolved)
|
|
1228
|
+
return null;
|
|
1229
|
+
if (resolved === '..' || resolved.startsWith('../'))
|
|
1230
|
+
return null;
|
|
1231
|
+
return resolved;
|
|
1232
|
+
}
|
|
1233
|
+
function isSourcePathNotFoundError(error) {
|
|
1234
|
+
return error instanceof Error && error.message.startsWith('File not found:');
|
|
1235
|
+
}
|
|
618
1236
|
/**
|
|
619
1237
|
* Fetch a single skill by name from a repository
|
|
620
1238
|
*/
|
|
@@ -1075,11 +1693,8 @@ async function fetchFromGitHub(owner, repo, skillPath) {
|
|
|
1075
1693
|
const url = `${GITHUB_API}/repos/${owner}/${repo}/contents/${path}`;
|
|
1076
1694
|
verboseLog(`Fetching from GitHub: ${url}`);
|
|
1077
1695
|
try {
|
|
1078
|
-
const response = await
|
|
1079
|
-
|
|
1080
|
-
'Accept': 'application/vnd.github+json',
|
|
1081
|
-
'User-Agent': 'skillscat-cli/0.1.0'
|
|
1082
|
-
}
|
|
1696
|
+
const response = await githubRequest(url, {
|
|
1697
|
+
userAgent: 'skillscat-cli/0.1.0',
|
|
1083
1698
|
});
|
|
1084
1699
|
if (!response.ok) {
|
|
1085
1700
|
verboseLog(`GitHub fetch failed: ${response.status}`);
|
|
@@ -1167,6 +1782,110 @@ async function fetchSkill(skillIdentifier) {
|
|
|
1167
1782
|
throw error;
|
|
1168
1783
|
}
|
|
1169
1784
|
}
|
|
1785
|
+
async function fetchSkillsByRepo(owner, repo, options) {
|
|
1786
|
+
const registryUrl = getRegistryUrl();
|
|
1787
|
+
const path = options?.path?.replace(/^\/+|\/+$/g, '');
|
|
1788
|
+
const params = new URLSearchParams();
|
|
1789
|
+
if (path)
|
|
1790
|
+
params.set('path', path);
|
|
1791
|
+
const query = params.toString();
|
|
1792
|
+
const url = `${registryUrl}/repo/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}${query ? `?${query}` : ''}`;
|
|
1793
|
+
const headers = await getAuthHeaders();
|
|
1794
|
+
const startTime = Date.now();
|
|
1795
|
+
verboseRequest('GET', url, headers);
|
|
1796
|
+
try {
|
|
1797
|
+
const response = await fetch(url, { headers });
|
|
1798
|
+
verboseResponse(response.status, response.statusText, Date.now() - startTime);
|
|
1799
|
+
if (!response.ok) {
|
|
1800
|
+
if (response.status === 404) {
|
|
1801
|
+
return { skills: [], total: 0 };
|
|
1802
|
+
}
|
|
1803
|
+
const parsed = parseHttpError(response.status, response.statusText);
|
|
1804
|
+
throw new Error(parsed.message);
|
|
1805
|
+
}
|
|
1806
|
+
return await response.json();
|
|
1807
|
+
}
|
|
1808
|
+
catch (error) {
|
|
1809
|
+
if (error instanceof Error && !error.message.includes('Rate limit')) {
|
|
1810
|
+
const networkError = parseNetworkError(error);
|
|
1811
|
+
throw new Error(networkError.message);
|
|
1812
|
+
}
|
|
1813
|
+
throw error;
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
const CLI_USER_AGENT = 'skillscat-cli/0.1.0';
|
|
1818
|
+
const BACKGROUND_SUBMIT_ENV = 'SKILLSCAT_BACKGROUND_SUBMIT';
|
|
1819
|
+
const BACKGROUND_SUBMIT_DISABLE_ENV = 'SKILLSCAT_DISABLE_BACKGROUND_SUBMIT';
|
|
1820
|
+
function normalizeSkillPath$1(path) {
|
|
1821
|
+
if (!path)
|
|
1822
|
+
return undefined;
|
|
1823
|
+
const normalized = path.replace(/^\/+|\/+$/g, '');
|
|
1824
|
+
if (!normalized)
|
|
1825
|
+
return undefined;
|
|
1826
|
+
const pathWithoutSkillFile = normalized.replace(/(?:^|\/)SKILL\.md$/i, '');
|
|
1827
|
+
return pathWithoutSkillFile || undefined;
|
|
1828
|
+
}
|
|
1829
|
+
function buildGitHubRepoUrl(source) {
|
|
1830
|
+
const base = `https://github.com/${source.owner}/${source.repo}`;
|
|
1831
|
+
const path = source.path?.replace(/^\/+/, '');
|
|
1832
|
+
if (!path)
|
|
1833
|
+
return base;
|
|
1834
|
+
return `${base}/${path}`;
|
|
1835
|
+
}
|
|
1836
|
+
function envFlagFalse(value) {
|
|
1837
|
+
if (!value)
|
|
1838
|
+
return false;
|
|
1839
|
+
const normalized = value.trim().toLowerCase();
|
|
1840
|
+
return normalized === '0' || normalized === 'false' || normalized === 'off' || normalized === 'no';
|
|
1841
|
+
}
|
|
1842
|
+
function envFlagTrue(value) {
|
|
1843
|
+
if (!value)
|
|
1844
|
+
return false;
|
|
1845
|
+
const normalized = value.trim().toLowerCase();
|
|
1846
|
+
return normalized === '1' || normalized === 'true' || normalized === 'on' || normalized === 'yes';
|
|
1847
|
+
}
|
|
1848
|
+
function isBackgroundSubmitEnabled() {
|
|
1849
|
+
if (envFlagTrue(process.env[BACKGROUND_SUBMIT_DISABLE_ENV])) {
|
|
1850
|
+
return false;
|
|
1851
|
+
}
|
|
1852
|
+
if (process.env[BACKGROUND_SUBMIT_ENV] !== undefined) {
|
|
1853
|
+
return !envFlagFalse(process.env[BACKGROUND_SUBMIT_ENV]);
|
|
1854
|
+
}
|
|
1855
|
+
return true;
|
|
1856
|
+
}
|
|
1857
|
+
/**
|
|
1858
|
+
* Best-effort, fire-and-forget anonymous repo submission for indexing.
|
|
1859
|
+
* Intentionally does not await the response and fails silently.
|
|
1860
|
+
*/
|
|
1861
|
+
function submitRepoForIndexingInBackground(source) {
|
|
1862
|
+
if (source.platform !== 'github')
|
|
1863
|
+
return;
|
|
1864
|
+
if (!isBackgroundSubmitEnabled())
|
|
1865
|
+
return;
|
|
1866
|
+
try {
|
|
1867
|
+
const baseUrl = getBaseUrl();
|
|
1868
|
+
const payload = {
|
|
1869
|
+
url: buildGitHubRepoUrl(source),
|
|
1870
|
+
};
|
|
1871
|
+
const normalizedSkillPath = normalizeSkillPath$1(source.path);
|
|
1872
|
+
if (normalizedSkillPath) {
|
|
1873
|
+
payload.skillPath = normalizedSkillPath;
|
|
1874
|
+
}
|
|
1875
|
+
void fetch(`${baseUrl}/api/submit`, {
|
|
1876
|
+
method: 'POST',
|
|
1877
|
+
headers: {
|
|
1878
|
+
'Content-Type': 'application/json',
|
|
1879
|
+
'User-Agent': CLI_USER_AGENT,
|
|
1880
|
+
'X-Skillscat-Background-Submit': '1',
|
|
1881
|
+
},
|
|
1882
|
+
body: JSON.stringify(payload),
|
|
1883
|
+
}).catch(() => { });
|
|
1884
|
+
}
|
|
1885
|
+
catch {
|
|
1886
|
+
// Intentionally fail silent.
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1170
1889
|
|
|
1171
1890
|
function sanitizeSkillDirName(skillName) {
|
|
1172
1891
|
const sanitized = skillName
|
|
@@ -1573,8 +2292,9 @@ function box(content, title) {
|
|
|
1573
2292
|
console.log(pc.dim(bottom));
|
|
1574
2293
|
}
|
|
1575
2294
|
|
|
2295
|
+
const COMPANION_MANIFEST_FILE = '.skillscat-companion-files.json';
|
|
2296
|
+
const COMPANION_MANIFEST_VERSION = 1;
|
|
1576
2297
|
async function add(source, options) {
|
|
1577
|
-
// Parse source
|
|
1578
2298
|
const repoSource = parseSource(source);
|
|
1579
2299
|
if (!repoSource) {
|
|
1580
2300
|
error('Invalid source. Supported formats:');
|
|
@@ -1584,84 +2304,58 @@ async function add(source, options) {
|
|
|
1584
2304
|
process.exit(1);
|
|
1585
2305
|
}
|
|
1586
2306
|
const sourceLabel = `${repoSource.owner}/${repoSource.repo}`;
|
|
2307
|
+
const isExplicitGitHubRefSource = repoSource.platform === 'github' && repoSource.hasExplicitRef === true;
|
|
2308
|
+
const githubSnapshot = createGitHubRepoSnapshot(repoSource);
|
|
2309
|
+
if (isExplicitGitHubRefSource) {
|
|
2310
|
+
verboseLog(`Explicit GitHub ${repoSource.refKind || 'ref'} source detected; using direct GitHub install path`);
|
|
2311
|
+
}
|
|
1587
2312
|
info$1(`Fetching skills from ${pc.cyan(sourceLabel)}...`);
|
|
1588
|
-
// Check cache first for each potential skill path
|
|
1589
2313
|
const cached = getCachedSkill(repoSource.owner, repoSource.repo, repoSource.path);
|
|
1590
2314
|
if (cached && !options.force) {
|
|
1591
2315
|
verboseLog('Found cached skill content');
|
|
1592
2316
|
}
|
|
1593
|
-
// Discover skills
|
|
1594
2317
|
const discoverSpinner = spinner('Discovering skills');
|
|
1595
|
-
let
|
|
1596
|
-
let installSource = repoSource;
|
|
1597
|
-
let trackingSlug = `${repoSource.owner}/${repoSource.repo}`;
|
|
1598
|
-
let updateStrategy = 'git';
|
|
1599
|
-
let cacheOwner = repoSource.owner;
|
|
1600
|
-
let cacheRepo = repoSource.repo;
|
|
1601
|
-
let cachePath = repoSource.path;
|
|
1602
|
-
let registrySlug;
|
|
2318
|
+
let resolvedSkills = [];
|
|
1603
2319
|
try {
|
|
1604
|
-
|
|
2320
|
+
resolvedSkills = await resolveInstallSkills({
|
|
2321
|
+
sourceInput: source,
|
|
2322
|
+
repoSource,
|
|
2323
|
+
requestedSkillNames: options.skill ?? [],
|
|
2324
|
+
explicitRefBypassRegistry: isExplicitGitHubRefSource,
|
|
2325
|
+
githubSnapshot,
|
|
2326
|
+
});
|
|
1605
2327
|
}
|
|
1606
2328
|
catch (err) {
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
try {
|
|
1611
|
-
const registrySkill = await fetchSkill(source);
|
|
1612
|
-
if (registrySkill && registrySkill.content) {
|
|
1613
|
-
const parsedGitSource = getSourceFromRegistrySkill(registrySkill);
|
|
1614
|
-
installSource = parsedGitSource ?? installSource;
|
|
1615
|
-
updateStrategy = 'registry';
|
|
1616
|
-
registrySlug = getRegistrySlug$1(registrySkill, source);
|
|
1617
|
-
trackingSlug = registrySlug;
|
|
1618
|
-
if (parsedGitSource) {
|
|
1619
|
-
cacheOwner = parsedGitSource.owner;
|
|
1620
|
-
cacheRepo = parsedGitSource.repo;
|
|
1621
|
-
cachePath = parsedGitSource.path || registrySkill.skillPath;
|
|
1622
|
-
}
|
|
1623
|
-
else if (registrySkill.owner && registrySkill.repo) {
|
|
1624
|
-
cacheOwner = registrySkill.owner;
|
|
1625
|
-
cacheRepo = registrySkill.repo;
|
|
1626
|
-
cachePath = registrySkill.skillPath;
|
|
1627
|
-
}
|
|
1628
|
-
skills = [{
|
|
1629
|
-
name: registrySkill.name,
|
|
1630
|
-
description: registrySkill.description || '',
|
|
1631
|
-
path: registrySkill.skillPath
|
|
1632
|
-
? (registrySkill.skillPath.endsWith('SKILL.md') ? registrySkill.skillPath : `${registrySkill.skillPath}/SKILL.md`)
|
|
1633
|
-
: 'SKILL.md',
|
|
1634
|
-
content: registrySkill.content,
|
|
1635
|
-
contentHash: registrySkill.contentHash,
|
|
1636
|
-
}];
|
|
1637
|
-
}
|
|
1638
|
-
else {
|
|
1639
|
-
discoverSpinner.stop(false);
|
|
1640
|
-
error(err instanceof Error ? err.message : 'Failed to discover skills');
|
|
1641
|
-
process.exit(1);
|
|
1642
|
-
}
|
|
1643
|
-
}
|
|
1644
|
-
catch {
|
|
1645
|
-
discoverSpinner.stop(false);
|
|
1646
|
-
error(err instanceof Error ? err.message : 'Failed to discover skills');
|
|
1647
|
-
process.exit(1);
|
|
1648
|
-
}
|
|
2329
|
+
discoverSpinner.stop(false);
|
|
2330
|
+
error(err instanceof Error ? err.message : 'Failed to discover skills');
|
|
2331
|
+
process.exit(1);
|
|
1649
2332
|
}
|
|
1650
2333
|
discoverSpinner.stop(true);
|
|
1651
|
-
if (
|
|
2334
|
+
if (resolvedSkills.length === 0) {
|
|
1652
2335
|
warn('No skills found in this repository.');
|
|
1653
2336
|
console.log(pc.dim('Make sure the repository contains SKILL.md files with valid frontmatter.'));
|
|
1654
2337
|
process.exit(1);
|
|
1655
2338
|
}
|
|
2339
|
+
const missingRequestedNames = getMissingRequestedNames(options.skill ?? [], resolvedSkills);
|
|
2340
|
+
if (missingRequestedNames.length > 0) {
|
|
2341
|
+
error(`No skills found matching: ${missingRequestedNames.join(', ')}`);
|
|
2342
|
+
if (resolvedSkills.length > 0) {
|
|
2343
|
+
console.log(pc.dim('Available skills:'));
|
|
2344
|
+
for (const entry of resolvedSkills) {
|
|
2345
|
+
console.log(pc.dim(` - ${entry.skill.name}`));
|
|
2346
|
+
}
|
|
2347
|
+
}
|
|
2348
|
+
process.exit(1);
|
|
2349
|
+
}
|
|
1656
2350
|
// List mode - just show skills and exit
|
|
1657
2351
|
if (options.list) {
|
|
1658
2352
|
console.log();
|
|
1659
|
-
console.log(pc.bold(`Found ${
|
|
2353
|
+
console.log(pc.bold(`Found ${resolvedSkills.length} skill(s):`));
|
|
1660
2354
|
console.log();
|
|
1661
|
-
for (const
|
|
1662
|
-
console.log(` ${pc.cyan(skill.name)}`);
|
|
1663
|
-
console.log(` ${pc.dim(skill.description)}`);
|
|
1664
|
-
console.log(` ${pc.dim(`Path: ${skill.path}`)}`);
|
|
2355
|
+
for (const entry of resolvedSkills) {
|
|
2356
|
+
console.log(` ${pc.cyan(entry.skill.name)}`);
|
|
2357
|
+
console.log(` ${pc.dim(entry.skill.description)}`);
|
|
2358
|
+
console.log(` ${pc.dim(`Path: ${entry.skill.path}`)}`);
|
|
1665
2359
|
console.log();
|
|
1666
2360
|
}
|
|
1667
2361
|
console.log(pc.dim('─'.repeat(50)));
|
|
@@ -1669,19 +2363,22 @@ async function add(source, options) {
|
|
|
1669
2363
|
console.log(` ${pc.cyan(`npx skillscat add ${source}`)}`);
|
|
1670
2364
|
return;
|
|
1671
2365
|
}
|
|
1672
|
-
//
|
|
1673
|
-
let
|
|
2366
|
+
// Final name filter (safety net after mixed registry+git resolution)
|
|
2367
|
+
let selectedEntries = resolvedSkills;
|
|
1674
2368
|
if (options.skill && options.skill.length > 0) {
|
|
1675
|
-
|
|
1676
|
-
if (
|
|
2369
|
+
selectedEntries = resolvedSkills.filter((entry) => options.skill.some((name) => entry.skill.name.toLowerCase() === name.toLowerCase()));
|
|
2370
|
+
if (selectedEntries.length === 0) {
|
|
1677
2371
|
error(`No skills found matching: ${options.skill.join(', ')}`);
|
|
1678
2372
|
console.log(pc.dim('Available skills:'));
|
|
1679
|
-
for (const
|
|
1680
|
-
console.log(pc.dim(` - ${skill.name}`));
|
|
2373
|
+
for (const entry of resolvedSkills) {
|
|
2374
|
+
console.log(pc.dim(` - ${entry.skill.name}`));
|
|
1681
2375
|
}
|
|
1682
2376
|
process.exit(1);
|
|
1683
2377
|
}
|
|
1684
2378
|
}
|
|
2379
|
+
else if (!options.yes && resolvedSkills.length > 1) {
|
|
2380
|
+
selectedEntries = [await selectSingleSkillInteractive(resolvedSkills)];
|
|
2381
|
+
}
|
|
1685
2382
|
// Detect or select agents
|
|
1686
2383
|
let targetAgents;
|
|
1687
2384
|
if (options.agent && options.agent.length > 0) {
|
|
@@ -1696,10 +2393,8 @@ async function add(source, options) {
|
|
|
1696
2393
|
}
|
|
1697
2394
|
}
|
|
1698
2395
|
else {
|
|
1699
|
-
// Auto-detect installed agents
|
|
1700
2396
|
targetAgents = detectInstalledAgents();
|
|
1701
2397
|
if (targetAgents.length === 0) {
|
|
1702
|
-
// No agents detected, ask user
|
|
1703
2398
|
if (!options.yes) {
|
|
1704
2399
|
console.log();
|
|
1705
2400
|
warn('No coding agents detected.');
|
|
@@ -1714,10 +2409,10 @@ async function add(source, options) {
|
|
|
1714
2409
|
targetAgents = AGENTS;
|
|
1715
2410
|
}
|
|
1716
2411
|
else {
|
|
1717
|
-
const indices = response.split(',').map(s => parseInt(s.trim()) - 1);
|
|
2412
|
+
const indices = response.split(',').map((s) => parseInt(s.trim()) - 1);
|
|
1718
2413
|
targetAgents = indices
|
|
1719
|
-
.filter(i => i >= 0 && i < AGENTS.length)
|
|
1720
|
-
.map(i => AGENTS[i]);
|
|
2414
|
+
.filter((i) => i >= 0 && i < AGENTS.length)
|
|
2415
|
+
.map((i) => AGENTS[i]);
|
|
1721
2416
|
}
|
|
1722
2417
|
if (targetAgents.length === 0) {
|
|
1723
2418
|
error('No agents selected.');
|
|
@@ -1725,20 +2420,19 @@ async function add(source, options) {
|
|
|
1725
2420
|
}
|
|
1726
2421
|
}
|
|
1727
2422
|
else {
|
|
1728
|
-
|
|
1729
|
-
targetAgents = AGENTS.filter(a => a.id === 'claude-code');
|
|
2423
|
+
targetAgents = AGENTS.filter((a) => a.id === 'claude-code');
|
|
1730
2424
|
}
|
|
1731
2425
|
}
|
|
1732
2426
|
}
|
|
1733
2427
|
const isGlobal = options.global ?? false;
|
|
1734
2428
|
const locationLabel = isGlobal ? 'global' : 'project';
|
|
1735
2429
|
console.log();
|
|
1736
|
-
console.log(pc.bold(`Installing ${
|
|
2430
|
+
console.log(pc.bold(`Installing ${selectedEntries.length} skill(s) to ${targetAgents.length} agent(s):`));
|
|
1737
2431
|
console.log();
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
console.log(`
|
|
1741
|
-
console.log(` ${pc.dim(
|
|
2432
|
+
for (const entry of selectedEntries) {
|
|
2433
|
+
console.log(` ${pc.green('•')} ${pc.bold(entry.skill.name)}`);
|
|
2434
|
+
console.log(` ${pc.dim(entry.skill.description)}`);
|
|
2435
|
+
console.log(` ${pc.dim(`Source: ${entry.updateStrategy === 'registry' ? 'registry' : 'github'}`)}`);
|
|
1742
2436
|
}
|
|
1743
2437
|
console.log();
|
|
1744
2438
|
console.log(pc.dim('Target agents:'));
|
|
@@ -1747,7 +2441,6 @@ async function add(source, options) {
|
|
|
1747
2441
|
console.log(` ${pc.cyan('•')} ${agent.name} → ${pc.dim(path)}`);
|
|
1748
2442
|
}
|
|
1749
2443
|
console.log();
|
|
1750
|
-
// Confirmation
|
|
1751
2444
|
if (!options.yes) {
|
|
1752
2445
|
const confirm = await prompt(`Install to ${locationLabel} directory? [Y/n] `);
|
|
1753
2446
|
if (confirm.toLowerCase() === 'n') {
|
|
@@ -1755,19 +2448,30 @@ async function add(source, options) {
|
|
|
1755
2448
|
process.exit(0);
|
|
1756
2449
|
}
|
|
1757
2450
|
}
|
|
1758
|
-
|
|
2451
|
+
const prepareSpinner = spinner('Preparing skill files');
|
|
2452
|
+
try {
|
|
2453
|
+
await hydrateCompanionFilesForInstall(selectedEntries, githubSnapshot);
|
|
2454
|
+
prepareSpinner.stop(true);
|
|
2455
|
+
}
|
|
2456
|
+
catch (err) {
|
|
2457
|
+
prepareSpinner.stop(false);
|
|
2458
|
+
error(err instanceof Error ? err.message : 'Failed to prepare skill files');
|
|
2459
|
+
process.exit(1);
|
|
2460
|
+
}
|
|
1759
2461
|
let installed = 0;
|
|
1760
2462
|
let skipped = 0;
|
|
1761
|
-
|
|
2463
|
+
let wroteGitDiscoveredSkill = false;
|
|
2464
|
+
for (const entry of selectedEntries) {
|
|
2465
|
+
const { skill } = entry;
|
|
1762
2466
|
const activeAgentIds = new Set();
|
|
1763
2467
|
for (const agent of targetAgents) {
|
|
1764
2468
|
const skillDir = getSkillPath(agent, skill.name, isGlobal);
|
|
1765
2469
|
const skillFile = join(skillDir, 'SKILL.md');
|
|
1766
2470
|
const existedBefore = existsSync(skillFile);
|
|
1767
|
-
// Check if already installed
|
|
1768
2471
|
if (existedBefore && !options.force) {
|
|
1769
2472
|
const existingContent = readFileSync(skillFile, 'utf-8');
|
|
1770
|
-
if (existingContent === skill.content
|
|
2473
|
+
if (existingContent === skill.content
|
|
2474
|
+
&& companionFilesAreUpToDate(skillDir, skill, { skipValidation: entry.companionFilesHydrationFailed === true })) {
|
|
1771
2475
|
skipped++;
|
|
1772
2476
|
activeAgentIds.add(agent.id);
|
|
1773
2477
|
continue;
|
|
@@ -1782,18 +2486,20 @@ async function add(source, options) {
|
|
|
1782
2486
|
}
|
|
1783
2487
|
}
|
|
1784
2488
|
}
|
|
1785
|
-
// Create directory and write file
|
|
1786
2489
|
try {
|
|
1787
2490
|
mkdirSync(dirname(skillFile), { recursive: true });
|
|
1788
2491
|
writeFileSync(skillFile, skill.content, 'utf-8');
|
|
2492
|
+
if (!entry.companionFilesHydrationFailed) {
|
|
2493
|
+
syncCompanionFiles(skillDir, skill);
|
|
2494
|
+
}
|
|
1789
2495
|
installed++;
|
|
1790
2496
|
activeAgentIds.add(agent.id);
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
cacheSkill(cacheOwner, cacheRepo, skill.content, updateStrategy === 'registry' ? 'registry' : 'github', cacheSkillPath, skill.sha);
|
|
2497
|
+
if (entry.updateStrategy === 'git') {
|
|
2498
|
+
wroteGitDiscoveredSkill = true;
|
|
2499
|
+
}
|
|
2500
|
+
if (entry.cacheOwner && entry.cacheRepo) {
|
|
2501
|
+
const cacheSkillPath = normalizeSkillPath(entry.cachePath ?? skill.path);
|
|
2502
|
+
cacheSkill(entry.cacheOwner, entry.cacheRepo, skill.content, entry.updateStrategy === 'registry' ? 'registry' : 'github', cacheSkillPath, skill.sha);
|
|
1797
2503
|
verboseLog(`Cached skill: ${skill.name}`);
|
|
1798
2504
|
}
|
|
1799
2505
|
}
|
|
@@ -1808,20 +2514,26 @@ async function add(source, options) {
|
|
|
1808
2514
|
recordInstallation({
|
|
1809
2515
|
name: skill.name,
|
|
1810
2516
|
description: skill.description,
|
|
1811
|
-
source: installSource,
|
|
1812
|
-
registrySlug,
|
|
1813
|
-
updateStrategy,
|
|
2517
|
+
source: entry.installSource,
|
|
2518
|
+
registrySlug: entry.registrySlug,
|
|
2519
|
+
updateStrategy: entry.updateStrategy,
|
|
1814
2520
|
agents: Array.from(activeAgentIds),
|
|
1815
2521
|
global: isGlobal,
|
|
1816
2522
|
installedAt: Date.now(),
|
|
1817
2523
|
sha: skill.sha,
|
|
1818
2524
|
path: skill.path,
|
|
1819
|
-
contentHash: skill.contentHash
|
|
2525
|
+
contentHash: skill.contentHash,
|
|
1820
2526
|
});
|
|
1821
|
-
|
|
1822
|
-
trackInstallation(trackingSlug).catch(() => { });
|
|
2527
|
+
trackInstallation(entry.trackingSlug).catch(() => { });
|
|
1823
2528
|
}
|
|
1824
2529
|
}
|
|
2530
|
+
if (installed > 0 &&
|
|
2531
|
+
wroteGitDiscoveredSkill &&
|
|
2532
|
+
repoSource.platform === 'github' &&
|
|
2533
|
+
!isExplicitGitHubRefSource &&
|
|
2534
|
+
isDefaultRegistry()) {
|
|
2535
|
+
submitRepoForIndexingInBackground(repoSource);
|
|
2536
|
+
}
|
|
1825
2537
|
console.log();
|
|
1826
2538
|
if (installed > 0) {
|
|
1827
2539
|
success(`Installed ${installed} skill(s) successfully!`);
|
|
@@ -1833,6 +2545,382 @@ async function add(source, options) {
|
|
|
1833
2545
|
console.log(pc.dim('Skills are now available in your coding agents.'));
|
|
1834
2546
|
console.log(pc.dim('Restart your agent or start a new session to use them.'));
|
|
1835
2547
|
}
|
|
2548
|
+
async function resolveInstallSkills({ sourceInput, repoSource, requestedSkillNames, explicitRefBypassRegistry, githubSnapshot, }) {
|
|
2549
|
+
const requestedNamesLower = new Set(requestedSkillNames.map((name) => name.toLowerCase()));
|
|
2550
|
+
const needsRegistryFirst = repoSource.platform === 'github' && !explicitRefBypassRegistry;
|
|
2551
|
+
let resolved = [];
|
|
2552
|
+
let registryRepoMatchesFound = false;
|
|
2553
|
+
let registrySummariesNeedingGitBackfill = [];
|
|
2554
|
+
if (needsRegistryFirst) {
|
|
2555
|
+
try {
|
|
2556
|
+
const registryRepo = await fetchSkillsByRepo(repoSource.owner, repoSource.repo, {
|
|
2557
|
+
path: normalizeSkillPath(repoSource.path),
|
|
2558
|
+
});
|
|
2559
|
+
if (registryRepo.skills.length > 0) {
|
|
2560
|
+
registryRepoMatchesFound = true;
|
|
2561
|
+
let summaries = registryRepo.skills;
|
|
2562
|
+
if (repoSource.path) {
|
|
2563
|
+
const normalizedPath = normalizeSkillPath(repoSource.path);
|
|
2564
|
+
summaries = summaries.filter((item) => normalizeSkillPath(item.skillPath) === normalizedPath);
|
|
2565
|
+
}
|
|
2566
|
+
let selectedSummaries = summaries;
|
|
2567
|
+
if (requestedNamesLower.size > 0) {
|
|
2568
|
+
selectedSummaries = summaries.filter((item) => requestedNamesLower.has(item.name.toLowerCase()));
|
|
2569
|
+
}
|
|
2570
|
+
if (selectedSummaries.length > 0) {
|
|
2571
|
+
const registryFetch = await fetchRegistryResolvedSkills(selectedSummaries, sourceInput, repoSource);
|
|
2572
|
+
resolved.push(...registryFetch.resolved);
|
|
2573
|
+
registrySummariesNeedingGitBackfill = registryFetch.missingSummaries;
|
|
2574
|
+
}
|
|
2575
|
+
}
|
|
2576
|
+
}
|
|
2577
|
+
catch (err) {
|
|
2578
|
+
verboseLog(`Registry repo lookup failed: ${err instanceof Error ? err.message : 'unknown'}`);
|
|
2579
|
+
}
|
|
2580
|
+
}
|
|
2581
|
+
const missingRequestedNames = requestedSkillNames.filter((name) => !resolved.some((entry) => entry.skill.name.toLowerCase() === name.toLowerCase()));
|
|
2582
|
+
const shouldRunGitDiscovery = explicitRefBypassRegistry ||
|
|
2583
|
+
repoSource.platform !== 'github' ||
|
|
2584
|
+
!registryRepoMatchesFound ||
|
|
2585
|
+
resolved.length === 0 ||
|
|
2586
|
+
missingRequestedNames.length > 0 ||
|
|
2587
|
+
registrySummariesNeedingGitBackfill.length > 0 ||
|
|
2588
|
+
(repoSource.path && resolved.length === 0);
|
|
2589
|
+
if (shouldRunGitDiscovery) {
|
|
2590
|
+
try {
|
|
2591
|
+
const gitSkills = await discoverSkills(repoSource, { githubSnapshot });
|
|
2592
|
+
let gitResolved = gitSkills.map((skill) => toGitResolvedSkill(skill, repoSource));
|
|
2593
|
+
if (missingRequestedNames.length > 0) {
|
|
2594
|
+
const missingLower = new Set(missingRequestedNames.map((name) => name.toLowerCase()));
|
|
2595
|
+
gitResolved = gitResolved.filter((entry) => missingLower.has(entry.skill.name.toLowerCase()));
|
|
2596
|
+
}
|
|
2597
|
+
else if (requestedNamesLower.size > 0 && resolved.length === 0) {
|
|
2598
|
+
gitResolved = gitResolved.filter((entry) => requestedNamesLower.has(entry.skill.name.toLowerCase()));
|
|
2599
|
+
}
|
|
2600
|
+
else if (registrySummariesNeedingGitBackfill.length > 0) {
|
|
2601
|
+
const backfillKeys = new Set(registrySummariesNeedingGitBackfill.map((summary) => getRegistrySummaryIdentityKey(summary)));
|
|
2602
|
+
gitResolved = gitResolved.filter((entry) => backfillKeys.has(getResolvedSkillIdentityKey(entry)));
|
|
2603
|
+
}
|
|
2604
|
+
resolved = mergeResolvedSkills(resolved, gitResolved);
|
|
2605
|
+
}
|
|
2606
|
+
catch (err) {
|
|
2607
|
+
if (explicitRefBypassRegistry) {
|
|
2608
|
+
throw err;
|
|
2609
|
+
}
|
|
2610
|
+
// Fallback: preserve existing behavior for registry slugs or private skills.
|
|
2611
|
+
if (resolved.length === 0 && true) {
|
|
2612
|
+
const registrySkill = await fetchSkill(sourceInput).catch(() => null);
|
|
2613
|
+
if (registrySkill?.content) {
|
|
2614
|
+
resolved.push(toRegistryResolvedSkill(registrySkill, sourceInput, repoSource));
|
|
2615
|
+
return mergeResolvedSkills([], resolved);
|
|
2616
|
+
}
|
|
2617
|
+
}
|
|
2618
|
+
// If some registry skills were already resolved (partial hit), keep them.
|
|
2619
|
+
if (resolved.length > 0) {
|
|
2620
|
+
if (getMissingRequestedNames(requestedSkillNames, resolved).length > 0) {
|
|
2621
|
+
throw err;
|
|
2622
|
+
}
|
|
2623
|
+
return mergeResolvedSkills([], resolved);
|
|
2624
|
+
}
|
|
2625
|
+
throw err;
|
|
2626
|
+
}
|
|
2627
|
+
}
|
|
2628
|
+
if (requestedNamesLower.size > 0 && resolved.length > 0) {
|
|
2629
|
+
resolved = resolved.filter((entry) => requestedNamesLower.has(entry.skill.name.toLowerCase()));
|
|
2630
|
+
}
|
|
2631
|
+
return mergeResolvedSkills([], resolved);
|
|
2632
|
+
}
|
|
2633
|
+
async function fetchRegistryResolvedSkills(summaries, sourceInput, repoSource) {
|
|
2634
|
+
const resolved = [];
|
|
2635
|
+
const missingSummaries = [];
|
|
2636
|
+
for (const summary of summaries) {
|
|
2637
|
+
if (!summary.slug)
|
|
2638
|
+
continue;
|
|
2639
|
+
try {
|
|
2640
|
+
const full = await fetchSkill(summary.slug);
|
|
2641
|
+
if (!full?.content) {
|
|
2642
|
+
missingSummaries.push(summary);
|
|
2643
|
+
continue;
|
|
2644
|
+
}
|
|
2645
|
+
resolved.push(toRegistryResolvedSkill(full, sourceInput, repoSource));
|
|
2646
|
+
}
|
|
2647
|
+
catch (err) {
|
|
2648
|
+
verboseLog(`Failed to fetch registry skill ${summary.slug}: ${err instanceof Error ? err.message : 'unknown'}`);
|
|
2649
|
+
missingSummaries.push(summary);
|
|
2650
|
+
}
|
|
2651
|
+
}
|
|
2652
|
+
return {
|
|
2653
|
+
resolved,
|
|
2654
|
+
missingSummaries,
|
|
2655
|
+
};
|
|
2656
|
+
}
|
|
2657
|
+
function mergeResolvedSkills(existing, incoming) {
|
|
2658
|
+
const merged = [...existing];
|
|
2659
|
+
const seen = new Set(existing.map((entry) => getResolvedSkillIdentityKey(entry)));
|
|
2660
|
+
for (const entry of incoming) {
|
|
2661
|
+
const key = getResolvedSkillIdentityKey(entry);
|
|
2662
|
+
if (seen.has(key)) {
|
|
2663
|
+
continue;
|
|
2664
|
+
}
|
|
2665
|
+
merged.push(entry);
|
|
2666
|
+
seen.add(key);
|
|
2667
|
+
}
|
|
2668
|
+
return merged;
|
|
2669
|
+
}
|
|
2670
|
+
function getResolvedSkillIdentityKey(entry) {
|
|
2671
|
+
const normalizedPath = normalizeSkillPath(entry.skill.path);
|
|
2672
|
+
if (normalizedPath)
|
|
2673
|
+
return `path:${normalizedPath.toLowerCase()}`;
|
|
2674
|
+
return `name:${entry.skill.name.toLowerCase()}`;
|
|
2675
|
+
}
|
|
2676
|
+
function getRegistrySummaryIdentityKey(summary) {
|
|
2677
|
+
const normalizedPath = normalizeSkillPath(summary.skillPath);
|
|
2678
|
+
if (normalizedPath)
|
|
2679
|
+
return `path:${normalizedPath.toLowerCase()}`;
|
|
2680
|
+
return `name:${summary.name.toLowerCase()}`;
|
|
2681
|
+
}
|
|
2682
|
+
function toGitResolvedSkill(skill, repoSource) {
|
|
2683
|
+
return {
|
|
2684
|
+
skill,
|
|
2685
|
+
installSource: repoSource,
|
|
2686
|
+
updateStrategy: 'git',
|
|
2687
|
+
trackingSlug: `${repoSource.owner}/${repoSource.repo}`,
|
|
2688
|
+
cacheOwner: repoSource.owner,
|
|
2689
|
+
cacheRepo: repoSource.repo,
|
|
2690
|
+
cachePath: normalizeSkillPath(skill.path) || normalizeSkillPath(repoSource.path),
|
|
2691
|
+
};
|
|
2692
|
+
}
|
|
2693
|
+
function toRegistryResolvedSkill(skill, fallbackInput, fallbackSource) {
|
|
2694
|
+
const parsedGitSource = getSourceFromRegistrySkill(skill) ?? fallbackSource;
|
|
2695
|
+
const registrySlug = getRegistrySlug$1(skill, fallbackInput);
|
|
2696
|
+
const normalizedSkillPath = normalizeSkillPath(skill.skillPath);
|
|
2697
|
+
const skillPath = toSkillFilePath(normalizedSkillPath);
|
|
2698
|
+
return {
|
|
2699
|
+
skill: {
|
|
2700
|
+
name: skill.name,
|
|
2701
|
+
description: skill.description || '',
|
|
2702
|
+
path: skillPath,
|
|
2703
|
+
content: skill.content,
|
|
2704
|
+
contentHash: skill.contentHash,
|
|
2705
|
+
},
|
|
2706
|
+
installSource: parsedGitSource,
|
|
2707
|
+
registrySlug,
|
|
2708
|
+
updateStrategy: 'registry',
|
|
2709
|
+
trackingSlug: registrySlug,
|
|
2710
|
+
cacheOwner: parsedGitSource?.owner || skill.owner,
|
|
2711
|
+
cacheRepo: parsedGitSource?.repo || skill.repo,
|
|
2712
|
+
cachePath: normalizeSkillPath(parsedGitSource?.path) || normalizedSkillPath,
|
|
2713
|
+
};
|
|
2714
|
+
}
|
|
2715
|
+
async function selectSingleSkillInteractive(entries) {
|
|
2716
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
2717
|
+
error('Multiple skills found but no interactive terminal is available.');
|
|
2718
|
+
console.log(pc.dim('Use `--yes` to install all skills, or `-s <name>` to choose a specific skill.'));
|
|
2719
|
+
process.exit(1);
|
|
2720
|
+
}
|
|
2721
|
+
const inquirer = await import('inquirer');
|
|
2722
|
+
const answer = await inquirer.default.prompt([
|
|
2723
|
+
{
|
|
2724
|
+
type: 'list',
|
|
2725
|
+
name: 'skillKey',
|
|
2726
|
+
message: 'Multiple skills found. Select one to install:',
|
|
2727
|
+
choices: entries.map((entry, index) => ({
|
|
2728
|
+
name: `${entry.skill.name} ${pc.dim(`(${entry.skill.path})`)}${entry.skill.description ? ` - ${entry.skill.description}` : ''}`,
|
|
2729
|
+
value: String(index),
|
|
2730
|
+
})),
|
|
2731
|
+
},
|
|
2732
|
+
]);
|
|
2733
|
+
const selected = entries[Number.parseInt(answer.skillKey, 10)];
|
|
2734
|
+
if (!selected) {
|
|
2735
|
+
error('No skill selected.');
|
|
2736
|
+
process.exit(1);
|
|
2737
|
+
}
|
|
2738
|
+
return selected;
|
|
2739
|
+
}
|
|
2740
|
+
async function hydrateCompanionFilesForInstall(entries, githubSnapshot) {
|
|
2741
|
+
for (const entry of entries) {
|
|
2742
|
+
if (entry.skill.companionFiles)
|
|
2743
|
+
continue;
|
|
2744
|
+
const installSource = entry.installSource;
|
|
2745
|
+
if (!installSource || installSource.platform !== 'github') {
|
|
2746
|
+
continue;
|
|
2747
|
+
}
|
|
2748
|
+
try {
|
|
2749
|
+
entry.skill.companionFiles = await fetchSkillCompanionFilesWithOptions(installSource, entry.skill.path, { githubSnapshot });
|
|
2750
|
+
entry.companionFilesHydrationFailed = false;
|
|
2751
|
+
}
|
|
2752
|
+
catch (err) {
|
|
2753
|
+
verboseLog(`Failed to fetch companion files for ${entry.skill.name}: ${err instanceof Error ? err.message : 'unknown'}`);
|
|
2754
|
+
entry.companionFilesHydrationFailed = true;
|
|
2755
|
+
}
|
|
2756
|
+
}
|
|
2757
|
+
}
|
|
2758
|
+
function companionFilesAreUpToDate(skillDir, skill, options) {
|
|
2759
|
+
if (options?.skipValidation) {
|
|
2760
|
+
return true;
|
|
2761
|
+
}
|
|
2762
|
+
const expectedPaths = getExpectedCompanionPaths(skill);
|
|
2763
|
+
const manifestPaths = readCompanionManifest(skillDir);
|
|
2764
|
+
if (expectedPaths.length === 0) {
|
|
2765
|
+
if (manifestPaths && manifestPaths.length > 0) {
|
|
2766
|
+
return false;
|
|
2767
|
+
}
|
|
2768
|
+
return true;
|
|
2769
|
+
}
|
|
2770
|
+
// Force one rewrite for legacy installs without manifest so we can record managed files.
|
|
2771
|
+
if (!manifestPaths) {
|
|
2772
|
+
return false;
|
|
2773
|
+
}
|
|
2774
|
+
if (!sameStringArrays(manifestPaths, expectedPaths)) {
|
|
2775
|
+
return false;
|
|
2776
|
+
}
|
|
2777
|
+
for (const file of skill.companionFiles ?? []) {
|
|
2778
|
+
const destination = join(skillDir, file.path);
|
|
2779
|
+
if (!existsSync(destination)) {
|
|
2780
|
+
return false;
|
|
2781
|
+
}
|
|
2782
|
+
try {
|
|
2783
|
+
const existing = readFileSync(destination);
|
|
2784
|
+
const expected = Buffer.from(file.content);
|
|
2785
|
+
if (!existing.equals(expected)) {
|
|
2786
|
+
return false;
|
|
2787
|
+
}
|
|
2788
|
+
}
|
|
2789
|
+
catch {
|
|
2790
|
+
return false;
|
|
2791
|
+
}
|
|
2792
|
+
}
|
|
2793
|
+
return true;
|
|
2794
|
+
}
|
|
2795
|
+
function syncCompanionFiles(skillDir, skill) {
|
|
2796
|
+
const expectedFiles = getExpectedCompanionFiles(skill);
|
|
2797
|
+
const expectedPaths = expectedFiles.map((file) => file.path);
|
|
2798
|
+
const previousPaths = readCompanionManifest(skillDir) ?? [];
|
|
2799
|
+
for (const stalePath of previousPaths) {
|
|
2800
|
+
if (expectedPaths.includes(stalePath)) {
|
|
2801
|
+
continue;
|
|
2802
|
+
}
|
|
2803
|
+
const destination = join(skillDir, stalePath);
|
|
2804
|
+
try {
|
|
2805
|
+
rmSync(destination, { force: true });
|
|
2806
|
+
pruneEmptyParentDirs(skillDir, destination);
|
|
2807
|
+
}
|
|
2808
|
+
catch {
|
|
2809
|
+
// Best-effort cleanup only; write phase below still proceeds.
|
|
2810
|
+
}
|
|
2811
|
+
}
|
|
2812
|
+
for (const file of expectedFiles) {
|
|
2813
|
+
const destination = join(skillDir, file.path);
|
|
2814
|
+
mkdirSync(dirname(destination), { recursive: true });
|
|
2815
|
+
writeFileSync(destination, Buffer.from(file.content));
|
|
2816
|
+
}
|
|
2817
|
+
writeCompanionManifest(skillDir, expectedPaths);
|
|
2818
|
+
}
|
|
2819
|
+
function getExpectedCompanionFiles(skill) {
|
|
2820
|
+
const files = skill.companionFiles ?? [];
|
|
2821
|
+
const deduped = new Map();
|
|
2822
|
+
for (const file of files) {
|
|
2823
|
+
const normalized = normalizeCompanionRelativePath(file.path);
|
|
2824
|
+
if (!normalized)
|
|
2825
|
+
continue;
|
|
2826
|
+
deduped.set(normalized, file.content);
|
|
2827
|
+
}
|
|
2828
|
+
return Array.from(deduped.entries())
|
|
2829
|
+
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
2830
|
+
.map(([path, content]) => ({ path, content }));
|
|
2831
|
+
}
|
|
2832
|
+
function getExpectedCompanionPaths(skill) {
|
|
2833
|
+
return getExpectedCompanionFiles(skill).map((file) => file.path);
|
|
2834
|
+
}
|
|
2835
|
+
function readCompanionManifest(skillDir) {
|
|
2836
|
+
const manifestPath = join(skillDir, COMPANION_MANIFEST_FILE);
|
|
2837
|
+
if (!existsSync(manifestPath)) {
|
|
2838
|
+
return null;
|
|
2839
|
+
}
|
|
2840
|
+
try {
|
|
2841
|
+
const parsed = JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
2842
|
+
if (parsed.version !== COMPANION_MANIFEST_VERSION || !Array.isArray(parsed.files)) {
|
|
2843
|
+
return null;
|
|
2844
|
+
}
|
|
2845
|
+
const validFiles = parsed.files
|
|
2846
|
+
.map((value) => (typeof value === 'string' ? normalizeCompanionRelativePath(value) : ''))
|
|
2847
|
+
.filter((value) => Boolean(value));
|
|
2848
|
+
validFiles.sort((a, b) => a.localeCompare(b));
|
|
2849
|
+
return Array.from(new Set(validFiles));
|
|
2850
|
+
}
|
|
2851
|
+
catch {
|
|
2852
|
+
return null;
|
|
2853
|
+
}
|
|
2854
|
+
}
|
|
2855
|
+
function writeCompanionManifest(skillDir, files) {
|
|
2856
|
+
const manifestPath = join(skillDir, COMPANION_MANIFEST_FILE);
|
|
2857
|
+
const normalizedFiles = Array.from(new Set(files.map(normalizeCompanionRelativePath).filter(Boolean)))
|
|
2858
|
+
.sort((a, b) => a.localeCompare(b));
|
|
2859
|
+
if (normalizedFiles.length === 0) {
|
|
2860
|
+
try {
|
|
2861
|
+
rmSync(manifestPath, { force: true });
|
|
2862
|
+
}
|
|
2863
|
+
catch {
|
|
2864
|
+
// Ignore cleanup failures.
|
|
2865
|
+
}
|
|
2866
|
+
return;
|
|
2867
|
+
}
|
|
2868
|
+
mkdirSync(skillDir, { recursive: true });
|
|
2869
|
+
writeFileSync(manifestPath, JSON.stringify({
|
|
2870
|
+
version: COMPANION_MANIFEST_VERSION,
|
|
2871
|
+
files: normalizedFiles,
|
|
2872
|
+
}, null, 2));
|
|
2873
|
+
}
|
|
2874
|
+
function normalizeCompanionRelativePath(path) {
|
|
2875
|
+
const normalized = path.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
|
|
2876
|
+
if (!normalized)
|
|
2877
|
+
return '';
|
|
2878
|
+
const segments = normalized.split('/');
|
|
2879
|
+
if (segments.some((segment) => !segment || segment === '.' || segment === '..')) {
|
|
2880
|
+
return '';
|
|
2881
|
+
}
|
|
2882
|
+
if (segments.includes(COMPANION_MANIFEST_FILE)) {
|
|
2883
|
+
return '';
|
|
2884
|
+
}
|
|
2885
|
+
return segments.join('/');
|
|
2886
|
+
}
|
|
2887
|
+
function pruneEmptyParentDirs(skillDir, filePath) {
|
|
2888
|
+
let current = dirname(filePath);
|
|
2889
|
+
while (current && current !== skillDir && current.startsWith(`${skillDir}`)) {
|
|
2890
|
+
try {
|
|
2891
|
+
const entries = readdirSync(current);
|
|
2892
|
+
if (entries.length > 0) {
|
|
2893
|
+
break;
|
|
2894
|
+
}
|
|
2895
|
+
rmSync(current, { recursive: false, force: true });
|
|
2896
|
+
}
|
|
2897
|
+
catch {
|
|
2898
|
+
break;
|
|
2899
|
+
}
|
|
2900
|
+
current = dirname(current);
|
|
2901
|
+
}
|
|
2902
|
+
}
|
|
2903
|
+
function sameStringArrays(a, b) {
|
|
2904
|
+
if (a.length !== b.length)
|
|
2905
|
+
return false;
|
|
2906
|
+
for (let i = 0; i < a.length; i++) {
|
|
2907
|
+
if (a[i] !== b[i])
|
|
2908
|
+
return false;
|
|
2909
|
+
}
|
|
2910
|
+
return true;
|
|
2911
|
+
}
|
|
2912
|
+
function normalizeSkillPath(path) {
|
|
2913
|
+
if (!path)
|
|
2914
|
+
return '';
|
|
2915
|
+
const normalized = path.replace(/^\/+|\/+$/g, '');
|
|
2916
|
+
if (!normalized)
|
|
2917
|
+
return '';
|
|
2918
|
+
return normalized.replace(/(?:^|\/)SKILL\.md$/i, '');
|
|
2919
|
+
}
|
|
2920
|
+
function toSkillFilePath(path) {
|
|
2921
|
+
const normalized = normalizeSkillPath(path);
|
|
2922
|
+
return normalized ? `${normalized}/SKILL.md` : 'SKILL.md';
|
|
2923
|
+
}
|
|
1836
2924
|
function getSourceFromRegistrySkill(skill) {
|
|
1837
2925
|
if (skill.githubUrl) {
|
|
1838
2926
|
const source = parseSource(skill.githubUrl);
|
|
@@ -1845,7 +2933,7 @@ function getSourceFromRegistrySkill(skill) {
|
|
|
1845
2933
|
platform: 'github',
|
|
1846
2934
|
owner: skill.owner,
|
|
1847
2935
|
repo: skill.repo,
|
|
1848
|
-
path: skill.skillPath,
|
|
2936
|
+
path: normalizeSkillPath(skill.skillPath) || undefined,
|
|
1849
2937
|
};
|
|
1850
2938
|
}
|
|
1851
2939
|
return null;
|
|
@@ -1863,6 +2951,22 @@ function getRegistrySlug$1(skill, fallback) {
|
|
|
1863
2951
|
}
|
|
1864
2952
|
return fallback;
|
|
1865
2953
|
}
|
|
2954
|
+
function getMissingRequestedNames(requestedSkillNames, resolved) {
|
|
2955
|
+
if (requestedSkillNames.length === 0)
|
|
2956
|
+
return [];
|
|
2957
|
+
const foundNames = new Set(resolved.map((entry) => entry.skill.name.toLowerCase()));
|
|
2958
|
+
const missing = [];
|
|
2959
|
+
const seenMissing = new Set();
|
|
2960
|
+
for (const requested of requestedSkillNames) {
|
|
2961
|
+
const key = requested.toLowerCase();
|
|
2962
|
+
if (foundNames.has(key) || seenMissing.has(key)) {
|
|
2963
|
+
continue;
|
|
2964
|
+
}
|
|
2965
|
+
seenMissing.add(key);
|
|
2966
|
+
missing.push(requested);
|
|
2967
|
+
}
|
|
2968
|
+
return missing;
|
|
2969
|
+
}
|
|
1866
2970
|
|
|
1867
2971
|
function discoverLocalSkills(agents, global) {
|
|
1868
2972
|
const skills = [];
|