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.
- package/commands/marketplace.js +4 -3
- package/github-import.js +113 -76
- package/package.json +1 -1
package/commands/marketplace.js
CHANGED
|
@@ -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
|
|
55
|
-
const hasScripts = globSync(`${clonedDir}
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
251
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
267
|
-
|
|
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
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
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
|
-
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
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');
|