promptgraph-mcp 2.9.56 → 2.9.58

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/marketplace.js CHANGED
@@ -1,889 +1,889 @@
1
- import fs from 'fs';
2
- import path from 'path';
3
- import https from 'https';
4
- import { createHash } from 'crypto';
5
- import { spawnSync } from 'child_process';
6
- import { createRequire } from 'module';
7
- import { getDb } from './db.js';
8
- import { globSync } from 'glob';
9
- import { validateSkill, validateBundle } from './validator.js';
10
- import { loadConfig, saveConfig, PROMPTGRAPH_DIR, getSkillsStoreDir } from './config.js';
11
- import { importFromGitHubLight, validateRepoSkills } from './github-import.js';
12
- import { isSkillFile } from './parser.js';
13
-
14
- const REGISTRY_URL = 'https://raw.githubusercontent.com/NeiP4n/promptgraph-registry/main/registry.json';
15
- const SKILL_COUNT_CACHE = path.join(PROMPTGRAPH_DIR, 'skill-counts.json');
16
- const DEAD_REPOS_FILE = path.join(PROMPTGRAPH_DIR, 'dead-repos.json');
17
-
18
- function getSkillsDir() { return path.join(getSkillsStoreDir(), 'marketplace'); }
19
-
20
- // Atomically write content to dest via tmp — cleans up on failure
21
- function writeSkillAtomic(dest, content) {
22
- const tmpPath = dest + '.tmp';
23
- try {
24
- fs.writeFileSync(tmpPath, content);
25
- const v = validateSkill(tmpPath);
26
- if (!v.ok) { fs.unlinkSync(tmpPath); return v; }
27
- fs.renameSync(tmpPath, dest);
28
- return { ok: true };
29
- } catch (e) {
30
- try { fs.unlinkSync(tmpPath); } catch {}
31
- return { ok: false, errors: [e.message] };
32
- }
33
- }
34
-
35
- // Convert GitHub blob URL → raw URL
36
- // https://github.com/owner/repo/blob/branch/path/file.md
37
- // → https://raw.githubusercontent.com/owner/repo/branch/path/file.md
38
- function githubToRaw(url) {
39
- const m = url.match(/^https?:\/\/github\.com\/([^/]+\/[^/]+)\/blob\/(.+)$/);
40
- if (m) return `https://raw.githubusercontent.com/${m[1]}/${m[2]}`;
41
- return null; // already raw or not a github URL
42
- }
43
-
44
- const _require = createRequire(import.meta.url);
45
- const PKG_VERSION = (() => { try { return _require('./package.json').version; } catch { return '1.x'; } })();
46
- const UA = `promptgraph-mcp/${PKG_VERSION}`;
47
-
48
- const REGISTRY_ID_RE = /^[a-z0-9][a-z0-9-]{1,63}$/;
49
-
50
- function validateRegistryEntry(item) {
51
- const errors = [];
52
- if (!item.id || typeof item.id !== 'string') errors.push('missing required id');
53
- else if (!REGISTRY_ID_RE.test(item.id)) errors.push(`invalid id "${item.id}" (must match /^[a-z0-9][a-z0-9-]{1,63}$/)`);
54
- if (!item.name || typeof item.name !== 'string') errors.push('missing required name');
55
- else if (item.name.length < 3) errors.push(`name "${item.name}" too short (min 3 chars)`);
56
- if (!item.description || typeof item.description !== 'string') errors.push('missing required description');
57
- else if (item.description.length < 15) errors.push(`description too short (min 15 chars)`);
58
- if (item.version !== undefined && typeof item.version !== 'string') errors.push('version must be a string');
59
- if (item.author !== undefined && typeof item.author !== 'string') errors.push('author must be a string');
60
- if (item.license !== undefined && typeof item.license !== 'string') errors.push('license must be a string');
61
- if (item.updated_at !== undefined) {
62
- if (typeof item.updated_at !== 'string') errors.push('updated_at must be a string');
63
- else if (isNaN(Date.parse(item.updated_at))) errors.push(`updated_at "${item.updated_at}" is not a valid ISO date`);
64
- }
65
- if (item.downloads !== undefined && typeof item.downloads !== 'number') errors.push('downloads must be a number');
66
- if (item.verified !== undefined && typeof item.verified !== 'boolean') errors.push('verified must be a boolean');
67
- if (item.trustLevel !== undefined && typeof item.trustLevel !== 'string') errors.push('trustLevel must be a string');
68
- if (item.rating !== undefined) {
69
- if (typeof item.rating !== 'number') errors.push('rating must be a number');
70
- else if (item.rating < 0 || item.rating > 5) errors.push('rating must be between 0 and 5');
71
- }
72
- if (item.popularity !== undefined && typeof item.popularity !== 'number') errors.push('popularity must be a number');
73
- if (item.lastUpdate !== undefined) {
74
- if (typeof item.lastUpdate !== 'string') errors.push('lastUpdate must be a string');
75
- else if (isNaN(Date.parse(item.lastUpdate))) errors.push(`lastUpdate "${item.lastUpdate}" is not a valid ISO date`);
76
- }
77
- if (errors.length > 0) {
78
- console.warn(`[PromptGraph] Skipping invalid registry entry "${item.id || item.name || '(unnamed)'}": ${errors.join('; ')}`);
79
- return { ok: false };
80
- }
81
- return { ok: true };
82
- }
83
-
84
- // Deterministic short code from an id. Same id always yields the same code,
85
- // so codes auto-generate — no need to assign them by hand.
86
- export function codeFor(id) {
87
- return 'pg-' + createHash('md5').update(String(id)).digest('hex').slice(0, 6);
88
- }
89
-
90
- // node:https GET — reliable and fast on Windows (undici fetch can hang ~10s there).
91
- function httpGet(url) {
92
- return new Promise((resolve, reject) => {
93
- const req = https.get(url, { headers: { 'User-Agent': UA }, timeout: 8000, family: 4 }, (res) => {
94
- if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
95
- return httpGet(res.headers.location).then(resolve, reject);
96
- }
97
- if (res.statusCode !== 200) {
98
- res.resume();
99
- return reject(new Error(`HTTP ${res.statusCode}`));
100
- }
101
- let data = '';
102
- res.setEncoding('utf8');
103
- res.on('data', c => data += c);
104
- res.on('end', () => resolve(data.charCodeAt(0) === 0xFEFF ? data.slice(1) : data));
105
- });
106
- req.on('timeout', () => { req.destroy(new Error('request timed out')); });
107
- req.on('error', reject);
108
- });
109
- }
110
-
111
- // Primary path is httpGet (fast/reliable on Windows); undici fetch only as fallback.
112
- async function rawFetch(url) {
113
- try {
114
- return await httpGet(url);
115
- } catch {
116
- const res = await fetch(url, { headers: { 'User-Agent': UA } });
117
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
118
- return await res.text();
119
- }
120
- }
121
-
122
- // Disk cache for the registry (network to GitHub raw can be slow on some networks).
123
- const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
124
- const SKILL_COUNT_TTL = 24 * 60 * 60 * 1000; // 24 hours for skill counts
125
-
126
- // Return GitHub API auth headers if GITHUB_TOKEN is set
127
- function githubAuthHeaders() {
128
- const token = process.env.GITHUB_TOKEN;
129
- if (!token) return { 'User-Agent': 'promptgraph-mcp' };
130
- return { 'User-Agent': 'promptgraph-mcp', 'Authorization': `Bearer ${token}` };
131
- }
132
-
133
- // Read skill count cache, returns {} on miss
134
- function readSkillCountCache() {
135
- try {
136
- if (fs.existsSync(SKILL_COUNT_CACHE)) return JSON.parse(fs.readFileSync(SKILL_COUNT_CACHE, 'utf8'));
137
- } catch {}
138
- return {};
139
- }
140
-
141
- function writeSkillCountCache(data) {
142
- try {
143
- fs.mkdirSync(path.dirname(SKILL_COUNT_CACHE), { recursive: true });
144
- fs.writeFileSync(SKILL_COUNT_CACHE, JSON.stringify(data));
145
- } catch {}
146
- }
147
-
148
- function readDeadRepos() {
149
- try { return JSON.parse(fs.readFileSync(DEAD_REPOS_FILE, 'utf8')); } catch { return []; }
150
- }
151
-
152
- function markDeadRepo(repoUrl) {
153
- try {
154
- const dead = readDeadRepos();
155
- if (!dead.includes(repoUrl)) {
156
- dead.push(repoUrl);
157
- fs.mkdirSync(path.dirname(DEAD_REPOS_FILE), { recursive: true });
158
- fs.writeFileSync(DEAD_REPOS_FILE, JSON.stringify(dead, null, 2));
159
- }
160
- } catch {}
161
- }
162
-
163
- async function fetchText(url) {
164
- const cacheFile = path.join(PROMPTGRAPH_DIR, 'registry-cache.json');
165
- const isRegistry = url === REGISTRY_URL;
166
-
167
- if (isRegistry && fs.existsSync(cacheFile)) {
168
- try {
169
- const stat = fs.statSync(cacheFile);
170
- if (Date.now() - stat.mtimeMs < CACHE_TTL) {
171
- return fs.readFileSync(cacheFile, 'utf8');
172
- }
173
- } catch {}
174
- }
175
-
176
- const text = await rawFetch(url);
177
-
178
- if (isRegistry) {
179
- try {
180
- fs.mkdirSync(PROMPTGRAPH_DIR, { recursive: true });
181
- fs.writeFileSync(cacheFile, text);
182
- } catch {}
183
- }
184
- return text;
185
- }
186
-
187
- export async function browseMarketplace(topK = 20) {
188
- try {
189
- const text = await fetchText(REGISTRY_URL);
190
- const registry = JSON.parse(text);
191
- if (!Array.isArray(registry.skills)) return { error: 'Invalid registry format' };
192
- return registry.skills
193
- .map(s => Object.assign(Object.create(null), s, { code: s.code || codeFor(s.id) }))
194
- .filter(s => validateRegistryEntry(s).ok)
195
- .filter(s => s.raw_url)
196
- .sort((a, b) => (b.stars || 0) - (a.stars || 0))
197
- .slice(0, topK);
198
- } catch (e) {
199
- return { error: `Registry unavailable: ${e.message}` };
200
- }
201
- }
202
-
203
- export async function installSkillFromUrl(url) {
204
- try {
205
- const rawUrl = githubToRaw(url) || url;
206
- const content = await fetchText(rawUrl);
207
- fs.mkdirSync(getSkillsDir(), { recursive: true });
208
- ensureMarketplaceSource();
209
-
210
- // derive filename from URL
211
- const urlName = rawUrl.split('/').pop().replace(/[^a-z0-9-_.]/gi, '-');
212
- const dest = path.join(getSkillsDir(), urlName.endsWith('.md') ? urlName : urlName + '.md');
213
- const resolvedDest = path.resolve(dest);
214
- if (!resolvedDest.startsWith(path.resolve(getSkillsDir()))) {
215
- return { error: 'Path traversal blocked: destination outside marketplace directory' };
216
- }
217
- const v = writeSkillAtomic(dest, content);
218
- if (!v.ok) return { error: 'Downloaded skill failed validation', issues: v.errors };
219
- return { success: true, path: dest, url: rawUrl };
220
- } catch (e) {
221
- return { error: e.message };
222
- }
223
- }
224
-
225
- export async function installSkill(query) {
226
- try {
227
- // GitHub URL or raw URL → direct install
228
- if (query.startsWith('http://') || query.startsWith('https://')) {
229
- return installSkillFromUrl(query);
230
- }
231
-
232
- const text = await fetchText(REGISTRY_URL);
233
- const registry = JSON.parse(text);
234
- const q = String(query).trim().toLowerCase();
235
- const validSkills = (registry.skills || []).filter(s => validateRegistryEntry(s).ok);
236
- // match by code (stored OR auto-generated), id, or name
237
- const skill = validSkills.find(s =>
238
- (s.code || codeFor(s.id)).toLowerCase() === q ||
239
- s.id?.toLowerCase() === q ||
240
- s.name?.toLowerCase() === q
241
- );
242
- if (!skill) {
243
- const bundle = (registry.bundles || []).find(b =>
244
- (b.code || codeFor(b.id)).toLowerCase() === q || b.id?.toLowerCase() === q
245
- );
246
- if (bundle) return { error: `"${query}" is a bundle. Use pg_bundle_install("${bundle.id}") instead.` };
247
- return { error: `No skill matching "${query}" (try a code, id, name, or GitHub URL)` };
248
- }
249
- if (!skill.raw_url) return { error: `Skill "${skill.id}" has no download URL` };
250
- const skillId = skill.id;
251
-
252
- fs.mkdirSync(getSkillsDir(), { recursive: true });
253
- ensureMarketplaceSource();
254
- const dest = path.join(getSkillsDir(), `${skillId}.md`);
255
- const resolvedDest = path.resolve(dest);
256
- if (!resolvedDest.startsWith(path.resolve(getSkillsDir()))) {
257
- return { error: 'Path traversal blocked: destination outside marketplace directory' };
258
- }
259
-
260
- const content = await fetchText(skill.raw_url);
261
- const v = writeSkillAtomic(dest, content);
262
- if (!v.ok) return { error: 'Downloaded skill failed validation', issues: v.errors };
263
- return { success: true, path: dest, name: skill.name };
264
- } catch (e) {
265
- return { error: e.message };
266
- }
267
- }
268
-
269
- // ── filter skill files (exclude docs) — delegates to parser.js isSkillFile ─────
270
- // was: local isSkillFile(path) — removed in favor of shared parser.js version
271
-
272
- async function countRepoSkills(repoUrl) {
273
- try {
274
- const apiUrl = `https://api.github.com/repos/${repoUrl}/git/trees/HEAD?recursive=1`;
275
- const res = await fetch(apiUrl, { headers: githubAuthHeaders() });
276
- if (!res.ok) return null;
277
- const data = await res.json();
278
- return (data.tree || []).filter(f => f.type === 'blob' && isSkillFile(f.path)).length;
279
- } catch { return null; }
280
- }
281
-
282
- // Count real .md files on disk for an installed bundle (always correct)
283
- export function localSkillCount(repoUrl) {
284
- const repoName = repoUrl.replace(/^https?:\/\/github\.com\//, '').replace(/\.git$/, '').replace('/', '-');
285
- const dest = path.join(getSkillsStoreDir(), 'github', repoName);
286
- if (!fs.existsSync(dest)) return null;
287
- const files = globSync(`${dest}/**/*.md`);
288
- return files.length;
289
- }
290
-
291
- export async function browseBundles(topK = 20) {
292
- try {
293
- const text = await fetchText(REGISTRY_URL);
294
- const registry = JSON.parse(text);
295
- const deadRepos = new Set(readDeadRepos());
296
- const bundles = (registry.bundles || []).filter(b =>
297
- validateRegistryEntry(b).ok && !deadRepos.has(b.repo_url)
298
- );
299
- const cache = readSkillCountCache();
300
- const now = Date.now();
301
- let changed = false;
302
-
303
- await Promise.all(bundles.map(async b => {
304
- if (!b.repo_url) return;
305
-
306
- // 1. If installed locally — count real files on disk (always correct)
307
- const local = localSkillCount(b.repo_url);
308
- if (local !== null) {
309
- if (b.skillCount !== local) {
310
- b.skillCount = local;
311
- cache[b.repo_url] = { count: local, ts: now };
312
- changed = true;
313
- }
314
- return;
315
- }
316
-
317
- // 2. Not installed — validated registry count is authoritative; use cache if fresh
318
- const cached = cache[b.repo_url];
319
- if (cached && (now - cached.ts) < SKILL_COUNT_TTL) {
320
- b.skillCount = cached.count;
321
- return;
322
- }
323
- if (b.validated && b.skill_count) {
324
- b.skillCount = b.skill_count;
325
- return;
326
- }
327
-
328
- // 3. Fetch from GitHub API
329
- const count = await countRepoSkills(b.repo_url);
330
- if (count !== null) {
331
- b.skillCount = count;
332
- cache[b.repo_url] = { count, ts: now };
333
- changed = true;
334
- } else {
335
- b.skillCount = cached?.count ?? b.skill_count ?? 0;
336
- }
337
- }));
338
-
339
- if (changed) writeSkillCountCache(cache);
340
-
341
- return bundles
342
- .filter(b => !b.repo_url || b.skillCount > 0)
343
- .map(b => Object.assign(Object.create(null), b, { code: b.code || codeFor(b.id) }))
344
- .sort((a, b) => (b.stars || 0) - (a.stars || 0))
345
- .slice(0, topK);
346
- } catch (e) {
347
- return { error: `Registry unavailable: ${e.message}` };
348
- }
349
- }
350
-
351
- // Ensure marketplace has its own source entry (separate from skills-store)
352
- // so marketplace skills never collide with local skills of the same name.
353
- function ensureMarketplaceSource() {
354
- const config = loadConfig();
355
- if (!config.sources.find(s => s.source === 'marketplace')) {
356
- config.sources.push({ dir: getSkillsDir(), source: 'marketplace' });
357
- saveConfig(config);
358
- }
359
- }
360
-
361
- // ── helpers extracted from installBundle ─────────────────────────────────────
362
-
363
- async function _findBundle(bundleId) {
364
- const text = await fetchText(REGISTRY_URL);
365
- const registry = JSON.parse(text);
366
- const q = String(bundleId).trim().toLowerCase();
367
- const valid = (registry.bundles || []).filter(b => validateRegistryEntry(b).ok);
368
- const validSkills = (registry.skills || []).filter(s => validateRegistryEntry(s).ok);
369
- // 1. Exact match (code, id, name)
370
- let bundle = valid.find(b =>
371
- (b.code || codeFor(b.id)).toLowerCase() === q ||
372
- b.id?.toLowerCase() === q ||
373
- b.name?.toLowerCase() === q
374
- );
375
- // 2. Partial name/id match (starts with)
376
- if (!bundle) bundle = valid.find(b =>
377
- b.name?.toLowerCase().startsWith(q) || b.id?.toLowerCase().startsWith(q)
378
- );
379
- // 3. Contains match
380
- if (!bundle) bundle = valid.find(b =>
381
- b.name?.toLowerCase().includes(q) || b.id?.toLowerCase().includes(q)
382
- );
383
- if (!bundle) return { error: `No bundle matching "${bundleId}"` };
384
- return { bundle, validSkills };
385
- }
386
-
387
- async function _execRepoInstall(bundle) {
388
- try {
389
- await importFromGitHubLight(bundle.repo_url);
390
- } catch (e) {
391
- // Only mark dead on confirmed 404 — not on validation failures or network errors
392
- if (e.message && (e.message.includes('HTTP 404') || e.message.includes('not found'))) {
393
- markDeadRepo(bundle.repo_url);
394
- }
395
- throw e;
396
- }
397
- const real = localSkillCount(bundle.repo_url);
398
- if (real !== null) {
399
- const cache = readSkillCountCache();
400
- cache[bundle.repo_url] = { count: real, ts: Date.now() };
401
- writeSkillCountCache(cache);
402
- }
403
- return { success: true, bundle: bundle.name, type: 'repo_import', repo_url: bundle.repo_url };
404
- }
405
-
406
- async function _execSkillsInstall(bundle, validSkills) {
407
- const skillsDir = getSkillsDir();
408
- fs.mkdirSync(skillsDir, { recursive: true });
409
- ensureMarketplaceSource();
410
- const installed = [];
411
- const failed = [];
412
- const toolsInstalled = [];
413
- const delay = (ms) => new Promise(r => setTimeout(r, ms));
414
-
415
- for (const skillId of bundle.skills || []) {
416
- const skill = validSkills.find(s => s.id === skillId);
417
- if (!skill?.raw_url) { failed.push(skillId); continue; }
418
- try {
419
- if (installed.length > 0) await delay(300);
420
- const content = await fetchText(skill.raw_url);
421
- const dest = path.join(skillsDir, `${skillId}.md`);
422
- if (!path.resolve(dest).startsWith(path.resolve(skillsDir))) { failed.push(skillId); continue; }
423
- const v = writeSkillAtomic(dest, content);
424
- if (!v.ok) { failed.push(skillId); continue; }
425
- installed.push(skillId);
426
- } catch { failed.push(skillId); }
427
- }
428
-
429
- // Install tool files if bundle has them (scripts, .py, .sh, .js etc)
430
- for (const tool of bundle.tool_files || []) {
431
- if (!tool?.raw_url || !tool?.path) continue;
432
- try {
433
- await delay(200);
434
- const content = await fetchText(tool.raw_url);
435
- const dest = path.join(skillsDir, tool.path);
436
- if (!path.resolve(dest).startsWith(path.resolve(skillsDir))) continue;
437
- fs.mkdirSync(path.dirname(dest), { recursive: true });
438
- fs.writeFileSync(dest, content);
439
- // Make executable on unix
440
- if (process.platform !== 'win32') {
441
- try { fs.chmodSync(dest, 0o755); } catch {}
442
- }
443
- toolsInstalled.push(tool.path);
444
- } catch {}
445
- }
446
-
447
- return { success: true, bundle: bundle.name, installed, failed, toolsInstalled, dir: skillsDir, has_tools: bundle.has_tools || false };
448
- }
449
-
450
- export async function installBundle(bundleId) {
451
- try {
452
- const found = await _findBundle(bundleId);
453
- if (found.error) return { error: found.error };
454
- const { bundle, validSkills } = found;
455
- return bundle.repo_url
456
- ? await _execRepoInstall(bundle)
457
- : await _execSkillsInstall(bundle, validSkills);
458
- } catch (e) {
459
- return { error: e.message };
460
- }
461
- }
462
-
463
- // ── Background install queue ──────────────────────────────────────────────────
464
- // Allows the TUI to queue multiple installs without blocking.
465
-
466
- const _bgQueue = [];
467
- let _bgRunning = false;
468
- let _bgCurrentId = null;
469
- const _bgPendingKeys = new Set();
470
-
471
- async function _bgProcess() {
472
- if (_bgRunning) return;
473
- _bgRunning = true;
474
- while (_bgQueue.length > 0) {
475
- const { bundle, validSkills, onDone, _dedupKey } = _bgQueue.shift();
476
- if (_dedupKey) _bgPendingKeys.delete(_dedupKey);
477
- _bgCurrentId = bundle.id;
478
- try {
479
- const result = bundle.repo_url
480
- ? await _execRepoInstall(bundle)
481
- : await _execSkillsInstall(bundle, validSkills);
482
- await onDone?.(null, result);
483
- } catch (e) {
484
- await onDone?.(e, null);
485
- } finally {
486
- _bgCurrentId = null;
487
- }
488
- }
489
- _bgRunning = false;
490
- }
491
-
492
- // Install a bundle in the background, returning immediately.
493
- // The actual work is serialized through an internal queue.
494
- // onDone(err, result) is called when the queue finishes processing this bundle.
495
- export async function installBundleBg(bundleId, onDone) {
496
- const q = String(bundleId).trim().toLowerCase();
497
- // Synchronous dedup check BEFORE any await — prevents race on double-Enter
498
- if (_bgPendingKeys.has(q)) {
499
- return { dedup: true, error: `"${bundleId}" is already queued or installing` };
500
- }
501
- _bgPendingKeys.add(q);
502
-
503
- const found = await _findBundle(bundleId);
504
- if (found.error) { _bgPendingKeys.delete(q); return found; }
505
- const { bundle, validSkills } = found;
506
-
507
- if (_bgCurrentId === bundle.id || _bgQueue.some(e => e.bundle.id === bundle.id)) {
508
- _bgPendingKeys.delete(q);
509
- return { dedup: true, error: `"${bundle.name}" is already queued or installing` };
510
- }
511
-
512
- _bgQueue.push({ bundle, validSkills, onDone, _dedupKey: q });
513
- _bgProcess();
514
- return { queued: true, id: bundle.id, name: bundle.name };
515
- }
516
-
517
- // Publishing to the marketplace requires an authenticated GitHub CLI account.
518
- // `gh auth status` exits 0 only when a user is logged in; ENOENT means gh is
519
- // not installed. Returns { ok: true } or { ok: false, error } with guidance.
520
- export function requireGhAuth() {
521
- let res;
522
- try {
523
- res = spawnSync('gh', ['auth', 'status'], { encoding: 'utf8', timeout: 10000 });
524
- } catch {
525
- res = { error: { code: 'ENOENT' } };
526
- }
527
- if (res.error?.code === 'ENOENT') {
528
- return { ok: false, error: 'Publishing requires the GitHub CLI. Install it from https://cli.github.com, then run: gh auth login' };
529
- }
530
- if (res.status !== 0) {
531
- return { ok: false, error: 'Publishing requires a signed-in GitHub account. Run: gh auth login' };
532
- }
533
- return { ok: true };
534
- }
535
-
536
- function ghPublish(filePath, desc) {
537
- try {
538
- const result = spawnSync('gh', ['gist', 'create', filePath, '--desc', desc, '--public'], { encoding: 'utf8' });
539
- if (result.error?.code === 'ENOENT') return { ok: false, no_gh: true };
540
- if (result.status !== 0) return { ok: false, error: result.stderr?.trim() || 'gh CLI error — run: gh auth login' };
541
- return { ok: true, url: result.stdout.trim() };
542
- } catch {
543
- return { ok: false, no_gh: true };
544
- }
545
- }
546
-
547
- const REGISTRY_REPO = 'NeiP4n/promptgraph-registry';
548
- const REGISTRY_ISSUES = `https://github.com/${REGISTRY_REPO}/issues/new`;
549
-
550
- // Open the registry submission issue automatically via the authenticated gh CLI.
551
- // Removes the manual "open browser → paste → submit" step. `gh issue create`
552
- // prints the new issue URL to stdout. Returns { ok, url } or { ok:false, error }.
553
- function ghCreateIssue(title, body) {
554
- try {
555
- const r = spawnSync('gh', ['issue', 'create', '--repo', REGISTRY_REPO, '--title', title, '--body', body], { encoding: 'utf8', timeout: 20000 });
556
- if (r.error?.code === 'ENOENT') return { ok: false, no_gh: true };
557
- if (r.status !== 0) return { ok: false, error: r.stderr?.trim() || 'gh issue create failed' };
558
- return { ok: true, url: r.stdout.trim() };
559
- } catch (e) {
560
- return { ok: false, error: e.message };
561
- }
562
- }
563
-
564
- export async function publishSkill(filePath) {
565
- if (!fs.existsSync(filePath)) return { error: `File not found: ${filePath}` };
566
-
567
- const validation = validateSkill(filePath);
568
- if (!validation.ok) {
569
- return { error: 'Validation failed', issues: validation.errors, warnings: validation.warnings };
570
- }
571
-
572
- const auth = requireGhAuth();
573
- if (!auth.ok) return { error: auth.error };
574
-
575
- const name = path.basename(filePath, '.md');
576
- const gh = ghPublish(filePath, `PromptGraph skill: ${name}`);
577
-
578
- if (gh.no_gh) return { error: 'GitHub CLI became unavailable. Install it from https://cli.github.com and run: gh auth login' };
579
- if (!gh.ok) return { error: gh.error };
580
-
581
- // Auto-submit the registry issue — no manual step needed.
582
- const issue = ghCreateIssue(`Skill: ${name}`, `Skill submission (gist): ${gh.url}`);
583
- if (!issue.ok) {
584
- return { success: true, gist_url: gh.url, submitted: false, submit_url: REGISTRY_ISSUES, message: `Gist published (${gh.url}) but auto-submit failed: ${issue.error || 'gh unavailable'}. Submit manually: ${REGISTRY_ISSUES}` };
585
- }
586
- return { success: true, gist_url: gh.url, submitted: true, issue_url: issue.url, message: `Published & submitted: ${issue.url}` };
587
- }
588
-
589
- export async function publishBundle(bundleDef) {
590
- // bundleDef: { id, name, description, skills: [...], tags: [...] }
591
- // OR a path to a .json file
592
- let def = bundleDef;
593
- if (typeof bundleDef === 'string') {
594
- if (!fs.existsSync(bundleDef)) return { error: `File not found: ${bundleDef}` };
595
- try { def = JSON.parse(fs.readFileSync(bundleDef, 'utf8')); }
596
- catch (e) { return { error: `Invalid JSON: ${e.message}` }; }
597
- }
598
-
599
- const validation = validateBundle(def);
600
- if (!validation.ok) {
601
- return { error: 'Bundle validation failed', issues: validation.errors, warnings: validation.warnings };
602
- }
603
-
604
- const auth = requireGhAuth();
605
- if (!auth.ok) return { error: auth.error };
606
-
607
- // Best-effort repo validation (client-side). GitHub Actions is the source of truth.
608
- let repoWarnings = [];
609
- if (def.repo_url) {
610
- try {
611
- const ownerRepo = def.repo_url.replace(/^https?:\/\/github\.com\//, '').replace(/\.git$/, '');
612
- const repoValidation = await validateRepoSkills(ownerRepo);
613
- if (!repoValidation.ok) {
614
- repoWarnings = repoValidation.errors;
615
- }
616
- } catch (e) {
617
- repoWarnings = [`Repo validation warning: ${e.message} (will be checked by CI)`];
618
- }
619
- }
620
-
621
- const bundleJson = JSON.stringify(def, null, 2);
622
-
623
- // Auto-submit: create the registry issue directly with the bundle JSON inline
624
- // (the format the registry bot parses) — no manual browser/paste step.
625
- const body = [
626
- 'Bundle definition:',
627
- '',
628
- '```json',
629
- bundleJson,
630
- '```',
631
- ...(def.repo_url ? ['', 'Repo will be validated by CI (GitHub Actions) on submission.'] : []),
632
- ...(repoWarnings.length ? ['', '⚠ Client-side repo warnings (CI re-checks):', ...repoWarnings.map(w => `- ${w}`)] : []),
633
- ].join('\n');
634
-
635
- const issue = ghCreateIssue(`Bundle: ${def.name}`, body);
636
- if (issue.no_gh) return { error: 'GitHub CLI became unavailable. Install it from https://cli.github.com and run: gh auth login' };
637
- if (!issue.ok) return { error: `Auto-submit failed: ${issue.error}` };
638
- return { success: true, submitted: true, issue_url: issue.url, message: `Bundle submitted: ${issue.url}`, warnings: repoWarnings };
639
- }
640
-
641
- export function getTopRated(topK = 10) {
642
- const db = getDb();
643
- return db.prepare(`
644
- SELECT s.id, s.name, s.description, s.source,
645
- r.uses, r.success, r.fail,
646
- CASE WHEN (r.success + r.fail) > 0
647
- THEN ROUND(CAST(r.success AS FLOAT) / (r.success + r.fail), 2)
648
- ELSE NULL END as rating
649
- FROM skills s
650
- LEFT JOIN ratings r ON s.id = r.skill_id
651
- WHERE (r.success + r.fail) >= 3
652
- ORDER BY rating DESC, r.uses DESC
653
- LIMIT ?
654
- `).all(topK);
655
- }
656
-
657
- export function recordUse(skillId) {
658
- const db = getDb();
659
- db.prepare(`
660
- INSERT INTO ratings (skill_id, uses, success, fail)
661
- VALUES (?, 1, 0, 0)
662
- ON CONFLICT(skill_id) DO UPDATE SET uses = uses + 1
663
- `).run(skillId);
664
- }
665
-
666
- export function recordSuccess(skillId) {
667
- const db = getDb();
668
- db.prepare(`
669
- INSERT INTO ratings (skill_id, uses, success, fail)
670
- VALUES (?, 0, 1, 0)
671
- ON CONFLICT(skill_id) DO UPDATE SET success = success + 1
672
- `).run(skillId);
673
- }
674
-
675
- export function recordFail(skillId) {
676
- const db = getDb();
677
- db.prepare(`
678
- INSERT INTO ratings (skill_id, uses, success, fail)
679
- VALUES (?, 0, 0, 1)
680
- ON CONFLICT(skill_id) DO UPDATE SET fail = fail + 1
681
- `).run(skillId);
682
- }
683
-
684
- // Scan all imported repos, validate their .md files, and remove repos that fail.
685
- // Returns { removed: string[], kept: string[], errors: string[] }.
686
- export function pruneInvalidRepos() {
687
- const config = loadConfig();
688
- const removed = [];
689
- const kept = [];
690
- const errors = [];
691
-
692
- const repoSources = config.sources.filter(s => s.source?.startsWith('github:'));
693
- if (repoSources.length === 0) {
694
- return { removed, kept, errors: [], message: 'No imported repos found.' };
695
- }
696
-
697
- for (const src of repoSources) {
698
- const repoName = src.source.replace('github:', '');
699
- if (!fs.existsSync(src.dir)) {
700
- removed.push({ repo: repoName, reason: 'directory not found' });
701
- config.sources = config.sources.filter(s => s !== src);
702
- continue;
703
- }
704
-
705
- const mdFiles = globSync(`${src.dir}/**/*.md`).map(fp => path.resolve(fp));
706
- if (mdFiles.length === 0) {
707
- removed.push({ repo: repoName, reason: 'no .md files' });
708
- config.sources = config.sources.filter(s => s !== src);
709
- try { fs.rmSync(src.dir, { recursive: true, force: true }); } catch {}
710
- continue;
711
- }
712
-
713
- const invalid = [];
714
- for (const fp of mdFiles) {
715
- const v = validateSkill(fp);
716
- if (!v.ok) {
717
- invalid.push({ file: path.relative(src.dir, fp), errors: v.errors });
718
- }
719
- }
720
-
721
- if (invalid.length > 0) {
722
- removed.push({ repo: repoName, reason: `${invalid.length}/${mdFiles.length} .md files failed validation`, invalid });
723
- config.sources = config.sources.filter(s => s !== src);
724
- try { fs.rmSync(src.dir, { recursive: true, force: true }); } catch (e) { errors.push(`Failed to remove ${src.dir}: ${e.message}`); }
725
- } else {
726
- kept.push(repoName);
727
- }
728
- }
729
-
730
- saveConfig(config);
731
- return { removed, kept, errors };
732
- }
733
-
734
- // ── Trust level system ─────────────────────────────────────────────────────────
735
- const VALID_TRUST_LEVELS = ['verified', 'official', 'community', 'trusted', 'unknown']
736
- const TRUST_LEVEL_BOOST = { verified: 1.15, official: 1.10, trusted: 1.05, community: 1.0, unknown: 0.95 }
737
-
738
- // Popularity = log(downloads+1) × (rating+1), then decayed by age (days since last_update, halved every 180 days)
739
- function calcPopularity(downloads = 0, rating = 0, lastUpdateStr = null) {
740
- const logDownloads = Math.log10((downloads || 0) + 1)
741
- const ratingFactor = ((rating || 0) + 1) / 6 // normalised to 0–1
742
- let pop = logDownloads * ratingFactor * 100
743
-
744
- if (lastUpdateStr) {
745
- const daysSince = (Date.now() - new Date(lastUpdateStr + 'Z').getTime()) / 86400000
746
- if (daysSince > 0) {
747
- pop *= Math.pow(0.5, daysSince / 180) // half-life 180 days
748
- }
749
- }
750
- return Math.round(pop * 100) / 100
751
- }
752
-
753
- // Auto-promote threshold: downloads at which a skill graduates to the next trust level
754
- const AUTO_PROMOTE_THRESHOLDS = [
755
- { minDownloads: 10000, level: 'verified' },
756
- { minDownloads: 1000, level: 'official' },
757
- { minDownloads: 100, level: 'trusted' },
758
- ]
759
-
760
- function autoPromote(downloads, currentLevel) {
761
- const rank = VALID_TRUST_LEVELS.indexOf(currentLevel || 'unknown')
762
- for (const t of AUTO_PROMOTE_THRESHOLDS) {
763
- if (downloads >= t.minDownloads && VALID_TRUST_LEVELS.indexOf(t.level) > rank) {
764
- return t.level
765
- }
766
- }
767
- return null
768
- }
769
-
770
- export async function setTrustLevel(name, level) {
771
- const db = getDb()
772
- if (!VALID_TRUST_LEVELS.includes(level)) {
773
- return { ok: false, error: `Invalid trust level "${level}". Must be one of: ${VALID_TRUST_LEVELS.join(', ')}` }
774
- }
775
- const id = String(name)
776
- db.prepare(`
777
- INSERT INTO registry_entries (id, trust_level, last_update)
778
- VALUES (?, ?, datetime('now'))
779
- ON CONFLICT(id) DO UPDATE SET trust_level = excluded.trust_level, last_update = excluded.last_update
780
- `).run(id, level)
781
- return { ok: true }
782
- }
783
-
784
- export async function getByTrustLevel(level) {
785
- const db = getDb()
786
- if (level && !VALID_TRUST_LEVELS.includes(level)) {
787
- return { error: `Invalid trust level "${level}". Must be one of: ${VALID_TRUST_LEVELS.join(', ')}` }
788
- }
789
- if (level) {
790
- return db.prepare('SELECT * FROM registry_entries WHERE trust_level = ? ORDER BY downloads DESC, popularity DESC').all(level)
791
- }
792
- return db.prepare('SELECT * FROM registry_entries ORDER BY downloads DESC, popularity DESC').all()
793
- }
794
-
795
- export async function incrementDownloads(name) {
796
- const db = getDb()
797
- const id = String(name)
798
- const existing = db.prepare('SELECT downloads, rating, last_update, trust_level FROM registry_entries WHERE id = ?').get(id)
799
- if (existing) {
800
- const newDownloads = (existing.downloads || 0) + 1
801
- const pop = calcPopularity(newDownloads, existing.rating || 0, existing.last_update)
802
- db.prepare('UPDATE registry_entries SET downloads = ?, popularity = ?, last_update = datetime(\'now\') WHERE id = ?')
803
- .run(newDownloads, pop, id)
804
-
805
- // Auto-promote if threshold crossed
806
- const newLevel = autoPromote(newDownloads, existing.trust_level)
807
- if (newLevel) {
808
- db.prepare('UPDATE registry_entries SET trust_level = ? WHERE id = ?').run(newLevel, id)
809
- }
810
- } else {
811
- const pop = calcPopularity(1, 0)
812
- db.prepare('INSERT INTO registry_entries (id, downloads, popularity, trust_level, last_update) VALUES (?, 1, ?, \'unknown\', datetime(\'now\'))')
813
- .run(id, pop)
814
- }
815
- return { ok: true }
816
- }
817
-
818
- // ── validate & prune all installed marketplace files ──────────────────────────
819
-
820
- export function validateAndPruneMarketplace() {
821
- const results = { valid: [], removed: [], errors: [] };
822
- if (!fs.existsSync(getSkillsDir())) {
823
- return { ...results, message: 'No marketplace directory found.' };
824
- }
825
-
826
- const mdFiles = globSync(`${getSkillsDir()}/**/*.md`, { absolute: true });
827
- for (const fp of mdFiles) {
828
- const name = path.relative(getSkillsDir(), fp);
829
- try {
830
- const v = validateSkill(fp);
831
- if (!v.ok) {
832
- try { fs.unlinkSync(fp); results.removed.push({ file: name, errors: v.errors }); } catch (e) { results.errors.push(`Failed to remove ${name}: ${e.message}`); }
833
- } else {
834
- results.valid.push(name);
835
- }
836
- } catch (e) {
837
- results.errors.push(`Error validating ${name}: ${e.message}`);
838
- }
839
- }
840
-
841
- // Clean up empty dirs left behind
842
- removeEmptyDirs(getSkillsDir());
843
-
844
- // Also remove DB entries for deleted files
845
- const db = getDb();
846
- for (const row of db.prepare('SELECT id, path FROM skills WHERE source = ?').all('marketplace')) {
847
- if (!fs.existsSync(row.path)) {
848
- db.prepare('DELETE FROM skills WHERE id = ?').run(row.id);
849
- db.prepare('DELETE FROM chunks WHERE skill_id = ?').run(row.id);
850
- }
851
- }
852
-
853
- return results;
854
- }
855
-
856
- function removeEmptyDirs(dirPath) {
857
- let entries;
858
- try { entries = fs.readdirSync(dirPath, { withFileTypes: true }); } catch { return; }
859
- for (const entry of entries) {
860
- if (!entry.isDirectory()) continue;
861
- const fullPath = path.join(dirPath, entry.name);
862
- removeEmptyDirs(fullPath);
863
- try { if (fs.readdirSync(fullPath).length === 0) fs.rmdirSync(fullPath); } catch {}
864
- }
865
- }
866
-
867
- export { VALID_TRUST_LEVELS, TRUST_LEVEL_BOOST, calcPopularity, autoPromote }
868
-
869
- export async function rateSkill(name, rating) {
870
- const db = getDb()
871
- if (typeof rating !== 'number' || rating < 0 || rating > 5) {
872
- return { ok: false, error: 'Rating must be a number between 0 and 5' }
873
- }
874
- const id = String(name)
875
- const existing = db.prepare('SELECT rating, rating_count, downloads FROM registry_entries WHERE id = ?').get(id)
876
- if (existing) {
877
- const count = existing.rating_count || 0
878
- const newRating = Math.round(((existing.rating || 0) * count + rating) / (count + 1) * 100) / 100
879
- const newCount = count + 1
880
- const pop = calcPopularity(existing.downloads || 0, newRating)
881
- db.prepare('UPDATE registry_entries SET rating = ?, rating_count = ?, popularity = ?, last_update = datetime(\'now\') WHERE id = ?')
882
- .run(newRating, newCount, pop, id)
883
- } else {
884
- const pop = calcPopularity(0, rating)
885
- db.prepare('INSERT INTO registry_entries (id, rating, rating_count, popularity, last_update) VALUES (?, ?, 1, ?, datetime(\'now\'))')
886
- .run(id, rating, pop)
887
- }
888
- return { ok: true }
889
- }
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import https from 'https';
4
+ import { createHash } from 'crypto';
5
+ import { spawnSync } from 'child_process';
6
+ import { createRequire } from 'module';
7
+ import { getDb } from './db.js';
8
+ import { globSync } from 'glob';
9
+ import { validateSkill, validateBundle } from './validator.js';
10
+ import { loadConfig, saveConfig, PROMPTGRAPH_DIR, getSkillsStoreDir } from './config.js';
11
+ import { importFromGitHubLight, validateRepoSkills } from './github-import.js';
12
+ import { isSkillFile } from './parser.js';
13
+
14
+ const REGISTRY_URL = 'https://raw.githubusercontent.com/NeiP4n/promptgraph-registry/main/registry.json';
15
+ const SKILL_COUNT_CACHE = path.join(PROMPTGRAPH_DIR, 'skill-counts.json');
16
+ const DEAD_REPOS_FILE = path.join(PROMPTGRAPH_DIR, 'dead-repos.json');
17
+
18
+ function getSkillsDir() { return path.join(getSkillsStoreDir(), 'marketplace'); }
19
+
20
+ // Atomically write content to dest via tmp — cleans up on failure
21
+ function writeSkillAtomic(dest, content) {
22
+ const tmpPath = dest + '.tmp';
23
+ try {
24
+ fs.writeFileSync(tmpPath, content);
25
+ const v = validateSkill(tmpPath);
26
+ if (!v.ok) { fs.unlinkSync(tmpPath); return v; }
27
+ fs.renameSync(tmpPath, dest);
28
+ return { ok: true };
29
+ } catch (e) {
30
+ try { fs.unlinkSync(tmpPath); } catch {}
31
+ return { ok: false, errors: [e.message] };
32
+ }
33
+ }
34
+
35
+ // Convert GitHub blob URL → raw URL
36
+ // https://github.com/owner/repo/blob/branch/path/file.md
37
+ // → https://raw.githubusercontent.com/owner/repo/branch/path/file.md
38
+ function githubToRaw(url) {
39
+ const m = url.match(/^https?:\/\/github\.com\/([^/]+\/[^/]+)\/blob\/(.+)$/);
40
+ if (m) return `https://raw.githubusercontent.com/${m[1]}/${m[2]}`;
41
+ return null; // already raw or not a github URL
42
+ }
43
+
44
+ const _require = createRequire(import.meta.url);
45
+ const PKG_VERSION = (() => { try { return _require('./package.json').version; } catch { return '1.x'; } })();
46
+ const UA = `promptgraph-mcp/${PKG_VERSION}`;
47
+
48
+ const REGISTRY_ID_RE = /^[a-z0-9][a-z0-9-]{1,63}$/;
49
+
50
+ function validateRegistryEntry(item) {
51
+ const errors = [];
52
+ if (!item.id || typeof item.id !== 'string') errors.push('missing required id');
53
+ else if (!REGISTRY_ID_RE.test(item.id)) errors.push(`invalid id "${item.id}" (must match /^[a-z0-9][a-z0-9-]{1,63}$/)`);
54
+ if (!item.name || typeof item.name !== 'string') errors.push('missing required name');
55
+ else if (item.name.length < 3) errors.push(`name "${item.name}" too short (min 3 chars)`);
56
+ if (!item.description || typeof item.description !== 'string') errors.push('missing required description');
57
+ else if (item.description.length < 15) errors.push(`description too short (min 15 chars)`);
58
+ if (item.version !== undefined && typeof item.version !== 'string') errors.push('version must be a string');
59
+ if (item.author !== undefined && typeof item.author !== 'string') errors.push('author must be a string');
60
+ if (item.license !== undefined && typeof item.license !== 'string') errors.push('license must be a string');
61
+ if (item.updated_at !== undefined) {
62
+ if (typeof item.updated_at !== 'string') errors.push('updated_at must be a string');
63
+ else if (isNaN(Date.parse(item.updated_at))) errors.push(`updated_at "${item.updated_at}" is not a valid ISO date`);
64
+ }
65
+ if (item.downloads !== undefined && typeof item.downloads !== 'number') errors.push('downloads must be a number');
66
+ if (item.verified !== undefined && typeof item.verified !== 'boolean') errors.push('verified must be a boolean');
67
+ if (item.trustLevel !== undefined && typeof item.trustLevel !== 'string') errors.push('trustLevel must be a string');
68
+ if (item.rating !== undefined) {
69
+ if (typeof item.rating !== 'number') errors.push('rating must be a number');
70
+ else if (item.rating < 0 || item.rating > 5) errors.push('rating must be between 0 and 5');
71
+ }
72
+ if (item.popularity !== undefined && typeof item.popularity !== 'number') errors.push('popularity must be a number');
73
+ if (item.lastUpdate !== undefined) {
74
+ if (typeof item.lastUpdate !== 'string') errors.push('lastUpdate must be a string');
75
+ else if (isNaN(Date.parse(item.lastUpdate))) errors.push(`lastUpdate "${item.lastUpdate}" is not a valid ISO date`);
76
+ }
77
+ if (errors.length > 0) {
78
+ console.warn(`[PromptGraph] Skipping invalid registry entry "${item.id || item.name || '(unnamed)'}": ${errors.join('; ')}`);
79
+ return { ok: false };
80
+ }
81
+ return { ok: true };
82
+ }
83
+
84
+ // Deterministic short code from an id. Same id always yields the same code,
85
+ // so codes auto-generate — no need to assign them by hand.
86
+ export function codeFor(id) {
87
+ return 'pg-' + createHash('md5').update(String(id)).digest('hex').slice(0, 6);
88
+ }
89
+
90
+ // node:https GET — reliable and fast on Windows (undici fetch can hang ~10s there).
91
+ function httpGet(url) {
92
+ return new Promise((resolve, reject) => {
93
+ const req = https.get(url, { headers: { 'User-Agent': UA }, timeout: 8000, family: 4 }, (res) => {
94
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
95
+ return httpGet(res.headers.location).then(resolve, reject);
96
+ }
97
+ if (res.statusCode !== 200) {
98
+ res.resume();
99
+ return reject(new Error(`HTTP ${res.statusCode}`));
100
+ }
101
+ let data = '';
102
+ res.setEncoding('utf8');
103
+ res.on('data', c => data += c);
104
+ res.on('end', () => resolve(data.charCodeAt(0) === 0xFEFF ? data.slice(1) : data));
105
+ });
106
+ req.on('timeout', () => { req.destroy(new Error('request timed out')); });
107
+ req.on('error', reject);
108
+ });
109
+ }
110
+
111
+ // Primary path is httpGet (fast/reliable on Windows); undici fetch only as fallback.
112
+ async function rawFetch(url) {
113
+ try {
114
+ return await httpGet(url);
115
+ } catch {
116
+ const res = await fetch(url, { headers: { 'User-Agent': UA } });
117
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
118
+ return await res.text();
119
+ }
120
+ }
121
+
122
+ // Disk cache for the registry (network to GitHub raw can be slow on some networks).
123
+ const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
124
+ const SKILL_COUNT_TTL = 24 * 60 * 60 * 1000; // 24 hours for skill counts
125
+
126
+ // Return GitHub API auth headers if GITHUB_TOKEN is set
127
+ function githubAuthHeaders() {
128
+ const token = process.env.GITHUB_TOKEN;
129
+ if (!token) return { 'User-Agent': 'promptgraph-mcp' };
130
+ return { 'User-Agent': 'promptgraph-mcp', 'Authorization': `Bearer ${token}` };
131
+ }
132
+
133
+ // Read skill count cache, returns {} on miss
134
+ function readSkillCountCache() {
135
+ try {
136
+ if (fs.existsSync(SKILL_COUNT_CACHE)) return JSON.parse(fs.readFileSync(SKILL_COUNT_CACHE, 'utf8'));
137
+ } catch {}
138
+ return {};
139
+ }
140
+
141
+ function writeSkillCountCache(data) {
142
+ try {
143
+ fs.mkdirSync(path.dirname(SKILL_COUNT_CACHE), { recursive: true });
144
+ fs.writeFileSync(SKILL_COUNT_CACHE, JSON.stringify(data));
145
+ } catch {}
146
+ }
147
+
148
+ function readDeadRepos() {
149
+ try { return JSON.parse(fs.readFileSync(DEAD_REPOS_FILE, 'utf8')); } catch { return []; }
150
+ }
151
+
152
+ function markDeadRepo(repoUrl) {
153
+ try {
154
+ const dead = readDeadRepos();
155
+ if (!dead.includes(repoUrl)) {
156
+ dead.push(repoUrl);
157
+ fs.mkdirSync(path.dirname(DEAD_REPOS_FILE), { recursive: true });
158
+ fs.writeFileSync(DEAD_REPOS_FILE, JSON.stringify(dead, null, 2));
159
+ }
160
+ } catch {}
161
+ }
162
+
163
+ async function fetchText(url) {
164
+ const cacheFile = path.join(PROMPTGRAPH_DIR, 'registry-cache.json');
165
+ const isRegistry = url === REGISTRY_URL;
166
+
167
+ if (isRegistry && fs.existsSync(cacheFile)) {
168
+ try {
169
+ const stat = fs.statSync(cacheFile);
170
+ if (Date.now() - stat.mtimeMs < CACHE_TTL) {
171
+ return fs.readFileSync(cacheFile, 'utf8');
172
+ }
173
+ } catch {}
174
+ }
175
+
176
+ const text = await rawFetch(url);
177
+
178
+ if (isRegistry) {
179
+ try {
180
+ fs.mkdirSync(PROMPTGRAPH_DIR, { recursive: true });
181
+ fs.writeFileSync(cacheFile, text);
182
+ } catch {}
183
+ }
184
+ return text;
185
+ }
186
+
187
+ export async function browseMarketplace(topK = 20) {
188
+ try {
189
+ const text = await fetchText(REGISTRY_URL);
190
+ const registry = JSON.parse(text);
191
+ if (!Array.isArray(registry.skills)) return { error: 'Invalid registry format' };
192
+ return registry.skills
193
+ .map(s => Object.assign(Object.create(null), s, { code: s.code || codeFor(s.id) }))
194
+ .filter(s => validateRegistryEntry(s).ok)
195
+ .filter(s => s.raw_url)
196
+ .sort((a, b) => (b.stars || 0) - (a.stars || 0))
197
+ .slice(0, topK);
198
+ } catch (e) {
199
+ return { error: `Registry unavailable: ${e.message}` };
200
+ }
201
+ }
202
+
203
+ export async function installSkillFromUrl(url) {
204
+ try {
205
+ const rawUrl = githubToRaw(url) || url;
206
+ const content = await fetchText(rawUrl);
207
+ fs.mkdirSync(getSkillsDir(), { recursive: true });
208
+ ensureMarketplaceSource();
209
+
210
+ // derive filename from URL
211
+ const urlName = rawUrl.split('/').pop().replace(/[^a-z0-9-_.]/gi, '-');
212
+ const dest = path.join(getSkillsDir(), urlName.endsWith('.md') ? urlName : urlName + '.md');
213
+ const resolvedDest = path.resolve(dest);
214
+ if (!resolvedDest.startsWith(path.resolve(getSkillsDir()))) {
215
+ return { error: 'Path traversal blocked: destination outside marketplace directory' };
216
+ }
217
+ const v = writeSkillAtomic(dest, content);
218
+ if (!v.ok) return { error: 'Downloaded skill failed validation', issues: v.errors };
219
+ return { success: true, path: dest, url: rawUrl };
220
+ } catch (e) {
221
+ return { error: e.message };
222
+ }
223
+ }
224
+
225
+ export async function installSkill(query) {
226
+ try {
227
+ // GitHub URL or raw URL → direct install
228
+ if (query.startsWith('http://') || query.startsWith('https://')) {
229
+ return installSkillFromUrl(query);
230
+ }
231
+
232
+ const text = await fetchText(REGISTRY_URL);
233
+ const registry = JSON.parse(text);
234
+ const q = String(query).trim().toLowerCase();
235
+ const validSkills = (registry.skills || []).filter(s => validateRegistryEntry(s).ok);
236
+ // match by code (stored OR auto-generated), id, or name
237
+ const skill = validSkills.find(s =>
238
+ (s.code || codeFor(s.id)).toLowerCase() === q ||
239
+ s.id?.toLowerCase() === q ||
240
+ s.name?.toLowerCase() === q
241
+ );
242
+ if (!skill) {
243
+ const bundle = (registry.bundles || []).find(b =>
244
+ (b.code || codeFor(b.id)).toLowerCase() === q || b.id?.toLowerCase() === q
245
+ );
246
+ if (bundle) return { error: `"${query}" is a bundle. Use pg_bundle_install("${bundle.id}") instead.` };
247
+ return { error: `No skill matching "${query}" (try a code, id, name, or GitHub URL)` };
248
+ }
249
+ if (!skill.raw_url) return { error: `Skill "${skill.id}" has no download URL` };
250
+ const skillId = skill.id;
251
+
252
+ fs.mkdirSync(getSkillsDir(), { recursive: true });
253
+ ensureMarketplaceSource();
254
+ const dest = path.join(getSkillsDir(), `${skillId}.md`);
255
+ const resolvedDest = path.resolve(dest);
256
+ if (!resolvedDest.startsWith(path.resolve(getSkillsDir()))) {
257
+ return { error: 'Path traversal blocked: destination outside marketplace directory' };
258
+ }
259
+
260
+ const content = await fetchText(skill.raw_url);
261
+ const v = writeSkillAtomic(dest, content);
262
+ if (!v.ok) return { error: 'Downloaded skill failed validation', issues: v.errors };
263
+ return { success: true, path: dest, name: skill.name };
264
+ } catch (e) {
265
+ return { error: e.message };
266
+ }
267
+ }
268
+
269
+ // ── filter skill files (exclude docs) — delegates to parser.js isSkillFile ─────
270
+ // was: local isSkillFile(path) — removed in favor of shared parser.js version
271
+
272
+ async function countRepoSkills(repoUrl) {
273
+ try {
274
+ const apiUrl = `https://api.github.com/repos/${repoUrl}/git/trees/HEAD?recursive=1`;
275
+ const res = await fetch(apiUrl, { headers: githubAuthHeaders() });
276
+ if (!res.ok) return null;
277
+ const data = await res.json();
278
+ return (data.tree || []).filter(f => f.type === 'blob' && isSkillFile(f.path)).length;
279
+ } catch { return null; }
280
+ }
281
+
282
+ // Count real .md files on disk for an installed bundle (always correct)
283
+ export function localSkillCount(repoUrl) {
284
+ const repoName = repoUrl.replace(/^https?:\/\/github\.com\//, '').replace(/\.git$/, '').replace('/', '-');
285
+ const dest = path.join(getSkillsStoreDir(), 'github', repoName);
286
+ if (!fs.existsSync(dest)) return null;
287
+ const files = globSync(`${dest}/**/*.md`, { dot: true });
288
+ return files.length;
289
+ }
290
+
291
+ export async function browseBundles(topK = 20) {
292
+ try {
293
+ const text = await fetchText(REGISTRY_URL);
294
+ const registry = JSON.parse(text);
295
+ const deadRepos = new Set(readDeadRepos());
296
+ const bundles = (registry.bundles || []).filter(b =>
297
+ validateRegistryEntry(b).ok && !deadRepos.has(b.repo_url)
298
+ );
299
+ const cache = readSkillCountCache();
300
+ const now = Date.now();
301
+ let changed = false;
302
+
303
+ await Promise.all(bundles.map(async b => {
304
+ if (!b.repo_url) return;
305
+
306
+ // 1. If installed locally — count real files on disk (always correct)
307
+ const local = localSkillCount(b.repo_url);
308
+ if (local !== null) {
309
+ if (b.skillCount !== local) {
310
+ b.skillCount = local;
311
+ cache[b.repo_url] = { count: local, ts: now };
312
+ changed = true;
313
+ }
314
+ return;
315
+ }
316
+
317
+ // 2. Not installed — validated registry count is authoritative; use cache if fresh
318
+ const cached = cache[b.repo_url];
319
+ if (cached && (now - cached.ts) < SKILL_COUNT_TTL) {
320
+ b.skillCount = cached.count;
321
+ return;
322
+ }
323
+ if (b.validated && b.skill_count) {
324
+ b.skillCount = b.skill_count;
325
+ return;
326
+ }
327
+
328
+ // 3. Fetch from GitHub API
329
+ const count = await countRepoSkills(b.repo_url);
330
+ if (count !== null) {
331
+ b.skillCount = count;
332
+ cache[b.repo_url] = { count, ts: now };
333
+ changed = true;
334
+ } else {
335
+ b.skillCount = cached?.count ?? b.skill_count ?? 0;
336
+ }
337
+ }));
338
+
339
+ if (changed) writeSkillCountCache(cache);
340
+
341
+ return bundles
342
+ .filter(b => !b.repo_url || b.skillCount > 0)
343
+ .map(b => Object.assign(Object.create(null), b, { code: b.code || codeFor(b.id) }))
344
+ .sort((a, b) => (b.stars || 0) - (a.stars || 0))
345
+ .slice(0, topK);
346
+ } catch (e) {
347
+ return { error: `Registry unavailable: ${e.message}` };
348
+ }
349
+ }
350
+
351
+ // Ensure marketplace has its own source entry (separate from skills-store)
352
+ // so marketplace skills never collide with local skills of the same name.
353
+ function ensureMarketplaceSource() {
354
+ const config = loadConfig();
355
+ if (!config.sources.find(s => s.source === 'marketplace')) {
356
+ config.sources.push({ dir: getSkillsDir(), source: 'marketplace' });
357
+ saveConfig(config);
358
+ }
359
+ }
360
+
361
+ // ── helpers extracted from installBundle ─────────────────────────────────────
362
+
363
+ async function _findBundle(bundleId) {
364
+ const text = await fetchText(REGISTRY_URL);
365
+ const registry = JSON.parse(text);
366
+ const q = String(bundleId).trim().toLowerCase();
367
+ const valid = (registry.bundles || []).filter(b => validateRegistryEntry(b).ok);
368
+ const validSkills = (registry.skills || []).filter(s => validateRegistryEntry(s).ok);
369
+ // 1. Exact match (code, id, name)
370
+ let bundle = valid.find(b =>
371
+ (b.code || codeFor(b.id)).toLowerCase() === q ||
372
+ b.id?.toLowerCase() === q ||
373
+ b.name?.toLowerCase() === q
374
+ );
375
+ // 2. Partial name/id match (starts with)
376
+ if (!bundle) bundle = valid.find(b =>
377
+ b.name?.toLowerCase().startsWith(q) || b.id?.toLowerCase().startsWith(q)
378
+ );
379
+ // 3. Contains match
380
+ if (!bundle) bundle = valid.find(b =>
381
+ b.name?.toLowerCase().includes(q) || b.id?.toLowerCase().includes(q)
382
+ );
383
+ if (!bundle) return { error: `No bundle matching "${bundleId}"` };
384
+ return { bundle, validSkills };
385
+ }
386
+
387
+ async function _execRepoInstall(bundle) {
388
+ try {
389
+ await importFromGitHubLight(bundle.repo_url);
390
+ } catch (e) {
391
+ // Only mark dead on confirmed 404 — not on validation failures or network errors
392
+ if (e.message && (e.message.includes('HTTP 404') || e.message.includes('not found'))) {
393
+ markDeadRepo(bundle.repo_url);
394
+ }
395
+ throw e;
396
+ }
397
+ const real = localSkillCount(bundle.repo_url);
398
+ if (real !== null) {
399
+ const cache = readSkillCountCache();
400
+ cache[bundle.repo_url] = { count: real, ts: Date.now() };
401
+ writeSkillCountCache(cache);
402
+ }
403
+ return { success: true, bundle: bundle.name, type: 'repo_import', repo_url: bundle.repo_url };
404
+ }
405
+
406
+ async function _execSkillsInstall(bundle, validSkills) {
407
+ const skillsDir = getSkillsDir();
408
+ fs.mkdirSync(skillsDir, { recursive: true });
409
+ ensureMarketplaceSource();
410
+ const installed = [];
411
+ const failed = [];
412
+ const toolsInstalled = [];
413
+ const delay = (ms) => new Promise(r => setTimeout(r, ms));
414
+
415
+ for (const skillId of bundle.skills || []) {
416
+ const skill = validSkills.find(s => s.id === skillId);
417
+ if (!skill?.raw_url) { failed.push(skillId); continue; }
418
+ try {
419
+ if (installed.length > 0) await delay(300);
420
+ const content = await fetchText(skill.raw_url);
421
+ const dest = path.join(skillsDir, `${skillId}.md`);
422
+ if (!path.resolve(dest).startsWith(path.resolve(skillsDir))) { failed.push(skillId); continue; }
423
+ const v = writeSkillAtomic(dest, content);
424
+ if (!v.ok) { failed.push(skillId); continue; }
425
+ installed.push(skillId);
426
+ } catch { failed.push(skillId); }
427
+ }
428
+
429
+ // Install tool files if bundle has them (scripts, .py, .sh, .js etc)
430
+ for (const tool of bundle.tool_files || []) {
431
+ if (!tool?.raw_url || !tool?.path) continue;
432
+ try {
433
+ await delay(200);
434
+ const content = await fetchText(tool.raw_url);
435
+ const dest = path.join(skillsDir, tool.path);
436
+ if (!path.resolve(dest).startsWith(path.resolve(skillsDir))) continue;
437
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
438
+ fs.writeFileSync(dest, content);
439
+ // Make executable on unix
440
+ if (process.platform !== 'win32') {
441
+ try { fs.chmodSync(dest, 0o755); } catch {}
442
+ }
443
+ toolsInstalled.push(tool.path);
444
+ } catch {}
445
+ }
446
+
447
+ return { success: true, bundle: bundle.name, installed, failed, toolsInstalled, dir: skillsDir, has_tools: bundle.has_tools || false };
448
+ }
449
+
450
+ export async function installBundle(bundleId) {
451
+ try {
452
+ const found = await _findBundle(bundleId);
453
+ if (found.error) return { error: found.error };
454
+ const { bundle, validSkills } = found;
455
+ return bundle.repo_url
456
+ ? await _execRepoInstall(bundle)
457
+ : await _execSkillsInstall(bundle, validSkills);
458
+ } catch (e) {
459
+ return { error: e.message };
460
+ }
461
+ }
462
+
463
+ // ── Background install queue ──────────────────────────────────────────────────
464
+ // Allows the TUI to queue multiple installs without blocking.
465
+
466
+ const _bgQueue = [];
467
+ let _bgRunning = false;
468
+ let _bgCurrentId = null;
469
+ const _bgPendingKeys = new Set();
470
+
471
+ async function _bgProcess() {
472
+ if (_bgRunning) return;
473
+ _bgRunning = true;
474
+ while (_bgQueue.length > 0) {
475
+ const { bundle, validSkills, onDone, _dedupKey } = _bgQueue.shift();
476
+ if (_dedupKey) _bgPendingKeys.delete(_dedupKey);
477
+ _bgCurrentId = bundle.id;
478
+ try {
479
+ const result = bundle.repo_url
480
+ ? await _execRepoInstall(bundle)
481
+ : await _execSkillsInstall(bundle, validSkills);
482
+ await onDone?.(null, result);
483
+ } catch (e) {
484
+ await onDone?.(e, null);
485
+ } finally {
486
+ _bgCurrentId = null;
487
+ }
488
+ }
489
+ _bgRunning = false;
490
+ }
491
+
492
+ // Install a bundle in the background, returning immediately.
493
+ // The actual work is serialized through an internal queue.
494
+ // onDone(err, result) is called when the queue finishes processing this bundle.
495
+ export async function installBundleBg(bundleId, onDone) {
496
+ const q = String(bundleId).trim().toLowerCase();
497
+ // Synchronous dedup check BEFORE any await — prevents race on double-Enter
498
+ if (_bgPendingKeys.has(q)) {
499
+ return { dedup: true, error: `"${bundleId}" is already queued or installing` };
500
+ }
501
+ _bgPendingKeys.add(q);
502
+
503
+ const found = await _findBundle(bundleId);
504
+ if (found.error) { _bgPendingKeys.delete(q); return found; }
505
+ const { bundle, validSkills } = found;
506
+
507
+ if (_bgCurrentId === bundle.id || _bgQueue.some(e => e.bundle.id === bundle.id)) {
508
+ _bgPendingKeys.delete(q);
509
+ return { dedup: true, error: `"${bundle.name}" is already queued or installing` };
510
+ }
511
+
512
+ _bgQueue.push({ bundle, validSkills, onDone, _dedupKey: q });
513
+ _bgProcess();
514
+ return { queued: true, id: bundle.id, name: bundle.name };
515
+ }
516
+
517
+ // Publishing to the marketplace requires an authenticated GitHub CLI account.
518
+ // `gh auth status` exits 0 only when a user is logged in; ENOENT means gh is
519
+ // not installed. Returns { ok: true } or { ok: false, error } with guidance.
520
+ export function requireGhAuth() {
521
+ let res;
522
+ try {
523
+ res = spawnSync('gh', ['auth', 'status'], { encoding: 'utf8', timeout: 10000 });
524
+ } catch {
525
+ res = { error: { code: 'ENOENT' } };
526
+ }
527
+ if (res.error?.code === 'ENOENT') {
528
+ return { ok: false, error: 'Publishing requires the GitHub CLI. Install it from https://cli.github.com, then run: gh auth login' };
529
+ }
530
+ if (res.status !== 0) {
531
+ return { ok: false, error: 'Publishing requires a signed-in GitHub account. Run: gh auth login' };
532
+ }
533
+ return { ok: true };
534
+ }
535
+
536
+ function ghPublish(filePath, desc) {
537
+ try {
538
+ const result = spawnSync('gh', ['gist', 'create', filePath, '--desc', desc, '--public'], { encoding: 'utf8' });
539
+ if (result.error?.code === 'ENOENT') return { ok: false, no_gh: true };
540
+ if (result.status !== 0) return { ok: false, error: result.stderr?.trim() || 'gh CLI error — run: gh auth login' };
541
+ return { ok: true, url: result.stdout.trim() };
542
+ } catch {
543
+ return { ok: false, no_gh: true };
544
+ }
545
+ }
546
+
547
+ const REGISTRY_REPO = 'NeiP4n/promptgraph-registry';
548
+ const REGISTRY_ISSUES = `https://github.com/${REGISTRY_REPO}/issues/new`;
549
+
550
+ // Open the registry submission issue automatically via the authenticated gh CLI.
551
+ // Removes the manual "open browser → paste → submit" step. `gh issue create`
552
+ // prints the new issue URL to stdout. Returns { ok, url } or { ok:false, error }.
553
+ function ghCreateIssue(title, body) {
554
+ try {
555
+ const r = spawnSync('gh', ['issue', 'create', '--repo', REGISTRY_REPO, '--title', title, '--body', body], { encoding: 'utf8', timeout: 20000 });
556
+ if (r.error?.code === 'ENOENT') return { ok: false, no_gh: true };
557
+ if (r.status !== 0) return { ok: false, error: r.stderr?.trim() || 'gh issue create failed' };
558
+ return { ok: true, url: r.stdout.trim() };
559
+ } catch (e) {
560
+ return { ok: false, error: e.message };
561
+ }
562
+ }
563
+
564
+ export async function publishSkill(filePath) {
565
+ if (!fs.existsSync(filePath)) return { error: `File not found: ${filePath}` };
566
+
567
+ const validation = validateSkill(filePath);
568
+ if (!validation.ok) {
569
+ return { error: 'Validation failed', issues: validation.errors, warnings: validation.warnings };
570
+ }
571
+
572
+ const auth = requireGhAuth();
573
+ if (!auth.ok) return { error: auth.error };
574
+
575
+ const name = path.basename(filePath, '.md');
576
+ const gh = ghPublish(filePath, `PromptGraph skill: ${name}`);
577
+
578
+ if (gh.no_gh) return { error: 'GitHub CLI became unavailable. Install it from https://cli.github.com and run: gh auth login' };
579
+ if (!gh.ok) return { error: gh.error };
580
+
581
+ // Auto-submit the registry issue — no manual step needed.
582
+ const issue = ghCreateIssue(`Skill: ${name}`, `Skill submission (gist): ${gh.url}`);
583
+ if (!issue.ok) {
584
+ return { success: true, gist_url: gh.url, submitted: false, submit_url: REGISTRY_ISSUES, message: `Gist published (${gh.url}) but auto-submit failed: ${issue.error || 'gh unavailable'}. Submit manually: ${REGISTRY_ISSUES}` };
585
+ }
586
+ return { success: true, gist_url: gh.url, submitted: true, issue_url: issue.url, message: `Published & submitted: ${issue.url}` };
587
+ }
588
+
589
+ export async function publishBundle(bundleDef) {
590
+ // bundleDef: { id, name, description, skills: [...], tags: [...] }
591
+ // OR a path to a .json file
592
+ let def = bundleDef;
593
+ if (typeof bundleDef === 'string') {
594
+ if (!fs.existsSync(bundleDef)) return { error: `File not found: ${bundleDef}` };
595
+ try { def = JSON.parse(fs.readFileSync(bundleDef, 'utf8')); }
596
+ catch (e) { return { error: `Invalid JSON: ${e.message}` }; }
597
+ }
598
+
599
+ const validation = validateBundle(def);
600
+ if (!validation.ok) {
601
+ return { error: 'Bundle validation failed', issues: validation.errors, warnings: validation.warnings };
602
+ }
603
+
604
+ const auth = requireGhAuth();
605
+ if (!auth.ok) return { error: auth.error };
606
+
607
+ // Best-effort repo validation (client-side). GitHub Actions is the source of truth.
608
+ let repoWarnings = [];
609
+ if (def.repo_url) {
610
+ try {
611
+ const ownerRepo = def.repo_url.replace(/^https?:\/\/github\.com\//, '').replace(/\.git$/, '');
612
+ const repoValidation = await validateRepoSkills(ownerRepo);
613
+ if (!repoValidation.ok) {
614
+ repoWarnings = repoValidation.errors;
615
+ }
616
+ } catch (e) {
617
+ repoWarnings = [`Repo validation warning: ${e.message} (will be checked by CI)`];
618
+ }
619
+ }
620
+
621
+ const bundleJson = JSON.stringify(def, null, 2);
622
+
623
+ // Auto-submit: create the registry issue directly with the bundle JSON inline
624
+ // (the format the registry bot parses) — no manual browser/paste step.
625
+ const body = [
626
+ 'Bundle definition:',
627
+ '',
628
+ '```json',
629
+ bundleJson,
630
+ '```',
631
+ ...(def.repo_url ? ['', 'Repo will be validated by CI (GitHub Actions) on submission.'] : []),
632
+ ...(repoWarnings.length ? ['', '⚠ Client-side repo warnings (CI re-checks):', ...repoWarnings.map(w => `- ${w}`)] : []),
633
+ ].join('\n');
634
+
635
+ const issue = ghCreateIssue(`Bundle: ${def.name}`, body);
636
+ if (issue.no_gh) return { error: 'GitHub CLI became unavailable. Install it from https://cli.github.com and run: gh auth login' };
637
+ if (!issue.ok) return { error: `Auto-submit failed: ${issue.error}` };
638
+ return { success: true, submitted: true, issue_url: issue.url, message: `Bundle submitted: ${issue.url}`, warnings: repoWarnings };
639
+ }
640
+
641
+ export function getTopRated(topK = 10) {
642
+ const db = getDb();
643
+ return db.prepare(`
644
+ SELECT s.id, s.name, s.description, s.source,
645
+ r.uses, r.success, r.fail,
646
+ CASE WHEN (r.success + r.fail) > 0
647
+ THEN ROUND(CAST(r.success AS FLOAT) / (r.success + r.fail), 2)
648
+ ELSE NULL END as rating
649
+ FROM skills s
650
+ LEFT JOIN ratings r ON s.id = r.skill_id
651
+ WHERE (r.success + r.fail) >= 3
652
+ ORDER BY rating DESC, r.uses DESC
653
+ LIMIT ?
654
+ `).all(topK);
655
+ }
656
+
657
+ export function recordUse(skillId) {
658
+ const db = getDb();
659
+ db.prepare(`
660
+ INSERT INTO ratings (skill_id, uses, success, fail)
661
+ VALUES (?, 1, 0, 0)
662
+ ON CONFLICT(skill_id) DO UPDATE SET uses = uses + 1
663
+ `).run(skillId);
664
+ }
665
+
666
+ export function recordSuccess(skillId) {
667
+ const db = getDb();
668
+ db.prepare(`
669
+ INSERT INTO ratings (skill_id, uses, success, fail)
670
+ VALUES (?, 0, 1, 0)
671
+ ON CONFLICT(skill_id) DO UPDATE SET success = success + 1
672
+ `).run(skillId);
673
+ }
674
+
675
+ export function recordFail(skillId) {
676
+ const db = getDb();
677
+ db.prepare(`
678
+ INSERT INTO ratings (skill_id, uses, success, fail)
679
+ VALUES (?, 0, 0, 1)
680
+ ON CONFLICT(skill_id) DO UPDATE SET fail = fail + 1
681
+ `).run(skillId);
682
+ }
683
+
684
+ // Scan all imported repos, validate their .md files, and remove repos that fail.
685
+ // Returns { removed: string[], kept: string[], errors: string[] }.
686
+ export function pruneInvalidRepos() {
687
+ const config = loadConfig();
688
+ const removed = [];
689
+ const kept = [];
690
+ const errors = [];
691
+
692
+ const repoSources = config.sources.filter(s => s.source?.startsWith('github:'));
693
+ if (repoSources.length === 0) {
694
+ return { removed, kept, errors: [], message: 'No imported repos found.' };
695
+ }
696
+
697
+ for (const src of repoSources) {
698
+ const repoName = src.source.replace('github:', '');
699
+ if (!fs.existsSync(src.dir)) {
700
+ removed.push({ repo: repoName, reason: 'directory not found' });
701
+ config.sources = config.sources.filter(s => s !== src);
702
+ continue;
703
+ }
704
+
705
+ const mdFiles = globSync(`${src.dir}/**/*.md`, { dot: true }).map(fp => path.resolve(fp));
706
+ if (mdFiles.length === 0) {
707
+ removed.push({ repo: repoName, reason: 'no .md files' });
708
+ config.sources = config.sources.filter(s => s !== src);
709
+ try { fs.rmSync(src.dir, { recursive: true, force: true }); } catch {}
710
+ continue;
711
+ }
712
+
713
+ const invalid = [];
714
+ for (const fp of mdFiles) {
715
+ const v = validateSkill(fp);
716
+ if (!v.ok) {
717
+ invalid.push({ file: path.relative(src.dir, fp), errors: v.errors });
718
+ }
719
+ }
720
+
721
+ if (invalid.length > 0) {
722
+ removed.push({ repo: repoName, reason: `${invalid.length}/${mdFiles.length} .md files failed validation`, invalid });
723
+ config.sources = config.sources.filter(s => s !== src);
724
+ try { fs.rmSync(src.dir, { recursive: true, force: true }); } catch (e) { errors.push(`Failed to remove ${src.dir}: ${e.message}`); }
725
+ } else {
726
+ kept.push(repoName);
727
+ }
728
+ }
729
+
730
+ saveConfig(config);
731
+ return { removed, kept, errors };
732
+ }
733
+
734
+ // ── Trust level system ─────────────────────────────────────────────────────────
735
+ const VALID_TRUST_LEVELS = ['verified', 'official', 'community', 'trusted', 'unknown']
736
+ const TRUST_LEVEL_BOOST = { verified: 1.15, official: 1.10, trusted: 1.05, community: 1.0, unknown: 0.95 }
737
+
738
+ // Popularity = log(downloads+1) × (rating+1), then decayed by age (days since last_update, halved every 180 days)
739
+ function calcPopularity(downloads = 0, rating = 0, lastUpdateStr = null) {
740
+ const logDownloads = Math.log10((downloads || 0) + 1)
741
+ const ratingFactor = ((rating || 0) + 1) / 6 // normalised to 0–1
742
+ let pop = logDownloads * ratingFactor * 100
743
+
744
+ if (lastUpdateStr) {
745
+ const daysSince = (Date.now() - new Date(lastUpdateStr + 'Z').getTime()) / 86400000
746
+ if (daysSince > 0) {
747
+ pop *= Math.pow(0.5, daysSince / 180) // half-life 180 days
748
+ }
749
+ }
750
+ return Math.round(pop * 100) / 100
751
+ }
752
+
753
+ // Auto-promote threshold: downloads at which a skill graduates to the next trust level
754
+ const AUTO_PROMOTE_THRESHOLDS = [
755
+ { minDownloads: 10000, level: 'verified' },
756
+ { minDownloads: 1000, level: 'official' },
757
+ { minDownloads: 100, level: 'trusted' },
758
+ ]
759
+
760
+ function autoPromote(downloads, currentLevel) {
761
+ const rank = VALID_TRUST_LEVELS.indexOf(currentLevel || 'unknown')
762
+ for (const t of AUTO_PROMOTE_THRESHOLDS) {
763
+ if (downloads >= t.minDownloads && VALID_TRUST_LEVELS.indexOf(t.level) > rank) {
764
+ return t.level
765
+ }
766
+ }
767
+ return null
768
+ }
769
+
770
+ export async function setTrustLevel(name, level) {
771
+ const db = getDb()
772
+ if (!VALID_TRUST_LEVELS.includes(level)) {
773
+ return { ok: false, error: `Invalid trust level "${level}". Must be one of: ${VALID_TRUST_LEVELS.join(', ')}` }
774
+ }
775
+ const id = String(name)
776
+ db.prepare(`
777
+ INSERT INTO registry_entries (id, trust_level, last_update)
778
+ VALUES (?, ?, datetime('now'))
779
+ ON CONFLICT(id) DO UPDATE SET trust_level = excluded.trust_level, last_update = excluded.last_update
780
+ `).run(id, level)
781
+ return { ok: true }
782
+ }
783
+
784
+ export async function getByTrustLevel(level) {
785
+ const db = getDb()
786
+ if (level && !VALID_TRUST_LEVELS.includes(level)) {
787
+ return { error: `Invalid trust level "${level}". Must be one of: ${VALID_TRUST_LEVELS.join(', ')}` }
788
+ }
789
+ if (level) {
790
+ return db.prepare('SELECT * FROM registry_entries WHERE trust_level = ? ORDER BY downloads DESC, popularity DESC').all(level)
791
+ }
792
+ return db.prepare('SELECT * FROM registry_entries ORDER BY downloads DESC, popularity DESC').all()
793
+ }
794
+
795
+ export async function incrementDownloads(name) {
796
+ const db = getDb()
797
+ const id = String(name)
798
+ const existing = db.prepare('SELECT downloads, rating, last_update, trust_level FROM registry_entries WHERE id = ?').get(id)
799
+ if (existing) {
800
+ const newDownloads = (existing.downloads || 0) + 1
801
+ const pop = calcPopularity(newDownloads, existing.rating || 0, existing.last_update)
802
+ db.prepare('UPDATE registry_entries SET downloads = ?, popularity = ?, last_update = datetime(\'now\') WHERE id = ?')
803
+ .run(newDownloads, pop, id)
804
+
805
+ // Auto-promote if threshold crossed
806
+ const newLevel = autoPromote(newDownloads, existing.trust_level)
807
+ if (newLevel) {
808
+ db.prepare('UPDATE registry_entries SET trust_level = ? WHERE id = ?').run(newLevel, id)
809
+ }
810
+ } else {
811
+ const pop = calcPopularity(1, 0)
812
+ db.prepare('INSERT INTO registry_entries (id, downloads, popularity, trust_level, last_update) VALUES (?, 1, ?, \'unknown\', datetime(\'now\'))')
813
+ .run(id, pop)
814
+ }
815
+ return { ok: true }
816
+ }
817
+
818
+ // ── validate & prune all installed marketplace files ──────────────────────────
819
+
820
+ export function validateAndPruneMarketplace() {
821
+ const results = { valid: [], removed: [], errors: [] };
822
+ if (!fs.existsSync(getSkillsDir())) {
823
+ return { ...results, message: 'No marketplace directory found.' };
824
+ }
825
+
826
+ const mdFiles = globSync(`${getSkillsDir()}/**/*.md`, { absolute: true, dot: true });
827
+ for (const fp of mdFiles) {
828
+ const name = path.relative(getSkillsDir(), fp);
829
+ try {
830
+ const v = validateSkill(fp);
831
+ if (!v.ok) {
832
+ try { fs.unlinkSync(fp); results.removed.push({ file: name, errors: v.errors }); } catch (e) { results.errors.push(`Failed to remove ${name}: ${e.message}`); }
833
+ } else {
834
+ results.valid.push(name);
835
+ }
836
+ } catch (e) {
837
+ results.errors.push(`Error validating ${name}: ${e.message}`);
838
+ }
839
+ }
840
+
841
+ // Clean up empty dirs left behind
842
+ removeEmptyDirs(getSkillsDir());
843
+
844
+ // Also remove DB entries for deleted files
845
+ const db = getDb();
846
+ for (const row of db.prepare('SELECT id, path FROM skills WHERE source = ?').all('marketplace')) {
847
+ if (!fs.existsSync(row.path)) {
848
+ db.prepare('DELETE FROM skills WHERE id = ?').run(row.id);
849
+ db.prepare('DELETE FROM chunks WHERE skill_id = ?').run(row.id);
850
+ }
851
+ }
852
+
853
+ return results;
854
+ }
855
+
856
+ function removeEmptyDirs(dirPath) {
857
+ let entries;
858
+ try { entries = fs.readdirSync(dirPath, { withFileTypes: true }); } catch { return; }
859
+ for (const entry of entries) {
860
+ if (!entry.isDirectory()) continue;
861
+ const fullPath = path.join(dirPath, entry.name);
862
+ removeEmptyDirs(fullPath);
863
+ try { if (fs.readdirSync(fullPath).length === 0) fs.rmdirSync(fullPath); } catch {}
864
+ }
865
+ }
866
+
867
+ export { VALID_TRUST_LEVELS, TRUST_LEVEL_BOOST, calcPopularity, autoPromote }
868
+
869
+ export async function rateSkill(name, rating) {
870
+ const db = getDb()
871
+ if (typeof rating !== 'number' || rating < 0 || rating > 5) {
872
+ return { ok: false, error: 'Rating must be a number between 0 and 5' }
873
+ }
874
+ const id = String(name)
875
+ const existing = db.prepare('SELECT rating, rating_count, downloads FROM registry_entries WHERE id = ?').get(id)
876
+ if (existing) {
877
+ const count = existing.rating_count || 0
878
+ const newRating = Math.round(((existing.rating || 0) * count + rating) / (count + 1) * 100) / 100
879
+ const newCount = count + 1
880
+ const pop = calcPopularity(existing.downloads || 0, newRating)
881
+ db.prepare('UPDATE registry_entries SET rating = ?, rating_count = ?, popularity = ?, last_update = datetime(\'now\') WHERE id = ?')
882
+ .run(newRating, newCount, pop, id)
883
+ } else {
884
+ const pop = calcPopularity(0, rating)
885
+ db.prepare('INSERT INTO registry_entries (id, rating, rating_count, popularity, last_update) VALUES (?, ?, 1, ?, datetime(\'now\'))')
886
+ .run(id, rating, pop)
887
+ }
888
+ return { ok: true }
889
+ }