promptgraph-mcp 1.7.0 → 1.8.0

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.
Files changed (4) hide show
  1. package/index.js +39 -1
  2. package/marketplace.js +370 -364
  3. package/package.json +54 -53
  4. package/validator.js +159 -151
package/index.js CHANGED
@@ -12,7 +12,7 @@ const args = process.argv.slice(2);
12
12
  const rawBin = process.argv[1]?.split(/[\\/]/).pop()?.replace(/\.js$/, '');
13
13
  const bin = (rawBin && rawBin !== 'index') ? rawBin : 'pg';
14
14
 
15
- const KNOWN_COMMANDS = new Set(['init', 'reindex', 'import', 'setup', 'validate', 'marketplace', 'doctor', 'search', 'help', '--help', '-h']);
15
+ const KNOWN_COMMANDS = new Set(['init', 'reindex', 'update', 'import', 'setup', 'validate', 'marketplace', 'doctor', 'search', 'help', '--help', '-h']);
16
16
 
17
17
  function showHelp() {
18
18
  console.log(
@@ -31,6 +31,7 @@ function showHelp() {
31
31
  ['marketplace [page]', 'Browse the community skill registry'],
32
32
  ['validate <file.md>', 'Validate a skill before publishing'],
33
33
  ['doctor', 'Clean orphaned chunks/edges/ratings'],
34
+ ['update', 'Update to the latest version from npm'],
34
35
  ['setup <platform>', 'Register MCP in platform config'],
35
36
  ['help', 'Show this help'],
36
37
  ];
@@ -309,6 +310,43 @@ if (args[0] === 'init') {
309
310
  process.exit(0);
310
311
  }
311
312
 
313
+ if (args[0] === 'update') {
314
+ const { spawnSync } = await import('child_process');
315
+ const { createRequire } = await import('module');
316
+ const req = createRequire(import.meta.url);
317
+ const currentVersion = req('./package.json').version;
318
+
319
+ // Check latest version on npm
320
+ const spin = (await import('./cli.js')).spinner('Checking latest version...');
321
+ spin.start();
322
+ let latest = null;
323
+ try {
324
+ const r = spawnSync('npm', ['view', 'promptgraph-mcp', 'version'], { encoding: 'utf8' });
325
+ latest = r.stdout?.trim();
326
+ } catch {}
327
+ spin.stop();
328
+
329
+ if (!latest) { error('Could not reach npm registry. Check your network.'); process.exit(1); }
330
+ if (latest === currentVersion) {
331
+ success(`Already on latest version ${chalk.white.bold('v' + currentVersion)}`);
332
+ process.exit(0);
333
+ }
334
+
335
+ info(`Current: ${chalk.gray('v' + currentVersion)} → Latest: ${chalk.white.bold('v' + latest)}`);
336
+ const updateSpin = (await import('./cli.js')).spinner(`Installing promptgraph-mcp@${latest}...`);
337
+ updateSpin.start();
338
+ const result = spawnSync('npm', ['install', '-g', `promptgraph-mcp@${latest}`], { encoding: 'utf8', stdio: 'pipe' });
339
+ updateSpin.stop();
340
+
341
+ if (result.status !== 0) {
342
+ error('Update failed:');
343
+ console.log(chalk.gray(result.stderr || result.stdout));
344
+ process.exit(1);
345
+ }
346
+ success(`Updated to ${chalk.white.bold('v' + latest)}`);
347
+ process.exit(0);
348
+ }
349
+
312
350
  if (args[0] === 'reindex') {
313
351
  const { indexAll } = await import('./indexer.js');
314
352
  await indexAll();
package/marketplace.js CHANGED
@@ -1,364 +1,370 @@
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 { validateSkill, validateBundle } from './validator.js';
9
- import { loadConfig, saveConfig, PROMPTGRAPH_DIR, SKILLS_STORE_DIR } from './config.js';
10
-
11
- const REGISTRY_URL = 'https://raw.githubusercontent.com/NeiP4n/promptgraph-registry/main/registry.json';
12
- const SKILLS_DIR = path.join(SKILLS_STORE_DIR, 'marketplace');
13
-
14
- // Atomically write content to dest via tmp — cleans up on failure
15
- function writeSkillAtomic(dest, content) {
16
- const tmpPath = dest + '.tmp';
17
- try {
18
- fs.writeFileSync(tmpPath, content);
19
- const v = validateSkill(tmpPath);
20
- if (!v.ok) { fs.unlinkSync(tmpPath); return v; }
21
- fs.renameSync(tmpPath, dest);
22
- return { ok: true };
23
- } catch (e) {
24
- try { fs.unlinkSync(tmpPath); } catch {}
25
- return { ok: false, errors: [e.message] };
26
- }
27
- }
28
-
29
- // Convert GitHub blob URL → raw URL
30
- // https://github.com/owner/repo/blob/branch/path/file.md
31
- // https://raw.githubusercontent.com/owner/repo/branch/path/file.md
32
- function githubToRaw(url) {
33
- const m = url.match(/^https?:\/\/github\.com\/([^/]+\/[^/]+)\/blob\/(.+)$/);
34
- if (m) return `https://raw.githubusercontent.com/${m[1]}/${m[2]}`;
35
- return null; // already raw or not a github URL
36
- }
37
-
38
- const _require = createRequire(import.meta.url);
39
- const PKG_VERSION = (() => { try { return _require('./package.json').version; } catch { return '1.x'; } })();
40
- const UA = `promptgraph-mcp/${PKG_VERSION}`;
41
-
42
- // Deterministic short code from an id. Same id always yields the same code,
43
- // so codes auto-generate no need to assign them by hand.
44
- export function codeFor(id) {
45
- return 'pg-' + createHash('md5').update(String(id)).digest('hex').slice(0, 6);
46
- }
47
-
48
- // node:https GET — reliable and fast on Windows (undici fetch can hang ~10s there).
49
- function httpGet(url) {
50
- return new Promise((resolve, reject) => {
51
- const req = https.get(url, { headers: { 'User-Agent': UA }, timeout: 8000, family: 4 }, (res) => {
52
- if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
53
- return httpGet(res.headers.location).then(resolve, reject);
54
- }
55
- if (res.statusCode !== 200) {
56
- res.resume();
57
- return reject(new Error(`HTTP ${res.statusCode}`));
58
- }
59
- let data = '';
60
- res.setEncoding('utf8');
61
- res.on('data', c => data += c);
62
- res.on('end', () => resolve(data));
63
- });
64
- req.on('timeout', () => { req.destroy(new Error('request timed out')); });
65
- req.on('error', reject);
66
- });
67
- }
68
-
69
- // Primary path is httpGet (fast/reliable on Windows); undici fetch only as fallback.
70
- async function rawFetch(url) {
71
- try {
72
- return await httpGet(url);
73
- } catch {
74
- const res = await fetch(url, { headers: { 'User-Agent': UA } });
75
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
76
- return await res.text();
77
- }
78
- }
79
-
80
- // Disk cache for the registry (network to GitHub raw can be slow on some networks).
81
- const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
82
-
83
- async function fetchText(url) {
84
- const cacheFile = path.join(PROMPTGRAPH_DIR, 'registry-cache.json');
85
- const isRegistry = url === REGISTRY_URL;
86
-
87
- if (isRegistry && fs.existsSync(cacheFile)) {
88
- try {
89
- const stat = fs.statSync(cacheFile);
90
- if (Date.now() - stat.mtimeMs < CACHE_TTL) {
91
- return fs.readFileSync(cacheFile, 'utf8');
92
- }
93
- } catch {}
94
- }
95
-
96
- const text = await rawFetch(url);
97
-
98
- if (isRegistry) {
99
- try {
100
- fs.mkdirSync(PROMPTGRAPH_DIR, { recursive: true });
101
- fs.writeFileSync(cacheFile, text);
102
- } catch {}
103
- }
104
- return text;
105
- }
106
-
107
- export async function browseMarketplace(topK = 20) {
108
- try {
109
- const text = await fetchText(REGISTRY_URL);
110
- const registry = JSON.parse(text);
111
- if (!Array.isArray(registry.skills)) return { error: 'Invalid registry format' };
112
- return registry.skills
113
- .map(s => ({ ...s, code: s.code || codeFor(s.id) })) // auto-fill code
114
- .sort((a, b) => (b.stars || 0) - (a.stars || 0))
115
- .slice(0, topK);
116
- } catch (e) {
117
- return { error: `Registry unavailable: ${e.message}` };
118
- }
119
- }
120
-
121
- export async function installSkillFromUrl(url) {
122
- try {
123
- const rawUrl = githubToRaw(url) || url;
124
- const content = await fetchText(rawUrl);
125
- fs.mkdirSync(SKILLS_DIR, { recursive: true });
126
- ensureMarketplaceSource();
127
-
128
- // derive filename from URL
129
- const urlName = rawUrl.split('/').pop().replace(/[^a-z0-9-_.]/gi, '-');
130
- const dest = path.join(SKILLS_DIR, urlName.endsWith('.md') ? urlName : urlName + '.md');
131
- const v = writeSkillAtomic(dest, content);
132
- if (!v.ok) return { error: 'Downloaded skill failed validation', issues: v.errors };
133
- return { success: true, path: dest, url: rawUrl };
134
- } catch (e) {
135
- return { error: e.message };
136
- }
137
- }
138
-
139
- export async function installSkill(query) {
140
- try {
141
- // GitHub URL or raw URL → direct install
142
- if (query.startsWith('http://') || query.startsWith('https://')) {
143
- return installSkillFromUrl(query);
144
- }
145
-
146
- const text = await fetchText(REGISTRY_URL);
147
- const registry = JSON.parse(text);
148
- const q = String(query).trim().toLowerCase();
149
- // match by code (stored OR auto-generated), id, or name
150
- const skill = registry.skills?.find(s =>
151
- (s.code || codeFor(s.id)).toLowerCase() === q ||
152
- s.id?.toLowerCase() === q ||
153
- s.name?.toLowerCase() === q
154
- );
155
- if (!skill) {
156
- const bundle = (registry.bundles || []).find(b =>
157
- (b.code || codeFor(b.id)).toLowerCase() === q || b.id?.toLowerCase() === q
158
- );
159
- if (bundle) return { error: `"${query}" is a bundle. Use pg_bundle_install("${bundle.id}") instead.` };
160
- return { error: `No skill matching "${query}" (try a code, id, name, or GitHub URL)` };
161
- }
162
- if (!skill.raw_url) return { error: `Skill "${skill.id}" has no download URL` };
163
- const skillId = skill.id;
164
-
165
- fs.mkdirSync(SKILLS_DIR, { recursive: true });
166
- ensureMarketplaceSource();
167
- const dest = path.join(SKILLS_DIR, `${skillId}.md`);
168
-
169
- const content = await fetchText(skill.raw_url);
170
- const v = writeSkillAtomic(dest, content);
171
- if (!v.ok) return { error: 'Downloaded skill failed validation', issues: v.errors };
172
- return { success: true, path: dest, name: skill.name };
173
- } catch (e) {
174
- return { error: e.message };
175
- }
176
- }
177
-
178
- export async function browseBundles(topK = 20) {
179
- try {
180
- const text = await fetchText(REGISTRY_URL);
181
- const registry = JSON.parse(text);
182
- const bundles = registry.bundles || [];
183
- return bundles
184
- .map(b => ({ ...b, code: b.code || codeFor(b.id) }))
185
- .sort((a, b) => (b.stars || 0) - (a.stars || 0))
186
- .slice(0, topK);
187
- } catch (e) {
188
- return { error: `Registry unavailable: ${e.message}` };
189
- }
190
- }
191
-
192
- // Ensure marketplace has its own source entry (separate from skills-store)
193
- // so marketplace skills never collide with local skills of the same name.
194
- function ensureMarketplaceSource() {
195
- const config = loadConfig();
196
- if (!config.sources.find(s => s.source === 'marketplace')) {
197
- config.sources.push({ dir: SKILLS_DIR, source: 'marketplace' });
198
- saveConfig(config);
199
- }
200
- }
201
-
202
- export async function installBundle(bundleId) {
203
- try {
204
- const text = await fetchText(REGISTRY_URL);
205
- const registry = JSON.parse(text);
206
- const q = String(bundleId).trim().toLowerCase();
207
- const bundle = (registry.bundles || []).find(b =>
208
- (b.code || codeFor(b.id)).toLowerCase() === q || b.id?.toLowerCase() === q || b.name?.toLowerCase() === q
209
- );
210
- if (!bundle) return { error: `No bundle matching "${bundleId}"` };
211
-
212
- fs.mkdirSync(SKILLS_DIR, { recursive: true });
213
- ensureMarketplaceSource();
214
- const installed = [];
215
- const failed = [];
216
-
217
- const delay = (ms) => new Promise(r => setTimeout(r, ms));
218
- for (const skillId of bundle.skills || []) {
219
- const skill = registry.skills?.find(s => s.id === skillId);
220
- if (!skill?.raw_url) { failed.push(skillId); continue; }
221
- try {
222
- if (installed.length > 0) await delay(300); // rate limit: 300ms between requests
223
- const content = await fetchText(skill.raw_url);
224
- const dest = path.join(SKILLS_DIR, `${skillId}.md`);
225
- const v = writeSkillAtomic(dest, content);
226
- if (!v.ok) { failed.push(skillId); continue; }
227
- installed.push(skillId);
228
- } catch {
229
- failed.push(skillId);
230
- }
231
- }
232
-
233
- return { success: true, bundle: bundle.name, installed, failed, dir: SKILLS_DIR };
234
- } catch (e) {
235
- return { error: e.message };
236
- }
237
- }
238
-
239
- function ghPublish(filePath, desc) {
240
- try {
241
- const result = spawnSync('gh', ['gist', 'create', filePath, '--desc', desc, '--public'], { encoding: 'utf8' });
242
- if (result.error?.code === 'ENOENT') return { ok: false, no_gh: true };
243
- if (result.status !== 0) return { ok: false, error: result.stderr?.trim() || 'gh CLI error — run: gh auth login' };
244
- return { ok: true, url: result.stdout.trim() };
245
- } catch {
246
- return { ok: false, no_gh: true };
247
- }
248
- }
249
-
250
- const REGISTRY_ISSUES = 'https://github.com/NeiP4n/promptgraph-registry/issues/new';
251
-
252
- export async function publishSkill(filePath) {
253
- if (!fs.existsSync(filePath)) return { error: `File not found: ${filePath}` };
254
-
255
- const validation = validateSkill(filePath);
256
- if (!validation.ok) {
257
- return { error: 'Validation failed', issues: validation.errors, warnings: validation.warnings };
258
- }
259
-
260
- const name = path.basename(filePath, '.md');
261
- const gh = ghPublish(filePath, `PromptGraph skill: ${name}`);
262
-
263
- if (gh.no_gh) {
264
- const content = fs.readFileSync(filePath, 'utf8');
265
- return {
266
- success: true,
267
- gh_not_installed: true,
268
- instructions: [
269
- '1. Install gh CLI: https://cli.github.com',
270
- ' OR manually create a public Gist at https://gist.github.com with the file content',
271
- `2. Submit to registry: ${REGISTRY_ISSUES}`,
272
- `3. Paste the Gist URL in the issue`,
273
- ].join('\n'),
274
- file_content: content,
275
- };
276
- }
277
- if (!gh.ok) return { error: gh.error };
278
- return { success: true, url: gh.url, message: `Published! Submit to registry: ${REGISTRY_ISSUES}` };
279
- }
280
-
281
- export async function publishBundle(bundleDef) {
282
- // bundleDef: { id, name, description, skills: [...], tags: [...] }
283
- // OR a path to a .json file
284
- let def = bundleDef;
285
- if (typeof bundleDef === 'string') {
286
- if (!fs.existsSync(bundleDef)) return { error: `File not found: ${bundleDef}` };
287
- try { def = JSON.parse(fs.readFileSync(bundleDef, 'utf8')); }
288
- catch (e) { return { error: `Invalid JSON: ${e.message}` }; }
289
- }
290
-
291
- const validation = validateBundle(def);
292
- if (!validation.ok) {
293
- return { error: 'Bundle validation failed', issues: validation.errors, warnings: validation.warnings };
294
- }
295
-
296
- const bundleJson = JSON.stringify(def, null, 2);
297
- const tmpFile = path.join(PROMPTGRAPH_DIR, `bundle-${def.id}.json`);
298
- fs.mkdirSync(PROMPTGRAPH_DIR, { recursive: true });
299
- fs.writeFileSync(tmpFile, bundleJson);
300
-
301
- const gh = ghPublish(tmpFile, `PromptGraph bundle: ${def.name}`);
302
- try { fs.unlinkSync(tmpFile); } catch {}
303
-
304
- if (gh.no_gh) {
305
- const issueUrl = `${REGISTRY_ISSUES}?title=Bundle%3A+${encodeURIComponent(def.name)}&body=${encodeURIComponent('Bundle definition:\n\n```json\n' + bundleJson + '\n```')}`;
306
- return {
307
- success: true,
308
- gh_not_installed: true,
309
- instructions: [
310
- '1. Install gh CLI: https://cli.github.com',
311
- ` OR open this pre-filled issue directly: ${issueUrl}`,
312
- '2. Paste the bundle JSON shown below into the issue body',
313
- ].join('\n'),
314
- bundle_json: bundleJson,
315
- submit_url: issueUrl,
316
- };
317
- }
318
- if (!gh.ok) return { error: gh.error };
319
- const issueUrl = `${REGISTRY_ISSUES}?title=Bundle%3A+${encodeURIComponent(def.name)}&body=Gist%3A+${encodeURIComponent(gh.url)}`;
320
- return { success: true, gist_url: gh.url, submit_url: issueUrl, message: `Bundle published! Submit: ${issueUrl}` };
321
- }
322
-
323
- export function getTopRated(topK = 10) {
324
- const db = getDb();
325
- return db.prepare(`
326
- SELECT s.id, s.name, s.description, s.source,
327
- r.uses, r.success, r.fail,
328
- CASE WHEN (r.success + r.fail) > 0
329
- THEN ROUND(CAST(r.success AS FLOAT) / (r.success + r.fail), 2)
330
- ELSE NULL END as rating
331
- FROM skills s
332
- LEFT JOIN ratings r ON s.id = r.skill_id
333
- WHERE (r.success + r.fail) >= 3
334
- ORDER BY rating DESC, r.uses DESC
335
- LIMIT ?
336
- `).all(topK);
337
- }
338
-
339
- export function recordUse(skillId) {
340
- const db = getDb();
341
- db.prepare(`
342
- INSERT INTO ratings (skill_id, uses, success, fail)
343
- VALUES (?, 1, 0, 0)
344
- ON CONFLICT(skill_id) DO UPDATE SET uses = uses + 1
345
- `).run(skillId);
346
- }
347
-
348
- export function recordSuccess(skillId) {
349
- const db = getDb();
350
- db.prepare(`
351
- INSERT INTO ratings (skill_id, uses, success, fail)
352
- VALUES (?, 0, 1, 0)
353
- ON CONFLICT(skill_id) DO UPDATE SET success = success + 1
354
- `).run(skillId);
355
- }
356
-
357
- export function recordFail(skillId) {
358
- const db = getDb();
359
- db.prepare(`
360
- INSERT INTO ratings (skill_id, uses, success, fail)
361
- VALUES (?, 0, 0, 1)
362
- ON CONFLICT(skill_id) DO UPDATE SET fail = fail + 1
363
- `).run(skillId);
364
- }
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 { validateSkill, validateBundle } from './validator.js';
9
+ import { loadConfig, saveConfig, PROMPTGRAPH_DIR, SKILLS_STORE_DIR } from './config.js';
10
+ import { importFromGitHub } from './github-import.js';
11
+
12
+ const REGISTRY_URL = 'https://raw.githubusercontent.com/NeiP4n/promptgraph-registry/main/registry.json';
13
+ const SKILLS_DIR = path.join(SKILLS_STORE_DIR, 'marketplace');
14
+
15
+ // Atomically write content to dest via tmp — cleans up on failure
16
+ function writeSkillAtomic(dest, content) {
17
+ const tmpPath = dest + '.tmp';
18
+ try {
19
+ fs.writeFileSync(tmpPath, content);
20
+ const v = validateSkill(tmpPath);
21
+ if (!v.ok) { fs.unlinkSync(tmpPath); return v; }
22
+ fs.renameSync(tmpPath, dest);
23
+ return { ok: true };
24
+ } catch (e) {
25
+ try { fs.unlinkSync(tmpPath); } catch {}
26
+ return { ok: false, errors: [e.message] };
27
+ }
28
+ }
29
+
30
+ // Convert GitHub blob URL → raw URL
31
+ // https://github.com/owner/repo/blob/branch/path/file.md
32
+ // https://raw.githubusercontent.com/owner/repo/branch/path/file.md
33
+ function githubToRaw(url) {
34
+ const m = url.match(/^https?:\/\/github\.com\/([^/]+\/[^/]+)\/blob\/(.+)$/);
35
+ if (m) return `https://raw.githubusercontent.com/${m[1]}/${m[2]}`;
36
+ return null; // already raw or not a github URL
37
+ }
38
+
39
+ const _require = createRequire(import.meta.url);
40
+ const PKG_VERSION = (() => { try { return _require('./package.json').version; } catch { return '1.x'; } })();
41
+ const UA = `promptgraph-mcp/${PKG_VERSION}`;
42
+
43
+ // Deterministic short code from an id. Same id always yields the same code,
44
+ // so codes auto-generate — no need to assign them by hand.
45
+ export function codeFor(id) {
46
+ return 'pg-' + createHash('md5').update(String(id)).digest('hex').slice(0, 6);
47
+ }
48
+
49
+ // node:https GET — reliable and fast on Windows (undici fetch can hang ~10s there).
50
+ function httpGet(url) {
51
+ return new Promise((resolve, reject) => {
52
+ const req = https.get(url, { headers: { 'User-Agent': UA }, timeout: 8000, family: 4 }, (res) => {
53
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
54
+ return httpGet(res.headers.location).then(resolve, reject);
55
+ }
56
+ if (res.statusCode !== 200) {
57
+ res.resume();
58
+ return reject(new Error(`HTTP ${res.statusCode}`));
59
+ }
60
+ let data = '';
61
+ res.setEncoding('utf8');
62
+ res.on('data', c => data += c);
63
+ res.on('end', () => resolve(data));
64
+ });
65
+ req.on('timeout', () => { req.destroy(new Error('request timed out')); });
66
+ req.on('error', reject);
67
+ });
68
+ }
69
+
70
+ // Primary path is httpGet (fast/reliable on Windows); undici fetch only as fallback.
71
+ async function rawFetch(url) {
72
+ try {
73
+ return await httpGet(url);
74
+ } catch {
75
+ const res = await fetch(url, { headers: { 'User-Agent': UA } });
76
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
77
+ return await res.text();
78
+ }
79
+ }
80
+
81
+ // Disk cache for the registry (network to GitHub raw can be slow on some networks).
82
+ const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
83
+
84
+ async function fetchText(url) {
85
+ const cacheFile = path.join(PROMPTGRAPH_DIR, 'registry-cache.json');
86
+ const isRegistry = url === REGISTRY_URL;
87
+
88
+ if (isRegistry && fs.existsSync(cacheFile)) {
89
+ try {
90
+ const stat = fs.statSync(cacheFile);
91
+ if (Date.now() - stat.mtimeMs < CACHE_TTL) {
92
+ return fs.readFileSync(cacheFile, 'utf8');
93
+ }
94
+ } catch {}
95
+ }
96
+
97
+ const text = await rawFetch(url);
98
+
99
+ if (isRegistry) {
100
+ try {
101
+ fs.mkdirSync(PROMPTGRAPH_DIR, { recursive: true });
102
+ fs.writeFileSync(cacheFile, text);
103
+ } catch {}
104
+ }
105
+ return text;
106
+ }
107
+
108
+ export async function browseMarketplace(topK = 20) {
109
+ try {
110
+ const text = await fetchText(REGISTRY_URL);
111
+ const registry = JSON.parse(text);
112
+ if (!Array.isArray(registry.skills)) return { error: 'Invalid registry format' };
113
+ return registry.skills
114
+ .map(s => ({ ...s, code: s.code || codeFor(s.id) })) // auto-fill code
115
+ .sort((a, b) => (b.stars || 0) - (a.stars || 0))
116
+ .slice(0, topK);
117
+ } catch (e) {
118
+ return { error: `Registry unavailable: ${e.message}` };
119
+ }
120
+ }
121
+
122
+ export async function installSkillFromUrl(url) {
123
+ try {
124
+ const rawUrl = githubToRaw(url) || url;
125
+ const content = await fetchText(rawUrl);
126
+ fs.mkdirSync(SKILLS_DIR, { recursive: true });
127
+ ensureMarketplaceSource();
128
+
129
+ // derive filename from URL
130
+ const urlName = rawUrl.split('/').pop().replace(/[^a-z0-9-_.]/gi, '-');
131
+ const dest = path.join(SKILLS_DIR, urlName.endsWith('.md') ? urlName : urlName + '.md');
132
+ const v = writeSkillAtomic(dest, content);
133
+ if (!v.ok) return { error: 'Downloaded skill failed validation', issues: v.errors };
134
+ return { success: true, path: dest, url: rawUrl };
135
+ } catch (e) {
136
+ return { error: e.message };
137
+ }
138
+ }
139
+
140
+ export async function installSkill(query) {
141
+ try {
142
+ // GitHub URL or raw URL → direct install
143
+ if (query.startsWith('http://') || query.startsWith('https://')) {
144
+ return installSkillFromUrl(query);
145
+ }
146
+
147
+ const text = await fetchText(REGISTRY_URL);
148
+ const registry = JSON.parse(text);
149
+ const q = String(query).trim().toLowerCase();
150
+ // match by code (stored OR auto-generated), id, or name
151
+ const skill = registry.skills?.find(s =>
152
+ (s.code || codeFor(s.id)).toLowerCase() === q ||
153
+ s.id?.toLowerCase() === q ||
154
+ s.name?.toLowerCase() === q
155
+ );
156
+ if (!skill) {
157
+ const bundle = (registry.bundles || []).find(b =>
158
+ (b.code || codeFor(b.id)).toLowerCase() === q || b.id?.toLowerCase() === q
159
+ );
160
+ if (bundle) return { error: `"${query}" is a bundle. Use pg_bundle_install("${bundle.id}") instead.` };
161
+ return { error: `No skill matching "${query}" (try a code, id, name, or GitHub URL)` };
162
+ }
163
+ if (!skill.raw_url) return { error: `Skill "${skill.id}" has no download URL` };
164
+ const skillId = skill.id;
165
+
166
+ fs.mkdirSync(SKILLS_DIR, { recursive: true });
167
+ ensureMarketplaceSource();
168
+ const dest = path.join(SKILLS_DIR, `${skillId}.md`);
169
+
170
+ const content = await fetchText(skill.raw_url);
171
+ const v = writeSkillAtomic(dest, content);
172
+ if (!v.ok) return { error: 'Downloaded skill failed validation', issues: v.errors };
173
+ return { success: true, path: dest, name: skill.name };
174
+ } catch (e) {
175
+ return { error: e.message };
176
+ }
177
+ }
178
+
179
+ export async function browseBundles(topK = 20) {
180
+ try {
181
+ const text = await fetchText(REGISTRY_URL);
182
+ const registry = JSON.parse(text);
183
+ const bundles = registry.bundles || [];
184
+ return bundles
185
+ .map(b => ({ ...b, code: b.code || codeFor(b.id) }))
186
+ .sort((a, b) => (b.stars || 0) - (a.stars || 0))
187
+ .slice(0, topK);
188
+ } catch (e) {
189
+ return { error: `Registry unavailable: ${e.message}` };
190
+ }
191
+ }
192
+
193
+ // Ensure marketplace has its own source entry (separate from skills-store)
194
+ // so marketplace skills never collide with local skills of the same name.
195
+ function ensureMarketplaceSource() {
196
+ const config = loadConfig();
197
+ if (!config.sources.find(s => s.source === 'marketplace')) {
198
+ config.sources.push({ dir: SKILLS_DIR, source: 'marketplace' });
199
+ saveConfig(config);
200
+ }
201
+ }
202
+
203
+ export async function installBundle(bundleId) {
204
+ try {
205
+ const text = await fetchText(REGISTRY_URL);
206
+ const registry = JSON.parse(text);
207
+ const q = String(bundleId).trim().toLowerCase();
208
+ const bundle = (registry.bundles || []).find(b =>
209
+ (b.code || codeFor(b.id)).toLowerCase() === q || b.id?.toLowerCase() === q || b.name?.toLowerCase() === q
210
+ );
211
+ if (!bundle) return { error: `No bundle matching "${bundleId}"` };
212
+
213
+ if (bundle.repo_url) {
214
+ await importFromGitHub(bundle.repo_url);
215
+ return { success: true, bundle: bundle.name, type: 'repo_import', repo_url: bundle.repo_url };
216
+ }
217
+
218
+ fs.mkdirSync(SKILLS_DIR, { recursive: true });
219
+ ensureMarketplaceSource();
220
+ const installed = [];
221
+ const failed = [];
222
+
223
+ const delay = (ms) => new Promise(r => setTimeout(r, ms));
224
+ for (const skillId of bundle.skills || []) {
225
+ const skill = registry.skills?.find(s => s.id === skillId);
226
+ if (!skill?.raw_url) { failed.push(skillId); continue; }
227
+ try {
228
+ if (installed.length > 0) await delay(300); // rate limit: 300ms between requests
229
+ const content = await fetchText(skill.raw_url);
230
+ const dest = path.join(SKILLS_DIR, `${skillId}.md`);
231
+ const v = writeSkillAtomic(dest, content);
232
+ if (!v.ok) { failed.push(skillId); continue; }
233
+ installed.push(skillId);
234
+ } catch {
235
+ failed.push(skillId);
236
+ }
237
+ }
238
+
239
+ return { success: true, bundle: bundle.name, installed, failed, dir: SKILLS_DIR };
240
+ } catch (e) {
241
+ return { error: e.message };
242
+ }
243
+ }
244
+
245
+ function ghPublish(filePath, desc) {
246
+ try {
247
+ const result = spawnSync('gh', ['gist', 'create', filePath, '--desc', desc, '--public'], { encoding: 'utf8' });
248
+ if (result.error?.code === 'ENOENT') return { ok: false, no_gh: true };
249
+ if (result.status !== 0) return { ok: false, error: result.stderr?.trim() || 'gh CLI error — run: gh auth login' };
250
+ return { ok: true, url: result.stdout.trim() };
251
+ } catch {
252
+ return { ok: false, no_gh: true };
253
+ }
254
+ }
255
+
256
+ const REGISTRY_ISSUES = 'https://github.com/NeiP4n/promptgraph-registry/issues/new';
257
+
258
+ export async function publishSkill(filePath) {
259
+ if (!fs.existsSync(filePath)) return { error: `File not found: ${filePath}` };
260
+
261
+ const validation = validateSkill(filePath);
262
+ if (!validation.ok) {
263
+ return { error: 'Validation failed', issues: validation.errors, warnings: validation.warnings };
264
+ }
265
+
266
+ const name = path.basename(filePath, '.md');
267
+ const gh = ghPublish(filePath, `PromptGraph skill: ${name}`);
268
+
269
+ if (gh.no_gh) {
270
+ const content = fs.readFileSync(filePath, 'utf8');
271
+ return {
272
+ success: true,
273
+ gh_not_installed: true,
274
+ instructions: [
275
+ '1. Install gh CLI: https://cli.github.com',
276
+ ' OR manually create a public Gist at https://gist.github.com with the file content',
277
+ `2. Submit to registry: ${REGISTRY_ISSUES}`,
278
+ `3. Paste the Gist URL in the issue`,
279
+ ].join('\n'),
280
+ file_content: content,
281
+ };
282
+ }
283
+ if (!gh.ok) return { error: gh.error };
284
+ return { success: true, url: gh.url, message: `Published! Submit to registry: ${REGISTRY_ISSUES}` };
285
+ }
286
+
287
+ export async function publishBundle(bundleDef) {
288
+ // bundleDef: { id, name, description, skills: [...], tags: [...] }
289
+ // OR a path to a .json file
290
+ let def = bundleDef;
291
+ if (typeof bundleDef === 'string') {
292
+ if (!fs.existsSync(bundleDef)) return { error: `File not found: ${bundleDef}` };
293
+ try { def = JSON.parse(fs.readFileSync(bundleDef, 'utf8')); }
294
+ catch (e) { return { error: `Invalid JSON: ${e.message}` }; }
295
+ }
296
+
297
+ const validation = validateBundle(def);
298
+ if (!validation.ok) {
299
+ return { error: 'Bundle validation failed', issues: validation.errors, warnings: validation.warnings };
300
+ }
301
+
302
+ const bundleJson = JSON.stringify(def, null, 2);
303
+ const tmpFile = path.join(PROMPTGRAPH_DIR, `bundle-${def.id}.json`);
304
+ fs.mkdirSync(PROMPTGRAPH_DIR, { recursive: true });
305
+ fs.writeFileSync(tmpFile, bundleJson);
306
+
307
+ const gh = ghPublish(tmpFile, `PromptGraph bundle: ${def.name}`);
308
+ try { fs.unlinkSync(tmpFile); } catch {}
309
+
310
+ if (gh.no_gh) {
311
+ const issueUrl = `${REGISTRY_ISSUES}?title=Bundle%3A+${encodeURIComponent(def.name)}&body=${encodeURIComponent('Bundle definition:\n\n```json\n' + bundleJson + '\n```')}`;
312
+ return {
313
+ success: true,
314
+ gh_not_installed: true,
315
+ instructions: [
316
+ '1. Install gh CLI: https://cli.github.com',
317
+ ` OR open this pre-filled issue directly: ${issueUrl}`,
318
+ '2. Paste the bundle JSON shown below into the issue body',
319
+ ].join('\n'),
320
+ bundle_json: bundleJson,
321
+ submit_url: issueUrl,
322
+ };
323
+ }
324
+ if (!gh.ok) return { error: gh.error };
325
+ const issueUrl = `${REGISTRY_ISSUES}?title=Bundle%3A+${encodeURIComponent(def.name)}&body=Gist%3A+${encodeURIComponent(gh.url)}`;
326
+ return { success: true, gist_url: gh.url, submit_url: issueUrl, message: `Bundle published! Submit: ${issueUrl}` };
327
+ }
328
+
329
+ export function getTopRated(topK = 10) {
330
+ const db = getDb();
331
+ return db.prepare(`
332
+ SELECT s.id, s.name, s.description, s.source,
333
+ r.uses, r.success, r.fail,
334
+ CASE WHEN (r.success + r.fail) > 0
335
+ THEN ROUND(CAST(r.success AS FLOAT) / (r.success + r.fail), 2)
336
+ ELSE NULL END as rating
337
+ FROM skills s
338
+ LEFT JOIN ratings r ON s.id = r.skill_id
339
+ WHERE (r.success + r.fail) >= 3
340
+ ORDER BY rating DESC, r.uses DESC
341
+ LIMIT ?
342
+ `).all(topK);
343
+ }
344
+
345
+ export function recordUse(skillId) {
346
+ const db = getDb();
347
+ db.prepare(`
348
+ INSERT INTO ratings (skill_id, uses, success, fail)
349
+ VALUES (?, 1, 0, 0)
350
+ ON CONFLICT(skill_id) DO UPDATE SET uses = uses + 1
351
+ `).run(skillId);
352
+ }
353
+
354
+ export function recordSuccess(skillId) {
355
+ const db = getDb();
356
+ db.prepare(`
357
+ INSERT INTO ratings (skill_id, uses, success, fail)
358
+ VALUES (?, 0, 1, 0)
359
+ ON CONFLICT(skill_id) DO UPDATE SET success = success + 1
360
+ `).run(skillId);
361
+ }
362
+
363
+ export function recordFail(skillId) {
364
+ const db = getDb();
365
+ db.prepare(`
366
+ INSERT INTO ratings (skill_id, uses, success, fail)
367
+ VALUES (?, 0, 0, 1)
368
+ ON CONFLICT(skill_id) DO UPDATE SET fail = fail + 1
369
+ `).run(skillId);
370
+ }
package/package.json CHANGED
@@ -1,53 +1,54 @@
1
- {
2
- "name": "promptgraph-mcp",
3
- "version": "1.7.0",
4
- "main": "index.js",
5
- "type": "module",
6
- "bin": {
7
- "pg": "./index.js",
8
- "promptgraph": "./index.js",
9
- "promptgraph-mcp": "./index.js"
10
- },
11
- "scripts": {
12
- "start": "node index.js",
13
- "init": "node index.js init",
14
- "reindex": "node index.js reindex",
15
- "test": "vitest run"
16
- },
17
- "keywords": [
18
- "claude",
19
- "claude-code",
20
- "mcp",
21
- "ai",
22
- "skills",
23
- "embeddings"
24
- ],
25
- "author": "NeiP4n",
26
- "license": "MIT",
27
- "description": "Semantic skill router for Claude Code — load only the skills you need, save 20k+ tokens per session",
28
- "repository": {
29
- "type": "git",
30
- "url": "https://github.com/NeiP4n/promptgraph.git"
31
- },
32
- "homepage": "https://github.com/NeiP4n/promptgraph#readme",
33
- "engines": {
34
- "node": ">=18"
35
- },
36
- "dependencies": {
37
- "@modelcontextprotocol/sdk": "^1.29.0",
38
- "better-sqlite3": "^12.10.0",
39
- "boxen": "^8.0.1",
40
- "chalk": "^5.6.2",
41
- "chokidar": "^5.0.0",
42
- "fastembed": "^2.1.0",
43
- "glob": "^13.0.6",
44
- "gray-matter": "^4.0.3",
45
- "ora": "^9.4.0"
46
- },
47
- "devDependencies": {
48
- "vitest": "^4.1.8"
49
- },
50
- "overrides": {
51
- "tar": "^6.2.1"
52
- }
53
- }
1
+ {
2
+ "name": "promptgraph-mcp",
3
+
4
+ "version": "1.8.0",
5
+ "main": "index.js",
6
+ "type": "module",
7
+ "bin": {
8
+ "pg": "./index.js",
9
+ "promptgraph": "./index.js",
10
+ "promptgraph-mcp": "./index.js"
11
+ },
12
+ "scripts": {
13
+ "start": "node index.js",
14
+ "init": "node index.js init",
15
+ "reindex": "node index.js reindex",
16
+ "test": "vitest run"
17
+ },
18
+ "keywords": [
19
+ "claude",
20
+ "claude-code",
21
+ "mcp",
22
+ "ai",
23
+ "skills",
24
+ "embeddings"
25
+ ],
26
+ "author": "NeiP4n",
27
+ "license": "MIT",
28
+ "description": "Semantic skill router for Claude Code — load only the skills you need, save 20k+ tokens per session",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "https://github.com/NeiP4n/promptgraph.git"
32
+ },
33
+ "homepage": "https://github.com/NeiP4n/promptgraph#readme",
34
+ "engines": {
35
+ "node": ">=18"
36
+ },
37
+ "dependencies": {
38
+ "@modelcontextprotocol/sdk": "^1.29.0",
39
+ "better-sqlite3": "^12.10.0",
40
+ "boxen": "^8.0.1",
41
+ "chalk": "^5.6.2",
42
+ "chokidar": "^5.0.0",
43
+ "fastembed": "^2.1.0",
44
+ "glob": "^13.0.6",
45
+ "gray-matter": "^4.0.3",
46
+ "ora": "^9.4.0"
47
+ },
48
+ "devDependencies": {
49
+ "vitest": "^4.1.8"
50
+ },
51
+ "overrides": {
52
+ "tar": "^6.2.1"
53
+ }
54
+ }
package/validator.js CHANGED
@@ -1,151 +1,159 @@
1
- import fs from 'fs';
2
- import matter from 'gray-matter';
3
-
4
- // patterns that indicate malicious or junk skills
5
- const DANGEROUS_PATTERNS = [
6
- { re: /curl\s+[^\n|]*\|\s*(ba)?sh/i, msg: 'pipes remote content to shell (curl | sh)' },
7
- { re: /wget\s+[^\n|]*\|\s*(ba)?sh/i, msg: 'pipes remote content to shell (wget | sh)' },
8
- { re: /rm\s+-rf\s+[~/]/i, msg: 'destructive rm -rf on home/root' },
9
- { re: /\b(eval|exec)\s*\(\s*(atob|base64|fromCharCode)/i, msg: 'obfuscated code execution' },
10
- { re: /(AWS|SECRET|PRIVATE|API)_?KEY\s*=\s*["'][A-Za-z0-9/+]{16,}/i, msg: 'hardcoded credential' },
11
- { re: /process\.env\.[A-Z_]+\s*[^\n]{0,40}(fetch|http|post|curl)/i, msg: 'reads env vars and exfiltrates over network' },
12
- { re: /\b(ignore|disregard|forget)\s+(all\s+)?(previous|prior|above)\s+(instructions|prompts?|rules)/i, msg: 'prompt injection attempt' },
13
- { re: /\b(reveal|print|output|show)\s+(your\s+)?(system\s+prompt|instructions|api\s*key)/i, msg: 'prompt extraction attempt' },
14
- { re: /\.ssh\/id_rsa|\.aws\/credentials|\.env\b.*(cat|read|cp|mv)/i, msg: 'accesses sensitive credential files' },
15
- ];
16
-
17
- const MIN_CONTENT_LENGTH = 200; // chars of actual instruction
18
- const MAX_CONTENT_LENGTH = 100000; // 100KB cap
19
- const MIN_DESCRIPTION_LENGTH = 15;
20
- const NAME_RE = /^[a-z0-9][a-z0-9-]{1,63}$/;
21
-
22
- export function validateSkill(filePath) {
23
- const errors = [];
24
- const warnings = [];
25
-
26
- if (!fs.existsSync(filePath)) {
27
- return { ok: false, errors: ['File does not exist'], warnings: [] };
28
- }
29
-
30
- const raw = fs.readFileSync(filePath, 'utf8');
31
-
32
- // size checks
33
- if (raw.length < MIN_CONTENT_LENGTH) {
34
- errors.push(`Too short (${raw.length} chars, min ${MIN_CONTENT_LENGTH}). Likely not a real skill.`);
35
- }
36
- if (raw.length > MAX_CONTENT_LENGTH) {
37
- errors.push(`Too large (${raw.length} chars, max ${MAX_CONTENT_LENGTH}).`);
38
- }
39
-
40
- // frontmatter
41
- let data, content;
42
- try {
43
- const parsed = matter(raw);
44
- data = parsed.data;
45
- content = parsed.content;
46
- } catch (e) {
47
- errors.push(`Invalid frontmatter: ${e.message}`);
48
- return { ok: false, errors, warnings };
49
- }
50
-
51
- // name
52
- if (!data.name) {
53
- errors.push('Missing required field: name');
54
- } else if (typeof data.name !== 'string') {
55
- errors.push('Field "name" must be a string');
56
- } else if (!NAME_RE.test(data.name)) {
57
- errors.push(`Invalid name "${data.name}". Use lowercase, digits, hyphens (2-64 chars).`);
58
- }
59
-
60
- // description
61
- if (!data.description) {
62
- errors.push('Missing required field: description');
63
- } else if (typeof data.description !== 'string') {
64
- errors.push('Field "description" must be a string');
65
- } else if (data.description.trim().length < MIN_DESCRIPTION_LENGTH) {
66
- errors.push(`Description too short (min ${MIN_DESCRIPTION_LENGTH} chars).`);
67
- }
68
-
69
- // body must have real instruction content
70
- if (content && content.trim().length < MIN_CONTENT_LENGTH) {
71
- warnings.push('Body is very short — may lack actionable instructions.');
72
- }
73
-
74
- // security scan over the whole file
75
- for (const { re, msg } of DANGEROUS_PATTERNS) {
76
- if (re.test(raw)) {
77
- errors.push(`Security: ${msg}`);
78
- }
79
- }
80
-
81
- // junk filename heuristic
82
- const base = filePath.split(/[\\/]/).pop().toLowerCase();
83
- if (['readme.md', 'changelog.md', 'license.md', 'contributing.md'].includes(base)) {
84
- warnings.push('Filename looks like a docs file, not a skill.');
85
- }
86
-
87
- return { ok: errors.length === 0, errors, warnings };
88
- }
89
-
90
- const BUNDLE_ID_RE = /^[a-z0-9][a-z0-9-]{1,63}$/;
91
-
92
- export function validateBundle(def) {
93
- const errors = [];
94
- const warnings = [];
95
-
96
- if (!def.id || typeof def.id !== 'string') {
97
- errors.push('Missing required field: id');
98
- } else if (!BUNDLE_ID_RE.test(def.id)) {
99
- errors.push(`Invalid id "${def.id}". Use lowercase, digits, hyphens (2-64 chars).`);
100
- }
101
-
102
- if (!def.name || typeof def.name !== 'string' || def.name.trim().length < 3) {
103
- errors.push('Missing or too short field: name (min 3 chars)');
104
- }
105
-
106
- if (!def.description || typeof def.description !== 'string' || def.description.trim().length < 15) {
107
- errors.push('Missing or too short field: description (min 15 chars)');
108
- }
109
-
110
- if (!Array.isArray(def.skills) || def.skills.length < 2) {
111
- errors.push('Field "skills" must be an array with at least 2 skill IDs');
112
- } else {
113
- for (const s of def.skills) {
114
- if (typeof s !== 'string' || !BUNDLE_ID_RE.test(s)) {
115
- errors.push(`Invalid skill id in bundle: "${s}"`);
116
- }
117
- }
118
- }
119
-
120
- if (def.tags && !Array.isArray(def.tags)) {
121
- errors.push('Field "tags" must be an array of strings');
122
- }
123
-
124
- if (def.skills?.length > 20) {
125
- warnings.push('Bundle has more than 20 skills — consider splitting into sub-bundles');
126
- }
127
-
128
- return { ok: errors.length === 0, errors, warnings };
129
- }
130
-
131
- // CLI: node validator.js <file>
132
- if (import.meta.url === `file://${process.argv[1]}`) {
133
- const file = process.argv[2];
134
- if (!file) {
135
- console.error('Usage: node validator.js <skill.md>');
136
- process.exit(1);
137
- }
138
- const result = validateSkill(file);
139
- if (result.warnings.length) {
140
- console.log('⚠ Warnings:');
141
- result.warnings.forEach(w => console.log(' - ' + w));
142
- }
143
- if (result.ok) {
144
- console.log('✓ Skill is valid');
145
- process.exit(0);
146
- } else {
147
- console.log('✗ Validation failed:');
148
- result.errors.forEach(e => console.log(' - ' + e));
149
- process.exit(1);
150
- }
151
- }
1
+ import fs from 'fs';
2
+ import matter from 'gray-matter';
3
+
4
+ // patterns that indicate malicious or junk skills
5
+ const DANGEROUS_PATTERNS = [
6
+ { re: /curl\s+[^\n|]*\|\s*(ba)?sh/i, msg: 'pipes remote content to shell (curl | sh)' },
7
+ { re: /wget\s+[^\n|]*\|\s*(ba)?sh/i, msg: 'pipes remote content to shell (wget | sh)' },
8
+ { re: /rm\s+-rf\s+[~/]/i, msg: 'destructive rm -rf on home/root' },
9
+ { re: /\b(eval|exec)\s*\(\s*(atob|base64|fromCharCode)/i, msg: 'obfuscated code execution' },
10
+ { re: /(AWS|SECRET|PRIVATE|API)_?KEY\s*=\s*["'][A-Za-z0-9/+]{16,}/i, msg: 'hardcoded credential' },
11
+ { re: /process\.env\.[A-Z_]+\s*[^\n]{0,40}(fetch|http|post|curl)/i, msg: 'reads env vars and exfiltrates over network' },
12
+ { re: /\b(ignore|disregard|forget)\s+(all\s+)?(previous|prior|above)\s+(instructions|prompts?|rules)/i, msg: 'prompt injection attempt' },
13
+ { re: /\b(reveal|print|output|show)\s+(your\s+)?(system\s+prompt|instructions|api\s*key)/i, msg: 'prompt extraction attempt' },
14
+ { re: /\.ssh\/id_rsa|\.aws\/credentials|\.env\b.*(cat|read|cp|mv)/i, msg: 'accesses sensitive credential files' },
15
+ ];
16
+
17
+ const MIN_CONTENT_LENGTH = 200; // chars of actual instruction
18
+ const MAX_CONTENT_LENGTH = 100000; // 100KB cap
19
+ const MIN_DESCRIPTION_LENGTH = 15;
20
+ const NAME_RE = /^[a-z0-9][a-z0-9-]{1,63}$/;
21
+
22
+ export function validateSkill(filePath) {
23
+ const errors = [];
24
+ const warnings = [];
25
+
26
+ if (!fs.existsSync(filePath)) {
27
+ return { ok: false, errors: ['File does not exist'], warnings: [] };
28
+ }
29
+
30
+ const raw = fs.readFileSync(filePath, 'utf8');
31
+
32
+ // size checks
33
+ if (raw.length < MIN_CONTENT_LENGTH) {
34
+ errors.push(`Too short (${raw.length} chars, min ${MIN_CONTENT_LENGTH}). Likely not a real skill.`);
35
+ }
36
+ if (raw.length > MAX_CONTENT_LENGTH) {
37
+ errors.push(`Too large (${raw.length} chars, max ${MAX_CONTENT_LENGTH}).`);
38
+ }
39
+
40
+ // frontmatter
41
+ let data, content;
42
+ try {
43
+ const parsed = matter(raw);
44
+ data = parsed.data;
45
+ content = parsed.content;
46
+ } catch (e) {
47
+ errors.push(`Invalid frontmatter: ${e.message}`);
48
+ return { ok: false, errors, warnings };
49
+ }
50
+
51
+ // name
52
+ if (!data.name) {
53
+ errors.push('Missing required field: name');
54
+ } else if (typeof data.name !== 'string') {
55
+ errors.push('Field "name" must be a string');
56
+ } else if (!NAME_RE.test(data.name)) {
57
+ errors.push(`Invalid name "${data.name}". Use lowercase, digits, hyphens (2-64 chars).`);
58
+ }
59
+
60
+ // description
61
+ if (!data.description) {
62
+ errors.push('Missing required field: description');
63
+ } else if (typeof data.description !== 'string') {
64
+ errors.push('Field "description" must be a string');
65
+ } else if (data.description.trim().length < MIN_DESCRIPTION_LENGTH) {
66
+ errors.push(`Description too short (min ${MIN_DESCRIPTION_LENGTH} chars).`);
67
+ }
68
+
69
+ // body must have real instruction content
70
+ if (content && content.trim().length < MIN_CONTENT_LENGTH) {
71
+ warnings.push('Body is very short — may lack actionable instructions.');
72
+ }
73
+
74
+ // security scan over the whole file
75
+ for (const { re, msg } of DANGEROUS_PATTERNS) {
76
+ if (re.test(raw)) {
77
+ errors.push(`Security: ${msg}`);
78
+ }
79
+ }
80
+
81
+ // junk filename heuristic
82
+ const base = filePath.split(/[\\/]/).pop().toLowerCase();
83
+ if (['readme.md', 'changelog.md', 'license.md', 'contributing.md'].includes(base)) {
84
+ warnings.push('Filename looks like a docs file, not a skill.');
85
+ }
86
+
87
+ return { ok: errors.length === 0, errors, warnings };
88
+ }
89
+
90
+ const BUNDLE_ID_RE = /^[a-z0-9][a-z0-9-]{1,63}$/;
91
+
92
+ const GITHUB_REPO_RE = /^(https?:\/\/github\.com\/[\w.-]+\/[\w.-]+|[\w.-]+\/[\w.-]+)$/;
93
+
94
+ export function validateBundle(def) {
95
+ const errors = [];
96
+ const warnings = [];
97
+
98
+ if (!def.id || typeof def.id !== 'string') {
99
+ errors.push('Missing required field: id');
100
+ } else if (!BUNDLE_ID_RE.test(def.id)) {
101
+ errors.push(`Invalid id "${def.id}". Use lowercase, digits, hyphens (2-64 chars).`);
102
+ }
103
+
104
+ if (!def.name || typeof def.name !== 'string' || def.name.trim().length < 3) {
105
+ errors.push('Missing or too short field: name (min 3 chars)');
106
+ }
107
+
108
+ if (!def.description || typeof def.description !== 'string' || def.description.trim().length < 15) {
109
+ errors.push('Missing or too short field: description (min 15 chars)');
110
+ }
111
+
112
+ if (def.repo_url) {
113
+ if (!GITHUB_REPO_RE.test(def.repo_url)) {
114
+ errors.push(`Invalid repo_url "${def.repo_url}". Use "owner/repo" or a full GitHub URL.`);
115
+ }
116
+ } else {
117
+ if (!Array.isArray(def.skills) || def.skills.length < 1) {
118
+ errors.push('Field "skills" must be an array with at least 1 skill ID (or use repo_url instead)');
119
+ } else {
120
+ for (const s of def.skills) {
121
+ if (typeof s !== 'string' || !BUNDLE_ID_RE.test(s)) {
122
+ errors.push(`Invalid skill id in bundle: "${s}"`);
123
+ }
124
+ }
125
+ }
126
+ }
127
+
128
+ if (def.tags && !Array.isArray(def.tags)) {
129
+ errors.push('Field "tags" must be an array of strings');
130
+ }
131
+
132
+ if (def.skills?.length > 20) {
133
+ warnings.push('Bundle has more than 20 skills — consider splitting into sub-bundles');
134
+ }
135
+
136
+ return { ok: errors.length === 0, errors, warnings };
137
+ }
138
+
139
+ // CLI: node validator.js <file>
140
+ if (import.meta.url === `file://${process.argv[1]}`) {
141
+ const file = process.argv[2];
142
+ if (!file) {
143
+ console.error('Usage: node validator.js <skill.md>');
144
+ process.exit(1);
145
+ }
146
+ const result = validateSkill(file);
147
+ if (result.warnings.length) {
148
+ console.log(' Warnings:');
149
+ result.warnings.forEach(w => console.log(' - ' + w));
150
+ }
151
+ if (result.ok) {
152
+ console.log('✓ Skill is valid');
153
+ process.exit(0);
154
+ } else {
155
+ console.log('✗ Validation failed:');
156
+ result.errors.forEach(e => console.log(' - ' + e));
157
+ process.exit(1);
158
+ }
159
+ }