promptgraph-mcp 2.3.9 → 2.4.1

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/github-import.js CHANGED
@@ -4,7 +4,8 @@ import fs from 'fs';
4
4
  import https from 'https';
5
5
  import { globSync } from 'glob';
6
6
  import { indexAll, indexSource } from './indexer.js';
7
- import { loadConfig, saveConfig, SKILLS_STORE_DIR } from './config.js';
7
+ import { loadConfig, saveConfig, PROMPTGRAPH_DIR, SKILLS_STORE_DIR } from './config.js';
8
+ import { validateSkill } from './validator.js';
8
9
 
9
10
  const SKILL_DIRS = ['skills', 'commands', 'prompts', 'agents', 'skills-store', 'slash-commands', 'custom-commands', 'templates'];
10
11
 
@@ -33,6 +34,58 @@ function repoExists(ownerRepo) {
33
34
  });
34
35
  }
35
36
 
37
+ // Validate all .md files in a repo's skills subdir against validateSkill().
38
+ // Returns { ok, errors[], warnings[] }.
39
+ export async function validateRepoSkills(ownerRepo) {
40
+ const errors = [];
41
+ const warnings = [];
42
+
43
+ const detected = await detectSkillsDirFromAPI(ownerRepo);
44
+ if (!detected) {
45
+ return { ok: false, errors: [`No skills directory found in ${ownerRepo}`], warnings: [] };
46
+ }
47
+
48
+ const subdir = detected.subdir;
49
+ const listUrl = `https://api.github.com/repos/${ownerRepo}/contents/${subdir}`;
50
+ let entries;
51
+ try {
52
+ const json = await httpsGet(listUrl);
53
+ entries = JSON.parse(json);
54
+ } catch (e) {
55
+ return { ok: false, errors: [`Failed to list ${subdir}/ contents: ${e.message}`], warnings: [] };
56
+ }
57
+
58
+ const mdFiles = entries.filter(e => e.type === 'file' && e.name.endsWith('.md'));
59
+ if (mdFiles.length === 0) {
60
+ return { ok: false, errors: [`No .md files found in ${subdir}/`], warnings: [] };
61
+ }
62
+
63
+ const tmpDir = path.join(PROMPTGRAPH_DIR, '.validate-tmp');
64
+ fs.mkdirSync(tmpDir, { recursive: true });
65
+
66
+ for (const file of mdFiles) {
67
+ try {
68
+ const content = await httpsGet(file.download_url);
69
+ const tmpPath = path.join(tmpDir, file.name);
70
+ fs.writeFileSync(tmpPath, content);
71
+ const result = validateSkill(tmpPath);
72
+ if (!result.ok) {
73
+ errors.push(`${file.name}: ${result.errors.join('; ')}`);
74
+ }
75
+ if (result.warnings?.length) {
76
+ warnings.push(...result.warnings.map(w => `${file.name}: ${w}`));
77
+ }
78
+ fs.unlinkSync(tmpPath);
79
+ } catch (e) {
80
+ errors.push(`${file.name}: failed to validate — ${e.message}`);
81
+ }
82
+ }
83
+
84
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
85
+
86
+ return { ok: errors.length === 0, errors, warnings };
87
+ }
88
+
36
89
  // Ask GitHub API which subdir to use (without cloning anything). Exported for validation.
37
90
  export
38
91
  // Returns { subdir, label } or null (use root).
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/marketplace.js CHANGED
@@ -7,7 +7,7 @@ import { createRequire } from 'module';
7
7
  import { getDb } from './db.js';
8
8
  import { validateSkill, validateBundle } from './validator.js';
9
9
  import { loadConfig, saveConfig, PROMPTGRAPH_DIR, SKILLS_STORE_DIR } from './config.js';
10
- import { importFromGitHub } from './github-import.js';
10
+ import { importFromGitHub, validateRepoSkills } from './github-import.js';
11
11
 
12
12
  const REGISTRY_URL = 'https://raw.githubusercontent.com/NeiP4n/promptgraph-registry/main/registry.json';
13
13
  const SKILLS_DIR = path.join(SKILLS_STORE_DIR, 'marketplace');
@@ -316,6 +316,19 @@ export async function publishBundle(bundleDef) {
316
316
  return { error: 'Bundle validation failed', issues: validation.errors, warnings: validation.warnings };
317
317
  }
318
318
 
319
+ // Validate repo skills content if repo_url is set
320
+ if (def.repo_url) {
321
+ const ownerRepo = def.repo_url.replace(/^https?:\/\/github\.com\//, '').replace(/\.git$/, '');
322
+ const repoValidation = await validateRepoSkills(ownerRepo);
323
+ if (!repoValidation.ok) {
324
+ return {
325
+ error: 'Repo skills validation failed',
326
+ issues: repoValidation.errors,
327
+ warnings: repoValidation.warnings,
328
+ };
329
+ }
330
+ }
331
+
319
332
  const bundleJson = JSON.stringify(def, null, 2);
320
333
  const tmpFile = path.join(PROMPTGRAPH_DIR, `bundle-${def.id}.json`);
321
334
  fs.mkdirSync(PROMPTGRAPH_DIR, { recursive: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "promptgraph-mcp",
3
- "version": "2.3.9",
3
+ "version": "2.4.1",
4
4
  "main": "index.js",
5
5
  "type": "module",
6
6
  "bin": {
@@ -51,4 +51,4 @@
51
51
  "tar": "^6.2.1",
52
52
  "js-yaml": "^4.1.0"
53
53
  }
54
- }
54
+ }