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/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/branch/path
27
- const githubMatch = source.match(/github\.com\/([^\/]+)\/([^\/]+)(?:\/tree\/([^\/]+))?(?:\/(.+))?$/);
28
- if (githubMatch) {
29
- return {
30
- platform: 'github',
31
- owner: githubMatch[1],
32
- repo: githubMatch[2].replace(/\.git$/, ''),
33
- branch: githubMatch[3],
34
- path: githubMatch[4]
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 response = await fetch(`${GITHUB_API$1}/repos/${owner}/${repo}`, {
396
- headers: {
397
- 'Accept': 'application/vnd.github+json',
398
- 'User-Agent': 'skillscat-cli/1.0'
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
- const data = await response.json();
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 response = await fetch(`${GITHUB_API$1}/repos/${owner}/${repo}/git/trees/${branch}?recursive=1`, {
426
- headers: {
427
- 'Accept': 'application/vnd.github+json',
428
- 'User-Agent': 'skillscat-cli/1.0'
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
- * Fetch file content from GitHub
439
- */
440
- async function fetchGitHubFile(owner, repo, path, ref) {
441
- const url = ref
442
- ? `${GITHUB_API$1}/repos/${owner}/${repo}/contents/${path}?ref=${ref}`
443
- : `${GITHUB_API$1}/repos/${owner}/${repo}/contents/${path}`;
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(`File not found: ${path}`);
883
+ throw new Error(`Failed to fetch blob: ${sha}`);
452
884
  }
453
885
  const data = await response.json();
454
- if (data.encoding === 'base64' && data.content) {
455
- return Buffer.from(data.content, 'base64').toString('utf-8');
886
+ if (data.encoding !== 'base64' || !data.content) {
887
+ throw new Error(`Unexpected blob encoding for ${sha}`);
456
888
  }
457
- throw new Error(`Unexpected file encoding: ${data.encoding}`);
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 fetch(url, {
467
- headers: {
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
- return null;
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
- return data.sha;
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
- const content = platform === 'github'
546
- ? await fetchGitHubFile(owner, repo, skillPath, branch)
547
- : await fetchGitLabFile(owner, repo, skillPath, branch);
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: sha || undefined,
1024
+ sha,
559
1025
  contentHash: calculateContentHash(content)
560
1026
  });
561
1027
  }
562
1028
  }
563
- catch {
564
- // Skill not found at path
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 fetchGitHubFile(owner, repo, file.path, branch)
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 fetch(url, {
1079
- headers: {
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 skills;
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
- skills = await discoverSkills(repoSource);
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
- // GitHub/GitLab discovery failed — try the registry as fallback
1608
- verboseLog(`Git discovery failed: ${err instanceof Error ? err.message : 'unknown'}`);
1609
- verboseLog('Trying registry fallback...');
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 (skills.length === 0) {
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 ${skills.length} skill(s):`));
2353
+ console.log(pc.bold(`Found ${resolvedSkills.length} skill(s):`));
1660
2354
  console.log();
1661
- for (const skill of skills) {
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
- // Filter skills by name if specified
1673
- let selectedSkills = skills;
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
- selectedSkills = skills.filter(s => options.skill.some(name => s.name.toLowerCase() === name.toLowerCase()));
1676
- if (selectedSkills.length === 0) {
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 skill of skills) {
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
- // Default to Claude Code in --yes mode
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 ${selectedSkills.length} skill(s) to ${targetAgents.length} agent(s):`));
2430
+ console.log(pc.bold(`Installing ${selectedEntries.length} skill(s) to ${targetAgents.length} agent(s):`));
1737
2431
  console.log();
1738
- // Show what will be installed
1739
- for (const skill of selectedSkills) {
1740
- console.log(` ${pc.green('•')} ${pc.bold(skill.name)}`);
1741
- console.log(` ${pc.dim(skill.description)}`);
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
- // Install skills
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
- for (const skill of selectedSkills) {
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
- // Cache the skill content
1792
- if (cacheOwner && cacheRepo) {
1793
- const cacheSkillPath = skill.path !== 'SKILL.md'
1794
- ? skill.path.replace(/\/SKILL\.md$/, '')
1795
- : cachePath?.replace(/\/SKILL\.md$/, '');
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
- // Track installation on server (non-blocking, fail-silent)
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 = [];