promptgraph-mcp 2.4.3 → 2.4.5

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,36 @@ 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;
210
239
  }
211
240
 
212
241
  // Update sparse repo — fetch + reset
@@ -321,6 +350,9 @@ export async function importFromGitHub(repoUrl) {
321
350
  if (!cloneOk) throw new Error(`Update failed for ${repoName}`);
322
351
  }
323
352
 
353
+ // Remove doc files anywhere in the cloned tree
354
+ cleanupRepoRoot(dest);
355
+
324
356
  const { dir: skillsDir, label } = detectSkillsDirLocal(dest);
325
357
  const mdFiles = globSync(`${skillsDir}/**/*.md`);
326
358
 
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');
@@ -177,14 +201,20 @@ export async function installSkill(query) {
177
201
  }
178
202
  }
179
203
 
204
+ // ── filter skill files (exclude docs) ─────────────────────────────────────────
205
+ 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)/i;
206
+ function isSkillFile(path) {
207
+ const name = path.split('/').pop().toLowerCase();
208
+ return name.endsWith('.md') && !SKIP_DOCS.test(name.replace(/\.md$/i, ''));
209
+ }
210
+
180
211
  async function countRepoSkills(repoUrl) {
181
212
  try {
182
213
  const apiUrl = `https://api.github.com/repos/${repoUrl}/git/trees/HEAD?recursive=1`;
183
- const res = await fetch(apiUrl, { headers: { 'User-Agent': 'promptgraph-mcp' } });
214
+ const res = await fetch(apiUrl, { headers: githubAuthHeaders() });
184
215
  if (!res.ok) return null;
185
216
  const data = await res.json();
186
- const exts = ['.md', '.txt', '.yaml', '.yml', '.json'];
187
- return (data.tree || []).filter(f => f.type === 'blob' && exts.some(e => f.path.toLowerCase().endsWith(e))).length;
217
+ return (data.tree || []).filter(f => f.type === 'blob' && isSkillFile(f.path)).length;
188
218
  } catch { return null; }
189
219
  }
190
220
 
@@ -193,13 +223,33 @@ export async function browseBundles(topK = 20) {
193
223
  const text = await fetchText(REGISTRY_URL);
194
224
  const registry = JSON.parse(text);
195
225
  const bundles = registry.bundles || [];
226
+ const cache = readSkillCountCache();
227
+ const now = Date.now();
228
+ let changed = false;
229
+
196
230
  await Promise.all(bundles.map(async b => {
197
- if (b.repo_url && b.skillCount === undefined) {
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 {
198
237
  const count = await countRepoSkills(b.repo_url);
199
- if (count !== null) b.skillCount = count;
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
+ }
200
246
  }
201
247
  }));
248
+
249
+ if (changed) writeSkillCountCache(cache);
250
+
202
251
  return bundles
252
+ .filter(b => !b.repo_url || b.skillCount > 0)
203
253
  .map(b => ({ ...b, code: b.code || codeFor(b.id) }))
204
254
  .sort((a, b) => (b.stars || 0) - (a.stars || 0))
205
255
  .slice(0, topK);
@@ -317,16 +367,17 @@ export async function publishBundle(bundleDef) {
317
367
  return { error: 'Bundle validation failed', issues: validation.errors, warnings: validation.warnings };
318
368
  }
319
369
 
320
- // 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 = [];
321
372
  if (def.repo_url) {
322
- const ownerRepo = def.repo_url.replace(/^https?:\/\/github\.com\//, '').replace(/\.git$/, '');
323
- const repoValidation = await validateRepoSkills(ownerRepo);
324
- if (!repoValidation.ok) {
325
- return {
326
- error: 'Repo skills validation failed',
327
- issues: repoValidation.errors,
328
- warnings: repoValidation.warnings,
329
- };
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)`];
330
381
  }
331
382
  }
332
383
 
@@ -340,6 +391,7 @@ export async function publishBundle(bundleDef) {
340
391
 
341
392
  if (gh.no_gh) {
342
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}` : '';
343
395
  return {
344
396
  success: true,
345
397
  gh_not_installed: true,
@@ -347,6 +399,8 @@ export async function publishBundle(bundleDef) {
347
399
  '1. Install gh CLI: https://cli.github.com',
348
400
  ` OR open this pre-filled issue directly: ${issueUrl}`,
349
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.'] : []),
350
404
  ].join('\n'),
351
405
  bundle_json: bundleJson,
352
406
  submit_url: issueUrl,
@@ -354,7 +408,10 @@ export async function publishBundle(bundleDef) {
354
408
  }
355
409
  if (!gh.ok) return { error: gh.error };
356
410
  const issueUrl = `${REGISTRY_ISSUES}?title=Bundle%3A+${encodeURIComponent(def.name)}&body=Gist%3A+${encodeURIComponent(gh.url)}`;
357
- 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 };
358
415
  }
359
416
 
360
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.3",
3
+ "version": "2.4.5",
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
+ });