promptgraph-mcp 2.9.56 → 2.9.57
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/api.js +2 -2
- package/commands/add-dir.js +1 -1
- package/commands/marketplace.js +157 -157
- package/github-import.js +934 -923
- package/indexer.js +2 -2
- package/marketplace.js +889 -889
- package/package.json +1 -1
- package/src/filter/hard-filter.js +13 -3
- package/src/filter/train.js +1 -1
package/github-import.js
CHANGED
|
@@ -1,923 +1,934 @@
|
|
|
1
|
-
import { spawnSync } from 'child_process';
|
|
2
|
-
import path from 'path';
|
|
3
|
-
import fs from 'fs';
|
|
4
|
-
import https from 'https';
|
|
5
|
-
import { globSync } from 'glob';
|
|
6
|
-
import { indexAll, indexSource } from './indexer.js';
|
|
7
|
-
import { loadConfig, saveConfig, PROMPTGRAPH_DIR, getSkillsStoreDir, MAX_DOWNLOAD_SIZE, MAX_FILE_COUNT, MAX_REPO_SIZE, RATE_LIMIT_REQUESTS, RATE_LIMIT_WINDOW_MS } from './config.js';
|
|
8
|
-
import { validateSkill } from './validator.js';
|
|
9
|
-
import { isSkillFile, filterWithClassifier, parseSkillFile } from './parser.js';
|
|
10
|
-
import { RateLimiter } from './src/utils/rate-limiter.js';
|
|
11
|
-
|
|
12
|
-
const githubRateLimiter = new RateLimiter({ maxRequests: RATE_LIMIT_REQUESTS, windowMs: RATE_LIMIT_WINDOW_MS })
|
|
13
|
-
const downloadRateLimiter = new RateLimiter({ maxRequests: RATE_LIMIT_REQUESTS * 2, windowMs: RATE_LIMIT_WINDOW_MS })
|
|
14
|
-
|
|
15
|
-
const SKILL_DIRS = ['skills', 'commands', 'prompts', 'agents', 'skills-store', 'slash-commands', 'custom-commands', 'templates'];
|
|
16
|
-
|
|
17
|
-
// glob v13 brace-expansion ({py,sh,...}) returns nothing on Windows — use an
|
|
18
|
-
// array of explicit patterns instead so script detection works cross-platform.
|
|
19
|
-
export const SCRIPT_GLOBS = ['**/*.py', '**/*.sh', '**/*.bash', '**/*.js', '**/*.ts', '**/*.rb'];
|
|
20
|
-
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
if (
|
|
44
|
-
r.
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
if (
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
req
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
);
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
//
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
const
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
//
|
|
378
|
-
const
|
|
379
|
-
const
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
]);
|
|
387
|
-
|
|
388
|
-
//
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
if (
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
:
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
if (!
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
const
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
//
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
//
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
const
|
|
810
|
-
if (
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
//
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
//
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
//
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
const {
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
}
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
}
|
|
1
|
+
import { spawnSync } from 'child_process';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import https from 'https';
|
|
5
|
+
import { globSync } from 'glob';
|
|
6
|
+
import { indexAll, indexSource } from './indexer.js';
|
|
7
|
+
import { loadConfig, saveConfig, PROMPTGRAPH_DIR, getSkillsStoreDir, MAX_DOWNLOAD_SIZE, MAX_FILE_COUNT, MAX_REPO_SIZE, RATE_LIMIT_REQUESTS, RATE_LIMIT_WINDOW_MS } from './config.js';
|
|
8
|
+
import { validateSkill } from './validator.js';
|
|
9
|
+
import { isSkillFile, filterWithClassifier, parseSkillFile } from './parser.js';
|
|
10
|
+
import { RateLimiter } from './src/utils/rate-limiter.js';
|
|
11
|
+
|
|
12
|
+
const githubRateLimiter = new RateLimiter({ maxRequests: RATE_LIMIT_REQUESTS, windowMs: RATE_LIMIT_WINDOW_MS })
|
|
13
|
+
const downloadRateLimiter = new RateLimiter({ maxRequests: RATE_LIMIT_REQUESTS * 2, windowMs: RATE_LIMIT_WINDOW_MS })
|
|
14
|
+
|
|
15
|
+
const SKILL_DIRS = ['skills', 'commands', 'prompts', 'agents', 'skills-store', 'slash-commands', 'custom-commands', 'templates'];
|
|
16
|
+
|
|
17
|
+
// glob v13 brace-expansion ({py,sh,...}) returns nothing on Windows — use an
|
|
18
|
+
// array of explicit patterns instead so script detection works cross-platform.
|
|
19
|
+
export const SCRIPT_GLOBS = ['**/*.py', '**/*.sh', '**/*.bash', '**/*.js', '**/*.ts', '**/*.rb'];
|
|
20
|
+
|
|
21
|
+
// GitHub Copilot/agent convention places skills under .github/{skills,prompts,agents,commands}
|
|
22
|
+
// (e.g. microsoft/skills). Recognized as skill locations despite .github being a skip dir.
|
|
23
|
+
const COPILOT_SKILL_DIRS = new Set(['skills', 'prompts', 'agents', 'commands']);
|
|
24
|
+
|
|
25
|
+
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
const repoStats = new Map()
|
|
28
|
+
|
|
29
|
+
function getRepoStats(ownerRepo) {
|
|
30
|
+
if (!repoStats.has(ownerRepo)) {
|
|
31
|
+
repoStats.set(ownerRepo, { totalBytes: 0, fileCount: 0 })
|
|
32
|
+
}
|
|
33
|
+
return repoStats.get(ownerRepo)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function streamDownload(url, maxSize = MAX_DOWNLOAD_SIZE, redirects = 0) {
|
|
37
|
+
if (redirects > 5) return Promise.reject(new Error('Too many redirects'))
|
|
38
|
+
return new Promise((res, rej) => {
|
|
39
|
+
const token = process.env.GITHUB_TOKEN;
|
|
40
|
+
const headers = { 'User-Agent': 'promptgraph-mcp' };
|
|
41
|
+
if (token && url.startsWith('https://raw.')) headers['Authorization'] = `Bearer ${token}`;
|
|
42
|
+
const req = https.get(url, { headers }, r => {
|
|
43
|
+
if (r.statusCode >= 300 && r.statusCode < 400 && r.headers.location)
|
|
44
|
+
return streamDownload(r.headers.location, maxSize, redirects + 1).then(res, rej);
|
|
45
|
+
if (r.statusCode !== 200) { r.resume(); return rej(new Error(`HTTP ${r.statusCode}`)); }
|
|
46
|
+
const cl = parseInt(r.headers['content-length'], 10);
|
|
47
|
+
if (!isNaN(cl) && cl > maxSize) {
|
|
48
|
+
r.resume();
|
|
49
|
+
return rej(new Error(`Content-Length ${cl} exceeds max ${maxSize}`));
|
|
50
|
+
}
|
|
51
|
+
const chunks = []
|
|
52
|
+
let total = 0
|
|
53
|
+
r.setEncoding('utf8')
|
|
54
|
+
r.on('data', c => {
|
|
55
|
+
total += Buffer.byteLength(c, 'utf8')
|
|
56
|
+
if (total > maxSize) {
|
|
57
|
+
r.destroy()
|
|
58
|
+
return rej(new Error(`Download exceeded ${maxSize} bytes`))
|
|
59
|
+
}
|
|
60
|
+
chunks.push(c)
|
|
61
|
+
})
|
|
62
|
+
r.on('end', () => res(chunks.join('')))
|
|
63
|
+
})
|
|
64
|
+
req.setTimeout(30000, () => req.destroy(new Error('streamDownload timeout')))
|
|
65
|
+
req.on('error', rej)
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function getGhToken() {
|
|
70
|
+
const envToken = process.env.GITHUB_TOKEN;
|
|
71
|
+
if (envToken) return envToken;
|
|
72
|
+
try {
|
|
73
|
+
const r = spawnSync('gh', ['auth', 'token'], { encoding: 'utf8', timeout: 5000 });
|
|
74
|
+
if (r.status === 0 && r.stdout.trim()) return r.stdout.trim();
|
|
75
|
+
} catch {}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function httpsGet(url, redirects = 0) {
|
|
80
|
+
if (redirects > 5) return Promise.reject(new Error('Too many redirects'))
|
|
81
|
+
await githubRateLimiter.acquire()
|
|
82
|
+
const token = getGhToken();
|
|
83
|
+
const headers = { 'User-Agent': 'promptgraph-mcp' };
|
|
84
|
+
if (token && url.startsWith('https://api.github.com/')) headers['Authorization'] = `Bearer ${token}`;
|
|
85
|
+
return new Promise((res, rej) => {
|
|
86
|
+
const req = https.get(url, { headers }, r => {
|
|
87
|
+
if (r.statusCode >= 300 && r.statusCode < 400 && r.headers.location)
|
|
88
|
+
return httpsGet(r.headers.location, redirects + 1).then(res, rej);
|
|
89
|
+
if (r.statusCode !== 200) { r.resume(); return rej(new Error(`HTTP ${r.statusCode}`)); }
|
|
90
|
+
const chunks = []; r.setEncoding('utf8'); r.on('data', c => chunks.push(c)); r.on('end', () => res(chunks.join('')));
|
|
91
|
+
});
|
|
92
|
+
req.setTimeout(30000, () => req.destroy(new Error('httpsGet timeout')));
|
|
93
|
+
req.on('error', rej);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function repoExists(ownerRepo) {
|
|
98
|
+
return new Promise(resolve => {
|
|
99
|
+
const req = https.request(
|
|
100
|
+
{ host: 'github.com', path: `/${ownerRepo}`, method: 'HEAD', headers: { 'User-Agent': 'promptgraph-mcp' } },
|
|
101
|
+
r => resolve(r.statusCode < 400)
|
|
102
|
+
);
|
|
103
|
+
req.on('error', () => resolve(false));
|
|
104
|
+
req.end();
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Download one .md file, run validateSkill on it, return errors/warnings.
|
|
109
|
+
async function validateMdFile(file, tmpDir, ownerRepo) {
|
|
110
|
+
const errors = [];
|
|
111
|
+
const warnings = [];
|
|
112
|
+
const stats = getRepoStats(ownerRepo);
|
|
113
|
+
try {
|
|
114
|
+
if (stats.fileCount >= MAX_FILE_COUNT) {
|
|
115
|
+
errors.push(`${file.name}: skipped — repo file count limit (${MAX_FILE_COUNT}) reached`);
|
|
116
|
+
return { errors, warnings };
|
|
117
|
+
}
|
|
118
|
+
await downloadRateLimiter.acquire()
|
|
119
|
+
const content = await streamDownload(file.download_url);
|
|
120
|
+
stats.totalBytes += Buffer.byteLength(content, 'utf8');
|
|
121
|
+
stats.fileCount++;
|
|
122
|
+
if (stats.totalBytes > MAX_REPO_SIZE) {
|
|
123
|
+
return { errors: [...errors, `${file.name}: repo size limit (${MAX_REPO_SIZE}) exceeded`], warnings };
|
|
124
|
+
}
|
|
125
|
+
const tmpPath = path.join(tmpDir, file.name);
|
|
126
|
+
fs.mkdirSync(path.dirname(tmpPath), { recursive: true });
|
|
127
|
+
fs.writeFileSync(tmpPath, content);
|
|
128
|
+
const result = validateSkill(tmpPath);
|
|
129
|
+
if (!result.ok) {
|
|
130
|
+
errors.push(`${file.name}: ${result.errors.join('; ')}`);
|
|
131
|
+
}
|
|
132
|
+
if (result.warnings?.length) {
|
|
133
|
+
warnings.push(...result.warnings.map(w => `${file.name}: ${w}`));
|
|
134
|
+
}
|
|
135
|
+
fs.unlinkSync(tmpPath);
|
|
136
|
+
} catch (e) {
|
|
137
|
+
errors.push(`${file.name}: failed to validate — ${e.message}`);
|
|
138
|
+
}
|
|
139
|
+
return { errors, warnings };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Validate all .md files in a repo's skills subdir against validateSkill().
|
|
143
|
+
// Falls back to root-level .md files if no skills subdirectory is found.
|
|
144
|
+
// Returns { ok, errors[], warnings[] }.
|
|
145
|
+
export async function validateRepoSkills(ownerRepo) {
|
|
146
|
+
const detected = await detectSkillsDirFromAPI(ownerRepo);
|
|
147
|
+
const tmpDir = path.join(PROMPTGRAPH_DIR, '.validate-tmp');
|
|
148
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
149
|
+
|
|
150
|
+
let mdFiles;
|
|
151
|
+
if (detected) {
|
|
152
|
+
// Has a skills subdirectory — use git tree API for recursive listing
|
|
153
|
+
const subdir = detected.subdir;
|
|
154
|
+
try {
|
|
155
|
+
const treeJson = await httpsGet(`https://api.github.com/repos/${ownerRepo}/git/trees/HEAD?recursive=1`);
|
|
156
|
+
const tree = JSON.parse(treeJson);
|
|
157
|
+
const prefix = subdir + '/';
|
|
158
|
+
const mdTreeEntries = (tree.tree || []).filter(f =>
|
|
159
|
+
f.type === 'blob' && f.path.startsWith(prefix) && f.path.endsWith('.md')
|
|
160
|
+
);
|
|
161
|
+
let branch = 'main';
|
|
162
|
+
try {
|
|
163
|
+
const repoJson = await httpsGet(`https://api.github.com/repos/${ownerRepo}`);
|
|
164
|
+
branch = JSON.parse(repoJson).default_branch || 'main';
|
|
165
|
+
} catch {}
|
|
166
|
+
mdFiles = mdTreeEntries.map(f => ({
|
|
167
|
+
name: f.path.replace(prefix, ''),
|
|
168
|
+
download_url: `https://raw.githubusercontent.com/${ownerRepo}/${branch}/${f.path}`
|
|
169
|
+
}));
|
|
170
|
+
} catch (e) {
|
|
171
|
+
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
|
172
|
+
return { ok: false, errors: [`Failed to list ${subdir}/ contents: ${e.message}`], warnings: [] };
|
|
173
|
+
}
|
|
174
|
+
} else {
|
|
175
|
+
// No skills subdir — fall back to root-level .md files
|
|
176
|
+
try {
|
|
177
|
+
const json = await httpsGet(`https://api.github.com/repos/${ownerRepo}/contents`);
|
|
178
|
+
const entries = JSON.parse(json);
|
|
179
|
+
mdFiles = entries.filter(e => e.type === 'file' && e.name.endsWith('.md'));
|
|
180
|
+
} catch (e) {
|
|
181
|
+
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
|
182
|
+
return { ok: false, errors: [`Failed to list repo root: ${e.message}`], warnings: [] };
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (!mdFiles || mdFiles.length === 0) {
|
|
187
|
+
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
|
188
|
+
return { ok: false, errors: [`No .md files found in ${ownerRepo}`], warnings: [] };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Filter out docs-like filenames (README, LICENSE, CHANGELOG, etc.)
|
|
192
|
+
const SKIP_DOCS = /^(readme|license|changelog|contributing|code.?of.?conduct|security|authors|credits|install|faq|index|overview|summary|todo|notes|template|copying|warranty|funding|roadmap|claude|bugs?\b|feature.?request)/i;
|
|
193
|
+
const mdTrimmed = mdFiles.filter(f => !SKIP_DOCS.test(f.name.replace(/\.md$/i, '')));
|
|
194
|
+
const mdToValidate = mdTrimmed.length > 0 ? mdTrimmed : mdFiles;
|
|
195
|
+
|
|
196
|
+
let errors = [];
|
|
197
|
+
let warnings = [];
|
|
198
|
+
|
|
199
|
+
for (const file of mdToValidate) {
|
|
200
|
+
const r = await validateMdFile(file, tmpDir, ownerRepo);
|
|
201
|
+
errors.push(...r.errors);
|
|
202
|
+
warnings.push(...r.warnings);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
|
206
|
+
|
|
207
|
+
return { ok: errors.length === 0, errors, warnings };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const SCRIPT_EXTS_API = new Set(['.py', '.sh', '.bash', '.js', '.ts', '.rb']);
|
|
211
|
+
|
|
212
|
+
// Ask GitHub API which subdir to use (without cloning anything). Exported for validation.
|
|
213
|
+
// Returns { subdir, label, validMdCount, hasScripts } or null (repo not found / no skills).
|
|
214
|
+
//
|
|
215
|
+
// Uses the recursive git-tree API (1 call) and counts .md files RECURSIVELY under each
|
|
216
|
+
// candidate dir. This handles nested layouts like skills/cloud/*.md or skills/<name>/SKILL.md
|
|
217
|
+
// where a skill dir holds category subfolders rather than .md files directly — the old
|
|
218
|
+
// shallow per-dir listing reported "0 skills" for those and blocked publishing.
|
|
219
|
+
export
|
|
220
|
+
async function detectSkillsDirFromAPI(ownerRepo) {
|
|
221
|
+
let tree;
|
|
222
|
+
try {
|
|
223
|
+
tree = JSON.parse(await httpsGet(`https://api.github.com/repos/${ownerRepo}/git/trees/HEAD?recursive=1`));
|
|
224
|
+
} catch {
|
|
225
|
+
return null; // repo not found or inaccessible
|
|
226
|
+
}
|
|
227
|
+
if (!tree || !Array.isArray(tree.tree)) return null;
|
|
228
|
+
|
|
229
|
+
const blobs = tree.tree.filter(f => f.type === 'blob');
|
|
230
|
+
if (blobs.length === 0) return null;
|
|
231
|
+
|
|
232
|
+
// A path is a valid skill .md if the filename isn't meta (readme/license/…) and no
|
|
233
|
+
// path segment is a skip dir (docs/tests/assets/…). Exception: .github/{skills,
|
|
234
|
+
// prompts,agents,commands} is the GitHub Copilot/agent skill convention.
|
|
235
|
+
const isValidMd = (p) => {
|
|
236
|
+
if (!p.endsWith('.md')) return false;
|
|
237
|
+
if (SKIP_RE.test(path.basename(p, '.md').toLowerCase())) return false;
|
|
238
|
+
const segs = p.split('/');
|
|
239
|
+
for (let i = 0; i < segs.length - 1; i++) {
|
|
240
|
+
const seg = segs[i].toLowerCase();
|
|
241
|
+
if (SKIP_DIRS_API.has(seg)) {
|
|
242
|
+
if (seg === '.github' && COPILOT_SKILL_DIRS.has((segs[i + 1] || '').toLowerCase())) continue;
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return true;
|
|
247
|
+
};
|
|
248
|
+
const mdBlobs = blobs.filter(f => isValidMd(f.path));
|
|
249
|
+
if (mdBlobs.length === 0) return null;
|
|
250
|
+
|
|
251
|
+
const countUnder = (prefix) => mdBlobs.filter(f => f.path.startsWith(prefix)).length;
|
|
252
|
+
const scriptUnder = (prefix) => blobs.some(f => f.path.startsWith(prefix) && SCRIPT_EXTS_API.has(path.extname(f.path).toLowerCase()));
|
|
253
|
+
|
|
254
|
+
// Map of top-level dir names (lowercase -> real casing)
|
|
255
|
+
const topDirs = new Map();
|
|
256
|
+
for (const f of blobs) {
|
|
257
|
+
const idx = f.path.indexOf('/');
|
|
258
|
+
if (idx > 0) { const d = f.path.slice(0, idx); topDirs.set(d.toLowerCase(), d); }
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// 1. Known skill dir names (priority order) — counted recursively
|
|
262
|
+
for (const d of SKILL_DIRS) {
|
|
263
|
+
if (topDirs.has(d)) {
|
|
264
|
+
const real = topDirs.get(d);
|
|
265
|
+
const c = countUnder(`${real}/`);
|
|
266
|
+
if (c > 0) return { subdir: real, label: real, validMdCount: c, hasScripts: scriptUnder(`${real}/`) };
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// 1.5 Nested skill dirs (.claude/skills, .claude-plugin/commands, .github/skills, …)
|
|
271
|
+
for (const prefix of ['.claude', '.claude-plugin', '.github']) {
|
|
272
|
+
if (topDirs.has(prefix)) {
|
|
273
|
+
const real = topDirs.get(prefix);
|
|
274
|
+
for (const d of SKILL_DIRS) {
|
|
275
|
+
const nested = `${real}/${d}`;
|
|
276
|
+
const c = countUnder(`${nested}/`);
|
|
277
|
+
if (c > 0) return { subdir: nested, label: nested, validMdCount: c, hasScripts: scriptUnder(`${nested}/`) };
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// 2. Root-level .md files (skills kept directly at repo root)
|
|
283
|
+
const rootMd = mdBlobs.filter(f => !f.path.includes('/')).length;
|
|
284
|
+
|
|
285
|
+
// 3. Best non-skip top-level subdir by recursive .md count
|
|
286
|
+
let best = null, bestCount = 0;
|
|
287
|
+
for (const [low, real] of topDirs) {
|
|
288
|
+
if (SKIP_DIRS_API.has(low)) continue;
|
|
289
|
+
const c = countUnder(`${real}/`);
|
|
290
|
+
if (c > bestCount) { bestCount = c; best = real; }
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Prefer root when it holds the skills (and is at least as rich as any subdir)
|
|
294
|
+
if (rootMd >= 1 && rootMd >= bestCount) {
|
|
295
|
+
return { subdir: null, label: 'root', validMdCount: rootMd, hasScripts: scriptUnder('') };
|
|
296
|
+
}
|
|
297
|
+
if (best) return { subdir: best, label: best, validMdCount: bestCount, hasScripts: scriptUnder(`${best}/`) };
|
|
298
|
+
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Deep-validate a repo bundle via 1 API call (tree) + raw file fetches (not rate-limited).
|
|
303
|
+
// Returns { passed, total, hasScripts } — passed = skills that survive validateSkill().
|
|
304
|
+
export async function deepValidateRepo(ownerRepo, subdir, onProgress) {
|
|
305
|
+
const treeJson = await httpsGet(`https://api.github.com/repos/${ownerRepo}/git/trees/HEAD?recursive=1`);
|
|
306
|
+
const tree = JSON.parse(treeJson);
|
|
307
|
+
|
|
308
|
+
const prefix = subdir ? `${subdir}/` : '';
|
|
309
|
+
const allBlobs = (tree.tree || []).filter(f => f.type === 'blob');
|
|
310
|
+
|
|
311
|
+
const mdFiles = allBlobs.filter(f =>
|
|
312
|
+
f.path.startsWith(prefix) &&
|
|
313
|
+
f.path.endsWith('.md') &&
|
|
314
|
+
!SKIP_RE.test(path.basename(f.path, '.md').toLowerCase())
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
const hasScripts = allBlobs.some(f =>
|
|
318
|
+
f.path.startsWith(prefix) && SCRIPT_EXTS.has(path.extname(f.path).toLowerCase())
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
const tmpDir = fs.mkdtempSync(path.join(PROMPTGRAPH_DIR, 'pg-val-'));
|
|
322
|
+
let passed = 0;
|
|
323
|
+
const total = mdFiles.length;
|
|
324
|
+
|
|
325
|
+
try {
|
|
326
|
+
for (let i = 0; i < mdFiles.length; i++) {
|
|
327
|
+
const f = mdFiles[i];
|
|
328
|
+
if (onProgress) onProgress(i + 1, total);
|
|
329
|
+
try {
|
|
330
|
+
const rawUrl = `https://raw.githubusercontent.com/${ownerRepo}/HEAD/${f.path}`;
|
|
331
|
+
const content = await streamDownload(rawUrl);
|
|
332
|
+
const tmpFile = path.join(tmpDir, `skill-${i}.md`);
|
|
333
|
+
fs.writeFileSync(tmpFile, content);
|
|
334
|
+
if (validateSkill(tmpFile).ok) passed++;
|
|
335
|
+
} catch {}
|
|
336
|
+
}
|
|
337
|
+
} finally {
|
|
338
|
+
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return { passed, total, hasScripts };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const SKIP_DIRS_API = new Set([
|
|
345
|
+
'.github', 'docs', 'doc', 'documentation', 'examples', 'example',
|
|
346
|
+
'tests', 'test', 'assets', 'images', 'img', 'media', 'static',
|
|
347
|
+
'node_modules', 'vendor', 'dist', 'build', '.git',
|
|
348
|
+
'references', 'reference', 'refs', 'cheatsheet', 'cheat-sheet',
|
|
349
|
+
'cheatsheets', 'resources',
|
|
350
|
+
'src', 'cli', 'lib', 'bin', 'scripts',
|
|
351
|
+
]);
|
|
352
|
+
|
|
353
|
+
function git(args, cwd, stdio = 'inherit') {
|
|
354
|
+
return spawnSync('git', args, { cwd, stdio });
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Clone only the skills subdir via sparse-checkout
|
|
358
|
+
function sparseClone(url, dest, subdir) {
|
|
359
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
360
|
+
|
|
361
|
+
// 1. init + add remote
|
|
362
|
+
if (git(['init'], dest, 'pipe').status !== 0) return false;
|
|
363
|
+
if (git(['remote', 'add', 'origin', url], dest, 'pipe').status !== 0) return false;
|
|
364
|
+
|
|
365
|
+
// 2. sparse-checkout — non-cone mode with *.md + script files
|
|
366
|
+
git(['sparse-checkout', 'init'], dest, 'pipe');
|
|
367
|
+
git(['sparse-checkout', 'set', '--no-cone',
|
|
368
|
+
`${subdir}/*.md`, `${subdir}/**/*.md`,
|
|
369
|
+
`${subdir}/**/*.py`, `${subdir}/**/*.sh`, `${subdir}/**/*.js`,
|
|
370
|
+
`${subdir}/**/*.ts`, `${subdir}/**/*.rb`, `${subdir}/**/*.bash`,
|
|
371
|
+
], dest, 'pipe');
|
|
372
|
+
|
|
373
|
+
// 3. fetch + checkout (depth=1, skip large blobs)
|
|
374
|
+
const fetch = git(['fetch', '--depth=1', '--filter=blob:none', 'origin'], dest);
|
|
375
|
+
if (fetch.status !== 0) return false;
|
|
376
|
+
|
|
377
|
+
// Try HEAD, then main, then master
|
|
378
|
+
for (const branch of ['HEAD', 'main', 'master']) {
|
|
379
|
+
const r = git(['checkout', branch === 'HEAD' ? 'FETCH_HEAD' : branch], dest, 'pipe');
|
|
380
|
+
if (r.status === 0) return finalizeCheckout(dest, true);
|
|
381
|
+
}
|
|
382
|
+
return false;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Script extensions to preserve during cleanup (sparse-checkout fetches them alongside .md)
|
|
386
|
+
const SCRIPT_EXTS = new Set(['.py', '.sh', '.bash', '.js', '.ts', '.rb']);
|
|
387
|
+
|
|
388
|
+
// Shared skip patterns — module scope so both cleanup functions can access them
|
|
389
|
+
const SKIP_RE = /^(readme|changelog|license|contributing|code.of.conduct|security|authors|credits|install|installation|usage|promotion|faq|glossary|index|overview|summary|roadmap|todo|notes|template|example|sample|demo|guide|tutorial|walkthrough|architecture|design|spec|requirements|privacy|terms|disclaimer|notice|copying|warranty|funding|claude|bugs?\b|feature.?request)/i;
|
|
390
|
+
const SKIP_DIRS_LOCAL = new Set([
|
|
391
|
+
'.github', 'docs', 'doc', 'assets', 'images', 'img', 'screenshots',
|
|
392
|
+
'media', 'static', 'scripts', 'ci_scripts', 'node_modules', 'vendor',
|
|
393
|
+
'dist', 'build', 'tests', 'test',
|
|
394
|
+
'references', 'reference', 'refs', 'cheatsheet', 'cheat-sheet',
|
|
395
|
+
'cheatsheets', 'resources',
|
|
396
|
+
'src', 'cli', 'lib', 'bin',
|
|
397
|
+
]);
|
|
398
|
+
|
|
399
|
+
// After full-clone root: remove files that are not skills and dirs we don't need
|
|
400
|
+
function cleanupRepoRoot(repoRoot) {
|
|
401
|
+
let removed = 0;
|
|
402
|
+
const entries = fs.readdirSync(repoRoot, { withFileTypes: true });
|
|
403
|
+
for (const entry of entries) {
|
|
404
|
+
if (entry.name === '.git') continue;
|
|
405
|
+
const fullPath = path.join(repoRoot, entry.name);
|
|
406
|
+
if (entry.isDirectory()) {
|
|
407
|
+
if (SKIP_DIRS_LOCAL.has(entry.name.toLowerCase())) {
|
|
408
|
+
fs.rmSync(fullPath, { recursive: true, force: true });
|
|
409
|
+
removed++;
|
|
410
|
+
} else {
|
|
411
|
+
// Recurse into subdirectory to remove doc files
|
|
412
|
+
removed += cleanupRepoDir(fullPath, SKIP_RE);
|
|
413
|
+
}
|
|
414
|
+
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
415
|
+
const base = entry.name.replace(/\.md$/i, '').toLowerCase();
|
|
416
|
+
if (SKIP_RE.test(base)) {
|
|
417
|
+
fs.unlinkSync(fullPath);
|
|
418
|
+
removed++;
|
|
419
|
+
}
|
|
420
|
+
} else if (entry.isFile() && !entry.name.endsWith('.md')) {
|
|
421
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
422
|
+
if (entry.name !== '.gitignore' && !SCRIPT_EXTS.has(ext)) {
|
|
423
|
+
try { fs.unlinkSync(fullPath); removed++; } catch {}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
if (removed > 0) console.log(`Cleaned up ${removed} non-skill files/dirs`);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Recursively remove doc .md files from subdirectories
|
|
431
|
+
function cleanupRepoDir(dirPath, SKIP_RE) {
|
|
432
|
+
let removed = 0;
|
|
433
|
+
let entries;
|
|
434
|
+
try { entries = fs.readdirSync(dirPath, { withFileTypes: true }); } catch { return 0; }
|
|
435
|
+
for (const entry of entries) {
|
|
436
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
437
|
+
if (entry.isDirectory()) {
|
|
438
|
+
// Remove entire skip dirs (e.g. references/) nested inside skills dirs
|
|
439
|
+
if (SKIP_DIRS_LOCAL.has(entry.name.toLowerCase())) {
|
|
440
|
+
fs.rmSync(fullPath, { recursive: true, force: true });
|
|
441
|
+
removed++;
|
|
442
|
+
} else {
|
|
443
|
+
removed += cleanupRepoDir(fullPath, SKIP_RE);
|
|
444
|
+
try { if (fs.readdirSync(fullPath).length === 0) fs.rmdirSync(fullPath); } catch {}
|
|
445
|
+
}
|
|
446
|
+
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
447
|
+
const base = entry.name.replace(/\.md$/i, '').toLowerCase();
|
|
448
|
+
if (SKIP_RE.test(base)) {
|
|
449
|
+
fs.unlinkSync(fullPath);
|
|
450
|
+
removed++;
|
|
451
|
+
}
|
|
452
|
+
} else if (entry.isFile() && !entry.name.endsWith('.md')) {
|
|
453
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
454
|
+
if (!SCRIPT_EXTS.has(ext)) { try { fs.unlinkSync(fullPath); removed++; } catch {} }
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return removed;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Recursively remove empty directories
|
|
461
|
+
function removeEmptyDirs(dirPath) {
|
|
462
|
+
let entries;
|
|
463
|
+
try { entries = fs.readdirSync(dirPath, { withFileTypes: true }); } catch { return; }
|
|
464
|
+
for (const entry of entries) {
|
|
465
|
+
if (entry.name === '.git') continue;
|
|
466
|
+
if (!entry.isDirectory()) continue;
|
|
467
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
468
|
+
removeEmptyDirs(fullPath);
|
|
469
|
+
try { if (fs.readdirSync(fullPath).length === 0) fs.rmdirSync(fullPath); } catch {}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// After fetch/checkout: force materialization of all sparse-matched files.
|
|
474
|
+
// partial clone (blob:none) + checkout often skips blob download on Windows.
|
|
475
|
+
function forceMaterialize(dest) {
|
|
476
|
+
git(['checkout', 'HEAD', '--', '.'], dest, 'pipe');
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Update sparse repo — fetch + checkout
|
|
480
|
+
function sparseUpdate(dest, subdir) {
|
|
481
|
+
const fetch = git(['fetch', '--depth=1', 'origin'], dest);
|
|
482
|
+
if (fetch.status !== 0) return false;
|
|
483
|
+
|
|
484
|
+
git(['sparse-checkout', 'set', '--no-cone',
|
|
485
|
+
`${subdir}/*.md`, `${subdir}/**/*.md`,
|
|
486
|
+
`${subdir}/**/*.py`, `${subdir}/**/*.sh`, `${subdir}/**/*.js`,
|
|
487
|
+
`${subdir}/**/*.ts`, `${subdir}/**/*.rb`, `${subdir}/**/*.bash`,
|
|
488
|
+
], dest, 'pipe');
|
|
489
|
+
|
|
490
|
+
for (const ref of ['origin/main', 'origin/master']) {
|
|
491
|
+
const r = git(['checkout', ref], dest, 'pipe');
|
|
492
|
+
if (r.status !== 0) continue;
|
|
493
|
+
forceMaterialize(dest);
|
|
494
|
+
return true;
|
|
495
|
+
}
|
|
496
|
+
return false;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// After checkout, force materialization of sparse-matched files.
|
|
500
|
+
function finalizeCheckout(dest, success) {
|
|
501
|
+
if (success) {
|
|
502
|
+
forceMaterialize(dest);
|
|
503
|
+
// Make scripts executable on unix
|
|
504
|
+
if (process.platform !== 'win32') {
|
|
505
|
+
const scriptExts = ['.py', '.sh', '.bash', '.rb'];
|
|
506
|
+
try {
|
|
507
|
+
const scripts = globSync(`${dest}/**/*{${scriptExts.join(',')}}`, { absolute: true, dot: true });
|
|
508
|
+
for (const s of scripts) { try { fs.chmodSync(s, 0o755); } catch {} }
|
|
509
|
+
} catch {}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
return success;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Fallback: clone root but only checkout .md files
|
|
516
|
+
function fullClone(url, dest) {
|
|
517
|
+
if (fs.existsSync(dest)) {
|
|
518
|
+
const fetch = git(['fetch', '--depth=1', '--filter=blob:none', 'origin'], dest);
|
|
519
|
+
if (fetch.status !== 0) return false;
|
|
520
|
+
for (const ref of ['origin/HEAD', 'origin/main', 'origin/master']) {
|
|
521
|
+
const ok = git(['reset', '--hard', ref], dest, 'pipe').status === 0;
|
|
522
|
+
if (ok) return finalizeCheckout(dest, true);
|
|
523
|
+
}
|
|
524
|
+
return false;
|
|
525
|
+
}
|
|
526
|
+
// init + sparse *.md + fetch + checkout
|
|
527
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
528
|
+
if (git(['init'], dest, 'pipe').status !== 0) return false;
|
|
529
|
+
if (git(['remote', 'add', 'origin', url], dest, 'pipe').status !== 0) return false;
|
|
530
|
+
git(['sparse-checkout', 'init'], dest, 'pipe');
|
|
531
|
+
git(['sparse-checkout', 'set', '--no-cone',
|
|
532
|
+
'*.md', '**/*.md',
|
|
533
|
+
'**/*.py', '**/*.sh', '**/*.js', '**/*.ts', '**/*.rb', '**/*.bash',
|
|
534
|
+
], dest, 'pipe');
|
|
535
|
+
const fetch = git(['fetch', '--depth=1', '--filter=blob:none', 'origin'], dest);
|
|
536
|
+
if (fetch.status !== 0) return false;
|
|
537
|
+
for (const branch of ['FETCH_HEAD', 'main', 'master']) {
|
|
538
|
+
const r = git(['checkout', branch], dest, 'pipe');
|
|
539
|
+
if (r.status === 0) return finalizeCheckout(dest, true);
|
|
540
|
+
}
|
|
541
|
+
return false;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// After clone: detect actual skills dir on disk (flat + nested)
|
|
545
|
+
function detectSkillsDirLocal(repoRoot) {
|
|
546
|
+
// 1. Flat dirs matching SKILL_DIRS (e.g. skills/, commands/)
|
|
547
|
+
for (const dir of SKILL_DIRS) {
|
|
548
|
+
const candidate = path.join(repoRoot, dir);
|
|
549
|
+
if (fs.existsSync(candidate)) {
|
|
550
|
+
const files = globSync(`${candidate}/**/*.md`, { dot: true });
|
|
551
|
+
if (files.length >= 1) return { dir: candidate, label: dir, sparse: true };
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
// 2. Nested dirs: .claude/skills, .claude-plugin/commands, etc.
|
|
555
|
+
for (const prefix of ['.claude', '.claude-plugin']) {
|
|
556
|
+
for (const dir of SKILL_DIRS) {
|
|
557
|
+
const candidate = path.join(repoRoot, prefix, dir);
|
|
558
|
+
if (fs.existsSync(candidate)) {
|
|
559
|
+
const files = globSync(`${candidate}/**/*.md`, { dot: true });
|
|
560
|
+
if (files.length >= 1) return { dir: candidate, label: `${prefix}/${dir}`, sparse: true };
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
return { dir: repoRoot, label: '(root)', sparse: false };
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Remove files classified as non-skills by embedding classifier (only if model is trained)
|
|
568
|
+
async function classifierCleanup(dest) {
|
|
569
|
+
const mdFiles = globSync(`${dest}/**/*.md`, { dot: true });
|
|
570
|
+
if (mdFiles.length === 0) return;
|
|
571
|
+
|
|
572
|
+
const parsed = [];
|
|
573
|
+
const fileMap = [];
|
|
574
|
+
|
|
575
|
+
for (const fp of mdFiles) {
|
|
576
|
+
try {
|
|
577
|
+
const raw = fs.readFileSync(fp, 'utf8');
|
|
578
|
+
if (!isSkillFile(fp, raw)) continue;
|
|
579
|
+
parsed.push(parseSkillFile(fp, '', { raw }));
|
|
580
|
+
fileMap.push(fp);
|
|
581
|
+
} catch {}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (parsed.length === 0) {
|
|
585
|
+
removeEmptyDirs(dest);
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const filtered = await filterWithClassifier(parsed);
|
|
590
|
+
const keptPaths = new Set(filtered.map(s => s.path));
|
|
591
|
+
|
|
592
|
+
let removed = 0;
|
|
593
|
+
for (const fp of fileMap) {
|
|
594
|
+
if (!keptPaths.has(fp)) {
|
|
595
|
+
try { fs.unlinkSync(fp); removed++; } catch {}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
if (removed > 0) {
|
|
600
|
+
console.log(`Removed ${removed} non-skill files (classifier)`);
|
|
601
|
+
removeEmptyDirs(dest);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// ── main export ───────────────────────────────────────────────────────────────
|
|
606
|
+
|
|
607
|
+
export async function importFromGitHub(repoUrl) {
|
|
608
|
+
if (!repoUrl) {
|
|
609
|
+
console.error('Usage: promptgraph-mcp import <github-url-or-owner/repo>');
|
|
610
|
+
process.exit(1);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const url = repoUrl.startsWith('http') ? repoUrl : `https://github.com/${repoUrl}`;
|
|
614
|
+
const ownerRepo = url.replace(/^https?:\/\/github\.com\//, '').replace(/\.git$/, '');
|
|
615
|
+
const repoName = ownerRepo.replace('/', '-');
|
|
616
|
+
const dest = path.join(getSkillsStoreDir(), 'github', repoName);
|
|
617
|
+
|
|
618
|
+
const isNew = !fs.existsSync(dest);
|
|
619
|
+
|
|
620
|
+
if (isNew) {
|
|
621
|
+
const exists = await repoExists(ownerRepo);
|
|
622
|
+
if (!exists) throw new Error(`Repository not found (404): ${url}`);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
626
|
+
|
|
627
|
+
let skillsSubdir = null;
|
|
628
|
+
let cloneOk = false;
|
|
629
|
+
|
|
630
|
+
if (isNew) {
|
|
631
|
+
// Detect skills dir via API before cloning
|
|
632
|
+
process.stdout.write(`Detecting skills directory for ${ownerRepo}... `);
|
|
633
|
+
const detected = await detectSkillsDirFromAPI(ownerRepo);
|
|
634
|
+
skillsSubdir = detected?.subdir || null;
|
|
635
|
+
|
|
636
|
+
if (!skillsSubdir) {
|
|
637
|
+
console.log(`found: (root) — no skills subdirectory, using full clone`);
|
|
638
|
+
cloneOk = fullClone(url, dest);
|
|
639
|
+
} else {
|
|
640
|
+
console.log(`found: ${detected.label}/`);
|
|
641
|
+
console.log(`Sparse-cloning ${url} (${skillsSubdir}/ only)...`);
|
|
642
|
+
cloneOk = sparseClone(url, dest, skillsSubdir);
|
|
643
|
+
}
|
|
644
|
+
if (!cloneOk) {
|
|
645
|
+
fs.rmSync(dest, { recursive: true, force: true });
|
|
646
|
+
throw new Error(`Sparse-checkout failed for ${url}`);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
if (!cloneOk) throw new Error(`Clone failed for ${url}`);
|
|
650
|
+
} else {
|
|
651
|
+
console.log(`Updating ${repoName}...`);
|
|
652
|
+
// Detect existing sparse subdir
|
|
653
|
+
const isSparse = git(['sparse-checkout', 'list'], dest, 'pipe').status === 0;
|
|
654
|
+
const sparseList = isSparse
|
|
655
|
+
? spawnSync('git', ['sparse-checkout', 'list'], { cwd: dest, encoding: 'utf8' }).stdout.trim()
|
|
656
|
+
: '';
|
|
657
|
+
skillsSubdir = (() => {
|
|
658
|
+
const lines = sparseList.split('\n').map(l => l.trim()).filter(Boolean);
|
|
659
|
+
if (lines.length === 0) return null;
|
|
660
|
+
const exact = lines.find(l => SKILL_DIRS.includes(l));
|
|
661
|
+
if (exact) return exact;
|
|
662
|
+
for (const line of lines) {
|
|
663
|
+
const m = line.match(/^(.+)\/(?:\*\*\/)?\*\.md$/);
|
|
664
|
+
if (m && !m[1].includes('*')) return m[1];
|
|
665
|
+
}
|
|
666
|
+
return null;
|
|
667
|
+
})();
|
|
668
|
+
|
|
669
|
+
cloneOk = skillsSubdir
|
|
670
|
+
? sparseUpdate(dest, skillsSubdir)
|
|
671
|
+
: fullClone(url, dest);
|
|
672
|
+
if (!cloneOk) throw new Error(`Update failed for ${repoName}`);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Remove doc files anywhere in the cloned tree
|
|
676
|
+
cleanupRepoRoot(dest);
|
|
677
|
+
removeEmptyDirs(dest);
|
|
678
|
+
|
|
679
|
+
// Validate every .md file via isSkillFile — delete low-quality files
|
|
680
|
+
const allMd = globSync(`${dest}/**/*.md`, { dot: true });
|
|
681
|
+
let removedInvalid = 0;
|
|
682
|
+
for (const fp of allMd) {
|
|
683
|
+
if (!isSkillFile(fp)) {
|
|
684
|
+
try { fs.unlinkSync(fp); removedInvalid++; } catch {}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
if (removedInvalid > 0) {
|
|
688
|
+
console.log(`Removed ${removedInvalid} low-quality .md files (isSkillFile)`);
|
|
689
|
+
removeEmptyDirs(dest);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// Full validateSkill() pass — remove files that fail marketplace-level validation
|
|
693
|
+
const remainingMd = globSync(`${dest}/**/*.md`, { dot: true });
|
|
694
|
+
let removedFailedValidation = 0;
|
|
695
|
+
for (const fp of remainingMd) {
|
|
696
|
+
const v = validateSkill(fp);
|
|
697
|
+
if (!v.ok) {
|
|
698
|
+
try { fs.unlinkSync(fp); removedFailedValidation++; } catch {}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
if (removedFailedValidation > 0) {
|
|
702
|
+
console.log(`Removed ${removedFailedValidation} files that failed validateSkill()`);
|
|
703
|
+
removeEmptyDirs(dest);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
await classifierCleanup(dest);
|
|
707
|
+
|
|
708
|
+
// Count survivors after all cleanup
|
|
709
|
+
const realCount = globSync(`${dest}/**/*.md`, { dot: true }).length;
|
|
710
|
+
const cacheKey = url.replace(/\.git$/, '');
|
|
711
|
+
const cachePath = path.join(PROMPTGRAPH_DIR, 'skill-counts.json');
|
|
712
|
+
try {
|
|
713
|
+
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
|
714
|
+
const cache = JSON.parse(fs.readFileSync(cachePath, 'utf8') || '{}');
|
|
715
|
+
cache[cacheKey] = { count: realCount, ts: Date.now() };
|
|
716
|
+
fs.writeFileSync(cachePath, JSON.stringify(cache, null, 2));
|
|
717
|
+
} catch {}
|
|
718
|
+
|
|
719
|
+
if (realCount < 1) {
|
|
720
|
+
fs.rmSync(dest, { recursive: true, force: true });
|
|
721
|
+
throw new Error(`No valid skills in repo — all files were filtered out`);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
const { dir: localDir, label: localLabel } = detectSkillsDirLocal(dest);
|
|
725
|
+
// Prefer the known skillsSubdir (from API detection or sparse patterns) as the
|
|
726
|
+
// canonical skills directory — it's more accurate than local heuristics for
|
|
727
|
+
// repos with non-standard dir names (e.g. "specialized", "cli", or nested paths)
|
|
728
|
+
const skillsDir = skillsSubdir ? path.join(dest, skillsSubdir) : localDir;
|
|
729
|
+
const label = skillsSubdir || localLabel;
|
|
730
|
+
const mdFiles = globSync(`${skillsDir}/**/*.md`, { dot: true });
|
|
731
|
+
|
|
732
|
+
if (mdFiles.length > MAX_FILE_COUNT) {
|
|
733
|
+
console.warn(`Warning: ${mdFiles.length} .md files exceeds limit of ${MAX_FILE_COUNT} — truncating`);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
if (skillsSubdir) {
|
|
737
|
+
console.log(`Sparse-checkout: ${label}/ only (${mdFiles.length} .md files, no other repo files)`);
|
|
738
|
+
} else {
|
|
739
|
+
console.log(`Full clone: scanning ${label} (${mdFiles.length} .md files)`);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const config = loadConfig();
|
|
743
|
+
const repoSource = `github:${repoName}`;
|
|
744
|
+
if (!config.sources.find(s => s.dir === skillsDir)) {
|
|
745
|
+
const oldIdx = config.sources.findIndex(s => s.source === repoSource);
|
|
746
|
+
if (oldIdx !== -1) config.sources.splice(oldIdx, 1);
|
|
747
|
+
config.sources.push({ dir: skillsDir, source: repoSource });
|
|
748
|
+
saveConfig(config);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
console.log();
|
|
752
|
+
await indexSource(skillsDir, repoSource);
|
|
753
|
+
console.log(`Done! Imported from ${repoName}/${label}`);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// ── Detect skills subdir from local git tree (no API calls) ───────────────────
|
|
757
|
+
|
|
758
|
+
function detectSubdirFromTree(repoRoot) {
|
|
759
|
+
const lsTree = spawnSync('git', ['-C', repoRoot, 'ls-tree', '--name-only', 'HEAD'], { encoding: 'utf8', stdio: 'pipe' });
|
|
760
|
+
if (lsTree.status !== 0) return null;
|
|
761
|
+
const entries = lsTree.stdout.trim().split('\n').filter(Boolean);
|
|
762
|
+
const dirMap = new Map();
|
|
763
|
+
for (const e of entries) dirMap.set(e.toLowerCase(), e);
|
|
764
|
+
|
|
765
|
+
for (const d of SKILL_DIRS) {
|
|
766
|
+
if (dirMap.has(d)) return dirMap.get(d);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
for (const prefix of ['.claude', '.claude-plugin', '.github']) {
|
|
770
|
+
if (dirMap.has(prefix)) {
|
|
771
|
+
const realPrefix = dirMap.get(prefix);
|
|
772
|
+
const sub = spawnSync('git', ['-C', repoRoot, 'ls-tree', '--name-only', `HEAD:${realPrefix}`], { encoding: 'utf8', stdio: 'pipe' });
|
|
773
|
+
if (sub.status === 0) {
|
|
774
|
+
for (const d of SKILL_DIRS) {
|
|
775
|
+
if (sub.stdout.split('\n').map(s => s.toLowerCase()).includes(d)) return `${realPrefix}/${d}`;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// No known skill dir found — check if repo root has .md files itself
|
|
782
|
+
const rootMdCount = entries.filter(e => e.endsWith('.md')).length;
|
|
783
|
+
if (rootMdCount >= 2) return null; // root has skills — take everything
|
|
784
|
+
|
|
785
|
+
// Otherwise check subdirs: if most non-skip subdirs have .md files, take root (all dirs)
|
|
786
|
+
const skipSet = new Set([...SKIP_DIRS_API]);
|
|
787
|
+
const candidates = entries.filter(e => !skipSet.has(e.toLowerCase()) && !e.includes('.'));
|
|
788
|
+
let dirsWithMd = 0;
|
|
789
|
+
let best = null, bestCount = 0;
|
|
790
|
+
for (const dir of candidates) {
|
|
791
|
+
const sub = spawnSync('git', ['-C', repoRoot, 'ls-tree', '-r', '--name-only', `HEAD:${dir}`], { encoding: 'utf8', stdio: 'pipe' });
|
|
792
|
+
if (sub.status !== 0) continue;
|
|
793
|
+
const mdCount = sub.stdout.split('\n').filter(f => f.endsWith('.md')).length;
|
|
794
|
+
if (mdCount >= 1) { dirsWithMd++; if (mdCount > bestCount) { best = dir; bestCount = mdCount; } }
|
|
795
|
+
}
|
|
796
|
+
// If multiple dirs have .md files — they're all skill categories, take root
|
|
797
|
+
if (dirsWithMd > 1) return null;
|
|
798
|
+
return best;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// ── light version (git sparse-checkout, no API rate limits) ───────────────────
|
|
802
|
+
|
|
803
|
+
// Core clone + filter pipeline. Single source of truth for both real installs
|
|
804
|
+
// and offline validation. Materializes the skills subdir into destBase, applies
|
|
805
|
+
// isSkillFile + validateSkill filters, and returns the ground-truth result.
|
|
806
|
+
// NO side effects (no config write, no indexing) — caller decides what to do.
|
|
807
|
+
// Throws 'No valid skills...' if nothing survives filtering.
|
|
808
|
+
export function cloneAndFilterRepo(ownerRepo, destBase, prog = () => {}) {
|
|
809
|
+
const cloneUrl = `https://github.com/${ownerRepo}.git`;
|
|
810
|
+
if (fs.existsSync(destBase)) fs.rmSync(destBase, { recursive: true, force: true });
|
|
811
|
+
fs.mkdirSync(destBase, { recursive: true });
|
|
812
|
+
|
|
813
|
+
const gitEnv = { ...process.env, GIT_TERMINAL_PROMPT: '0' };
|
|
814
|
+
// core.longpaths=true — required on Windows for repos with deep nested paths
|
|
815
|
+
// (>260 chars) that otherwise fail checkout with "UNKNOWN: open" / "Checkout failed".
|
|
816
|
+
const LP = ['-c', 'core.longpaths=true'];
|
|
817
|
+
|
|
818
|
+
// Step 1: treeless clone — gets file tree instantly, no blob download, no API
|
|
819
|
+
prog(`Cloning ${ownerRepo}...`);
|
|
820
|
+
const init = spawnSync('git', [...LP, 'clone', '--depth=1', '--filter=blob:none', '--no-checkout', '--progress', cloneUrl, destBase], { stdio: 'pipe', env: gitEnv, timeout: 120000 });
|
|
821
|
+
if (init.status !== 0) {
|
|
822
|
+
fs.rmSync(destBase, { recursive: true, force: true });
|
|
823
|
+
throw new Error(`Failed to clone ${ownerRepo}: ${(init.stderr?.toString() || '').trim().slice(0, 120)}`);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// Step 2: detect skills subdir from local tree — zero API calls
|
|
827
|
+
prog(`Detecting skill directory...`);
|
|
828
|
+
const subdir = detectSubdirFromTree(destBase);
|
|
829
|
+
|
|
830
|
+
// Step 3: sparse-checkout skills subdir — .md files + scripts (.py/.sh/.js/.ts/.rb/.bash)
|
|
831
|
+
prog(`Setting up sparse checkout${subdir ? ` (${subdir}/)` : ''}...`);
|
|
832
|
+
spawnSync('git', [...LP, '-C', destBase, 'sparse-checkout', 'init'], { stdio: 'pipe', env: gitEnv });
|
|
833
|
+
if (subdir) {
|
|
834
|
+
spawnSync('git', [...LP, '-C', destBase, 'sparse-checkout', 'set', '--no-cone',
|
|
835
|
+
`${subdir}/*.md`, `${subdir}/**/*.md`,
|
|
836
|
+
`${subdir}/**/*.py`, `${subdir}/**/*.sh`, `${subdir}/**/*.js`,
|
|
837
|
+
`${subdir}/**/*.ts`, `${subdir}/**/*.rb`, `${subdir}/**/*.bash`,
|
|
838
|
+
], { stdio: 'pipe', env: gitEnv });
|
|
839
|
+
} else {
|
|
840
|
+
spawnSync('git', [...LP, '-C', destBase, 'sparse-checkout', 'set', '--no-cone',
|
|
841
|
+
'*.md', '**/*.md',
|
|
842
|
+
'**/*.py', '**/*.sh', '**/*.js', '**/*.ts', '**/*.rb', '**/*.bash',
|
|
843
|
+
], { stdio: 'pipe', env: gitEnv });
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// Step 4: checkout to materialize only the selected files
|
|
847
|
+
prog(`Downloading .md files...`);
|
|
848
|
+
const co = spawnSync('git', [...LP, '-C', destBase, 'checkout'], { stdio: 'pipe', env: gitEnv, timeout: 120000 });
|
|
849
|
+
if (co.status !== 0) {
|
|
850
|
+
fs.rmSync(destBase, { recursive: true, force: true });
|
|
851
|
+
throw new Error(`Checkout failed for ${ownerRepo}`);
|
|
852
|
+
}
|
|
853
|
+
// Force blob materialization (needed for partial clones on Windows)
|
|
854
|
+
spawnSync('git', [...LP, '-C', destBase, 'checkout', 'HEAD', '--', '.'], { stdio: 'pipe', env: gitEnv, timeout: 120000 });
|
|
855
|
+
|
|
856
|
+
// Make scripts executable on unix
|
|
857
|
+
if (process.platform !== 'win32') {
|
|
858
|
+
try {
|
|
859
|
+
const scripts = globSync(SCRIPT_GLOBS.map(p => `${destBase}/${p}`), { absolute: true, dot: true });
|
|
860
|
+
for (const s of scripts) { try { fs.chmodSync(s, 0o755); } catch {} }
|
|
861
|
+
} catch {}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// hasScripts = real scripts on disk in the materialized subdir (ground truth)
|
|
865
|
+
const hasScripts = globSync(SCRIPT_GLOBS.map(p => `${destBase}/${p}`), { absolute: true, dot: true }).length > 0;
|
|
866
|
+
|
|
867
|
+
// Step 5: filter out non-skill files locally
|
|
868
|
+
// absolute:true — otherwise glob returns cwd-relative paths; when destBase is not
|
|
869
|
+
// under cwd those contain ".." and validateSkill flags every file as path-traversal.
|
|
870
|
+
const allMd = globSync(`${destBase}/**/*.md`, { absolute: true, dot: true });
|
|
871
|
+
prog(`Filtering ${allMd.length} files...`);
|
|
872
|
+
let removed = 0;
|
|
873
|
+
for (const fp of allMd) {
|
|
874
|
+
if (!isSkillFile(fp)) { try { fs.unlinkSync(fp); removed++; } catch {} }
|
|
875
|
+
}
|
|
876
|
+
if (removed > 0) removeEmptyDirs(destBase);
|
|
877
|
+
|
|
878
|
+
const remaining = globSync(`${destBase}/**/*.md`, { absolute: true, dot: true });
|
|
879
|
+
prog(`Validating ${remaining.length} skill files...`);
|
|
880
|
+
let removedV = 0;
|
|
881
|
+
for (const fp of remaining) {
|
|
882
|
+
const v = validateSkill(fp);
|
|
883
|
+
if (!v.ok) { try { fs.unlinkSync(fp); removedV++; } catch {} }
|
|
884
|
+
}
|
|
885
|
+
if (removedV > 0) removeEmptyDirs(destBase);
|
|
886
|
+
|
|
887
|
+
const realCount = globSync(`${destBase}/**/*.md`, { absolute: true, dot: true }).length;
|
|
888
|
+
|
|
889
|
+
if (realCount < 1) {
|
|
890
|
+
fs.rmSync(destBase, { recursive: true, force: true });
|
|
891
|
+
throw new Error('No valid skills in repo — all files were filtered out');
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
return { realCount, hasScripts, subdir };
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
export async function importFromGitHubLight(repoUrl) {
|
|
898
|
+
if (!repoUrl) throw new Error('Missing repoUrl');
|
|
899
|
+
|
|
900
|
+
const url = repoUrl.startsWith('http') ? repoUrl : `https://github.com/${repoUrl}`;
|
|
901
|
+
const ownerRepo = url.replace(/^https?:\/\/github\.com\//, '').replace(/\.git$/, '');
|
|
902
|
+
const repoName = ownerRepo.replace('/', '-');
|
|
903
|
+
const destBase = path.join(getSkillsStoreDir(), 'github', repoName);
|
|
904
|
+
|
|
905
|
+
const prog = (msg) => process.stderr.write(`\r\x1b[K ${msg}`);
|
|
906
|
+
|
|
907
|
+
const { realCount, subdir } = cloneAndFilterRepo(ownerRepo, destBase, prog);
|
|
908
|
+
process.stderr.write('\n');
|
|
909
|
+
|
|
910
|
+
// Update both caches with the real post-filter count
|
|
911
|
+
try {
|
|
912
|
+
const { setCachedCount } = await import('./bundle-counts.js');
|
|
913
|
+
const cacheKey = url.replace(/\.git$/, '').replace(/^https?:\/\/github\.com\//, '');
|
|
914
|
+
setCachedCount(cacheKey, realCount);
|
|
915
|
+
setCachedCount(url.replace(/\.git$/, ''), realCount);
|
|
916
|
+
} catch {}
|
|
917
|
+
|
|
918
|
+
const { dir: localDir, label: localLabel } = detectSkillsDirLocal(destBase);
|
|
919
|
+
const skillsDir = subdir ? path.join(destBase, subdir) : localDir;
|
|
920
|
+
const label = subdir || localLabel;
|
|
921
|
+
|
|
922
|
+
const config = loadConfig();
|
|
923
|
+
const repoSource = `github:${repoName}`;
|
|
924
|
+
if (!config.sources.find(s => s.dir === skillsDir)) {
|
|
925
|
+
const oldIdx = config.sources.findIndex(s => s.source === repoSource);
|
|
926
|
+
if (oldIdx !== -1) config.sources.splice(oldIdx, 1);
|
|
927
|
+
config.sources.push({ dir: skillsDir, source: repoSource });
|
|
928
|
+
saveConfig(config);
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
console.log();
|
|
932
|
+
await indexSource(skillsDir, repoSource);
|
|
933
|
+
console.log(`Done! Imported from ${repoName}/${label || 'root'} (${realCount} skills via git)`);
|
|
934
|
+
}
|