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