promptgraph-mcp 2.9.55 → 2.9.57
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/api.js +2 -2
- package/commands/add-dir.js +1 -1
- package/commands/marketplace.js +157 -157
- package/config.js +8 -1
- package/github-import.js +934 -923
- package/indexer.js +2 -2
- package/marketplace.js +889 -889
- package/package.json +1 -1
- package/src/filter/hard-filter.js +13 -3
- package/src/filter/train.js +1 -1
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
|
|
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
|
+
}
|