promptgraph-mcp 2.9.50 → 2.9.52

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.
@@ -42,6 +42,7 @@ export default async function handler(args, bin) {
42
42
  const { getCachedCount, setCachedCount, refreshCountsInBackground } = await import('../bundle-counts.js');
43
43
  const { SKILLS_STORE_DIR } = await import('../config.js');
44
44
  const { globSync } = await import('glob');
45
+ const { SCRIPT_GLOBS } = await import('../github-import.js');
45
46
  const githubDir = path.join(SKILLS_STORE_DIR, 'github');
46
47
 
47
48
  // For each bundle: if installed on disk — use real file count + detect scripts; otherwise use cache
@@ -51,14 +52,14 @@ export default async function handler(args, bin) {
51
52
  const repo = b.repo_url.split('/')[1];
52
53
  const clonedDir = path.join(githubDir, `${owner}-${repo}`);
53
54
  if (fs.existsSync(clonedDir) && fs.readdirSync(clonedDir).length > 0) {
54
- const realCount = globSync(`${clonedDir}/**/*.md`).length;
55
- const hasScripts = globSync(`${clonedDir}/**/*.{py,sh,bash,js,ts,rb}`).length > 0;
55
+ const realCount = globSync(`${clonedDir}/**/*.md`, { absolute: true }).length;
56
+ const hasScripts = globSync(SCRIPT_GLOBS.map(p => `${clonedDir}/${p}`), { absolute: true }).length > 0;
56
57
  setCachedCount(b.repo_url, realCount);
57
58
  return { ...b, skillCount: realCount, has_tools: b.has_tools || hasScripts };
58
59
  }
59
60
  const cached = getCachedCount(b.repo_url);
60
61
  const knownCount = cached ?? b.skill_count ?? null;
61
- return knownCount !== null ? { ...b, skillCount: knownCount } : b;
62
+ return knownCount !== null ? { ...b, skillCount: knownCount, has_tools: b.has_tools } : b;
62
63
  });
63
64
  refreshCountsInBackground(bundlesWithCounts);
64
65
 
