promptgraph-mcp 2.3.8 → 2.4.0

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.
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Local cache for bundle skillCounts.
3
+ * Refreshes from GitHub API in background; TTL = 24h.
4
+ * Counts only .md files in subdirectories (never root files).
5
+ */
6
+ import fs from 'fs';
7
+ import https from 'https';
8
+ import path from 'path';
9
+ import { PROMPTGRAPH_DIR } from './config.js';
10
+
11
+ const CACHE_FILE = path.join(PROMPTGRAPH_DIR, 'bundle-counts.json');
12
+ const TTL_MS = 24 * 60 * 60 * 1000; // 24h
13
+
14
+ const SKILL_DIRS = ['skills', 'commands', 'prompts', 'agents', 'skills-store', 'slash-commands', 'custom-commands', 'templates'];
15
+ const SKIP_ROOT_DIRS = new Set(['.github', 'docs', 'doc', 'assets', 'images', 'img', 'media', 'static', 'scripts', 'ci_scripts', 'node_modules', 'vendor', 'dist', 'build', 'tests', 'test', 'examples', 'example', 'fixtures']);
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
+
18
+ function httpsGet(url) {
19
+ return new Promise((res, rej) => {
20
+ const req = https.get(url, { headers: { 'User-Agent': 'promptgraph-mcp' } }, r => {
21
+ if (r.statusCode === 403 || r.statusCode === 429) return rej(new Error(`Rate limited`));
22
+ if (r.statusCode !== 200) { r.resume(); return rej(new Error(`HTTP ${r.statusCode}`)); }
23
+ let d = ''; r.setEncoding('utf8'); r.on('data', c => d += c); r.on('end', () => res(d));
24
+ });
25
+ req.on('error', rej);
26
+ });
27
+ }
28
+
29
+ function loadCache() {
30
+ try { return JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8')); } catch { return {}; }
31
+ }
32
+
33
+ function saveCache(data) {
34
+ try {
35
+ fs.mkdirSync(path.dirname(CACHE_FILE), { recursive: true });
36
+ fs.writeFileSync(CACHE_FILE, JSON.stringify(data, null, 2));
37
+ } catch {}
38
+ }
39
+
40
+ // Count .md files only in subdirectories — root files are ignored
41
+ async function countSubdirMdFiles(ownerRepo) {
42
+ const treeUrl = `https://api.github.com/repos/${ownerRepo}/git/trees/HEAD?recursive=1`;
43
+ const json = await httpsGet(treeUrl);
44
+ const { tree = [] } = JSON.parse(json);
45
+
46
+ // Only .md files that are inside a subdir (path has at least one /)
47
+ const mdFiles = tree.filter(f =>
48
+ f.type === 'blob' &&
49
+ f.path.endsWith('.md') &&
50
+ f.path.includes('/') // must be in a subdir, not root
51
+ );
52
+
53
+ // Try to find a known skills subdir first
54
+ for (const dir of SKILL_DIRS) {
55
+ const inDir = mdFiles.filter(f => f.path.startsWith(dir + '/'));
56
+ if (inDir.length > 0) {
57
+ return inDir.filter(f => {
58
+ const base = f.path.split('/').pop().replace(/\.md$/i, '').toLowerCase();
59
+ return !SKIP_NAMES.test(base);
60
+ }).length;
61
+ }
62
+ }
63
+
64
+ // No known dir — use any subdir, skip skip-dirs
65
+ return mdFiles.filter(f => {
66
+ const parts = f.path.split('/');
67
+ const topDir = parts[0].toLowerCase();
68
+ if (SKIP_ROOT_DIRS.has(topDir)) return false;
69
+ const base = parts[parts.length - 1].replace(/\.md$/i, '').toLowerCase();
70
+ return !SKIP_NAMES.test(base);
71
+ }).length;
72
+ }
73
+
74
+ // Read cached count for a bundle (returns null if stale or missing)
75
+ export function getCachedCount(repoUrl) {
76
+ const cache = loadCache();
77
+ const entry = cache[repoUrl];
78
+ if (!entry) return null;
79
+ if (Date.now() - entry.ts > TTL_MS) return null;
80
+ return entry.count;
81
+ }
82
+
83
+ // Refresh counts for all bundles in background (non-blocking)
84
+ export function refreshCountsInBackground(bundles) {
85
+ const cache = loadCache();
86
+ const stale = bundles.filter(b => {
87
+ if (!b.repo_url) return false;
88
+ const entry = cache[b.repo_url];
89
+ return !entry || Date.now() - entry.ts > TTL_MS;
90
+ });
91
+
92
+ if (!stale.length) return;
93
+
94
+ // Fire-and-forget
95
+ (async () => {
96
+ for (const b of stale) {
97
+ try {
98
+ const count = await countSubdirMdFiles(b.repo_url);
99
+ cache[b.repo_url] = { count, ts: Date.now() };
100
+ saveCache(cache);
101
+ } catch {
102
+ // rate limited or network error — skip, try next time
103
+ }
104
+ await new Promise(r => setTimeout(r, 500)); // 500ms between requests
105
+ }
106
+ })();
107
+ }
package/index.js CHANGED
@@ -209,6 +209,15 @@ if (args[0] === 'marketplace') {
209
209
 
210
210
  if (skills?.error) { error(skills.error); process.exit(1); }
211
211
 
212
+ // Apply cached skillCounts and refresh stale ones in background
213
+ const { getCachedCount, refreshCountsInBackground } = await import('./bundle-counts.js');
214
+ const bundlesWithCounts = (Array.isArray(bundles) ? bundles : []).map(b => {
215
+ if (!b.repo_url) return b;
216
+ const cached = getCachedCount(b.repo_url);
217
+ return cached !== null ? { ...b, skillCount: cached } : b;
218
+ });
219
+ refreshCountsInBackground(bundlesWithCounts);
220
+
212
221
  // Build installed set — source of truth is the FILESYSTEM, not DB/config
213
222
  const installedSet = new Set();
214
223
  try {
@@ -249,7 +258,7 @@ if (args[0] === 'marketplace') {
249
258
 
250
259
  await runTUI(
251
260
  Array.isArray(skills) ? skills : [],
252
- Array.isArray(bundles) ? bundles : [],
261
+ bundlesWithCounts,
253
262
  async (item) => {
254
263
  if (item.type === 'bundle') {
255
264
  const r = await installBundle(item.id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "promptgraph-mcp",
3
- "version": "2.3.8",
3
+ "version": "2.4.0",
4
4
  "main": "index.js",
5
5
  "type": "module",
6
6
  "bin": {
package/parser.js CHANGED
@@ -18,8 +18,8 @@ const SKIP_FILENAMES = new Set([
18
18
  'pull_request_template', 'issue_template', 'funding',
19
19
  ]);
20
20
 
21
- // Filename patterns that are never skills
22
- const SKIP_FILENAME_RE = /^(_|\.)|^v?\d+[\.\-]\d+|^\d{4}[\-_]\d{2}|^readme|^license|^changelog|^contributing/i;
21
+ // Filename patterns that are never skills — readme* catches ALL variants (readme_de, readme_zh-CN, etc.)
22
+ const SKIP_FILENAME_RE = /^(_|\.)|^v?\d+[\.\-]\d+|^\d{4}[\-_]\d{2}|^readme|^license|^changelog|^contributing|^code.of.conduct|^security|^authors|^credits|^disclaimer|^notice|^copying|^warranty|^promotion|^funding/i;
23
23
 
24
24
  // Path segments that indicate the file is NOT a skill
25
25
  const SKIP_DIRS = new Set([
@@ -30,7 +30,7 @@ const SKIP_DIRS = new Set([
30
30
  ]);
31
31
 
32
32
  // First-header values that signal documentation, not a skill
33
- const DOC_FIRST_HEADERS = /^(overview|introduction|about|background|welcome|getting started|what is|why |table of contents|toc|foreword|preface)/i;
33
+ const DOC_FIRST_HEADERS = /^(overview|introduction|about|background|welcome|getting started|what is|why |table of contents|toc|foreword|preface|readme)/i;
34
34
 
35
35
  // Imperative verbs commonly found in skill headers
36
36
  const IMPERATIVE_HEADERS = /\b(run|use|apply|execute|check|debug|fix|create|add|remove|deploy|test|write|generate|analyze|review|refactor|optimize|configure|setup|install|scan|audit|validate|search|find|extract|parse)\b/i;
@@ -119,6 +119,12 @@ export function isSkillFile(filePath, raw) {
119
119
 
120
120
  try {
121
121
  if (!raw) raw = fs.readFileSync(filePath, 'utf8');
122
+
123
+ // Hard-reject: content starts with README header or badge lines
124
+ const firstLines = raw.trimStart().slice(0, 300);
125
+ if (/^#\s*readme\b/i.test(firstLines)) return false;
126
+ if (/!\[.*\]\(https?:\/\/(img\.shields\.io|badge\.fury|travis-ci|github\.com\/[^)]+\/badge)/i.test(firstLines)) return false;
127
+
122
128
  return skillScore(raw, base) >= 3;
123
129
  } catch {
124
130
  return false;