promptgraph-mcp 2.8.1 → 2.8.3
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/README.md +205 -205
- package/ann.js +33 -33
- package/api.js +202 -202
- package/bundle-counts.js +111 -111
- package/chunker.js +28 -28
- package/cli.js +115 -115
- package/commands/bundle.js +150 -150
- package/commands/doctor.js +15 -15
- package/commands/import.js +7 -7
- package/commands/init.js +37 -37
- package/commands/marketplace.js +146 -146
- package/commands/reindex.js +10 -10
- package/commands/search.js +55 -55
- package/commands/setup.js +19 -19
- package/commands/status.js +110 -110
- package/commands/train.js +18 -18
- package/commands/update.js +49 -49
- package/commands/validate.js +63 -63
- package/config.js +72 -72
- package/db.js +157 -157
- package/doctor.js +48 -48
- package/embedder.js +54 -54
- package/github-import.js +750 -745
- package/indexer.js +310 -310
- package/package.json +61 -61
- package/parser.js +69 -69
- package/pg-hook.js +70 -70
- package/platform.js +120 -120
- package/search.js +216 -216
- package/src/filter/classifier.js +88 -88
- package/src/filter/hard-filter.js +62 -62
- package/src/filter/train.js +66 -66
- package/src/reranker/reranker.js +92 -92
- package/src/store/flat-store.js +61 -61
- package/src/store/hnsw-store.js +187 -187
- package/src/store/index.js +19 -19
- package/src/store/vector-store.js +9 -9
- package/src/utils/rate-limiter.js +33 -33
- package/tui.js +418 -418
- package/validate-repo-action.js +139 -139
- package/watcher.js +84 -84
package/github-import.js
CHANGED
|
@@ -1,745 +1,750 @@
|
|
|
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, SKILLS_STORE_DIR, 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
|
-
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
18
|
-
|
|
19
|
-
const repoStats = new Map()
|
|
20
|
-
|
|
21
|
-
function getRepoStats(ownerRepo) {
|
|
22
|
-
if (!repoStats.has(ownerRepo)) {
|
|
23
|
-
repoStats.set(ownerRepo, { totalBytes: 0, fileCount: 0 })
|
|
24
|
-
}
|
|
25
|
-
return repoStats.get(ownerRepo)
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function streamDownload(url, maxSize = MAX_DOWNLOAD_SIZE, redirects = 0) {
|
|
29
|
-
if (redirects > 5) return Promise.reject(new Error('Too many redirects'))
|
|
30
|
-
return new Promise((res, rej) => {
|
|
31
|
-
const token = process.env.GITHUB_TOKEN;
|
|
32
|
-
const headers = { 'User-Agent': 'promptgraph-mcp' };
|
|
33
|
-
if (token && url.startsWith('https://raw.')) headers['Authorization'] = `Bearer ${token}`;
|
|
34
|
-
const req = https.get(url, { headers }, r => {
|
|
35
|
-
if (r.statusCode >= 300 && r.statusCode < 400 && r.headers.location)
|
|
36
|
-
return streamDownload(r.headers.location, maxSize, redirects + 1).then(res, rej);
|
|
37
|
-
if (r.statusCode !== 200) { r.resume(); return rej(new Error(`HTTP ${r.statusCode}`)); }
|
|
38
|
-
const cl = parseInt(r.headers['content-length'], 10);
|
|
39
|
-
if (!isNaN(cl) && cl > maxSize) {
|
|
40
|
-
r.resume();
|
|
41
|
-
return rej(new Error(`Content-Length ${cl} exceeds max ${maxSize}`));
|
|
42
|
-
}
|
|
43
|
-
const chunks = []
|
|
44
|
-
let total = 0
|
|
45
|
-
r.setEncoding('utf8')
|
|
46
|
-
r.on('data', c => {
|
|
47
|
-
total += Buffer.byteLength(c, 'utf8')
|
|
48
|
-
if (total > maxSize) {
|
|
49
|
-
r.destroy()
|
|
50
|
-
return rej(new Error(`Download exceeded ${maxSize} bytes`))
|
|
51
|
-
}
|
|
52
|
-
chunks.push(c)
|
|
53
|
-
})
|
|
54
|
-
r.on('end', () => res(chunks.join('')))
|
|
55
|
-
})
|
|
56
|
-
req.on('error', rej)
|
|
57
|
-
})
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
async function httpsGet(url, redirects = 0) {
|
|
61
|
-
if (redirects > 5) return Promise.reject(new Error('Too many redirects'))
|
|
62
|
-
await githubRateLimiter.acquire()
|
|
63
|
-
const token = process.env.GITHUB_TOKEN;
|
|
64
|
-
const headers = { 'User-Agent': 'promptgraph-mcp' };
|
|
65
|
-
if (token && url.startsWith('https://api.github.com/')) headers['Authorization'] = `Bearer ${token}`;
|
|
66
|
-
return new Promise((res, rej) => {
|
|
67
|
-
const req = https.get(url, { headers }, r => {
|
|
68
|
-
if (r.statusCode >= 300 && r.statusCode < 400 && r.headers.location)
|
|
69
|
-
return httpsGet(r.headers.location, redirects + 1).then(res, rej);
|
|
70
|
-
if (r.statusCode !== 200) { r.resume(); return rej(new Error(`HTTP ${r.statusCode}`)); }
|
|
71
|
-
const chunks = []; r.setEncoding('utf8'); r.on('data', c => chunks.push(c)); r.on('end', () => res(chunks.join('')));
|
|
72
|
-
});
|
|
73
|
-
req.on('error', rej);
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function repoExists(ownerRepo) {
|
|
78
|
-
return new Promise(resolve => {
|
|
79
|
-
const req = https.request(
|
|
80
|
-
{ host: 'github.com', path: `/${ownerRepo}`, method: 'HEAD', headers: { 'User-Agent': 'promptgraph-mcp' } },
|
|
81
|
-
r => resolve(r.statusCode < 400)
|
|
82
|
-
);
|
|
83
|
-
req.on('error', () => resolve(false));
|
|
84
|
-
req.end();
|
|
85
|
-
});
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// Download one .md file, run validateSkill on it, return errors/warnings.
|
|
89
|
-
async function validateMdFile(file, tmpDir, ownerRepo) {
|
|
90
|
-
const errors = [];
|
|
91
|
-
const warnings = [];
|
|
92
|
-
const stats = getRepoStats(ownerRepo);
|
|
93
|
-
try {
|
|
94
|
-
if (stats.fileCount >= MAX_FILE_COUNT) {
|
|
95
|
-
errors.push(`${file.name}: skipped — repo file count limit (${MAX_FILE_COUNT}) reached`);
|
|
96
|
-
return { errors, warnings };
|
|
97
|
-
}
|
|
98
|
-
await downloadRateLimiter.acquire()
|
|
99
|
-
const content = await streamDownload(file.download_url);
|
|
100
|
-
stats.totalBytes += Buffer.byteLength(content, 'utf8');
|
|
101
|
-
stats.fileCount++;
|
|
102
|
-
if (stats.totalBytes > MAX_REPO_SIZE) {
|
|
103
|
-
return { errors: [...errors, `${file.name}: repo size limit (${MAX_REPO_SIZE}) exceeded`], warnings };
|
|
104
|
-
}
|
|
105
|
-
const tmpPath = path.join(tmpDir, file.name);
|
|
106
|
-
fs.mkdirSync(path.dirname(tmpPath), { recursive: true });
|
|
107
|
-
fs.writeFileSync(tmpPath, content);
|
|
108
|
-
const result = validateSkill(tmpPath);
|
|
109
|
-
if (!result.ok) {
|
|
110
|
-
errors.push(`${file.name}: ${result.errors.join('; ')}`);
|
|
111
|
-
}
|
|
112
|
-
if (result.warnings?.length) {
|
|
113
|
-
warnings.push(...result.warnings.map(w => `${file.name}: ${w}`));
|
|
114
|
-
}
|
|
115
|
-
fs.unlinkSync(tmpPath);
|
|
116
|
-
} catch (e) {
|
|
117
|
-
errors.push(`${file.name}: failed to validate — ${e.message}`);
|
|
118
|
-
}
|
|
119
|
-
return { errors, warnings };
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// Validate all .md files in a repo's skills subdir against validateSkill().
|
|
123
|
-
// Falls back to root-level .md files if no skills subdirectory is found.
|
|
124
|
-
// Returns { ok, errors[], warnings[] }.
|
|
125
|
-
export async function validateRepoSkills(ownerRepo) {
|
|
126
|
-
const detected = await detectSkillsDirFromAPI(ownerRepo);
|
|
127
|
-
const tmpDir = path.join(PROMPTGRAPH_DIR, '.validate-tmp');
|
|
128
|
-
fs.mkdirSync(tmpDir, { recursive: true });
|
|
129
|
-
|
|
130
|
-
let mdFiles;
|
|
131
|
-
if (detected) {
|
|
132
|
-
// Has a skills subdirectory — use git tree API for recursive listing
|
|
133
|
-
const subdir = detected.subdir;
|
|
134
|
-
try {
|
|
135
|
-
const treeJson = await httpsGet(`https://api.github.com/repos/${ownerRepo}/git/trees/HEAD?recursive=1`);
|
|
136
|
-
const tree = JSON.parse(treeJson);
|
|
137
|
-
const prefix = subdir + '/';
|
|
138
|
-
const mdTreeEntries = (tree.tree || []).filter(f =>
|
|
139
|
-
f.type === 'blob' && f.path.startsWith(prefix) && f.path.endsWith('.md')
|
|
140
|
-
);
|
|
141
|
-
let branch = 'main';
|
|
142
|
-
try {
|
|
143
|
-
const repoJson = await httpsGet(`https://api.github.com/repos/${ownerRepo}`);
|
|
144
|
-
branch = JSON.parse(repoJson).default_branch || 'main';
|
|
145
|
-
} catch {}
|
|
146
|
-
mdFiles = mdTreeEntries.map(f => ({
|
|
147
|
-
name: f.path.replace(prefix, ''),
|
|
148
|
-
download_url: `https://raw.githubusercontent.com/${ownerRepo}/${branch}/${f.path}`
|
|
149
|
-
}));
|
|
150
|
-
} catch (e) {
|
|
151
|
-
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
|
152
|
-
return { ok: false, errors: [`Failed to list ${subdir}/ contents: ${e.message}`], warnings: [] };
|
|
153
|
-
}
|
|
154
|
-
} else {
|
|
155
|
-
// No skills subdir — fall back to root-level .md files
|
|
156
|
-
try {
|
|
157
|
-
const json = await httpsGet(`https://api.github.com/repos/${ownerRepo}/contents`);
|
|
158
|
-
const entries = JSON.parse(json);
|
|
159
|
-
mdFiles = entries.filter(e => e.type === 'file' && e.name.endsWith('.md'));
|
|
160
|
-
} catch (e) {
|
|
161
|
-
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
|
162
|
-
return { ok: false, errors: [`Failed to list repo root: ${e.message}`], warnings: [] };
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
if (!mdFiles || mdFiles.length === 0) {
|
|
167
|
-
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
|
168
|
-
return { ok: false, errors: [`No .md files found in ${ownerRepo}`], warnings: [] };
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// Filter out docs-like filenames (README, LICENSE, CHANGELOG, etc.)
|
|
172
|
-
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;
|
|
173
|
-
const mdTrimmed = mdFiles.filter(f => !SKIP_DOCS.test(f.name.replace(/\.md$/i, '')));
|
|
174
|
-
const mdToValidate = mdTrimmed.length > 0 ? mdTrimmed : mdFiles;
|
|
175
|
-
|
|
176
|
-
let errors = [];
|
|
177
|
-
let warnings = [];
|
|
178
|
-
|
|
179
|
-
for (const file of mdToValidate) {
|
|
180
|
-
const r = await validateMdFile(file, tmpDir, ownerRepo);
|
|
181
|
-
errors.push(...r.errors);
|
|
182
|
-
warnings.push(...r.warnings);
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
|
186
|
-
|
|
187
|
-
return { ok: errors.length === 0, errors, warnings };
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// Ask GitHub API which subdir to use (without cloning anything). Exported for validation.
|
|
191
|
-
export
|
|
192
|
-
// Returns { subdir, label } or null (use root).
|
|
193
|
-
async function detectSkillsDirFromAPI(ownerRepo) {
|
|
194
|
-
try {
|
|
195
|
-
const json = await httpsGet(`https://api.github.com/repos/${ownerRepo}/contents`);
|
|
196
|
-
const entries = JSON.parse(json);
|
|
197
|
-
|
|
198
|
-
// 1. Known skill dir names (priority order)
|
|
199
|
-
const dirMap = new Map(entries.filter(e => e.type === 'dir').map(e => [e.name.toLowerCase(), e.name]));
|
|
200
|
-
for (const d of SKILL_DIRS) {
|
|
201
|
-
if (dirMap.has(d)) return { subdir: dirMap.get(d), label: d };
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// 1.5 Nested skills dirs (e.g. .claude/skills, .claude-plugin/commands)
|
|
205
|
-
for (const prefix of ['.claude', '.claude-plugin']) {
|
|
206
|
-
if (dirMap.has(prefix)) {
|
|
207
|
-
for (const d of SKILL_DIRS) {
|
|
208
|
-
const nested = `${prefix}/${d}`;
|
|
209
|
-
try {
|
|
210
|
-
const sub = await httpsGet(`https://api.github.com/repos/${ownerRepo}/contents/${nested}`);
|
|
211
|
-
const subEntries = JSON.parse(sub);
|
|
212
|
-
if (subEntries.length > 0) return { subdir: nested, label: nested };
|
|
213
|
-
} catch {}
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// 2. Any subdir with 2+ .md files — pick the one with most .md files
|
|
219
|
-
const subdirCandidates = entries.filter(e => e.type === 'dir' && !SKIP_DIRS_API.has(e.name.toLowerCase()));
|
|
220
|
-
let best = null, bestCount = 0;
|
|
221
|
-
for (const dir of subdirCandidates) {
|
|
222
|
-
try {
|
|
223
|
-
const sub = await httpsGet(`https://api.github.com/repos/${ownerRepo}/contents/${dir.name}`);
|
|
224
|
-
const subEntries = JSON.parse(sub);
|
|
225
|
-
const mdCount = subEntries.filter(e => e.type === 'file' && e.name.endsWith('.md')).length;
|
|
226
|
-
if (mdCount >= 1 && mdCount > bestCount) { best = dir.name; bestCount = mdCount; }
|
|
227
|
-
} catch {}
|
|
228
|
-
}
|
|
229
|
-
if (best) return { subdir: best, label: best };
|
|
230
|
-
|
|
231
|
-
} catch {}
|
|
232
|
-
return null; // no good subdir found — use root
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
const SKIP_DIRS_API = new Set([
|
|
236
|
-
'.github', 'docs', 'doc', 'documentation', 'examples', 'example',
|
|
237
|
-
'tests', 'test', 'assets', 'images', 'img', 'media', 'static',
|
|
238
|
-
'node_modules', 'vendor', 'dist', 'build', '.git',
|
|
239
|
-
'references', 'reference', 'refs', 'cheatsheet', 'cheat-sheet',
|
|
240
|
-
'cheatsheets', 'resources',
|
|
241
|
-
'src', 'cli', 'lib', 'bin', 'scripts',
|
|
242
|
-
]);
|
|
243
|
-
|
|
244
|
-
function git(args, cwd, stdio = 'inherit') {
|
|
245
|
-
return spawnSync('git', args, { cwd, stdio });
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// Clone only the skills subdir via sparse-checkout
|
|
249
|
-
function sparseClone(url, dest, subdir) {
|
|
250
|
-
fs.mkdirSync(dest, { recursive: true });
|
|
251
|
-
|
|
252
|
-
// 1. init + add remote
|
|
253
|
-
if (git(['init'], dest, 'pipe').status !== 0) return false;
|
|
254
|
-
if (git(['remote', 'add', 'origin', url], dest, 'pipe').status !== 0) return false;
|
|
255
|
-
|
|
256
|
-
// 2. sparse-checkout — non-cone mode with *.md glob
|
|
257
|
-
git(['sparse-checkout', 'init'], dest, 'pipe');
|
|
258
|
-
git(['sparse-checkout', 'set', '--no-cone', `${subdir}/*.md`, `${subdir}/**/*.md`], dest, 'pipe');
|
|
259
|
-
|
|
260
|
-
// 3. fetch + checkout (depth=1, skip large blobs)
|
|
261
|
-
const fetch = git(['fetch', '--depth=1', '--filter=blob:none', 'origin'], dest);
|
|
262
|
-
if (fetch.status !== 0) return false;
|
|
263
|
-
|
|
264
|
-
// Try HEAD, then main, then master
|
|
265
|
-
for (const branch of ['HEAD', 'main', 'master']) {
|
|
266
|
-
const r = git(['checkout', branch === 'HEAD' ? 'FETCH_HEAD' : branch], dest, 'pipe');
|
|
267
|
-
if (r.status === 0) return finalizeCheckout(dest, true);
|
|
268
|
-
}
|
|
269
|
-
return false;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
// Shared skip patterns — module scope so both cleanup functions can access them
|
|
273
|
-
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;
|
|
274
|
-
const SKIP_DIRS_LOCAL = new Set([
|
|
275
|
-
'.github', 'docs', 'doc', 'assets', 'images', 'img', 'screenshots',
|
|
276
|
-
'media', 'static', 'scripts', 'ci_scripts', 'node_modules', 'vendor',
|
|
277
|
-
'dist', 'build', 'tests', 'test',
|
|
278
|
-
'references', 'reference', 'refs', 'cheatsheet', 'cheat-sheet',
|
|
279
|
-
'cheatsheets', 'resources',
|
|
280
|
-
'src', 'cli', 'lib', 'bin',
|
|
281
|
-
]);
|
|
282
|
-
|
|
283
|
-
// After full-clone root: remove files that are not skills and dirs we don't need
|
|
284
|
-
function cleanupRepoRoot(repoRoot) {
|
|
285
|
-
let removed = 0;
|
|
286
|
-
const entries = fs.readdirSync(repoRoot, { withFileTypes: true });
|
|
287
|
-
for (const entry of entries) {
|
|
288
|
-
if (entry.name === '.git') continue;
|
|
289
|
-
const fullPath = path.join(repoRoot, entry.name);
|
|
290
|
-
if (entry.isDirectory()) {
|
|
291
|
-
if (SKIP_DIRS_LOCAL.has(entry.name.toLowerCase())) {
|
|
292
|
-
fs.rmSync(fullPath, { recursive: true, force: true });
|
|
293
|
-
removed++;
|
|
294
|
-
} else {
|
|
295
|
-
// Recurse into subdirectory to remove doc files
|
|
296
|
-
removed += cleanupRepoDir(fullPath, SKIP_RE);
|
|
297
|
-
}
|
|
298
|
-
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
299
|
-
const base = entry.name.replace(/\.md$/i, '').toLowerCase();
|
|
300
|
-
if (SKIP_RE.test(base)) {
|
|
301
|
-
fs.unlinkSync(fullPath);
|
|
302
|
-
removed++;
|
|
303
|
-
}
|
|
304
|
-
} else if (entry.isFile() && !entry.name.endsWith('.md')) {
|
|
305
|
-
if (entry.name !== '.gitignore') {
|
|
306
|
-
try { fs.unlinkSync(fullPath); removed++; } catch {}
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
if (removed > 0) console.log(`Cleaned up ${removed} non-skill files/dirs`);
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
// Recursively remove doc .md files from subdirectories
|
|
314
|
-
function cleanupRepoDir(dirPath, SKIP_RE) {
|
|
315
|
-
let removed = 0;
|
|
316
|
-
let entries;
|
|
317
|
-
try { entries = fs.readdirSync(dirPath, { withFileTypes: true }); } catch { return 0; }
|
|
318
|
-
for (const entry of entries) {
|
|
319
|
-
const fullPath = path.join(dirPath, entry.name);
|
|
320
|
-
if (entry.isDirectory()) {
|
|
321
|
-
// Remove entire skip dirs (e.g. references/) nested inside skills dirs
|
|
322
|
-
if (SKIP_DIRS_LOCAL.has(entry.name.toLowerCase())) {
|
|
323
|
-
fs.rmSync(fullPath, { recursive: true, force: true });
|
|
324
|
-
removed++;
|
|
325
|
-
} else {
|
|
326
|
-
removed += cleanupRepoDir(fullPath, SKIP_RE);
|
|
327
|
-
try { if (fs.readdirSync(fullPath).length === 0) fs.rmdirSync(fullPath); } catch {}
|
|
328
|
-
}
|
|
329
|
-
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
330
|
-
const base = entry.name.replace(/\.md$/i, '').toLowerCase();
|
|
331
|
-
if (SKIP_RE.test(base)) {
|
|
332
|
-
fs.unlinkSync(fullPath);
|
|
333
|
-
removed++;
|
|
334
|
-
}
|
|
335
|
-
} else if (entry.isFile() && !entry.name.endsWith('.md')) {
|
|
336
|
-
try { fs.unlinkSync(fullPath); removed++; } catch {}
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
return removed;
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
// Recursively remove empty directories
|
|
343
|
-
function removeEmptyDirs(dirPath) {
|
|
344
|
-
let entries;
|
|
345
|
-
try { entries = fs.readdirSync(dirPath, { withFileTypes: true }); } catch { return; }
|
|
346
|
-
for (const entry of entries) {
|
|
347
|
-
if (entry.name === '.git') continue;
|
|
348
|
-
if (!entry.isDirectory()) continue;
|
|
349
|
-
const fullPath = path.join(dirPath, entry.name);
|
|
350
|
-
removeEmptyDirs(fullPath);
|
|
351
|
-
try { if (fs.readdirSync(fullPath).length === 0) fs.rmdirSync(fullPath); } catch {}
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
// After fetch/checkout: force materialization of all sparse-matched files.
|
|
356
|
-
// partial clone (blob:none) + checkout often skips blob download on Windows.
|
|
357
|
-
function forceMaterialize(dest) {
|
|
358
|
-
git(['checkout', 'HEAD', '--', '.'], dest, 'pipe');
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
// Update sparse repo — fetch + checkout
|
|
362
|
-
function sparseUpdate(dest, subdir) {
|
|
363
|
-
const fetch = git(['fetch', '--depth=1', 'origin'], dest);
|
|
364
|
-
if (fetch.status !== 0) return false;
|
|
365
|
-
|
|
366
|
-
git(['sparse-checkout', 'set', '--no-cone', `${subdir}/*.md`, `${subdir}/**/*.md`], dest, 'pipe');
|
|
367
|
-
|
|
368
|
-
for (const ref of ['origin/main', 'origin/master']) {
|
|
369
|
-
const r = git(['checkout', ref], dest, 'pipe');
|
|
370
|
-
if (r.status !== 0) continue;
|
|
371
|
-
forceMaterialize(dest);
|
|
372
|
-
return true;
|
|
373
|
-
}
|
|
374
|
-
return false;
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
// After checkout, force materialization of sparse-matched files.
|
|
378
|
-
function finalizeCheckout(dest, success) {
|
|
379
|
-
if (success) forceMaterialize(dest);
|
|
380
|
-
return success;
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
// Fallback: clone root but only checkout .md files
|
|
384
|
-
function fullClone(url, dest) {
|
|
385
|
-
if (fs.existsSync(dest)) {
|
|
386
|
-
const fetch = git(['fetch', '--depth=1', '--filter=blob:none', 'origin'], dest);
|
|
387
|
-
if (fetch.status !== 0) return false;
|
|
388
|
-
for (const ref of ['origin/HEAD', 'origin/main', 'origin/master']) {
|
|
389
|
-
const ok = git(['reset', '--hard', ref], dest, 'pipe').status === 0;
|
|
390
|
-
if (ok) return finalizeCheckout(dest, true);
|
|
391
|
-
}
|
|
392
|
-
return false;
|
|
393
|
-
}
|
|
394
|
-
// init + sparse *.md + fetch + checkout
|
|
395
|
-
fs.mkdirSync(dest, { recursive: true });
|
|
396
|
-
if (git(['init'], dest, 'pipe').status !== 0) return false;
|
|
397
|
-
if (git(['remote', 'add', 'origin', url], dest, 'pipe').status !== 0) return false;
|
|
398
|
-
git(['sparse-checkout', 'init'], dest, 'pipe');
|
|
399
|
-
git(['sparse-checkout', 'set', '--no-cone', '*.md', '**/*.md'], dest, 'pipe');
|
|
400
|
-
const fetch = git(['fetch', '--depth=1', '--filter=blob:none', 'origin'], dest);
|
|
401
|
-
if (fetch.status !== 0) return false;
|
|
402
|
-
for (const branch of ['FETCH_HEAD', 'main', 'master']) {
|
|
403
|
-
const r = git(['checkout', branch], dest, 'pipe');
|
|
404
|
-
if (r.status === 0) return finalizeCheckout(dest, true);
|
|
405
|
-
}
|
|
406
|
-
return false;
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
// After clone: detect actual skills dir on disk (flat + nested)
|
|
410
|
-
function detectSkillsDirLocal(repoRoot) {
|
|
411
|
-
// 1. Flat dirs matching SKILL_DIRS (e.g. skills/, commands/)
|
|
412
|
-
for (const dir of SKILL_DIRS) {
|
|
413
|
-
const candidate = path.join(repoRoot, dir);
|
|
414
|
-
if (fs.existsSync(candidate)) {
|
|
415
|
-
const files = globSync(`${candidate}/**/*.md`);
|
|
416
|
-
if (files.length >= 1) return { dir: candidate, label: dir, sparse: true };
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
// 2. Nested dirs: .claude/skills, .claude-plugin/commands, etc.
|
|
420
|
-
for (const prefix of ['.claude', '.claude-plugin']) {
|
|
421
|
-
for (const dir of SKILL_DIRS) {
|
|
422
|
-
const candidate = path.join(repoRoot, prefix, dir);
|
|
423
|
-
if (fs.existsSync(candidate)) {
|
|
424
|
-
const files = globSync(`${candidate}/**/*.md`);
|
|
425
|
-
if (files.length >= 1) return { dir: candidate, label: `${prefix}/${dir}`, sparse: true };
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
return { dir: repoRoot, label: '(root)', sparse: false };
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
// Remove files classified as non-skills by embedding classifier (only if model is trained)
|
|
433
|
-
async function classifierCleanup(dest) {
|
|
434
|
-
const mdFiles = globSync(`${dest}/**/*.md`);
|
|
435
|
-
if (mdFiles.length === 0) return;
|
|
436
|
-
|
|
437
|
-
const parsed = [];
|
|
438
|
-
const fileMap = [];
|
|
439
|
-
|
|
440
|
-
for (const fp of mdFiles) {
|
|
441
|
-
try {
|
|
442
|
-
const raw = fs.readFileSync(fp, 'utf8');
|
|
443
|
-
if (!isSkillFile(fp, raw)) continue;
|
|
444
|
-
parsed.push(parseSkillFile(fp, '', { raw }));
|
|
445
|
-
fileMap.push(fp);
|
|
446
|
-
} catch {}
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
if (parsed.length === 0) {
|
|
450
|
-
removeEmptyDirs(dest);
|
|
451
|
-
return;
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
const filtered = await filterWithClassifier(parsed);
|
|
455
|
-
const keptPaths = new Set(filtered.map(s => s.path));
|
|
456
|
-
|
|
457
|
-
let removed = 0;
|
|
458
|
-
for (const fp of fileMap) {
|
|
459
|
-
if (!keptPaths.has(fp)) {
|
|
460
|
-
try { fs.unlinkSync(fp); removed++; } catch {}
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
if (removed > 0) {
|
|
465
|
-
console.log(`Removed ${removed} non-skill files (classifier)`);
|
|
466
|
-
removeEmptyDirs(dest);
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
// ── main export ───────────────────────────────────────────────────────────────
|
|
471
|
-
|
|
472
|
-
export async function importFromGitHub(repoUrl) {
|
|
473
|
-
if (!repoUrl) {
|
|
474
|
-
console.error('Usage: promptgraph-mcp import <github-url-or-owner/repo>');
|
|
475
|
-
process.exit(1);
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
const url = repoUrl.startsWith('http') ? repoUrl : `https://github.com/${repoUrl}`;
|
|
479
|
-
const ownerRepo = url.replace(/^https?:\/\/github\.com\//, '').replace(/\.git$/, '');
|
|
480
|
-
const repoName = ownerRepo.replace('/', '-');
|
|
481
|
-
const dest = path.join(SKILLS_STORE_DIR, 'github', repoName);
|
|
482
|
-
|
|
483
|
-
const isNew = !fs.existsSync(dest);
|
|
484
|
-
|
|
485
|
-
if (isNew) {
|
|
486
|
-
const exists = await repoExists(ownerRepo);
|
|
487
|
-
if (!exists) throw new Error(`Repository not found (404): ${url}`);
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
491
|
-
|
|
492
|
-
let skillsSubdir = null;
|
|
493
|
-
let cloneOk = false;
|
|
494
|
-
|
|
495
|
-
if (isNew) {
|
|
496
|
-
// Detect skills dir via API before cloning
|
|
497
|
-
process.stdout.write(`Detecting skills directory for ${ownerRepo}... `);
|
|
498
|
-
const detected = await detectSkillsDirFromAPI(ownerRepo);
|
|
499
|
-
skillsSubdir = detected?.subdir || null;
|
|
500
|
-
|
|
501
|
-
if (!skillsSubdir) {
|
|
502
|
-
console.log(`found: (root) — no skills subdirectory, using full clone`);
|
|
503
|
-
cloneOk = fullClone(url, dest);
|
|
504
|
-
} else {
|
|
505
|
-
console.log(`found: ${detected.label}/`);
|
|
506
|
-
console.log(`Sparse-cloning ${url} (${skillsSubdir}/ only)...`);
|
|
507
|
-
cloneOk = sparseClone(url, dest, skillsSubdir);
|
|
508
|
-
}
|
|
509
|
-
if (!cloneOk) {
|
|
510
|
-
fs.rmSync(dest, { recursive: true, force: true });
|
|
511
|
-
throw new Error(`Sparse-checkout failed for ${url}`);
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
if (!cloneOk) throw new Error(`Clone failed for ${url}`);
|
|
515
|
-
} else {
|
|
516
|
-
console.log(`Updating ${repoName}...`);
|
|
517
|
-
// Detect existing sparse subdir
|
|
518
|
-
const isSparse = git(['sparse-checkout', 'list'], dest, 'pipe').status === 0;
|
|
519
|
-
const sparseList = isSparse
|
|
520
|
-
? spawnSync('git', ['sparse-checkout', 'list'], { cwd: dest, encoding: 'utf8' }).stdout.trim()
|
|
521
|
-
: '';
|
|
522
|
-
skillsSubdir = (() => {
|
|
523
|
-
const lines = sparseList.split('\n').map(l => l.trim()).filter(Boolean);
|
|
524
|
-
if (lines.length === 0) return null;
|
|
525
|
-
const exact = lines.find(l => SKILL_DIRS.includes(l));
|
|
526
|
-
if (exact) return exact;
|
|
527
|
-
for (const line of lines) {
|
|
528
|
-
const m = line.match(/^(.+)\/(?:\*\*\/)?\*\.md$/);
|
|
529
|
-
if (m && !m[1].includes('*')) return m[1];
|
|
530
|
-
}
|
|
531
|
-
return null;
|
|
532
|
-
})();
|
|
533
|
-
|
|
534
|
-
cloneOk = skillsSubdir
|
|
535
|
-
? sparseUpdate(dest, skillsSubdir)
|
|
536
|
-
: fullClone(url, dest);
|
|
537
|
-
if (!cloneOk) throw new Error(`Update failed for ${repoName}`);
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
// Remove doc files anywhere in the cloned tree
|
|
541
|
-
cleanupRepoRoot(dest);
|
|
542
|
-
removeEmptyDirs(dest);
|
|
543
|
-
|
|
544
|
-
// Validate every .md file via isSkillFile — delete low-quality files
|
|
545
|
-
const allMd = globSync(`${dest}/**/*.md`);
|
|
546
|
-
let removedInvalid = 0;
|
|
547
|
-
for (const fp of allMd) {
|
|
548
|
-
if (!isSkillFile(fp)) {
|
|
549
|
-
try { fs.unlinkSync(fp); removedInvalid++; } catch {}
|
|
550
|
-
}
|
|
551
|
-
}
|
|
552
|
-
if (removedInvalid > 0) {
|
|
553
|
-
console.log(`Removed ${removedInvalid} low-quality .md files (isSkillFile)`);
|
|
554
|
-
removeEmptyDirs(dest);
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
// Full validateSkill() pass — remove files that fail marketplace-level validation
|
|
558
|
-
const remainingMd = globSync(`${dest}/**/*.md`);
|
|
559
|
-
let removedFailedValidation = 0;
|
|
560
|
-
for (const fp of remainingMd) {
|
|
561
|
-
const v = validateSkill(fp);
|
|
562
|
-
if (!v.ok) {
|
|
563
|
-
try { fs.unlinkSync(fp); removedFailedValidation++; } catch {}
|
|
564
|
-
}
|
|
565
|
-
}
|
|
566
|
-
if (removedFailedValidation > 0) {
|
|
567
|
-
console.log(`Removed ${removedFailedValidation} files that failed validateSkill()`);
|
|
568
|
-
removeEmptyDirs(dest);
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
await classifierCleanup(dest);
|
|
572
|
-
|
|
573
|
-
// Count survivors after all cleanup
|
|
574
|
-
const realCount = globSync(`${dest}/**/*.md`).length;
|
|
575
|
-
const cacheKey = url.replace(/\.git$/, '');
|
|
576
|
-
const cachePath = path.join(PROMPTGRAPH_DIR, 'skill-counts.json');
|
|
577
|
-
try {
|
|
578
|
-
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
|
579
|
-
const cache = JSON.parse(fs.readFileSync(cachePath, 'utf8') || '{}');
|
|
580
|
-
cache[cacheKey] = { count: realCount, ts: Date.now() };
|
|
581
|
-
fs.writeFileSync(cachePath, JSON.stringify(cache, null, 2));
|
|
582
|
-
} catch {}
|
|
583
|
-
|
|
584
|
-
if (realCount < 1) {
|
|
585
|
-
fs.rmSync(dest, { recursive: true, force: true });
|
|
586
|
-
throw new Error(`No valid skills in repo — all files were filtered out`);
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
const { dir: localDir, label: localLabel } = detectSkillsDirLocal(dest);
|
|
590
|
-
// Prefer the known skillsSubdir (from API detection or sparse patterns) as the
|
|
591
|
-
// canonical skills directory — it's more accurate than local heuristics for
|
|
592
|
-
// repos with non-standard dir names (e.g. "specialized", "cli", or nested paths)
|
|
593
|
-
const skillsDir = skillsSubdir ? path.join(dest, skillsSubdir) : localDir;
|
|
594
|
-
const label = skillsSubdir || localLabel;
|
|
595
|
-
const mdFiles = globSync(`${skillsDir}/**/*.md`);
|
|
596
|
-
|
|
597
|
-
if (mdFiles.length > MAX_FILE_COUNT) {
|
|
598
|
-
console.warn(`Warning: ${mdFiles.length} .md files exceeds limit of ${MAX_FILE_COUNT} — truncating`);
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
if (skillsSubdir) {
|
|
602
|
-
console.log(`Sparse-checkout: ${label}/ only (${mdFiles.length} .md files, no other repo files)`);
|
|
603
|
-
} else {
|
|
604
|
-
console.log(`Full clone: scanning ${label} (${mdFiles.length} .md files)`);
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
const config = loadConfig();
|
|
608
|
-
const repoSource = `github:${repoName}`;
|
|
609
|
-
if (!config.sources.find(s => s.dir === skillsDir)) {
|
|
610
|
-
const oldIdx = config.sources.findIndex(s => s.source === repoSource);
|
|
611
|
-
if (oldIdx !== -1) config.sources.splice(oldIdx, 1);
|
|
612
|
-
config.sources.push({ dir: skillsDir, source: repoSource });
|
|
613
|
-
saveConfig(config);
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
console.log();
|
|
617
|
-
await indexSource(skillsDir, repoSource);
|
|
618
|
-
console.log(`Done! Imported from ${repoName}/${label}`);
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
// ──
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
const
|
|
629
|
-
|
|
630
|
-
const
|
|
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
|
-
if (
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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
|
-
fs.
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
const
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
}
|
|
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, SKILLS_STORE_DIR, 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
|
+
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
const repoStats = new Map()
|
|
20
|
+
|
|
21
|
+
function getRepoStats(ownerRepo) {
|
|
22
|
+
if (!repoStats.has(ownerRepo)) {
|
|
23
|
+
repoStats.set(ownerRepo, { totalBytes: 0, fileCount: 0 })
|
|
24
|
+
}
|
|
25
|
+
return repoStats.get(ownerRepo)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function streamDownload(url, maxSize = MAX_DOWNLOAD_SIZE, redirects = 0) {
|
|
29
|
+
if (redirects > 5) return Promise.reject(new Error('Too many redirects'))
|
|
30
|
+
return new Promise((res, rej) => {
|
|
31
|
+
const token = process.env.GITHUB_TOKEN;
|
|
32
|
+
const headers = { 'User-Agent': 'promptgraph-mcp' };
|
|
33
|
+
if (token && url.startsWith('https://raw.')) headers['Authorization'] = `Bearer ${token}`;
|
|
34
|
+
const req = https.get(url, { headers }, r => {
|
|
35
|
+
if (r.statusCode >= 300 && r.statusCode < 400 && r.headers.location)
|
|
36
|
+
return streamDownload(r.headers.location, maxSize, redirects + 1).then(res, rej);
|
|
37
|
+
if (r.statusCode !== 200) { r.resume(); return rej(new Error(`HTTP ${r.statusCode}`)); }
|
|
38
|
+
const cl = parseInt(r.headers['content-length'], 10);
|
|
39
|
+
if (!isNaN(cl) && cl > maxSize) {
|
|
40
|
+
r.resume();
|
|
41
|
+
return rej(new Error(`Content-Length ${cl} exceeds max ${maxSize}`));
|
|
42
|
+
}
|
|
43
|
+
const chunks = []
|
|
44
|
+
let total = 0
|
|
45
|
+
r.setEncoding('utf8')
|
|
46
|
+
r.on('data', c => {
|
|
47
|
+
total += Buffer.byteLength(c, 'utf8')
|
|
48
|
+
if (total > maxSize) {
|
|
49
|
+
r.destroy()
|
|
50
|
+
return rej(new Error(`Download exceeded ${maxSize} bytes`))
|
|
51
|
+
}
|
|
52
|
+
chunks.push(c)
|
|
53
|
+
})
|
|
54
|
+
r.on('end', () => res(chunks.join('')))
|
|
55
|
+
})
|
|
56
|
+
req.on('error', rej)
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function httpsGet(url, redirects = 0) {
|
|
61
|
+
if (redirects > 5) return Promise.reject(new Error('Too many redirects'))
|
|
62
|
+
await githubRateLimiter.acquire()
|
|
63
|
+
const token = process.env.GITHUB_TOKEN;
|
|
64
|
+
const headers = { 'User-Agent': 'promptgraph-mcp' };
|
|
65
|
+
if (token && url.startsWith('https://api.github.com/')) headers['Authorization'] = `Bearer ${token}`;
|
|
66
|
+
return new Promise((res, rej) => {
|
|
67
|
+
const req = https.get(url, { headers }, r => {
|
|
68
|
+
if (r.statusCode >= 300 && r.statusCode < 400 && r.headers.location)
|
|
69
|
+
return httpsGet(r.headers.location, redirects + 1).then(res, rej);
|
|
70
|
+
if (r.statusCode !== 200) { r.resume(); return rej(new Error(`HTTP ${r.statusCode}`)); }
|
|
71
|
+
const chunks = []; r.setEncoding('utf8'); r.on('data', c => chunks.push(c)); r.on('end', () => res(chunks.join('')));
|
|
72
|
+
});
|
|
73
|
+
req.on('error', rej);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function repoExists(ownerRepo) {
|
|
78
|
+
return new Promise(resolve => {
|
|
79
|
+
const req = https.request(
|
|
80
|
+
{ host: 'github.com', path: `/${ownerRepo}`, method: 'HEAD', headers: { 'User-Agent': 'promptgraph-mcp' } },
|
|
81
|
+
r => resolve(r.statusCode < 400)
|
|
82
|
+
);
|
|
83
|
+
req.on('error', () => resolve(false));
|
|
84
|
+
req.end();
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Download one .md file, run validateSkill on it, return errors/warnings.
|
|
89
|
+
async function validateMdFile(file, tmpDir, ownerRepo) {
|
|
90
|
+
const errors = [];
|
|
91
|
+
const warnings = [];
|
|
92
|
+
const stats = getRepoStats(ownerRepo);
|
|
93
|
+
try {
|
|
94
|
+
if (stats.fileCount >= MAX_FILE_COUNT) {
|
|
95
|
+
errors.push(`${file.name}: skipped — repo file count limit (${MAX_FILE_COUNT}) reached`);
|
|
96
|
+
return { errors, warnings };
|
|
97
|
+
}
|
|
98
|
+
await downloadRateLimiter.acquire()
|
|
99
|
+
const content = await streamDownload(file.download_url);
|
|
100
|
+
stats.totalBytes += Buffer.byteLength(content, 'utf8');
|
|
101
|
+
stats.fileCount++;
|
|
102
|
+
if (stats.totalBytes > MAX_REPO_SIZE) {
|
|
103
|
+
return { errors: [...errors, `${file.name}: repo size limit (${MAX_REPO_SIZE}) exceeded`], warnings };
|
|
104
|
+
}
|
|
105
|
+
const tmpPath = path.join(tmpDir, file.name);
|
|
106
|
+
fs.mkdirSync(path.dirname(tmpPath), { recursive: true });
|
|
107
|
+
fs.writeFileSync(tmpPath, content);
|
|
108
|
+
const result = validateSkill(tmpPath);
|
|
109
|
+
if (!result.ok) {
|
|
110
|
+
errors.push(`${file.name}: ${result.errors.join('; ')}`);
|
|
111
|
+
}
|
|
112
|
+
if (result.warnings?.length) {
|
|
113
|
+
warnings.push(...result.warnings.map(w => `${file.name}: ${w}`));
|
|
114
|
+
}
|
|
115
|
+
fs.unlinkSync(tmpPath);
|
|
116
|
+
} catch (e) {
|
|
117
|
+
errors.push(`${file.name}: failed to validate — ${e.message}`);
|
|
118
|
+
}
|
|
119
|
+
return { errors, warnings };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Validate all .md files in a repo's skills subdir against validateSkill().
|
|
123
|
+
// Falls back to root-level .md files if no skills subdirectory is found.
|
|
124
|
+
// Returns { ok, errors[], warnings[] }.
|
|
125
|
+
export async function validateRepoSkills(ownerRepo) {
|
|
126
|
+
const detected = await detectSkillsDirFromAPI(ownerRepo);
|
|
127
|
+
const tmpDir = path.join(PROMPTGRAPH_DIR, '.validate-tmp');
|
|
128
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
129
|
+
|
|
130
|
+
let mdFiles;
|
|
131
|
+
if (detected) {
|
|
132
|
+
// Has a skills subdirectory — use git tree API for recursive listing
|
|
133
|
+
const subdir = detected.subdir;
|
|
134
|
+
try {
|
|
135
|
+
const treeJson = await httpsGet(`https://api.github.com/repos/${ownerRepo}/git/trees/HEAD?recursive=1`);
|
|
136
|
+
const tree = JSON.parse(treeJson);
|
|
137
|
+
const prefix = subdir + '/';
|
|
138
|
+
const mdTreeEntries = (tree.tree || []).filter(f =>
|
|
139
|
+
f.type === 'blob' && f.path.startsWith(prefix) && f.path.endsWith('.md')
|
|
140
|
+
);
|
|
141
|
+
let branch = 'main';
|
|
142
|
+
try {
|
|
143
|
+
const repoJson = await httpsGet(`https://api.github.com/repos/${ownerRepo}`);
|
|
144
|
+
branch = JSON.parse(repoJson).default_branch || 'main';
|
|
145
|
+
} catch {}
|
|
146
|
+
mdFiles = mdTreeEntries.map(f => ({
|
|
147
|
+
name: f.path.replace(prefix, ''),
|
|
148
|
+
download_url: `https://raw.githubusercontent.com/${ownerRepo}/${branch}/${f.path}`
|
|
149
|
+
}));
|
|
150
|
+
} catch (e) {
|
|
151
|
+
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
|
152
|
+
return { ok: false, errors: [`Failed to list ${subdir}/ contents: ${e.message}`], warnings: [] };
|
|
153
|
+
}
|
|
154
|
+
} else {
|
|
155
|
+
// No skills subdir — fall back to root-level .md files
|
|
156
|
+
try {
|
|
157
|
+
const json = await httpsGet(`https://api.github.com/repos/${ownerRepo}/contents`);
|
|
158
|
+
const entries = JSON.parse(json);
|
|
159
|
+
mdFiles = entries.filter(e => e.type === 'file' && e.name.endsWith('.md'));
|
|
160
|
+
} catch (e) {
|
|
161
|
+
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
|
162
|
+
return { ok: false, errors: [`Failed to list repo root: ${e.message}`], warnings: [] };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (!mdFiles || mdFiles.length === 0) {
|
|
167
|
+
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
|
168
|
+
return { ok: false, errors: [`No .md files found in ${ownerRepo}`], warnings: [] };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Filter out docs-like filenames (README, LICENSE, CHANGELOG, etc.)
|
|
172
|
+
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;
|
|
173
|
+
const mdTrimmed = mdFiles.filter(f => !SKIP_DOCS.test(f.name.replace(/\.md$/i, '')));
|
|
174
|
+
const mdToValidate = mdTrimmed.length > 0 ? mdTrimmed : mdFiles;
|
|
175
|
+
|
|
176
|
+
let errors = [];
|
|
177
|
+
let warnings = [];
|
|
178
|
+
|
|
179
|
+
for (const file of mdToValidate) {
|
|
180
|
+
const r = await validateMdFile(file, tmpDir, ownerRepo);
|
|
181
|
+
errors.push(...r.errors);
|
|
182
|
+
warnings.push(...r.warnings);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
|
186
|
+
|
|
187
|
+
return { ok: errors.length === 0, errors, warnings };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Ask GitHub API which subdir to use (without cloning anything). Exported for validation.
|
|
191
|
+
export
|
|
192
|
+
// Returns { subdir, label } or null (use root).
|
|
193
|
+
async function detectSkillsDirFromAPI(ownerRepo) {
|
|
194
|
+
try {
|
|
195
|
+
const json = await httpsGet(`https://api.github.com/repos/${ownerRepo}/contents`);
|
|
196
|
+
const entries = JSON.parse(json);
|
|
197
|
+
|
|
198
|
+
// 1. Known skill dir names (priority order)
|
|
199
|
+
const dirMap = new Map(entries.filter(e => e.type === 'dir').map(e => [e.name.toLowerCase(), e.name]));
|
|
200
|
+
for (const d of SKILL_DIRS) {
|
|
201
|
+
if (dirMap.has(d)) return { subdir: dirMap.get(d), label: d };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// 1.5 Nested skills dirs (e.g. .claude/skills, .claude-plugin/commands)
|
|
205
|
+
for (const prefix of ['.claude', '.claude-plugin']) {
|
|
206
|
+
if (dirMap.has(prefix)) {
|
|
207
|
+
for (const d of SKILL_DIRS) {
|
|
208
|
+
const nested = `${prefix}/${d}`;
|
|
209
|
+
try {
|
|
210
|
+
const sub = await httpsGet(`https://api.github.com/repos/${ownerRepo}/contents/${nested}`);
|
|
211
|
+
const subEntries = JSON.parse(sub);
|
|
212
|
+
if (subEntries.length > 0) return { subdir: nested, label: nested };
|
|
213
|
+
} catch {}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// 2. Any subdir with 2+ .md files — pick the one with most .md files
|
|
219
|
+
const subdirCandidates = entries.filter(e => e.type === 'dir' && !SKIP_DIRS_API.has(e.name.toLowerCase()));
|
|
220
|
+
let best = null, bestCount = 0;
|
|
221
|
+
for (const dir of subdirCandidates) {
|
|
222
|
+
try {
|
|
223
|
+
const sub = await httpsGet(`https://api.github.com/repos/${ownerRepo}/contents/${dir.name}`);
|
|
224
|
+
const subEntries = JSON.parse(sub);
|
|
225
|
+
const mdCount = subEntries.filter(e => e.type === 'file' && e.name.endsWith('.md')).length;
|
|
226
|
+
if (mdCount >= 1 && mdCount > bestCount) { best = dir.name; bestCount = mdCount; }
|
|
227
|
+
} catch {}
|
|
228
|
+
}
|
|
229
|
+
if (best) return { subdir: best, label: best };
|
|
230
|
+
|
|
231
|
+
} catch {}
|
|
232
|
+
return null; // no good subdir found — use root
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const SKIP_DIRS_API = new Set([
|
|
236
|
+
'.github', 'docs', 'doc', 'documentation', 'examples', 'example',
|
|
237
|
+
'tests', 'test', 'assets', 'images', 'img', 'media', 'static',
|
|
238
|
+
'node_modules', 'vendor', 'dist', 'build', '.git',
|
|
239
|
+
'references', 'reference', 'refs', 'cheatsheet', 'cheat-sheet',
|
|
240
|
+
'cheatsheets', 'resources',
|
|
241
|
+
'src', 'cli', 'lib', 'bin', 'scripts',
|
|
242
|
+
]);
|
|
243
|
+
|
|
244
|
+
function git(args, cwd, stdio = 'inherit') {
|
|
245
|
+
return spawnSync('git', args, { cwd, stdio });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Clone only the skills subdir via sparse-checkout
|
|
249
|
+
function sparseClone(url, dest, subdir) {
|
|
250
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
251
|
+
|
|
252
|
+
// 1. init + add remote
|
|
253
|
+
if (git(['init'], dest, 'pipe').status !== 0) return false;
|
|
254
|
+
if (git(['remote', 'add', 'origin', url], dest, 'pipe').status !== 0) return false;
|
|
255
|
+
|
|
256
|
+
// 2. sparse-checkout — non-cone mode with *.md glob
|
|
257
|
+
git(['sparse-checkout', 'init'], dest, 'pipe');
|
|
258
|
+
git(['sparse-checkout', 'set', '--no-cone', `${subdir}/*.md`, `${subdir}/**/*.md`], dest, 'pipe');
|
|
259
|
+
|
|
260
|
+
// 3. fetch + checkout (depth=1, skip large blobs)
|
|
261
|
+
const fetch = git(['fetch', '--depth=1', '--filter=blob:none', 'origin'], dest);
|
|
262
|
+
if (fetch.status !== 0) return false;
|
|
263
|
+
|
|
264
|
+
// Try HEAD, then main, then master
|
|
265
|
+
for (const branch of ['HEAD', 'main', 'master']) {
|
|
266
|
+
const r = git(['checkout', branch === 'HEAD' ? 'FETCH_HEAD' : branch], dest, 'pipe');
|
|
267
|
+
if (r.status === 0) return finalizeCheckout(dest, true);
|
|
268
|
+
}
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Shared skip patterns — module scope so both cleanup functions can access them
|
|
273
|
+
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;
|
|
274
|
+
const SKIP_DIRS_LOCAL = new Set([
|
|
275
|
+
'.github', 'docs', 'doc', 'assets', 'images', 'img', 'screenshots',
|
|
276
|
+
'media', 'static', 'scripts', 'ci_scripts', 'node_modules', 'vendor',
|
|
277
|
+
'dist', 'build', 'tests', 'test',
|
|
278
|
+
'references', 'reference', 'refs', 'cheatsheet', 'cheat-sheet',
|
|
279
|
+
'cheatsheets', 'resources',
|
|
280
|
+
'src', 'cli', 'lib', 'bin',
|
|
281
|
+
]);
|
|
282
|
+
|
|
283
|
+
// After full-clone root: remove files that are not skills and dirs we don't need
|
|
284
|
+
function cleanupRepoRoot(repoRoot) {
|
|
285
|
+
let removed = 0;
|
|
286
|
+
const entries = fs.readdirSync(repoRoot, { withFileTypes: true });
|
|
287
|
+
for (const entry of entries) {
|
|
288
|
+
if (entry.name === '.git') continue;
|
|
289
|
+
const fullPath = path.join(repoRoot, entry.name);
|
|
290
|
+
if (entry.isDirectory()) {
|
|
291
|
+
if (SKIP_DIRS_LOCAL.has(entry.name.toLowerCase())) {
|
|
292
|
+
fs.rmSync(fullPath, { recursive: true, force: true });
|
|
293
|
+
removed++;
|
|
294
|
+
} else {
|
|
295
|
+
// Recurse into subdirectory to remove doc files
|
|
296
|
+
removed += cleanupRepoDir(fullPath, SKIP_RE);
|
|
297
|
+
}
|
|
298
|
+
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
299
|
+
const base = entry.name.replace(/\.md$/i, '').toLowerCase();
|
|
300
|
+
if (SKIP_RE.test(base)) {
|
|
301
|
+
fs.unlinkSync(fullPath);
|
|
302
|
+
removed++;
|
|
303
|
+
}
|
|
304
|
+
} else if (entry.isFile() && !entry.name.endsWith('.md')) {
|
|
305
|
+
if (entry.name !== '.gitignore') {
|
|
306
|
+
try { fs.unlinkSync(fullPath); removed++; } catch {}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (removed > 0) console.log(`Cleaned up ${removed} non-skill files/dirs`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Recursively remove doc .md files from subdirectories
|
|
314
|
+
function cleanupRepoDir(dirPath, SKIP_RE) {
|
|
315
|
+
let removed = 0;
|
|
316
|
+
let entries;
|
|
317
|
+
try { entries = fs.readdirSync(dirPath, { withFileTypes: true }); } catch { return 0; }
|
|
318
|
+
for (const entry of entries) {
|
|
319
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
320
|
+
if (entry.isDirectory()) {
|
|
321
|
+
// Remove entire skip dirs (e.g. references/) nested inside skills dirs
|
|
322
|
+
if (SKIP_DIRS_LOCAL.has(entry.name.toLowerCase())) {
|
|
323
|
+
fs.rmSync(fullPath, { recursive: true, force: true });
|
|
324
|
+
removed++;
|
|
325
|
+
} else {
|
|
326
|
+
removed += cleanupRepoDir(fullPath, SKIP_RE);
|
|
327
|
+
try { if (fs.readdirSync(fullPath).length === 0) fs.rmdirSync(fullPath); } catch {}
|
|
328
|
+
}
|
|
329
|
+
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
330
|
+
const base = entry.name.replace(/\.md$/i, '').toLowerCase();
|
|
331
|
+
if (SKIP_RE.test(base)) {
|
|
332
|
+
fs.unlinkSync(fullPath);
|
|
333
|
+
removed++;
|
|
334
|
+
}
|
|
335
|
+
} else if (entry.isFile() && !entry.name.endsWith('.md')) {
|
|
336
|
+
try { fs.unlinkSync(fullPath); removed++; } catch {}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return removed;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Recursively remove empty directories
|
|
343
|
+
function removeEmptyDirs(dirPath) {
|
|
344
|
+
let entries;
|
|
345
|
+
try { entries = fs.readdirSync(dirPath, { withFileTypes: true }); } catch { return; }
|
|
346
|
+
for (const entry of entries) {
|
|
347
|
+
if (entry.name === '.git') continue;
|
|
348
|
+
if (!entry.isDirectory()) continue;
|
|
349
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
350
|
+
removeEmptyDirs(fullPath);
|
|
351
|
+
try { if (fs.readdirSync(fullPath).length === 0) fs.rmdirSync(fullPath); } catch {}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// After fetch/checkout: force materialization of all sparse-matched files.
|
|
356
|
+
// partial clone (blob:none) + checkout often skips blob download on Windows.
|
|
357
|
+
function forceMaterialize(dest) {
|
|
358
|
+
git(['checkout', 'HEAD', '--', '.'], dest, 'pipe');
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Update sparse repo — fetch + checkout
|
|
362
|
+
function sparseUpdate(dest, subdir) {
|
|
363
|
+
const fetch = git(['fetch', '--depth=1', 'origin'], dest);
|
|
364
|
+
if (fetch.status !== 0) return false;
|
|
365
|
+
|
|
366
|
+
git(['sparse-checkout', 'set', '--no-cone', `${subdir}/*.md`, `${subdir}/**/*.md`], dest, 'pipe');
|
|
367
|
+
|
|
368
|
+
for (const ref of ['origin/main', 'origin/master']) {
|
|
369
|
+
const r = git(['checkout', ref], dest, 'pipe');
|
|
370
|
+
if (r.status !== 0) continue;
|
|
371
|
+
forceMaterialize(dest);
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// After checkout, force materialization of sparse-matched files.
|
|
378
|
+
function finalizeCheckout(dest, success) {
|
|
379
|
+
if (success) forceMaterialize(dest);
|
|
380
|
+
return success;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Fallback: clone root but only checkout .md files
|
|
384
|
+
function fullClone(url, dest) {
|
|
385
|
+
if (fs.existsSync(dest)) {
|
|
386
|
+
const fetch = git(['fetch', '--depth=1', '--filter=blob:none', 'origin'], dest);
|
|
387
|
+
if (fetch.status !== 0) return false;
|
|
388
|
+
for (const ref of ['origin/HEAD', 'origin/main', 'origin/master']) {
|
|
389
|
+
const ok = git(['reset', '--hard', ref], dest, 'pipe').status === 0;
|
|
390
|
+
if (ok) return finalizeCheckout(dest, true);
|
|
391
|
+
}
|
|
392
|
+
return false;
|
|
393
|
+
}
|
|
394
|
+
// init + sparse *.md + fetch + checkout
|
|
395
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
396
|
+
if (git(['init'], dest, 'pipe').status !== 0) return false;
|
|
397
|
+
if (git(['remote', 'add', 'origin', url], dest, 'pipe').status !== 0) return false;
|
|
398
|
+
git(['sparse-checkout', 'init'], dest, 'pipe');
|
|
399
|
+
git(['sparse-checkout', 'set', '--no-cone', '*.md', '**/*.md'], dest, 'pipe');
|
|
400
|
+
const fetch = git(['fetch', '--depth=1', '--filter=blob:none', 'origin'], dest);
|
|
401
|
+
if (fetch.status !== 0) return false;
|
|
402
|
+
for (const branch of ['FETCH_HEAD', 'main', 'master']) {
|
|
403
|
+
const r = git(['checkout', branch], dest, 'pipe');
|
|
404
|
+
if (r.status === 0) return finalizeCheckout(dest, true);
|
|
405
|
+
}
|
|
406
|
+
return false;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// After clone: detect actual skills dir on disk (flat + nested)
|
|
410
|
+
function detectSkillsDirLocal(repoRoot) {
|
|
411
|
+
// 1. Flat dirs matching SKILL_DIRS (e.g. skills/, commands/)
|
|
412
|
+
for (const dir of SKILL_DIRS) {
|
|
413
|
+
const candidate = path.join(repoRoot, dir);
|
|
414
|
+
if (fs.existsSync(candidate)) {
|
|
415
|
+
const files = globSync(`${candidate}/**/*.md`);
|
|
416
|
+
if (files.length >= 1) return { dir: candidate, label: dir, sparse: true };
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
// 2. Nested dirs: .claude/skills, .claude-plugin/commands, etc.
|
|
420
|
+
for (const prefix of ['.claude', '.claude-plugin']) {
|
|
421
|
+
for (const dir of SKILL_DIRS) {
|
|
422
|
+
const candidate = path.join(repoRoot, prefix, dir);
|
|
423
|
+
if (fs.existsSync(candidate)) {
|
|
424
|
+
const files = globSync(`${candidate}/**/*.md`);
|
|
425
|
+
if (files.length >= 1) return { dir: candidate, label: `${prefix}/${dir}`, sparse: true };
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return { dir: repoRoot, label: '(root)', sparse: false };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Remove files classified as non-skills by embedding classifier (only if model is trained)
|
|
433
|
+
async function classifierCleanup(dest) {
|
|
434
|
+
const mdFiles = globSync(`${dest}/**/*.md`);
|
|
435
|
+
if (mdFiles.length === 0) return;
|
|
436
|
+
|
|
437
|
+
const parsed = [];
|
|
438
|
+
const fileMap = [];
|
|
439
|
+
|
|
440
|
+
for (const fp of mdFiles) {
|
|
441
|
+
try {
|
|
442
|
+
const raw = fs.readFileSync(fp, 'utf8');
|
|
443
|
+
if (!isSkillFile(fp, raw)) continue;
|
|
444
|
+
parsed.push(parseSkillFile(fp, '', { raw }));
|
|
445
|
+
fileMap.push(fp);
|
|
446
|
+
} catch {}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (parsed.length === 0) {
|
|
450
|
+
removeEmptyDirs(dest);
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const filtered = await filterWithClassifier(parsed);
|
|
455
|
+
const keptPaths = new Set(filtered.map(s => s.path));
|
|
456
|
+
|
|
457
|
+
let removed = 0;
|
|
458
|
+
for (const fp of fileMap) {
|
|
459
|
+
if (!keptPaths.has(fp)) {
|
|
460
|
+
try { fs.unlinkSync(fp); removed++; } catch {}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (removed > 0) {
|
|
465
|
+
console.log(`Removed ${removed} non-skill files (classifier)`);
|
|
466
|
+
removeEmptyDirs(dest);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// ── main export ───────────────────────────────────────────────────────────────
|
|
471
|
+
|
|
472
|
+
export async function importFromGitHub(repoUrl) {
|
|
473
|
+
if (!repoUrl) {
|
|
474
|
+
console.error('Usage: promptgraph-mcp import <github-url-or-owner/repo>');
|
|
475
|
+
process.exit(1);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const url = repoUrl.startsWith('http') ? repoUrl : `https://github.com/${repoUrl}`;
|
|
479
|
+
const ownerRepo = url.replace(/^https?:\/\/github\.com\//, '').replace(/\.git$/, '');
|
|
480
|
+
const repoName = ownerRepo.replace('/', '-');
|
|
481
|
+
const dest = path.join(SKILLS_STORE_DIR, 'github', repoName);
|
|
482
|
+
|
|
483
|
+
const isNew = !fs.existsSync(dest);
|
|
484
|
+
|
|
485
|
+
if (isNew) {
|
|
486
|
+
const exists = await repoExists(ownerRepo);
|
|
487
|
+
if (!exists) throw new Error(`Repository not found (404): ${url}`);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
491
|
+
|
|
492
|
+
let skillsSubdir = null;
|
|
493
|
+
let cloneOk = false;
|
|
494
|
+
|
|
495
|
+
if (isNew) {
|
|
496
|
+
// Detect skills dir via API before cloning
|
|
497
|
+
process.stdout.write(`Detecting skills directory for ${ownerRepo}... `);
|
|
498
|
+
const detected = await detectSkillsDirFromAPI(ownerRepo);
|
|
499
|
+
skillsSubdir = detected?.subdir || null;
|
|
500
|
+
|
|
501
|
+
if (!skillsSubdir) {
|
|
502
|
+
console.log(`found: (root) — no skills subdirectory, using full clone`);
|
|
503
|
+
cloneOk = fullClone(url, dest);
|
|
504
|
+
} else {
|
|
505
|
+
console.log(`found: ${detected.label}/`);
|
|
506
|
+
console.log(`Sparse-cloning ${url} (${skillsSubdir}/ only)...`);
|
|
507
|
+
cloneOk = sparseClone(url, dest, skillsSubdir);
|
|
508
|
+
}
|
|
509
|
+
if (!cloneOk) {
|
|
510
|
+
fs.rmSync(dest, { recursive: true, force: true });
|
|
511
|
+
throw new Error(`Sparse-checkout failed for ${url}`);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (!cloneOk) throw new Error(`Clone failed for ${url}`);
|
|
515
|
+
} else {
|
|
516
|
+
console.log(`Updating ${repoName}...`);
|
|
517
|
+
// Detect existing sparse subdir
|
|
518
|
+
const isSparse = git(['sparse-checkout', 'list'], dest, 'pipe').status === 0;
|
|
519
|
+
const sparseList = isSparse
|
|
520
|
+
? spawnSync('git', ['sparse-checkout', 'list'], { cwd: dest, encoding: 'utf8' }).stdout.trim()
|
|
521
|
+
: '';
|
|
522
|
+
skillsSubdir = (() => {
|
|
523
|
+
const lines = sparseList.split('\n').map(l => l.trim()).filter(Boolean);
|
|
524
|
+
if (lines.length === 0) return null;
|
|
525
|
+
const exact = lines.find(l => SKILL_DIRS.includes(l));
|
|
526
|
+
if (exact) return exact;
|
|
527
|
+
for (const line of lines) {
|
|
528
|
+
const m = line.match(/^(.+)\/(?:\*\*\/)?\*\.md$/);
|
|
529
|
+
if (m && !m[1].includes('*')) return m[1];
|
|
530
|
+
}
|
|
531
|
+
return null;
|
|
532
|
+
})();
|
|
533
|
+
|
|
534
|
+
cloneOk = skillsSubdir
|
|
535
|
+
? sparseUpdate(dest, skillsSubdir)
|
|
536
|
+
: fullClone(url, dest);
|
|
537
|
+
if (!cloneOk) throw new Error(`Update failed for ${repoName}`);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Remove doc files anywhere in the cloned tree
|
|
541
|
+
cleanupRepoRoot(dest);
|
|
542
|
+
removeEmptyDirs(dest);
|
|
543
|
+
|
|
544
|
+
// Validate every .md file via isSkillFile — delete low-quality files
|
|
545
|
+
const allMd = globSync(`${dest}/**/*.md`);
|
|
546
|
+
let removedInvalid = 0;
|
|
547
|
+
for (const fp of allMd) {
|
|
548
|
+
if (!isSkillFile(fp)) {
|
|
549
|
+
try { fs.unlinkSync(fp); removedInvalid++; } catch {}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
if (removedInvalid > 0) {
|
|
553
|
+
console.log(`Removed ${removedInvalid} low-quality .md files (isSkillFile)`);
|
|
554
|
+
removeEmptyDirs(dest);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Full validateSkill() pass — remove files that fail marketplace-level validation
|
|
558
|
+
const remainingMd = globSync(`${dest}/**/*.md`);
|
|
559
|
+
let removedFailedValidation = 0;
|
|
560
|
+
for (const fp of remainingMd) {
|
|
561
|
+
const v = validateSkill(fp);
|
|
562
|
+
if (!v.ok) {
|
|
563
|
+
try { fs.unlinkSync(fp); removedFailedValidation++; } catch {}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
if (removedFailedValidation > 0) {
|
|
567
|
+
console.log(`Removed ${removedFailedValidation} files that failed validateSkill()`);
|
|
568
|
+
removeEmptyDirs(dest);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
await classifierCleanup(dest);
|
|
572
|
+
|
|
573
|
+
// Count survivors after all cleanup
|
|
574
|
+
const realCount = globSync(`${dest}/**/*.md`).length;
|
|
575
|
+
const cacheKey = url.replace(/\.git$/, '');
|
|
576
|
+
const cachePath = path.join(PROMPTGRAPH_DIR, 'skill-counts.json');
|
|
577
|
+
try {
|
|
578
|
+
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
|
579
|
+
const cache = JSON.parse(fs.readFileSync(cachePath, 'utf8') || '{}');
|
|
580
|
+
cache[cacheKey] = { count: realCount, ts: Date.now() };
|
|
581
|
+
fs.writeFileSync(cachePath, JSON.stringify(cache, null, 2));
|
|
582
|
+
} catch {}
|
|
583
|
+
|
|
584
|
+
if (realCount < 1) {
|
|
585
|
+
fs.rmSync(dest, { recursive: true, force: true });
|
|
586
|
+
throw new Error(`No valid skills in repo — all files were filtered out`);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const { dir: localDir, label: localLabel } = detectSkillsDirLocal(dest);
|
|
590
|
+
// Prefer the known skillsSubdir (from API detection or sparse patterns) as the
|
|
591
|
+
// canonical skills directory — it's more accurate than local heuristics for
|
|
592
|
+
// repos with non-standard dir names (e.g. "specialized", "cli", or nested paths)
|
|
593
|
+
const skillsDir = skillsSubdir ? path.join(dest, skillsSubdir) : localDir;
|
|
594
|
+
const label = skillsSubdir || localLabel;
|
|
595
|
+
const mdFiles = globSync(`${skillsDir}/**/*.md`);
|
|
596
|
+
|
|
597
|
+
if (mdFiles.length > MAX_FILE_COUNT) {
|
|
598
|
+
console.warn(`Warning: ${mdFiles.length} .md files exceeds limit of ${MAX_FILE_COUNT} — truncating`);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if (skillsSubdir) {
|
|
602
|
+
console.log(`Sparse-checkout: ${label}/ only (${mdFiles.length} .md files, no other repo files)`);
|
|
603
|
+
} else {
|
|
604
|
+
console.log(`Full clone: scanning ${label} (${mdFiles.length} .md files)`);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const config = loadConfig();
|
|
608
|
+
const repoSource = `github:${repoName}`;
|
|
609
|
+
if (!config.sources.find(s => s.dir === skillsDir)) {
|
|
610
|
+
const oldIdx = config.sources.findIndex(s => s.source === repoSource);
|
|
611
|
+
if (oldIdx !== -1) config.sources.splice(oldIdx, 1);
|
|
612
|
+
config.sources.push({ dir: skillsDir, source: repoSource });
|
|
613
|
+
saveConfig(config);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
console.log();
|
|
617
|
+
await indexSource(skillsDir, repoSource);
|
|
618
|
+
console.log(`Done! Imported from ${repoName}/${label}`);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// ── Detect skills subdir from local git tree (no API calls) ───────────────────
|
|
622
|
+
|
|
623
|
+
function detectSubdirFromTree(repoRoot) {
|
|
624
|
+
const lsTree = spawnSync('git', ['-C', repoRoot, 'ls-tree', '--name-only', 'HEAD'], { encoding: 'utf8', stdio: 'pipe' });
|
|
625
|
+
if (lsTree.status !== 0) return null;
|
|
626
|
+
const entries = lsTree.stdout.trim().split('\n').filter(Boolean);
|
|
627
|
+
const dirMap = new Map();
|
|
628
|
+
for (const e of entries) dirMap.set(e.toLowerCase(), e);
|
|
629
|
+
|
|
630
|
+
for (const d of SKILL_DIRS) {
|
|
631
|
+
if (dirMap.has(d)) return dirMap.get(d);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
for (const prefix of ['.claude', '.claude-plugin']) {
|
|
635
|
+
if (dirMap.has(prefix)) {
|
|
636
|
+
const realPrefix = dirMap.get(prefix);
|
|
637
|
+
const sub = spawnSync('git', ['-C', repoRoot, 'ls-tree', '--name-only', `HEAD:${realPrefix}`], { encoding: 'utf8', stdio: 'pipe' });
|
|
638
|
+
if (sub.status === 0) {
|
|
639
|
+
for (const d of SKILL_DIRS) {
|
|
640
|
+
if (sub.stdout.split('\n').map(s => s.toLowerCase()).includes(d)) return `${realPrefix}/${d}`;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const skipSet = new Set([...SKIP_DIRS_API]);
|
|
647
|
+
const candidates = entries.filter(e => !skipSet.has(e.toLowerCase()) && !e.includes('.'));
|
|
648
|
+
let best = null, bestCount = 0;
|
|
649
|
+
for (const dir of candidates) {
|
|
650
|
+
const sub = spawnSync('git', ['-C', repoRoot, 'ls-tree', '--name-only', `HEAD:${dir}`], { encoding: 'utf8', stdio: 'pipe' });
|
|
651
|
+
if (sub.status !== 0) continue;
|
|
652
|
+
const mdCount = sub.stdout.split('\n').filter(f => f.endsWith('.md')).length;
|
|
653
|
+
if (mdCount >= 1 && mdCount > bestCount) { best = dir; bestCount = mdCount; }
|
|
654
|
+
}
|
|
655
|
+
return best;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// ── light version (git sparse-checkout, no API rate limits) ───────────────────
|
|
659
|
+
|
|
660
|
+
export async function importFromGitHubLight(repoUrl) {
|
|
661
|
+
if (!repoUrl) throw new Error('Missing repoUrl');
|
|
662
|
+
|
|
663
|
+
const url = repoUrl.startsWith('http') ? repoUrl : `https://github.com/${repoUrl}`;
|
|
664
|
+
const ownerRepo = url.replace(/^https?:\/\/github\.com\//, '').replace(/\.git$/, '');
|
|
665
|
+
const repoName = ownerRepo.replace('/', '-');
|
|
666
|
+
const destBase = path.join(SKILLS_STORE_DIR, 'github', repoName);
|
|
667
|
+
const cloneUrl = `https://github.com/${ownerRepo}.git`;
|
|
668
|
+
|
|
669
|
+
if (fs.existsSync(destBase)) fs.rmSync(destBase, { recursive: true, force: true });
|
|
670
|
+
fs.mkdirSync(destBase, { recursive: true });
|
|
671
|
+
|
|
672
|
+
const gitEnv = { ...process.env, GIT_TERMINAL_PROMPT: '0' };
|
|
673
|
+
|
|
674
|
+
// Step 1: treeless clone — gets file tree instantly, no blob download, no API
|
|
675
|
+
const init = spawnSync('git', ['clone', '--depth=1', '--filter=blob:none', '--no-checkout', cloneUrl, destBase], { stdio: 'pipe', env: gitEnv, timeout: 60000 });
|
|
676
|
+
if (init.status !== 0) {
|
|
677
|
+
fs.rmSync(destBase, { recursive: true, force: true });
|
|
678
|
+
throw new Error(`Failed to clone ${ownerRepo}: ${(init.stderr?.toString() || '').trim().slice(0, 120)}`);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Step 2: detect skills subdir from local tree — zero API calls
|
|
682
|
+
const subdir = detectSubdirFromTree(destBase);
|
|
683
|
+
|
|
684
|
+
// Step 3: sparse-checkout only the skills subdir .md files
|
|
685
|
+
spawnSync('git', ['-C', destBase, 'sparse-checkout', 'init'], { stdio: 'pipe', env: gitEnv });
|
|
686
|
+
if (subdir) {
|
|
687
|
+
spawnSync('git', ['-C', destBase, 'sparse-checkout', 'set', '--no-cone', `${subdir}/*.md`, `${subdir}/**/*.md`], { stdio: 'pipe', env: gitEnv });
|
|
688
|
+
} else {
|
|
689
|
+
spawnSync('git', ['-C', destBase, 'sparse-checkout', 'set', '--no-cone', '*.md', '**/*.md'], { stdio: 'pipe', env: gitEnv });
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// Step 4: checkout to materialize only the selected files
|
|
693
|
+
const co = spawnSync('git', ['-C', destBase, 'checkout'], { stdio: 'pipe', env: gitEnv, timeout: 60000 });
|
|
694
|
+
if (co.status !== 0) {
|
|
695
|
+
fs.rmSync(destBase, { recursive: true, force: true });
|
|
696
|
+
throw new Error(`Checkout failed for ${ownerRepo}`);
|
|
697
|
+
}
|
|
698
|
+
// Force blob materialization (needed for partial clones on Windows)
|
|
699
|
+
spawnSync('git', ['-C', destBase, 'checkout', 'HEAD', '--', '.'], { stdio: 'pipe', env: gitEnv, timeout: 60000 });
|
|
700
|
+
|
|
701
|
+
// Step 5: filter out non-skill files locally
|
|
702
|
+
const allMd = globSync(`${destBase}/**/*.md`);
|
|
703
|
+
let removed = 0;
|
|
704
|
+
for (const fp of allMd) {
|
|
705
|
+
if (!isSkillFile(fp)) { try { fs.unlinkSync(fp); removed++; } catch {} }
|
|
706
|
+
}
|
|
707
|
+
if (removed > 0) removeEmptyDirs(destBase);
|
|
708
|
+
|
|
709
|
+
const remaining = globSync(`${destBase}/**/*.md`);
|
|
710
|
+
let removedV = 0;
|
|
711
|
+
for (const fp of remaining) {
|
|
712
|
+
const v = validateSkill(fp);
|
|
713
|
+
if (!v.ok) { try { fs.unlinkSync(fp); removedV++; } catch {} }
|
|
714
|
+
}
|
|
715
|
+
if (removedV > 0) removeEmptyDirs(destBase);
|
|
716
|
+
|
|
717
|
+
await classifierCleanup(destBase);
|
|
718
|
+
|
|
719
|
+
const realCount = globSync(`${destBase}/**/*.md`).length;
|
|
720
|
+
const cacheKey = url.replace(/\.git$/, '');
|
|
721
|
+
const cachePath = path.join(PROMPTGRAPH_DIR, 'skill-counts.json');
|
|
722
|
+
try {
|
|
723
|
+
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
|
724
|
+
const cache = JSON.parse(fs.readFileSync(cachePath, 'utf8') || '{}');
|
|
725
|
+
cache[cacheKey] = { count: realCount, ts: Date.now() };
|
|
726
|
+
fs.writeFileSync(cachePath, JSON.stringify(cache, null, 2));
|
|
727
|
+
} catch {}
|
|
728
|
+
|
|
729
|
+
if (realCount < 1) {
|
|
730
|
+
fs.rmSync(destBase, { recursive: true, force: true });
|
|
731
|
+
throw new Error('No valid skills in repo — all files were filtered out');
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const { dir: localDir, label: localLabel } = detectSkillsDirLocal(destBase);
|
|
735
|
+
const skillsDir = subdir ? path.join(destBase, subdir) : localDir;
|
|
736
|
+
const label = subdir || localLabel;
|
|
737
|
+
|
|
738
|
+
const config = loadConfig();
|
|
739
|
+
const repoSource = `github:${repoName}`;
|
|
740
|
+
if (!config.sources.find(s => s.dir === skillsDir)) {
|
|
741
|
+
const oldIdx = config.sources.findIndex(s => s.source === repoSource);
|
|
742
|
+
if (oldIdx !== -1) config.sources.splice(oldIdx, 1);
|
|
743
|
+
config.sources.push({ dir: skillsDir, source: repoSource });
|
|
744
|
+
saveConfig(config);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
console.log();
|
|
748
|
+
await indexSource(skillsDir, repoSource);
|
|
749
|
+
console.log(`Done! Imported from ${repoName}/${label || 'root'} (${realCount} skills via git)`);
|
|
750
|
+
}
|