promptgraph-mcp 2.9.51 → 2.9.53

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()
@@ -201,70 +205,87 @@ export async function validateRepoSkills(ownerRepo) {
201
205
 
202
206
  const SCRIPT_EXTS_API = new Set(['.py', '.sh', '.bash', '.js', '.ts', '.rb']);
203
207
 
204
- function countValidMd(fileEntries) {
205
- return fileEntries.filter(e =>
206
- e.type === 'file' && e.name.endsWith('.md') &&
207
- !SKIP_RE.test(e.name.replace(/\.md$/i, '').toLowerCase())
208
- ).length;
209
- }
210
-
211
- function hasScriptFiles(fileEntries) {
212
- return fileEntries.some(e => e.type === 'file' && SCRIPT_EXTS_API.has(path.extname(e.name).toLowerCase()));
213
- }
214
-
215
208
  // Ask GitHub API which subdir to use (without cloning anything). Exported for validation.
216
209
  // Returns { subdir, label, validMdCount, hasScripts } or null (repo not found / no skills).
210
+ //
211
+ // Uses the recursive git-tree API (1 call) and counts .md files RECURSIVELY under each
212
+ // candidate dir. This handles nested layouts like skills/cloud/*.md or skills/<name>/SKILL.md
213
+ // where a skill dir holds category subfolders rather than .md files directly — the old
214
+ // shallow per-dir listing reported "0 skills" for those and blocked publishing.
217
215
  export
218
216
  async function detectSkillsDirFromAPI(ownerRepo) {
217
+ let tree;
219
218
  try {
220
- const json = await httpsGet(`https://api.github.com/repos/${ownerRepo}/contents`);
221
- const entries = JSON.parse(json);
222
-
223
- // 1. Known skill dir names (priority order)
224
- const dirMap = new Map(entries.filter(e => e.type === 'dir').map(e => [e.name.toLowerCase(), e.name]));
225
- for (const d of SKILL_DIRS) {
226
- if (dirMap.has(d)) {
227
- try {
228
- const sub = JSON.parse(await httpsGet(`https://api.github.com/repos/${ownerRepo}/contents/${dirMap.get(d)}`));
229
- const validMdCount = countValidMd(sub);
230
- if (validMdCount === 0) continue; // dir exists but no valid skills — try next
231
- return { subdir: dirMap.get(d), label: d, validMdCount, hasScripts: hasScriptFiles(sub) };
232
- } catch {}
233
- }
219
+ tree = JSON.parse(await httpsGet(`https://api.github.com/repos/${ownerRepo}/git/trees/HEAD?recursive=1`));
220
+ } catch {
221
+ return null; // repo not found or inaccessible
222
+ }
223
+ if (!tree || !Array.isArray(tree.tree)) return null;
224
+
225
+ const blobs = tree.tree.filter(f => f.type === 'blob');
226
+ if (blobs.length === 0) return null;
227
+
228
+ // A path is a valid skill .md if the filename isn't meta (readme/license/…) and no
229
+ // path segment is a skip dir (docs/tests/assets/…).
230
+ const isValidMd = (p) => {
231
+ if (!p.endsWith('.md')) return false;
232
+ if (SKIP_RE.test(path.basename(p, '.md').toLowerCase())) return false;
233
+ const segs = p.split('/');
234
+ for (const seg of segs.slice(0, -1)) if (SKIP_DIRS_API.has(seg.toLowerCase())) return false;
235
+ return true;
236
+ };
237
+ const mdBlobs = blobs.filter(f => isValidMd(f.path));
238
+ if (mdBlobs.length === 0) return null;
239
+
240
+ const countUnder = (prefix) => mdBlobs.filter(f => f.path.startsWith(prefix)).length;
241
+ const scriptUnder = (prefix) => blobs.some(f => f.path.startsWith(prefix) && SCRIPT_EXTS_API.has(path.extname(f.path).toLowerCase()));
242
+
243
+ // Map of top-level dir names (lowercase -> real casing)
244
+ const topDirs = new Map();
245
+ for (const f of blobs) {
246
+ const idx = f.path.indexOf('/');
247
+ if (idx > 0) { const d = f.path.slice(0, idx); topDirs.set(d.toLowerCase(), d); }
248
+ }
249
+
250
+ // 1. Known skill dir names (priority order) — counted recursively
251
+ for (const d of SKILL_DIRS) {
252
+ if (topDirs.has(d)) {
253
+ const real = topDirs.get(d);
254
+ const c = countUnder(`${real}/`);
255
+ if (c > 0) return { subdir: real, label: real, validMdCount: c, hasScripts: scriptUnder(`${real}/`) };
234
256
  }
257
+ }
235
258
 
236
- // 1.5 Nested skills dirs (e.g. .claude/skills, .claude-plugin/commands)
237
- for (const prefix of ['.claude', '.claude-plugin']) {
238
- if (dirMap.has(prefix)) {
239
- for (const d of SKILL_DIRS) {
240
- const nested = `${prefix}/${d}`;
241
- try {
242
- const sub = JSON.parse(await httpsGet(`https://api.github.com/repos/${ownerRepo}/contents/${nested}`));
243
- const validMdCount = countValidMd(sub);
244
- if (validMdCount > 0) return { subdir: nested, label: nested, validMdCount, hasScripts: hasScriptFiles(sub) };
245
- } catch {}
246
- }
259
+ // 1.5 Nested skill dirs (e.g. .claude/skills, .claude-plugin/commands)
260
+ for (const prefix of ['.claude', '.claude-plugin']) {
261
+ if (topDirs.has(prefix)) {
262
+ const real = topDirs.get(prefix);
263
+ for (const d of SKILL_DIRS) {
264
+ const nested = `${real}/${d}`;
265
+ const c = countUnder(`${nested}/`);
266
+ if (c > 0) return { subdir: nested, label: nested, validMdCount: c, hasScripts: scriptUnder(`${nested}/`) };
247
267
  }
248
268
  }
269
+ }
249
270
 
250
- // 2. Any subdir with valid .md files pick the one with most
251
- const subdirCandidates = entries.filter(e => e.type === 'dir' && !SKIP_DIRS_API.has(e.name.toLowerCase()));
252
- let best = null, bestCount = 0, bestScripts = false;
253
- for (const dir of subdirCandidates) {
254
- try {
255
- const sub = JSON.parse(await httpsGet(`https://api.github.com/repos/${ownerRepo}/contents/${dir.name}`));
256
- const mdCount = countValidMd(sub);
257
- if (mdCount > bestCount) { best = dir.name; bestCount = mdCount; bestScripts = hasScriptFiles(sub); }
258
- } catch {}
259
- }
260
- if (best) return { subdir: best, label: best, validMdCount: bestCount, hasScripts: bestScripts };
271
+ // 2. Root-level .md files (skills kept directly at repo root)
272
+ const rootMd = mdBlobs.filter(f => !f.path.includes('/')).length;
261
273
 
262
- // 3. Root-level valid .md files
263
- const validMdCount = countValidMd(entries);
264
- if (validMdCount >= 1) return { subdir: null, label: 'root', validMdCount, hasScripts: hasScriptFiles(entries) };
274
+ // 3. Best non-skip top-level subdir by recursive .md count
275
+ let best = null, bestCount = 0;
276
+ for (const [low, real] of topDirs) {
277
+ if (SKIP_DIRS_API.has(low)) continue;
278
+ const c = countUnder(`${real}/`);
279
+ if (c > bestCount) { bestCount = c; best = real; }
280
+ }
265
281
 
266
- } catch {}
267
- return null; // repo not found or inaccessible
282
+ // Prefer root when it holds the skills (and is at least as rich as any subdir)
283
+ if (rootMd >= 1 && rootMd >= bestCount) {
284
+ return { subdir: null, label: 'root', validMdCount: rootMd, hasScripts: scriptUnder('') };
285
+ }
286
+ if (best) return { subdir: best, label: best, validMdCount: bestCount, hasScripts: scriptUnder(`${best}/`) };
287
+
288
+ return null;
268
289
  }
269
290
 
270
291
  // Deep-validate a repo bundle via 1 API call (tree) + raw file fetches (not rate-limited).
@@ -768,26 +789,25 @@ function detectSubdirFromTree(repoRoot) {
768
789
 
769
790
  // ── light version (git sparse-checkout, no API rate limits) ───────────────────
770
791
 
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);
792
+ // Core clone + filter pipeline. Single source of truth for both real installs
793
+ // and offline validation. Materializes the skills subdir into destBase, applies
794
+ // isSkillFile + validateSkill filters, and returns the ground-truth result.
795
+ // NO side effects (no config write, no indexing) caller decides what to do.
796
+ // Throws 'No valid skills...' if nothing survives filtering.
797
+ export function cloneAndFilterRepo(ownerRepo, destBase, prog = () => {}) {
778
798
  const cloneUrl = `https://github.com/${ownerRepo}.git`;
779
-
780
799
  if (fs.existsSync(destBase)) fs.rmSync(destBase, { recursive: true, force: true });
781
800
  fs.mkdirSync(destBase, { recursive: true });
782
801
 
783
802
  const gitEnv = { ...process.env, GIT_TERMINAL_PROMPT: '0' };
784
- const prog = (msg) => process.stderr.write(`\r\x1b[K ${msg}`);
803
+ // core.longpaths=true required on Windows for repos with deep nested paths
804
+ // (>260 chars) that otherwise fail checkout with "UNKNOWN: open" / "Checkout failed".
805
+ const LP = ['-c', 'core.longpaths=true'];
785
806
 
786
807
  // Step 1: treeless clone — gets file tree instantly, no blob download, no API
787
808
  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 });
809
+ const init = spawnSync('git', [...LP, 'clone', '--depth=1', '--filter=blob:none', '--no-checkout', '--progress', cloneUrl, destBase], { stdio: 'pipe', env: gitEnv, timeout: 120000 });
789
810
  if (init.status !== 0) {
790
- process.stderr.write('\n');
791
811
  fs.rmSync(destBase, { recursive: true, force: true });
792
812
  throw new Error(`Failed to clone ${ownerRepo}: ${(init.stderr?.toString() || '').trim().slice(0, 120)}`);
793
813
  }
@@ -798,15 +818,15 @@ export async function importFromGitHubLight(repoUrl) {
798
818
 
799
819
  // Step 3: sparse-checkout skills subdir — .md files + scripts (.py/.sh/.js/.ts/.rb/.bash)
800
820
  prog(`Setting up sparse checkout${subdir ? ` (${subdir}/)` : ''}...`);
801
- spawnSync('git', ['-C', destBase, 'sparse-checkout', 'init'], { stdio: 'pipe', env: gitEnv });
821
+ spawnSync('git', [...LP, '-C', destBase, 'sparse-checkout', 'init'], { stdio: 'pipe', env: gitEnv });
802
822
  if (subdir) {
803
- spawnSync('git', ['-C', destBase, 'sparse-checkout', 'set', '--no-cone',
823
+ spawnSync('git', [...LP, '-C', destBase, 'sparse-checkout', 'set', '--no-cone',
804
824
  `${subdir}/*.md`, `${subdir}/**/*.md`,
805
825
  `${subdir}/**/*.py`, `${subdir}/**/*.sh`, `${subdir}/**/*.js`,
806
826
  `${subdir}/**/*.ts`, `${subdir}/**/*.rb`, `${subdir}/**/*.bash`,
807
827
  ], { stdio: 'pipe', env: gitEnv });
808
828
  } else {
809
- spawnSync('git', ['-C', destBase, 'sparse-checkout', 'set', '--no-cone',
829
+ spawnSync('git', [...LP, '-C', destBase, 'sparse-checkout', 'set', '--no-cone',
810
830
  '*.md', '**/*.md',
811
831
  '**/*.py', '**/*.sh', '**/*.js', '**/*.ts', '**/*.rb', '**/*.bash',
812
832
  ], { stdio: 'pipe', env: gitEnv });
@@ -814,26 +834,29 @@ export async function importFromGitHubLight(repoUrl) {
814
834
 
815
835
  // Step 4: checkout to materialize only the selected files
816
836
  prog(`Downloading .md files...`);
817
- const co = spawnSync('git', ['-C', destBase, 'checkout'], { stdio: 'pipe', env: gitEnv, timeout: 120000 });
837
+ const co = spawnSync('git', [...LP, '-C', destBase, 'checkout'], { stdio: 'pipe', env: gitEnv, timeout: 120000 });
818
838
  if (co.status !== 0) {
819
- process.stderr.write('\n');
820
839
  fs.rmSync(destBase, { recursive: true, force: true });
821
840
  throw new Error(`Checkout failed for ${ownerRepo}`);
822
841
  }
823
842
  // 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');
843
+ spawnSync('git', [...LP, '-C', destBase, 'checkout', 'HEAD', '--', '.'], { stdio: 'pipe', env: gitEnv, timeout: 120000 });
826
844
 
827
845
  // Make scripts executable on unix
828
846
  if (process.platform !== 'win32') {
829
847
  try {
830
- const scripts = globSync(`${destBase}/**/*.{py,sh,bash,js,ts,rb}`, { absolute: true });
848
+ const scripts = globSync(SCRIPT_GLOBS.map(p => `${destBase}/${p}`), { absolute: true });
831
849
  for (const s of scripts) { try { fs.chmodSync(s, 0o755); } catch {} }
832
850
  } catch {}
833
851
  }
834
852
 
853
+ // hasScripts = real scripts on disk in the materialized subdir (ground truth)
854
+ const hasScripts = globSync(SCRIPT_GLOBS.map(p => `${destBase}/${p}`), { absolute: true }).length > 0;
855
+
835
856
  // Step 5: filter out non-skill files locally
836
- const allMd = globSync(`${destBase}/**/*.md`);
857
+ // absolute:true otherwise glob returns cwd-relative paths; when destBase is not
858
+ // under cwd those contain ".." and validateSkill flags every file as path-traversal.
859
+ const allMd = globSync(`${destBase}/**/*.md`, { absolute: true });
837
860
  prog(`Filtering ${allMd.length} files...`);
838
861
  let removed = 0;
839
862
  for (const fp of allMd) {
@@ -841,7 +864,7 @@ export async function importFromGitHubLight(repoUrl) {
841
864
  }
842
865
  if (removed > 0) removeEmptyDirs(destBase);
843
866
 
844
- const remaining = globSync(`${destBase}/**/*.md`);
867
+ const remaining = globSync(`${destBase}/**/*.md`, { absolute: true });
845
868
  prog(`Validating ${remaining.length} skill files...`);
846
869
  let removedV = 0;
847
870
  for (const fp of remaining) {
@@ -850,15 +873,29 @@ export async function importFromGitHubLight(repoUrl) {
850
873
  }
851
874
  if (removedV > 0) removeEmptyDirs(destBase);
852
875
 
853
- process.stderr.write('\n');
854
-
855
- const realCount = globSync(`${destBase}/**/*.md`).length;
876
+ const realCount = globSync(`${destBase}/**/*.md`, { absolute: true }).length;
856
877
 
857
878
  if (realCount < 1) {
858
879
  fs.rmSync(destBase, { recursive: true, force: true });
859
880
  throw new Error('No valid skills in repo — all files were filtered out');
860
881
  }
861
882
 
883
+ return { realCount, hasScripts, subdir };
884
+ }
885
+
886
+ export async function importFromGitHubLight(repoUrl) {
887
+ if (!repoUrl) throw new Error('Missing repoUrl');
888
+
889
+ const url = repoUrl.startsWith('http') ? repoUrl : `https://github.com/${repoUrl}`;
890
+ const ownerRepo = url.replace(/^https?:\/\/github\.com\//, '').replace(/\.git$/, '');
891
+ const repoName = ownerRepo.replace('/', '-');
892
+ const destBase = path.join(getSkillsStoreDir(), 'github', repoName);
893
+
894
+ const prog = (msg) => process.stderr.write(`\r\x1b[K ${msg}`);
895
+
896
+ const { realCount, subdir } = cloneAndFilterRepo(ownerRepo, destBase, prog);
897
+ process.stderr.write('\n');
898
+
862
899
  // Update both caches with the real post-filter count
863
900
  try {
864
901
  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.51",
3
+ "version": "2.9.53",
4
4
  "files": [
5
5
  "*.js",
6
6
  "commands/",