promptgraph-mcp 2.4.4 → 2.4.6

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/bundle-counts.js CHANGED
@@ -16,8 +16,11 @@ const SKIP_ROOT_DIRS = new Set(['.github', 'docs', 'doc', 'assets', 'images', 'i
16
16
  const SKIP_NAMES = /^(readme|changelog|license|contributing|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)/i;
17
17
 
18
18
  function httpsGet(url) {
19
+ const token = process.env.GITHUB_TOKEN;
20
+ const headers = { 'User-Agent': 'promptgraph-mcp' };
21
+ if (token && url.startsWith('https://api.github.com/')) headers['Authorization'] = `Bearer ${token}`;
19
22
  return new Promise((res, rej) => {
20
- const req = https.get(url, { headers: { 'User-Agent': 'promptgraph-mcp' } }, r => {
23
+ const req = https.get(url, { headers }, r => {
21
24
  if (r.statusCode === 403 || r.statusCode === 429) return rej(new Error(`Rate limited`));
22
25
  if (r.statusCode !== 200) { r.resume(); return rej(new Error(`HTTP ${r.statusCode}`)); }
23
26
  let d = ''; r.setEncoding('utf8'); r.on('data', c => d += c); r.on('end', () => res(d));
package/github-import.js CHANGED
@@ -12,8 +12,11 @@ const SKILL_DIRS = ['skills', 'commands', 'prompts', 'agents', 'skills-store', '
12
12
  // ── helpers ───────────────────────────────────────────────────────────────────
13
13
 
14
14
  function httpsGet(url) {
15
+ const token = process.env.GITHUB_TOKEN;
16
+ const headers = { 'User-Agent': 'promptgraph-mcp' };
17
+ if (token && url.startsWith('https://api.github.com/')) headers['Authorization'] = `Bearer ${token}`;
15
18
  return new Promise((res, rej) => {
16
- const req = https.get(url, { headers: { 'User-Agent': 'promptgraph-mcp' } }, r => {
19
+ const req = https.get(url, { headers }, r => {
17
20
  if (r.statusCode >= 300 && r.statusCode < 400 && r.headers.location)
18
21
  return httpsGet(r.headers.location).then(res, rej);
19
22
  if (r.statusCode !== 200) { r.resume(); return rej(new Error(`HTTP ${r.statusCode}`)); }
@@ -192,6 +195,9 @@ function cleanupRepoRoot(repoRoot) {
192
195
  if (SKIP_DIRS_LOCAL.has(entry.name.toLowerCase())) {
193
196
  fs.rmSync(fullPath, { recursive: true, force: true });
194
197
  removed++;
198
+ } else {
199
+ // Recurse into subdirectory to remove doc files
200
+ removed += cleanupRepoDir(fullPath, SKIP_RE);
195
201
  }
196
202
  } else if (entry.isFile() && entry.name.endsWith('.md')) {
197
203
  const base = entry.name.replace(/\.md$/i, '').toLowerCase();
@@ -200,13 +206,49 @@ function cleanupRepoRoot(repoRoot) {
200
206
  removed++;
201
207
  }
202
208
  } else if (entry.isFile() && !entry.name.endsWith('.md')) {
203
- // Remove non-md files (gitignore, LICENSE, Makefile, etc.) — keep only .md
204
209
  if (entry.name !== '.gitignore') {
205
210
  try { fs.unlinkSync(fullPath); removed++; } catch {}
206
211
  }
207
212
  }
208
213
  }
209
- if (removed > 0) console.log(`Cleaned up ${removed} non-skill files/dirs from root`);
214
+ if (removed > 0) console.log(`Cleaned up ${removed} non-skill files/dirs`);
215
+ }
216
+
217
+ // Recursively remove doc .md files from subdirectories
218
+ function cleanupRepoDir(dirPath, SKIP_RE) {
219
+ let removed = 0;
220
+ let entries;
221
+ try { entries = fs.readdirSync(dirPath, { withFileTypes: true }); } catch { return 0; }
222
+ for (const entry of entries) {
223
+ const fullPath = path.join(dirPath, entry.name);
224
+ if (entry.isDirectory()) {
225
+ removed += cleanupRepoDir(fullPath, SKIP_RE);
226
+ // Remove empty dirs after cleanup
227
+ try { if (fs.readdirSync(fullPath).length === 0) fs.rmdirSync(fullPath); } catch {}
228
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
229
+ const base = entry.name.replace(/\.md$/i, '').toLowerCase();
230
+ if (SKIP_RE.test(base)) {
231
+ fs.unlinkSync(fullPath);
232
+ removed++;
233
+ }
234
+ } else if (entry.isFile() && !entry.name.endsWith('.md')) {
235
+ try { fs.unlinkSync(fullPath); removed++; } catch {}
236
+ }
237
+ }
238
+ return removed;
239
+ }
240
+
241
+ // Recursively remove empty directories
242
+ function removeEmptyDirs(dirPath) {
243
+ let entries;
244
+ try { entries = fs.readdirSync(dirPath, { withFileTypes: true }); } catch { return; }
245
+ for (const entry of entries) {
246
+ if (entry.name === '.git') continue;
247
+ if (!entry.isDirectory()) continue;
248
+ const fullPath = path.join(dirPath, entry.name);
249
+ removeEmptyDirs(fullPath);
250
+ try { if (fs.readdirSync(fullPath).length === 0) fs.rmdirSync(fullPath); } catch {}
251
+ }
210
252
  }
211
253
 
212
254
  // Update sparse repo — fetch + reset
@@ -321,6 +363,24 @@ export async function importFromGitHub(repoUrl) {
321
363
  if (!cloneOk) throw new Error(`Update failed for ${repoName}`);
322
364
  }
323
365
 
366
+ // Remove doc files anywhere in the cloned tree
367
+ cleanupRepoRoot(dest);
368
+
369
+ // Validate every .md file — delete those that fail
370
+ const allMd = globSync(`${dest}/**/*.md`);
371
+ let removedInvalid = 0;
372
+ for (const fp of allMd) {
373
+ const v = validateSkill(fp);
374
+ if (!v.ok) {
375
+ try { fs.unlinkSync(fp); removedInvalid++; } catch {}
376
+ }
377
+ }
378
+ if (removedInvalid > 0) {
379
+ console.log(`Removed ${removedInvalid} invalid .md files (failed validation)`);
380
+ // Clean up empty dirs left behind
381
+ removeEmptyDirs(dest);
382
+ }
383
+
324
384
  const { dir: skillsDir, label } = detectSkillsDirLocal(dest);
325
385
  const mdFiles = globSync(`${skillsDir}/**/*.md`);
326
386
 
package/marketplace.js CHANGED
@@ -11,6 +11,7 @@ import { loadConfig, saveConfig, PROMPTGRAPH_DIR, SKILLS_STORE_DIR } from './con
11
11
  import { importFromGitHub, validateRepoSkills } from './github-import.js';
12
12
 
13
13
  const REGISTRY_URL = 'https://raw.githubusercontent.com/NeiP4n/promptgraph-registry/main/registry.json';
14
+ const SKILL_COUNT_CACHE = path.join(PROMPTGRAPH_DIR, 'skill-counts.json');
14
15
  const SKILLS_DIR = path.join(SKILLS_STORE_DIR, 'marketplace');
15
16
 
16
17
  // Atomically write content to dest via tmp — cleans up on failure
@@ -81,6 +82,29 @@ async function rawFetch(url) {
81
82
 
82
83
  // Disk cache for the registry (network to GitHub raw can be slow on some networks).
83
84
  const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
85
+ const SKILL_COUNT_TTL = 24 * 60 * 60 * 1000; // 24 hours for skill counts
86
+
87
+ // Return GitHub API auth headers if GITHUB_TOKEN is set
88
+ function githubAuthHeaders() {
89
+ const token = process.env.GITHUB_TOKEN;
90
+ if (!token) return { 'User-Agent': 'promptgraph-mcp' };
91
+ return { 'User-Agent': 'promptgraph-mcp', 'Authorization': `Bearer ${token}` };
92
+ }
93
+
94
+ // Read skill count cache, returns {} on miss
95
+ function readSkillCountCache() {
96
+ try {
97
+ if (fs.existsSync(SKILL_COUNT_CACHE)) return JSON.parse(fs.readFileSync(SKILL_COUNT_CACHE, 'utf8'));
98
+ } catch {}
99
+ return {};
100
+ }
101
+
102
+ function writeSkillCountCache(data) {
103
+ try {
104
+ fs.mkdirSync(path.dirname(SKILL_COUNT_CACHE), { recursive: true });
105
+ fs.writeFileSync(SKILL_COUNT_CACHE, JSON.stringify(data));
106
+ } catch {}
107
+ }
84
108
 
85
109
  async function fetchText(url) {
86
110
  const cacheFile = path.join(PROMPTGRAPH_DIR, 'registry-cache.json');
@@ -187,7 +211,7 @@ function isSkillFile(path) {
187
211
  async function countRepoSkills(repoUrl) {
188
212
  try {
189
213
  const apiUrl = `https://api.github.com/repos/${repoUrl}/git/trees/HEAD?recursive=1`;
190
- const res = await fetch(apiUrl, { headers: { 'User-Agent': 'promptgraph-mcp' } });
214
+ const res = await fetch(apiUrl, { headers: githubAuthHeaders() });
191
215
  if (!res.ok) return null;
192
216
  const data = await res.json();
193
217
  return (data.tree || []).filter(f => f.type === 'blob' && isSkillFile(f.path)).length;
@@ -199,12 +223,33 @@ export async function browseBundles(topK = 20) {
199
223
  const text = await fetchText(REGISTRY_URL);
200
224
  const registry = JSON.parse(text);
201
225
  const bundles = registry.bundles || [];
226
+ const cache = readSkillCountCache();
227
+ const now = Date.now();
228
+ let changed = false;
229
+
202
230
  await Promise.all(bundles.map(async b => {
203
- // Re-count all repo bundles with the new .md-only filter
204
- if (b.repo_url) b.skillCount = await countRepoSkills(b.repo_url) ?? 0;
231
+ if (!b.repo_url) return;
232
+ const cached = cache[b.repo_url];
233
+ // Use cached count if fresh, else fetch from API
234
+ if (cached && (now - cached.ts) < SKILL_COUNT_TTL) {
235
+ b.skillCount = cached.count;
236
+ } else {
237
+ const count = await countRepoSkills(b.repo_url);
238
+ if (count !== null) {
239
+ b.skillCount = count;
240
+ cache[b.repo_url] = { count, ts: now };
241
+ changed = true;
242
+ } else {
243
+ // API failed — use stale cache if exists, else keep registry value as fallback
244
+ b.skillCount = cached?.count ?? b.skillCount ?? 0;
245
+ }
246
+ }
205
247
  }));
248
+
249
+ if (changed) writeSkillCountCache(cache);
250
+
206
251
  return bundles
207
- .filter(b => !b.repo_url || b.skillCount > 0) // hide bundles with 0 .md files
252
+ .filter(b => !b.repo_url || b.skillCount > 0)
208
253
  .map(b => ({ ...b, code: b.code || codeFor(b.id) }))
209
254
  .sort((a, b) => (b.stars || 0) - (a.stars || 0))
210
255
  .slice(0, topK);
@@ -322,16 +367,17 @@ export async function publishBundle(bundleDef) {
322
367
  return { error: 'Bundle validation failed', issues: validation.errors, warnings: validation.warnings };
323
368
  }
324
369
 
325
- // Validate repo skills content if repo_url is set
370
+ // Best-effort repo validation (client-side). GitHub Actions is the source of truth.
371
+ let repoWarnings = [];
326
372
  if (def.repo_url) {
327
- const ownerRepo = def.repo_url.replace(/^https?:\/\/github\.com\//, '').replace(/\.git$/, '');
328
- const repoValidation = await validateRepoSkills(ownerRepo);
329
- if (!repoValidation.ok) {
330
- return {
331
- error: 'Repo skills validation failed',
332
- issues: repoValidation.errors,
333
- warnings: repoValidation.warnings,
334
- };
373
+ try {
374
+ const ownerRepo = def.repo_url.replace(/^https?:\/\/github\.com\//, '').replace(/\.git$/, '');
375
+ const repoValidation = await validateRepoSkills(ownerRepo);
376
+ if (!repoValidation.ok) {
377
+ repoWarnings = repoValidation.errors;
378
+ }
379
+ } catch (e) {
380
+ repoWarnings = [`Repo validation warning: ${e.message} (will be checked by CI)`];
335
381
  }
336
382
  }
337
383
 
@@ -345,6 +391,7 @@ export async function publishBundle(bundleDef) {
345
391
 
346
392
  if (gh.no_gh) {
347
393
  const issueUrl = `${REGISTRY_ISSUES}?title=Bundle%3A+${encodeURIComponent(def.name)}&body=${encodeURIComponent('Bundle definition:\n\n```json\n' + bundleJson + '\n```')}`;
394
+ const actionNote = def.repo_url ? `\n\nNote: Your repo will be validated by CI (GitHub Actions) after submission.\nRun locally: node validate-repo-action.js ${def.repo_url}` : '';
348
395
  return {
349
396
  success: true,
350
397
  gh_not_installed: true,
@@ -352,6 +399,8 @@ export async function publishBundle(bundleDef) {
352
399
  '1. Install gh CLI: https://cli.github.com',
353
400
  ` OR open this pre-filled issue directly: ${issueUrl}`,
354
401
  '2. Paste the bundle JSON shown below into the issue body',
402
+ ...(repoWarnings.length ? ['', '⚠ Repo warnings (CI will re-check):', ...repoWarnings.map(w => ' - ' + w)] : []),
403
+ ...(def.repo_url ? ['', 'Your repo will be validated by CI when submitted.'] : []),
355
404
  ].join('\n'),
356
405
  bundle_json: bundleJson,
357
406
  submit_url: issueUrl,
@@ -359,7 +408,10 @@ export async function publishBundle(bundleDef) {
359
408
  }
360
409
  if (!gh.ok) return { error: gh.error };
361
410
  const issueUrl = `${REGISTRY_ISSUES}?title=Bundle%3A+${encodeURIComponent(def.name)}&body=Gist%3A+${encodeURIComponent(gh.url)}`;
362
- return { success: true, gist_url: gh.url, submit_url: issueUrl, message: `Bundle published! Submit: ${issueUrl}` };
411
+ const msg = def.repo_url
412
+ ? `Bundle published! Submit: ${issueUrl}\n\nRepo will be validated by CI. Run: node validate-repo-action.js ${def.repo_url}`
413
+ : `Bundle published! Submit: ${issueUrl}`;
414
+ return { success: true, gist_url: gh.url, submit_url: issueUrl, message: msg };
363
415
  }
364
416
 
365
417
  export function getTopRated(topK = 10) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "promptgraph-mcp",
3
- "version": "2.4.4",
3
+ "version": "2.4.6",
4
4
  "main": "index.js",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,139 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { spawnSync } from 'child_process';
4
+ import { validateSkill } from './validator.js';
5
+
6
+ // ── doc filename filter — same as cleanupRepoRoot in github-import.js ──────────
7
+ 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|changelog)/i;
8
+
9
+ const SKIP_DIRS_LOCAL = new Set([
10
+ '.github', 'docs', 'doc', 'documentation', 'assets', 'images', 'img',
11
+ 'screenshots', 'media', 'static', 'scripts', 'ci_scripts',
12
+ 'node_modules', 'vendor', 'dist', 'build', 'tests', 'test',
13
+ 'examples', 'example', 'fixtures',
14
+ ]);
15
+
16
+ function isDocFile(name) {
17
+ const base = path.basename(name, '.md').toLowerCase();
18
+ return SKIP_RE.test(base);
19
+ }
20
+
21
+ function isSkipDir(name) {
22
+ return SKIP_DIRS_LOCAL.has(name.toLowerCase());
23
+ }
24
+
25
+ // ── find .md files in subdirectories only (never root) ────────────────────────
26
+ function findSubdirMdFiles(repoRoot) {
27
+ const files = [];
28
+ const entries = fs.readdirSync(repoRoot, { withFileTypes: true });
29
+
30
+ for (const entry of entries) {
31
+ if (entry.name === '.git') continue;
32
+ const fullPath = path.join(repoRoot, entry.name);
33
+
34
+ if (entry.isDirectory()) {
35
+ if (isSkipDir(entry.name)) continue;
36
+ // Walk subdirectory recursively
37
+ walkDir(fullPath, entry.name, files);
38
+ }
39
+ // Root files are SKIPPED — never count them
40
+ }
41
+ return files;
42
+ }
43
+
44
+ function walkDir(dirPath, relativePrefix, out) {
45
+ let entries;
46
+ try { entries = fs.readdirSync(dirPath, { withFileTypes: true }); } catch { return; }
47
+
48
+ for (const entry of entries) {
49
+ const fullPath = path.join(dirPath, entry.name);
50
+ const relativePath = relativePrefix + '/' + entry.name;
51
+
52
+ if (entry.isDirectory()) {
53
+ if (isSkipDir(entry.name)) continue;
54
+ walkDir(fullPath, relativePath, out);
55
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
56
+ if (isDocFile(entry.name)) continue;
57
+ out.push({ path: fullPath, relative: relativePath });
58
+ }
59
+ }
60
+ }
61
+
62
+ // ── main ──────────────────────────────────────────────────────────────────────
63
+ async function main() {
64
+ const args = process.argv.slice(2);
65
+ const repoArg = args.find(a => a.startsWith('--repo='))?.split('=').slice(1).join('=') || args[0];
66
+
67
+ if (!repoArg) {
68
+ console.error(JSON.stringify({ ok: false, errors: ['Usage: node validate-repo-action.js <owner/repo>'] }));
69
+ process.exit(1);
70
+ }
71
+
72
+ const repo = repoArg.replace(/^https?:\/\/github\.com\//, '').replace(/\.git$/, '').replace(/\/$/, '');
73
+ const cloneUrl = `https://github.com/${repo}.git`;
74
+ const tmpDir = path.join(process.env.RUNNER_TEMP || process.env.TMPDIR || '/tmp', `validate-${repo.replace('/', '-')}`);
75
+
76
+ // Clean any previous run
77
+ if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true });
78
+
79
+ // Clone with depth 1 (no API calls, no rate limits)
80
+ const gitEnv = { ...process.env, GIT_TERMINAL_PROMPT: '0' };
81
+ const clone = spawnSync('git', ['clone', '--depth=1', cloneUrl, tmpDir], {
82
+ stdio: 'pipe', env: gitEnv,
83
+ });
84
+
85
+ if (clone.status !== 0) {
86
+ const msg = (clone.stderr?.toString() || 'unknown error').trim();
87
+ console.error(JSON.stringify({ ok: false, errors: [`Failed to clone ${repo}: ${msg}`] }));
88
+ if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true });
89
+ process.exit(1);
90
+ }
91
+
92
+ // Find .md files in subdirectories only
93
+ const candidates = findSubdirMdFiles(tmpDir);
94
+
95
+ if (candidates.length === 0) {
96
+ console.error(JSON.stringify({
97
+ ok: false,
98
+ errors: ['No valid .md skill files found in subdirectories'],
99
+ detail: 'Root-level .md files are ignored. Skills must be in a subdirectory (skills/, prompts/, etc.).',
100
+ }));
101
+ fs.rmSync(tmpDir, { recursive: true, force: true });
102
+ process.exit(1);
103
+ }
104
+
105
+ // Validate each .md file via validateSkill()
106
+ const results = [];
107
+ let totalErrors = 0;
108
+
109
+ for (const file of candidates) {
110
+ const result = validateSkill(file.path);
111
+ results.push({
112
+ file: file.relative,
113
+ ok: result.ok,
114
+ errors: result.errors,
115
+ warnings: result.warnings,
116
+ });
117
+ if (!result.ok) totalErrors++;
118
+ }
119
+
120
+ // Summary
121
+ const summary = {
122
+ ok: totalErrors === 0,
123
+ repo,
124
+ total_md_files: candidates.length,
125
+ passed: candidates.length - totalErrors,
126
+ failed: totalErrors,
127
+ results,
128
+ };
129
+
130
+ console.log(JSON.stringify(summary, null, 2));
131
+
132
+ fs.rmSync(tmpDir, { recursive: true, force: true });
133
+ process.exit(totalErrors === 0 ? 0 : 1);
134
+ }
135
+
136
+ main().catch(e => {
137
+ console.error(JSON.stringify({ ok: false, errors: [e.message] }));
138
+ process.exit(1);
139
+ });