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.
- package/bundle-counts.js +107 -0
- package/github-import.js +54 -1
- package/index.js +10 -1
- package/marketplace.js +14 -1
- package/package.json +2 -2
package/bundle-counts.js
ADDED
|
@@ -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
|
-
|
|
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 });
|