package/github-import.js CHANGED
@@ -14,6 +14,10 @@ const downloadRateLimiter = new RateLimiter({ maxRequests: RATE_LIMIT_REQUESTS *
14
14
 
15
15
  const SKILL_DIRS = ['skills', 'commands', 'prompts', 'agents', 'skills-store', 'slash-commands', 'custom-commands', 'templates'];
16
16
 
17
+ // glob v13 brace-expansion ({py,sh,...}) returns nothing on Windows — use an
18
+ // array of explicit patterns instead so script detection works cross-platform.
19
+ export const SCRIPT_GLOBS = ['**/*.py', '**/*.sh', '**/*.bash', '**/*.js', '**/*.ts', '**/*.rb'];
20
+
17
21
  // ── helpers ───────────────────────────────────────────────────────────────────
18
22
 
19
23
  const repoStats = new Map()
@@ -768,26 +772,25 @@ function detectSubdirFromTree(repoRoot) {
768
772
 
769
773
  // ── light version (git sparse-checkout, no API rate limits) ───────────────────
770
774
 
771
- export async function importFromGitHubLight(repoUrl) {
772
- if (!repoUrl) throw new Error('Missing repoUrl');
773
-
774
- const url = repoUrl.startsWith('http') ? repoUrl : `https://github.com/${repoUrl}`;
775
- const ownerRepo = url.replace(/^https?:\/\/github\.com\//, '').replace(/\.git$/, '');
776
- const repoName = ownerRepo.replace('/', '-');
777
- const destBase = path.join(getSkillsStoreDir(), 'github', repoName);
775
+ // Core clone + filter pipeline. Single source of truth for both real installs
776
+ // and offline validation. Materializes the skills subdir into destBase, applies
777
+ // isSkillFile + validateSkill filters, and returns the ground-truth result.
778
+ // NO side effects (no config write, no indexing) caller decides what to do.
779
+ // Throws 'No valid skills...' if nothing survives filtering.
780
+ export function cloneAndFilterRepo(ownerRepo, destBase, prog = () => {}) {
778
781
  const cloneUrl = `https://github.com/${ownerRepo}.git`;
779
-
780
782
  if (fs.existsSync(destBase)) fs.rmSync(destBase, { recursive: true, force: true });
781
783
  fs.mkdirSync(destBase, { recursive: true });
782
784
 
783
785
  const gitEnv = { ...process.env, GIT_TERMINAL_PROMPT: '0' };
784
- const prog = (msg) => process.stderr.write(`\r\x1b[K ${msg}`);
786
+ // core.longpaths=true required on Windows for repos with deep nested paths
787
+ // (>260 chars) that otherwise fail checkout with "UNKNOWN: open" / "Checkout failed".
788
+ const LP = ['-c', 'core.longpaths=true'];
785
789
 
786
790
  // Step 1: treeless clone — gets file tree instantly, no blob download, no API
787
791
  prog(`Cloning ${ownerRepo}...`);
788
- const init = spawnSync('git', ['clone', '--depth=1', '--filter=blob:none', '--no-checkout', '--progress', cloneUrl, destBase], { stdio: 'pipe', env: gitEnv, timeout: 120000 });
792
+ const init = spawnSync('git', [...LP, 'clone', '--depth=1', '--filter=blob:none', '--no-checkout', '--progress', cloneUrl, destBase], { stdio: 'pipe', env: gitEnv, timeout: 120000 });
789
793
  if (init.status !== 0) {
790
- process.stderr.write('\n');
791
794
  fs.rmSync(destBase, { recursive: true, force: true });
792
795
  throw new Error(`Failed to clone ${ownerRepo}: ${(init.stderr?.toString() || '').trim().slice(0, 120)}`);
793
796
  }
@@ -798,15 +801,15 @@ export async function importFromGitHubLight(repoUrl) {
798
801
 
799
802
  // Step 3: sparse-checkout skills subdir — .md files + scripts (.py/.sh/.js/.ts/.rb/.bash)
800
803
  prog(`Setting up sparse checkout${subdir ? ` (${subdir}/)` : ''}...`);
801
- spawnSync('git', ['-C', destBase, 'sparse-checkout', 'init'], { stdio: 'pipe', env: gitEnv });
804
+ spawnSync('git', [...LP, '-C', destBase, 'sparse-checkout', 'init'], { stdio: 'pipe', env: gitEnv });
802
805
  if (subdir) {
803
- spawnSync('git', ['-C', destBase, 'sparse-checkout', 'set', '--no-cone',
806
+ spawnSync('git', [...LP, '-C', destBase, 'sparse-checkout', 'set', '--no-cone',
804
807
  `${subdir}/*.md`, `${subdir}/**/*.md`,
805
808
  `${subdir}/**/*.py`, `${subdir}/**/*.sh`, `${subdir}/**/*.js`,
806
809
  `${subdir}/**/*.ts`, `${subdir}/**/*.rb`, `${subdir}/**/*.bash`,
807
810
  ], { stdio: 'pipe', env: gitEnv });
808
811
  } else {
809
- spawnSync('git', ['-C', destBase, 'sparse-checkout', 'set', '--no-cone',
812
+ spawnSync('git', [...LP, '-C', destBase, 'sparse-checkout', 'set', '--no-cone',
810
813
  '*.md', '**/*.md',
811
814
  '**/*.py', '**/*.sh', '**/*.js', '**/*.ts', '**/*.rb', '**/*.bash',
812
815
  ], { stdio: 'pipe', env: gitEnv });
@@ -814,26 +817,29 @@ export async function importFromGitHubLight(repoUrl) {
814
817
 
815
818
  // Step 4: checkout to materialize only the selected files
816
819
  prog(`Downloading .md files...`);
817
- const co = spawnSync('git', ['-C', destBase, 'checkout'], { stdio: 'pipe', env: gitEnv, timeout: 120000 });
820
+ const co = spawnSync('git', [...LP, '-C', destBase, 'checkout'], { stdio: 'pipe', env: gitEnv, timeout: 120000 });
818
821
  if (co.status !== 0) {
819
- process.stderr.write('\n');
820
822
  fs.rmSync(destBase, { recursive: true, force: true });
821
823
  throw new Error(`Checkout failed for ${ownerRepo}`);
822
824
  }
823
825
  // Force blob materialization (needed for partial clones on Windows)
824
- spawnSync('git', ['-C', destBase, 'checkout', 'HEAD', '--', '.'], { stdio: 'pipe', env: gitEnv, timeout: 120000 });
825
- process.stderr.write('\n');
826
+ spawnSync('git', [...LP, '-C', destBase, 'checkout', 'HEAD', '--', '.'], { stdio: 'pipe', env: gitEnv, timeout: 120000 });
826
827
 
827
828
  // Make scripts executable on unix
828
829
  if (process.platform !== 'win32') {
829
830
  try {
830
- const scripts = globSync(`${destBase}/**/*.{py,sh,bash,js,ts,rb}`, { absolute: true });
831
+ const scripts = globSync(SCRIPT_GLOBS.map(p => `${destBase}/${p}`), { absolute: true });
831
832
  for (const s of scripts) { try { fs.chmodSync(s, 0o755); } catch {} }
832
833
  } catch {}
833
834
  }
834
835
 
836
+ // hasScripts = real scripts on disk in the materialized subdir (ground truth)
837
+ const hasScripts = globSync(SCRIPT_GLOBS.map(p => `${destBase}/${p}`), { absolute: true }).length > 0;
838
+
835
839
  // Step 5: filter out non-skill files locally
836
- const allMd = globSync(`${destBase}/**/*.md`);
840
+ // absolute:true otherwise glob returns cwd-relative paths; when destBase is not
841
+ // under cwd those contain ".." and validateSkill flags every file as path-traversal.
842
+ const allMd = globSync(`${destBase}/**/*.md`, { absolute: true });
837
843
  prog(`Filtering ${allMd.length} files...`);
838
844
  let removed = 0;
839
845
  for (const fp of allMd) {
@@ -841,7 +847,7 @@ export async function importFromGitHubLight(repoUrl) {
841
847
  }
842
848
  if (removed > 0) removeEmptyDirs(destBase);
843
849
 
844
- const remaining = globSync(`${destBase}/**/*.md`);
850
+ const remaining = globSync(`${destBase}/**/*.md`, { absolute: true });
845
851
  prog(`Validating ${remaining.length} skill files...`);
846
852
  let removedV = 0;
847
853
  for (const fp of remaining) {
@@ -850,15 +856,29 @@ export async function importFromGitHubLight(repoUrl) {
850
856
  }
851
857
  if (removedV > 0) removeEmptyDirs(destBase);
852
858
 
853
- process.stderr.write('\n');
854
-
855
- const realCount = globSync(`${destBase}/**/*.md`).length;
859
+ const realCount = globSync(`${destBase}/**/*.md`, { absolute: true }).length;
856
860
 
857
861
  if (realCount < 1) {
858
862
  fs.rmSync(destBase, { recursive: true, force: true });
859
863
  throw new Error('No valid skills in repo — all files were filtered out');
860
864
  }
861
865
 
866
+ return { realCount, hasScripts, subdir };
867
+ }
868
+
869
+ export async function importFromGitHubLight(repoUrl) {
870
+ if (!repoUrl) throw new Error('Missing repoUrl');
871
+
872
+ const url = repoUrl.startsWith('http') ? repoUrl : `https://github.com/${repoUrl}`;
873
+ const ownerRepo = url.replace(/^https?:\/\/github\.com\//, '').replace(/\.git$/, '');
874
+ const repoName = ownerRepo.replace('/', '-');
875
+ const destBase = path.join(getSkillsStoreDir(), 'github', repoName);
876
+
877
+ const prog = (msg) => process.stderr.write(`\r\x1b[K ${msg}`);
878
+
879
+ const { realCount, subdir } = cloneAndFilterRepo(ownerRepo, destBase, prog);
880
+ process.stderr.write('\n');
881
+
862
882
  // Update both caches with the real post-filter count
863
883
  try {
864
884
  const { setCachedCount } = await import('./bundle-counts.js');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "promptgraph-mcp",
3
- "version": "2.9.50",
3
+ "version": "2.9.52",
4
4
  "files": [
5
5
  "*.js",
6
6
  "commands/",
package/tui.js CHANGED
@@ -194,7 +194,8 @@ function render(state, installedSet = new Set()) {
194
194
  : dim(' Enter') + chalk.white(' install') + dim(' ');
195
195
  write(instLabel + dim('Tab') + ' all/skills/bundles/recent ' + dim('/') + ' search ' + dim('q') + ' quit' + CLEAR_EOL + '\n');
196
196
  const ghUrl = sel.repo_url ? chalk.hex('#3B82F6')(` ↗ github.com/${sel.repo_url}`) : '';
197
- write(dim(` pg ${installCmd}`) + ghUrl + CLEAR_EOL + '\n');
197
+ const toolsNote = sel.has_tools ? chalk.hex('#F59E0B')(' 🔧 includes scripts (py/sh/js)') : '';
198
+ write(dim(` → pg ${installCmd}`) + ghUrl + toolsNote + CLEAR_EOL + '\n');
198
199
  } else if (state.confirming) {
199
200
  write(chalk.red.bold(' Remove ') + chalk.white(state.confirming.name) + chalk.red('? ') +
200
201
  chalk.white.bold('[y]') + chalk.gray('es ') + chalk.white.bold('[n]') + chalk.gray('o') + CLEAR_EOL + '\n');