promptgraph-mcp 2.7.1 → 2.8.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.
@@ -25,7 +25,7 @@ export default async function handler(args, bin) {
25
25
  error('marketplace TUI requires an interactive terminal');
26
26
  process.exit(1);
27
27
  }
28
- const { browseMarketplace, browseBundles, installSkill, installBundle } = await import('../marketplace.js');
28
+ const { browseMarketplace, browseBundles, installSkill, installBundle, installBundleBg, validateAndPruneMarketplace } = await import('../marketplace.js');
29
29
  const { loadConfig: _lcMkt } = await import('../config.js');
30
30
  const { getDb: _getDbMkt } = await import('../db.js');
31
31
  const { spinner: spin2 } = await import('../cli.js');
@@ -81,30 +81,32 @@ export default async function handler(args, bin) {
81
81
  const { loadConfig: _lcR, saveConfig: _scR, SKILLS_STORE_DIR: _ssR } = await import('../config.js');
82
82
  const { getDb: _getDbR } = await import('../db.js');
83
83
 
84
- const { validateAndPruneMarketplace } = await import('../marketplace.js');
85
-
86
84
  await runTUI(
87
85
  Array.isArray(skills) ? skills : [],
88
86
  bundlesWithCounts,
89
- async (item) => {
87
+ async (item, onStatus) => {
90
88
  if (item.type === 'bundle') {
91
- const r = await installBundle(item.id);
92
- if (r?.error) throw new Error(r.error);
93
- installedSet.add(item.id);
94
- // Update skill count on the bundle object to reflect real survivors
95
- const { getCachedCount: getCount } = await import('../bundle-counts.js');
96
- const cached = getCount(item.repo_url);
97
- if (cached !== null) item.skillCount = cached;
89
+ const r = await installBundleBg(item.id, async (err, result) => {
90
+ if (err) { onStatus(false, err.message?.slice(0, 60) || 'Install failed'); return; }
91
+ installedSet.add(item.id);
92
+ const { getCachedCount } = await import('../bundle-counts.js');
93
+ const cached = getCachedCount(item.repo_url);
94
+ if (cached !== null) item.skillCount = cached;
95
+ validateAndPruneMarketplace();
96
+ onStatus(true, `Installed ${item.name}`);
97
+ });
98
+ if (r?.error) {
99
+ if (!r.dedup) onStatus(false, r.error.slice(0, 60));
100
+ return;
101
+ }
102
+ onStatus(null, `Queued ${item.name}…`);
98
103
  } else {
99
104
  const r = await installSkill(item.code || item.id);
100
- if (r?.error) throw new Error(r.error);
105
+ if (r?.error) { onStatus(false, r.error.slice(0, 60)); return; }
101
106
  installedSet.add(item.id);
102
107
  if (item.code) installedSet.add(item.code);
103
- }
104
- // After every install, prune invalid files marketplace-wide
105
- const pruneResult = validateAndPruneMarketplace();
106
- if (pruneResult.removed.length > 0) {
107
- console.log(`Pruned ${pruneResult.removed.length} invalid files from marketplace.`);
108
+ validateAndPruneMarketplace();
109
+ onStatus(true, `Installed ${item.name}`);
108
110
  }
109
111
  },
110
112
  installedSet,
package/github-import.js CHANGED
@@ -617,3 +617,129 @@ export async function importFromGitHub(repoUrl) {
617
617
  await indexSource(skillsDir, repoSource);
618
618
  console.log(`Done! Imported from ${repoName}/${label}`);
619
619
  }
620
+
621
+ // ── light version (no git clone) ───────────────────────────────────────────────
622
+
623
+ const SKIP_DOCS_RE = /^(readme|license|changelog|contributing|code.?of.?conduct|security|authors|credits|install|faq|index|overview|summary|todo|notes|template|copying|warranty|funding|roadmap|claude|bugs?\b|feature.?request)/i;
624
+
625
+ export async function importFromGitHubLight(repoUrl) {
626
+ if (!repoUrl) throw new Error('Missing repoUrl');
627
+
628
+ const url = repoUrl.startsWith('http') ? repoUrl : `https://github.com/${repoUrl}`;
629
+ const ownerRepo = url.replace(/^https?:\/\/github\.com\//, '').replace(/\.git$/, '');
630
+ const repoName = ownerRepo.replace('/', '-');
631
+ const destBase = path.join(SKILLS_STORE_DIR, 'github', repoName);
632
+
633
+ const ok = await repoExists(ownerRepo);
634
+ if (!ok) throw new Error(`Repository not found (404): ${url}`);
635
+
636
+ const detected = await detectSkillsDirFromAPI(ownerRepo);
637
+ const subdir = detected?.subdir || null;
638
+
639
+ let branch = 'main';
640
+ try {
641
+ const repoJson = await httpsGet(`https://api.github.com/repos/${ownerRepo}`);
642
+ branch = JSON.parse(repoJson).default_branch || 'main';
643
+ } catch {}
644
+
645
+ let mdFiles;
646
+ if (subdir) {
647
+ const treeJson = await httpsGet(`https://api.github.com/repos/${ownerRepo}/git/trees/HEAD?recursive=1`);
648
+ const tree = JSON.parse(treeJson);
649
+ const prefix = subdir + '/';
650
+ mdFiles = (tree.tree || [])
651
+ .filter(f => f.type === 'blob' && f.path.startsWith(prefix) && f.path.endsWith('.md'))
652
+ .map(f => ({
653
+ relativePath: f.path.replace(prefix, ''),
654
+ fullPath: f.path,
655
+ download_url: `https://raw.githubusercontent.com/${ownerRepo}/${branch}/${f.path}`,
656
+ }));
657
+ } else {
658
+ const json = await httpsGet(`https://api.github.com/repos/${ownerRepo}/contents`);
659
+ const entries = JSON.parse(json);
660
+ mdFiles = entries
661
+ .filter(e => e.type === 'file' && e.name.endsWith('.md'))
662
+ .map(e => ({
663
+ relativePath: e.name,
664
+ fullPath: e.name,
665
+ download_url: e.download_url,
666
+ }));
667
+ }
668
+
669
+ if (mdFiles.length === 0) throw new Error(`No .md files found in ${ownerRepo}`);
670
+
671
+ const beforeFilter = mdFiles.length;
672
+ mdFiles = mdFiles.filter(f => !SKIP_DOCS_RE.test(f.relativePath.replace(/\.md$/i, '')));
673
+ if (mdFiles.length === 0) throw new Error(`All ${beforeFilter} .md files filtered as docs (README, LICENSE, etc.)`);
674
+
675
+ if (fs.existsSync(destBase)) fs.rmSync(destBase, { recursive: true, force: true });
676
+ fs.mkdirSync(destBase, { recursive: true });
677
+
678
+ let downloaded = 0;
679
+ for (const file of mdFiles) {
680
+ const destPath = path.join(destBase, file.fullPath);
681
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
682
+ try {
683
+ const content = await streamDownload(file.download_url);
684
+ fs.writeFileSync(destPath, content);
685
+ downloaded++;
686
+ } catch {
687
+ // skip failed downloads
688
+ }
689
+ }
690
+
691
+ if (downloaded === 0) {
692
+ fs.rmSync(destBase, { recursive: true, force: true });
693
+ throw new Error('Failed to download any files');
694
+ }
695
+
696
+ const allMd = globSync(`${destBase}/**/*.md`);
697
+ let removed = 0;
698
+ for (const fp of allMd) {
699
+ if (!isSkillFile(fp)) { try { fs.unlinkSync(fp); removed++; } catch {} }
700
+ }
701
+ if (removed > 0) removeEmptyDirs(destBase);
702
+
703
+ const remaining = globSync(`${destBase}/**/*.md`);
704
+ let removedV = 0;
705
+ for (const fp of remaining) {
706
+ const v = validateSkill(fp);
707
+ if (!v.ok) { try { fs.unlinkSync(fp); removedV++; } catch {} }
708
+ }
709
+ if (removedV > 0) removeEmptyDirs(destBase);
710
+
711
+ await classifierCleanup(destBase);
712
+
713
+ const realCount = globSync(`${destBase}/**/*.md`).length;
714
+ const cacheKey = url.replace(/\.git$/, '');
715
+ const cachePath = path.join(PROMPTGRAPH_DIR, 'skill-counts.json');
716
+ try {
717
+ fs.mkdirSync(path.dirname(cachePath), { recursive: true });
718
+ const cache = JSON.parse(fs.readFileSync(cachePath, 'utf8') || '{}');
719
+ cache[cacheKey] = { count: realCount, ts: Date.now() };
720
+ fs.writeFileSync(cachePath, JSON.stringify(cache, null, 2));
721
+ } catch {}
722
+
723
+ if (realCount < 1) {
724
+ fs.rmSync(destBase, { recursive: true, force: true });
725
+ throw new Error('No valid skills in repo — all files were filtered out');
726
+ }
727
+
728
+ const { dir: localDir, label: localLabel } = detectSkillsDirLocal(destBase);
729
+ const skillsDir = subdir ? path.join(destBase, subdir) : localDir;
730
+ const label = subdir || localLabel;
731
+ const mdFilesInSkills = globSync(`${skillsDir}/**/*.md`);
732
+
733
+ const config = loadConfig();
734
+ const repoSource = `github:${repoName}`;
735
+ if (!config.sources.find(s => s.dir === skillsDir)) {
736
+ const oldIdx = config.sources.findIndex(s => s.source === repoSource);
737
+ if (oldIdx !== -1) config.sources.splice(oldIdx, 1);
738
+ config.sources.push({ dir: skillsDir, source: repoSource });
739
+ saveConfig(config);
740
+ }
741
+
742
+ console.log();
743
+ await indexSource(skillsDir, repoSource);
744
+ console.log(`Done! Imported from ${repoName}/${label} (${realCount} skills, no git clone)`);
745
+ }
package/marketplace.js CHANGED
@@ -8,7 +8,7 @@ import { getDb } from './db.js';
8
8
  import { globSync } from 'glob';
9
9
  import { validateSkill, validateBundle } from './validator.js';
10
10
  import { loadConfig, saveConfig, PROMPTGRAPH_DIR, SKILLS_STORE_DIR } from './config.js';
11
- import { importFromGitHub, validateRepoSkills } from './github-import.js';
11
+ import { importFromGitHubLight, validateRepoSkills } from './github-import.js';
12
12
  import { isSkillFile } from './parser.js';
13
13
 
14
14
  const REGISTRY_URL = 'https://raw.githubusercontent.com/NeiP4n/promptgraph-registry/main/registry.json';
@@ -353,67 +353,126 @@ function ensureMarketplaceSource() {
353
353
  }
354
354
  }
355
355
 
356
- export async function installBundle(bundleId) {
356
+ // ── helpers extracted from installBundle ─────────────────────────────────────
357
+
358
+ async function _findBundle(bundleId) {
359
+ const text = await fetchText(REGISTRY_URL);
360
+ const registry = JSON.parse(text);
361
+ const q = String(bundleId).trim().toLowerCase();
362
+ const validSkills = (registry.skills || []).filter(s => validateRegistryEntry(s).ok);
363
+ const bundle = (registry.bundles || []).filter(b => validateRegistryEntry(b).ok).find(b =>
364
+ (b.code || codeFor(b.id)).toLowerCase() === q || b.id?.toLowerCase() === q || b.name?.toLowerCase() === q
365
+ );
366
+ if (!bundle) return { error: `No bundle matching "${bundleId}"` };
367
+ return { bundle, validSkills };
368
+ }
369
+
370
+ async function _execRepoInstall(bundle) {
357
371
  try {
358
- const text = await fetchText(REGISTRY_URL);
359
- const registry = JSON.parse(text);
360
- const q = String(bundleId).trim().toLowerCase();
361
- const validSkills = (registry.skills || []).filter(s => validateRegistryEntry(s).ok);
362
- const bundle = (registry.bundles || []).filter(b => validateRegistryEntry(b).ok).find(b =>
363
- (b.code || codeFor(b.id)).toLowerCase() === q || b.id?.toLowerCase() === q || b.name?.toLowerCase() === q
364
- );
365
- if (!bundle) return { error: `No bundle matching "${bundleId}"` };
366
-
367
- if (bundle.repo_url) {
368
- try {
369
- await importFromGitHub(bundle.repo_url);
370
- } catch (e) {
371
- if (e.message && e.message.includes('No valid skills')) {
372
- markDeadRepo(bundle.repo_url);
373
- }
374
- throw e;
375
- }
376
- // Update skill count cache with real on-disk count (post-filters)
377
- const real = localSkillCount(bundle.repo_url);
378
- if (real !== null) {
379
- const cache = readSkillCountCache();
380
- cache[bundle.repo_url] = { count: real, ts: Date.now() };
381
- writeSkillCountCache(cache);
382
- }
383
- return { success: true, bundle: bundle.name, type: 'repo_import', repo_url: bundle.repo_url };
384
- }
372
+ await importFromGitHubLight(bundle.repo_url);
373
+ } catch (e) {
374
+ if (e.message && e.message.includes('No valid skills')) markDeadRepo(bundle.repo_url);
375
+ throw e;
376
+ }
377
+ const real = localSkillCount(bundle.repo_url);
378
+ if (real !== null) {
379
+ const cache = readSkillCountCache();
380
+ cache[bundle.repo_url] = { count: real, ts: Date.now() };
381
+ writeSkillCountCache(cache);
382
+ }
383
+ return { success: true, bundle: bundle.name, type: 'repo_import', repo_url: bundle.repo_url };
384
+ }
385
385
 
386
- fs.mkdirSync(SKILLS_DIR, { recursive: true });
387
- ensureMarketplaceSource();
388
- const installed = [];
389
- const failed = [];
390
-
391
- const delay = (ms) => new Promise(r => setTimeout(r, ms));
392
- for (const skillId of bundle.skills || []) {
393
- const skill = validSkills.find(s => s.id === skillId);
394
- if (!skill?.raw_url) { failed.push(skillId); continue; }
395
- try {
396
- if (installed.length > 0) await delay(300); // rate limit: 300ms between requests
397
- const content = await fetchText(skill.raw_url);
398
- const dest = path.join(SKILLS_DIR, `${skillId}.md`);
399
- const resolvedDest = path.resolve(dest);
400
- if (!resolvedDest.startsWith(path.resolve(SKILLS_DIR))) {
401
- failed.push(skillId); continue;
402
- }
403
- const v = writeSkillAtomic(dest, content);
404
- if (!v.ok) { failed.push(skillId); continue; }
405
- installed.push(skillId);
406
- } catch {
407
- failed.push(skillId);
408
- }
409
- }
386
+ async function _execSkillsInstall(bundle, validSkills) {
387
+ fs.mkdirSync(SKILLS_DIR, { recursive: true });
388
+ ensureMarketplaceSource();
389
+ const installed = [];
390
+ const failed = [];
391
+ const delay = (ms) => new Promise(r => setTimeout(r, ms));
392
+ for (const skillId of bundle.skills || []) {
393
+ const skill = validSkills.find(s => s.id === skillId);
394
+ if (!skill?.raw_url) { failed.push(skillId); continue; }
395
+ try {
396
+ if (installed.length > 0) await delay(300);
397
+ const content = await fetchText(skill.raw_url);
398
+ const dest = path.join(SKILLS_DIR, `${skillId}.md`);
399
+ const resolvedDest = path.resolve(dest);
400
+ if (!resolvedDest.startsWith(path.resolve(SKILLS_DIR))) { failed.push(skillId); continue; }
401
+ const v = writeSkillAtomic(dest, content);
402
+ if (!v.ok) { failed.push(skillId); continue; }
403
+ installed.push(skillId);
404
+ } catch { failed.push(skillId); }
405
+ }
406
+ return { success: true, bundle: bundle.name, installed, failed, dir: SKILLS_DIR };
407
+ }
410
408
 
411
- return { success: true, bundle: bundle.name, installed, failed, dir: SKILLS_DIR };
409
+ export async function installBundle(bundleId) {
410
+ try {
411
+ const found = await _findBundle(bundleId);
412
+ if (found.error) return { error: found.error };
413
+ const { bundle, validSkills } = found;
414
+ return bundle.repo_url
415
+ ? await _execRepoInstall(bundle)
416
+ : await _execSkillsInstall(bundle, validSkills);
412
417
  } catch (e) {
413
418
  return { error: e.message };
414
419
  }
415
420
  }
416
421
 
422
+ // ── Background install queue ──────────────────────────────────────────────────
423
+ // Allows the TUI to queue multiple installs without blocking.
424
+
425
+ const _bgQueue = [];
426
+ let _bgRunning = false;
427
+ let _bgCurrentId = null;
428
+ const _bgPendingKeys = new Set();
429
+
430
+ async function _bgProcess() {
431
+ if (_bgRunning) return;
432
+ _bgRunning = true;
433
+ while (_bgQueue.length > 0) {
434
+ const { bundle, validSkills, onDone, _dedupKey } = _bgQueue.shift();
435
+ if (_dedupKey) _bgPendingKeys.delete(_dedupKey);
436
+ _bgCurrentId = bundle.id;
437
+ try {
438
+ const result = bundle.repo_url
439
+ ? await _execRepoInstall(bundle)
440
+ : await _execSkillsInstall(bundle, validSkills);
441
+ await onDone?.(null, result);
442
+ } catch (e) {
443
+ await onDone?.(e, null);
444
+ } finally {
445
+ _bgCurrentId = null;
446
+ }
447
+ }
448
+ _bgRunning = false;
449
+ }
450
+
451
+ // Install a bundle in the background, returning immediately.
452
+ // The actual work is serialized through an internal queue.
453
+ // onDone(err, result) is called when the queue finishes processing this bundle.
454
+ export async function installBundleBg(bundleId, onDone) {
455
+ const q = String(bundleId).trim().toLowerCase();
456
+ // Synchronous dedup check BEFORE any await — prevents race on double-Enter
457
+ if (_bgPendingKeys.has(q)) {
458
+ return { dedup: true, error: `"${bundleId}" is already queued or installing` };
459
+ }
460
+ _bgPendingKeys.add(q);
461
+
462
+ const found = await _findBundle(bundleId);
463
+ if (found.error) { _bgPendingKeys.delete(q); return found; }
464
+ const { bundle, validSkills } = found;
465
+
466
+ if (_bgCurrentId === bundle.id || _bgQueue.some(e => e.bundle.id === bundle.id)) {
467
+ _bgPendingKeys.delete(q);
468
+ return { dedup: true, error: `"${bundle.name}" is already queued or installing` };
469
+ }
470
+
471
+ _bgQueue.push({ bundle, validSkills, onDone, _dedupKey: q });
472
+ _bgProcess();
473
+ return { queued: true, id: bundle.id, name: bundle.name };
474
+ }
475
+
417
476
  function ghPublish(filePath, desc) {
418
477
  try {
419
478
  const result = spawnSync('gh', ['gist', 'create', filePath, '--desc', desc, '--public'], { encoding: 'utf8' });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "promptgraph-mcp",
3
- "version": "2.7.1",
3
+ "version": "2.8.1",
4
4
  "files": [
5
5
  "*.js",
6
6
  "commands/",
package/tui.js CHANGED
@@ -116,12 +116,12 @@ function render(state, installedSet = new Set()) {
116
116
  write(searchLabel + searchVal + CLEAR_EOL + '\n');
117
117
 
118
118
  // Row 4: separator (with optional status inline or spinner)
119
- if (state.installing) {
120
- const frame = SPINNER_FRAMES[Math.floor(Date.now() / 120) % SPINNER_FRAMES.length];
121
- write(dim('─'.repeat(4)) + magenta(` ${frame} Installing… `) + CLEAR_EOL + '\n');
122
- } else if (status) {
123
- const msg = status.ok ? green(' ' + status.msg) : red('' + status.msg);
124
- write(dim('─'.repeat(4)) + msg + CLEAR_EOL + '\n');
119
+ if (status) {
120
+ let line;
121
+ if (status.ok === true) line = green(' ' + status.msg);
122
+ else if (status.ok === false) line = red(' ✗ ' + status.msg);
123
+ else line = cyan(' ' + (status.msg || ''));
124
+ write(dim('─'.repeat(4)) + line + CLEAR_EOL + '\n');
125
125
  } else {
126
126
  write(dim('─'.repeat(cols)) + CLEAR_EOL + '\n');
127
127
  }
@@ -248,7 +248,6 @@ export async function runTUI(allSkills, allBundles, installFn, installedSet = ne
248
248
  scroll: 0,
249
249
  items: allItems,
250
250
  status: null,
251
- installing: false,
252
251
  };
253
252
 
254
253
  function refresh(q, t) {
@@ -391,21 +390,13 @@ export async function runTUI(allSkills, allBundles, installFn, installedSet = ne
391
390
 
392
391
  if (key.name === 'return' || key.name === 'i') {
393
392
  const sel = state.items[state.cursor];
394
- if (!sel || state.installing) return;
395
- state.installing = true;
396
- // Live spinner keeps rendering during long installs
397
- const spinInterval = setInterval(() => render(state, installedSet), 120);
398
- render(state, installedSet);
399
- try {
400
- await installFn(sel);
401
- clearInterval(spinInterval);
402
- setStatus(true, `Installed ${sel.id}`);
403
- } catch (e) {
404
- clearInterval(spinInterval);
405
- setStatus(false, e.message.slice(0, 60));
406
- } finally {
407
- state.installing = false;
408
- }
393
+ if (!sel) return;
394
+ // Fire install in background — TUI stays responsive for queuing more
395
+ installFn(sel, (ok, msg) => {
396
+ setStatus(ok, msg);
397
+ }).catch(e => {
398
+ setStatus(false, e.message?.slice(0, 60) || 'Install failed');
399
+ });
409
400
  return;
410
401
  }
411
402