promptgraph-mcp 1.5.25 → 1.7.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.
package/README.md CHANGED
@@ -11,13 +11,13 @@ PromptGraph is an MCP server that gives Claude Code a semantic skill index — v
11
11
 
12
12
  ## Why
13
13
 
14
- Claude Code dumps every file in `~/.claude/commands/` into the system prompt — every session, every message. At 40+ skills that's **20,000–50,000 wasted tokens before you say a word.**
14
+ Claude Code loads skill metadata from `~/.claude/commands/` each session. With 40+ skills that's thousands of tokens in routing overhead before you say a word. More importantly, when Claude loads and executes a full skill file (typically 1,000–5,000 tokens each), the cost multiplies fast.
15
15
 
16
- PromptGraph replaces that with one tiny router skill (`~150 tokens`) and a local vector index. When you ask something, Claude calls `pg_search` → gets the right skill path → reads only that file.
16
+ PromptGraph replaces that with one tiny router skill (`~150 tokens`) and a local vector index. Claude calls `pg_search` → gets the right skill path + a content snippet → reads only that file when needed.
17
17
 
18
18
  ```
19
- Before: all 40 skills loaded~40,000 tokens wasted
20
- After: 1 router + 1 match ~300 tokens total
19
+ Before: route across all 40 skills → 40 × read overhead
20
+ After: 1 pg_search call + snippet read only what you need
21
21
  ```
22
22
 
23
23
  ---
@@ -31,7 +31,7 @@ After: 1 router + 1 match → ~300 tokens total
31
31
  - ⚡ **Local embeddings** — `fastembed` BGE-Small-EN, 23 MB, no API key needed
32
32
  - 👁️ **File watcher** — auto-reindexes when you add or edit skills
33
33
  - 🛡️ **Validator** — blocks malicious/junk skills before they reach your machine
34
- - 🌐 **MCP-native** — works with Claude Code, Claude Desktop, Cline, and any MCP client
34
+ - 🌐 **MCP-native** — works with Claude Code, Claude Desktop, Cline, OpenCode, Cursor, Windsurf, and any MCP client
35
35
 
36
36
  ---
37
37
 
@@ -61,6 +61,22 @@ npx promptgraph-mcp init
61
61
  }
62
62
  ```
63
63
 
64
+ ### Add to OpenCode (`~/.config/opencode/opencode.json`)
65
+
66
+ ```json
67
+ {
68
+ "mcp": {
69
+ "promptgraph": {
70
+ "type": "local",
71
+ "command": ["npx", "promptgraph-mcp"],
72
+ "enabled": true
73
+ }
74
+ }
75
+ }
76
+ ```
77
+
78
+ > `pg setup` auto-detects OpenCode and writes this config for you.
79
+
64
80
  ### Move your skills out of `commands/`
65
81
 
66
82
  ```bash
package/ann.js CHANGED
@@ -1,61 +1,60 @@
1
- import { LocalIndex } from 'vectra';
2
- import path from 'path';
3
- import os from 'os';
4
- import { getDb } from './db.js';
5
-
6
- const INDEX_PATH = path.join(os.homedir(), '.claude', '.promptgraph', 'hnsw-index');
7
-
8
- let _index = null;
1
+ import { getDb, blobToVec } from './db.js';
2
+
3
+ // In-memory flat index — no external dependency.
4
+ // For typical skill counts (<5000) this is faster than vectra's disk-based HNSW
5
+ // because all data fits in RAM and no I/O is needed per query.
6
+ let _cache = null;
7
+ let _cacheChunkCount = -1;
8
+
9
+ function loadCache(db) {
10
+ const count = db.prepare('SELECT COUNT(*) as n FROM chunks').get().n;
11
+ if (_cache && count === _cacheChunkCount) return _cache;
12
+ const rows = db.prepare('SELECT skill_id, embedding FROM chunks').all();
13
+ _cache = rows.map(r => ({
14
+ skill_id: r.skill_id,
15
+ vec: new Float32Array(blobToVec(r.embedding)),
16
+ }));
17
+ _cacheChunkCount = count;
18
+ return _cache;
19
+ }
9
20
 
10
- async function getIndex() {
11
- if (_index) return _index;
12
- _index = new LocalIndex(INDEX_PATH);
13
- if (!await _index.isIndexCreated()) {
14
- await _index.createIndex({ version: 1, deleteIfExists: true });
21
+ function cosineSim(a, b) {
22
+ let dot = 0, na = 0, nb = 0;
23
+ for (let i = 0; i < a.length; i++) {
24
+ dot += a[i] * b[i];
25
+ na += a[i] * a[i];
26
+ nb += b[i] * b[i];
15
27
  }
16
- return _index;
28
+ return dot / (Math.sqrt(na) * Math.sqrt(nb) + 1e-8);
17
29
  }
18
30
 
31
+ // Called after reindex — invalidate cache so next search reloads
19
32
  export async function buildAnnIndex() {
20
- const index = await getIndex();
21
- await index.createIndex({ version: 1, deleteIfExists: true });
22
-
33
+ _cache = null;
34
+ _cacheChunkCount = -1;
23
35
  const db = getDb();
24
- const chunks = db.prepare('SELECT skill_id, chunk_index, embedding FROM chunks').all();
25
-
26
- // Batch ALL inserts into a single disk write — vectra otherwise
27
- // persists the whole index on every insertItem (O(N^2) I/O).
28
- await index.beginUpdate();
29
- try {
30
- for (const chunk of chunks) {
31
- const vec = JSON.parse(chunk.embedding);
32
- await index.insertItem({
33
- vector: vec,
34
- metadata: { skill_id: chunk.skill_id, chunk_index: chunk.chunk_index },
35
- });
36
- }
37
- await index.endUpdate();
38
- } catch (e) {
39
- try { index.cancelUpdate(); } catch {}
40
- throw e;
41
- }
42
-
43
- _index = null; // force reload so queries see fresh index
44
- console.error(`[PromptGraph] ANN index built: ${chunks.length} chunks`);
36
+ const count = db.prepare('SELECT COUNT(*) as n FROM chunks').get().n;
37
+ console.error(`[PromptGraph] In-memory index ready: ${count} chunks`);
45
38
  }
46
39
 
47
40
  export async function annSearch(queryVec, topK = 20) {
48
41
  try {
49
- const index = await getIndex();
50
- if (!await index.isIndexCreated()) return null;
51
- const items = await index.listItems();
52
- if (!items || items.length === 0) return null;
42
+ const db = getDb();
43
+ const cache = loadCache(db);
44
+ if (!cache.length) return null;
45
+
46
+ const qArr = new Float32Array(queryVec);
47
+ const bestBySkill = new Map();
48
+ for (const entry of cache) {
49
+ const score = cosineSim(qArr, entry.vec);
50
+ const prev = bestBySkill.get(entry.skill_id);
51
+ if (!prev || score > prev) bestBySkill.set(entry.skill_id, score);
52
+ }
53
53
 
54
- const results = await index.queryItems(queryVec, topK);
55
- return results.map(r => ({
56
- skill_id: r.item.metadata.skill_id,
57
- score: r.score,
58
- }));
54
+ return [...bestBySkill.entries()]
55
+ .sort((a, b) => b[1] - a[1])
56
+ .slice(0, topK)
57
+ .map(([skill_id, score]) => ({ skill_id, score }));
59
58
  } catch {
60
59
  return null;
61
60
  }
package/chunker.js CHANGED
@@ -2,15 +2,24 @@ const CHUNK_SIZE = 800;
2
2
  const CHUNK_OVERLAP = 100;
3
3
 
4
4
  export function chunkText(text) {
5
- const words = text.split(/\s+/);
5
+ // Split on markdown h1/h2/h3 headers to preserve semantic boundaries
6
+ const sections = text.split(/(?=\n#{1,3} )/);
6
7
  const chunks = [];
7
- let i = 0;
8
8
 
9
- while (i < words.length) {
10
- const chunk = words.slice(i, i + CHUNK_SIZE).join(' ');
11
- chunks.push(chunk);
12
- if (i + CHUNK_SIZE >= words.length) break;
13
- i += CHUNK_SIZE - CHUNK_OVERLAP;
9
+ for (const section of sections) {
10
+ const words = section.split(/\s+/).filter(Boolean);
11
+ if (words.length === 0) continue;
12
+
13
+ if (words.length <= CHUNK_SIZE) {
14
+ chunks.push(section.trim());
15
+ } else {
16
+ let i = 0;
17
+ while (i < words.length) {
18
+ chunks.push(words.slice(i, i + CHUNK_SIZE).join(' '));
19
+ if (i + CHUNK_SIZE >= words.length) break;
20
+ i += CHUNK_SIZE - CHUNK_OVERLAP;
21
+ }
22
+ }
14
23
  }
15
24
 
16
25
  return chunks.length > 0 ? chunks : [text];
package/config.js CHANGED
@@ -3,7 +3,9 @@ import path from 'path';
3
3
  import os from 'os';
4
4
  import readline from 'readline';
5
5
 
6
- const CONFIG_PATH = path.join(os.homedir(), '.claude', '.promptgraph', 'config.json');
6
+ export const PROMPTGRAPH_DIR = path.join(os.homedir(), '.claude', '.promptgraph');
7
+ export const SKILLS_STORE_DIR = path.join(os.homedir(), '.claude', 'skills-store');
8
+ const CONFIG_PATH = path.join(PROMPTGRAPH_DIR, 'config.json');
7
9
 
8
10
  const DEFAULTS = {
9
11
  sources: [
package/db.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import Database from 'better-sqlite3';
2
2
  import path from 'path';
3
- import os from 'os';
4
3
  import fs from 'fs';
4
+ import { PROMPTGRAPH_DIR } from './config.js';
5
5
 
6
- const DB_PATH = path.join(os.homedir(), '.claude', '.promptgraph', 'promptgraph.db');
6
+ const DB_PATH = path.join(PROMPTGRAPH_DIR, 'promptgraph.db');
7
7
 
8
8
  let _db = null;
9
9
 
@@ -30,7 +30,7 @@ export function getDb() {
30
30
  skill_id TEXT NOT NULL,
31
31
  chunk_index INTEGER NOT NULL,
32
32
  text TEXT NOT NULL,
33
- embedding TEXT NOT NULL,
33
+ embedding BLOB NOT NULL,
34
34
  UNIQUE(skill_id, chunk_index)
35
35
  );
36
36
 
@@ -54,9 +54,33 @@ export function getDb() {
54
54
  db.exec('ALTER TABLE skills ADD COLUMN hash TEXT');
55
55
  }
56
56
 
57
+ // migrate: convert JSON text embeddings to Float32 BLOB (one-time, ~10x smaller)
58
+ const textEmbeddings = db.prepare("SELECT COUNT(*) as n FROM chunks WHERE typeof(embedding) = 'text'").get();
59
+ if (textEmbeddings?.n > 0) {
60
+ const rows = db.prepare("SELECT rowid, embedding FROM chunks WHERE typeof(embedding) = 'text'").all();
61
+ const upd = db.prepare('UPDATE chunks SET embedding = ? WHERE rowid = ?');
62
+ db.transaction(() => {
63
+ for (const row of rows) {
64
+ const vec = JSON.parse(row.embedding);
65
+ upd.run(Buffer.from(new Float32Array(vec).buffer), row.rowid);
66
+ }
67
+ })();
68
+ console.error(`[PromptGraph] Migrated ${textEmbeddings.n} embeddings TEXT→BLOB`);
69
+ }
70
+
57
71
  return db;
58
72
  }
59
73
 
60
74
  export function skillId(source, name) {
61
75
  return `${source}::${name}`;
62
76
  }
77
+
78
+ export function vecToBlob(vec) {
79
+ return Buffer.from(new Float32Array(vec).buffer);
80
+ }
81
+
82
+ export function blobToVec(blob) {
83
+ if (typeof blob === 'string') return JSON.parse(blob);
84
+ const buf = Buffer.isBuffer(blob) ? blob : Buffer.from(blob);
85
+ return Array.from(new Float32Array(buf.buffer, buf.byteOffset, buf.length / 4));
86
+ }
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', 'help', '--help', '-h']);
15
+ const KNOWN_COMMANDS = new Set(['init', 'reindex', 'import', 'setup', 'validate', 'marketplace', 'doctor', 'search', 'help', '--help', '-h']);
16
16
 
17
17
  function showHelp() {
18
18
  console.log(
@@ -26,6 +26,7 @@ function showHelp() {
26
26
  const cmds = [
27
27
  ['init', 'First-time setup + index all skills'],
28
28
  ['reindex', 'Re-index all skills'],
29
+ ['search <query>', 'Search skills from the terminal'],
29
30
  ['import <owner/repo>', 'Import skills from GitHub'],
30
31
  ['marketplace [page]', 'Browse the community skill registry'],
31
32
  ['validate <file.md>', 'Validate a skill before publishing'],
@@ -36,7 +37,7 @@ function showHelp() {
36
37
  for (const [cmd, desc] of cmds) {
37
38
  console.log(' ' + chalk.hex('#7C3AED')((bin + ' ' + cmd).padEnd(28)) + chalk.gray(desc));
38
39
  }
39
- console.log(chalk.gray('\nPlatforms: claude-code, claude-desktop, cline, codex, cursor, windsurf'));
40
+ console.log(chalk.gray('\nPlatforms: claude-code, claude-desktop, cline, codex, cursor, windsurf, opencode'));
40
41
  console.log(chalk.gray('\n github.com/NeiP4n/promptgraph · npmjs.com/package/promptgraph-mcp\n'));
41
42
  }
42
43
 
@@ -115,8 +116,14 @@ if (args[0] === 'marketplace' && (args[1] === 'bundles' || args[1] === 'bundle')
115
116
  console.log();
116
117
  });
117
118
 
118
- console.log(' ' + chalk.gray('─'.repeat(54)));
119
- console.log(' ' + chalk.gray('Installs all skills in the set. Run ') + chalk.cyan(`${bin} marketplace`) + chalk.gray(' for single skills.'));
119
+ console.log(
120
+ boxen(
121
+ chalk.dim('install bundle ') + chalk.white('install bundle ') + chalk.hex('#A78BFA')('engineering-essentials') + '\n' +
122
+ chalk.dim('browse skills ') + chalk.cyan(`${bin} marketplace`) + '\n' +
123
+ chalk.dim('publish bundle ') + chalk.white('/pg-publish ') + chalk.hex('#A78BFA')('<bundle.json>'),
124
+ { padding: { top: 0, bottom: 0, left: 1, right: 1 }, borderStyle: 'round', borderColor: '#4B5563', dimBorder: true }
125
+ )
126
+ );
120
127
  console.log();
121
128
  process.exit(0);
122
129
  }
@@ -189,10 +196,21 @@ if (args[0] === 'marketplace') {
189
196
  if (page > 1) nav.push(chalk.dim('‹ ') + chalk.cyan(`${bin} marketplace ${page - 1}`));
190
197
  if (page < totalPages) nav.push(chalk.cyan(`${bin} marketplace ${page + 1}`) + chalk.dim(' ›'));
191
198
  console.log(' ' + nav.join(' '));
199
+ console.log();
192
200
  }
193
- console.log(' ' + chalk.dim('install ') + chalk.cyan('tell your AI:') + ' ' + chalk.white('install ') + chalk.hex('#A78BFA')(slice[0].code || slice[0].id));
194
- console.log(' ' + chalk.dim('bundles ') + chalk.cyan(`${bin} marketplace bundles`));
195
- console.log(' ' + chalk.dim('publish ') + chalk.cyan('tell your AI:') + ' ' + chalk.white('/pg-publish <file>'));
201
+
202
+ const exCode = slice[0]?.code || slice[0]?.id || 'pg-xxxxxx';
203
+ console.log(
204
+ boxen(
205
+ chalk.dim('install skill ') + chalk.white('install ') + chalk.hex('#A78BFA')(exCode) + '\n' +
206
+ chalk.dim('install bundle ') + chalk.white('install bundle ') + chalk.hex('#A78BFA')('engineering-essentials') + '\n' +
207
+ chalk.dim('from GitHub ') + chalk.white('install ') + chalk.hex('#A78BFA')('https://github.com/owner/repo/blob/main/skill.md') + '\n' +
208
+ chalk.dim('publish skill ') + chalk.white('/pg-publish ') + chalk.hex('#A78BFA')('<file.md>') + '\n' +
209
+ chalk.dim('publish bundle ') + chalk.white('/pg-publish ') + chalk.hex('#A78BFA')('<bundle.json>') + '\n' +
210
+ chalk.dim('view bundles ') + chalk.cyan(`${bin} marketplace bundles`),
211
+ { padding: { top: 0, bottom: 0, left: 1, right: 1 }, borderStyle: 'round', borderColor: '#4B5563', dimBorder: true }
212
+ )
213
+ );
196
214
  console.log();
197
215
  process.exit(0);
198
216
  }
@@ -213,6 +231,27 @@ if (args[0] === 'validate') {
213
231
  }
214
232
  }
215
233
 
234
+ if (args[0] === 'search') {
235
+ const query = args.slice(1).join(' ');
236
+ if (!query) { error('Usage: ' + bin + ' search <query>'); process.exit(1); }
237
+ const { search: searchSkills } = await import('./search.js');
238
+ const spin = (await import('./cli.js')).spinner('Searching...');
239
+ spin.start();
240
+ const results = await searchSkills(query, 10);
241
+ spin.stop();
242
+ if (!results.length) { info('No results for: ' + query); process.exit(0); }
243
+ const purple = chalk.hex('#7C3AED');
244
+ console.log();
245
+ results.forEach((s, i) => {
246
+ const score = chalk.dim((s.score * 100).toFixed(0) + '%');
247
+ console.log(' ' + chalk.dim(String(i + 1) + '.') + ' ' + chalk.bold.white(s.name) + ' ' + score);
248
+ console.log(' ' + chalk.dim(s.description || ''));
249
+ console.log(' ' + purple(s.source) + ' ' + chalk.dim(s.path));
250
+ console.log();
251
+ });
252
+ process.exit(0);
253
+ }
254
+
216
255
  if (args[0] === 'import') {
217
256
  const { importFromGitHub } = await import('./github-import.js');
218
257
  await importFromGitHub(args[1]);
@@ -239,6 +278,24 @@ if (args[0] === 'setup') {
239
278
  if (args[0] === 'init') {
240
279
  const { promptConfig } = await import('./config.js');
241
280
  const { indexAll } = await import('./indexer.js');
281
+ const os = await import('os');
282
+ const fs = await import('fs');
283
+ const path = await import('path');
284
+ const commandsDir = path.default.join(os.default.homedir(), '.claude', 'commands');
285
+ if (!fs.default.existsSync(commandsDir)) {
286
+ console.log(chalk.yellow('⚠') + ' ' + chalk.gray('~/.claude/commands/ not found — is Claude Code installed?'));
287
+ console.log(chalk.gray(' Install from: https://claude.ai/download\n'));
288
+ }
289
+ if (!args.includes('--yes') && !args.includes('-y')) {
290
+ const readline = await import('readline');
291
+ const rl = readline.default.createInterface({ input: process.stdin, output: process.stdout });
292
+ const answer = await new Promise(r => rl.question(
293
+ chalk.yellow(' ⚠') + chalk.gray(' First run downloads ~23 MB embedding model (BGE-Small-EN).\n Proceed? [Y/n] '), r
294
+ ));
295
+ rl.close();
296
+ if (answer.trim().toLowerCase() === 'n') { info('Aborted.'); process.exit(0); }
297
+ }
298
+ console.log(chalk.gray('\n Downloading embedding model (~23 MB, one-time)...\n'));
242
299
  const config = await promptConfig();
243
300
  await indexAll();
244
301
  console.log();
@@ -263,8 +320,9 @@ const { Server } = await import('@modelcontextprotocol/sdk/server/index.js');
263
320
  const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js');
264
321
  const { CallToolRequestSchema, ListToolsRequestSchema } = await import('@modelcontextprotocol/sdk/types.js');
265
322
  const { search, getContext, getCallers, getCallees, getImpact, listAll } = await import('./search.js');
323
+ const { loadConfig: _loadConfig, saveConfig: _saveConfig } = await import('./config.js');
266
324
  const { startWatcher } = await import('./watcher.js');
267
- const { browseMarketplace, installSkill, publishSkill, getTopRated, recordUse, recordSuccess, recordFail, browseBundles, installBundle } = await import('./marketplace.js');
325
+ const { browseMarketplace, installSkill, installSkillFromUrl, publishSkill, publishBundle, getTopRated, recordUse, recordSuccess, recordFail, browseBundles, installBundle } = await import('./marketplace.js');
268
326
 
269
327
  const server = new Server(
270
328
  { name: 'promptgraph', version: '1.0.0' },
@@ -386,6 +444,45 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
386
444
  required: ['bundle_id'],
387
445
  },
388
446
  },
447
+ {
448
+ name: 'pg_config',
449
+ description: 'Get or update PromptGraph config. action="get" returns current sources. action="add_source" adds a directory.',
450
+ inputSchema: {
451
+ type: 'object',
452
+ properties: {
453
+ action: { type: 'string', enum: ['get', 'add_source', 'remove_source'] },
454
+ dir: { type: 'string', description: 'Directory path (for add_source)' },
455
+ source: { type: 'string', description: 'Source label (for add_source/remove_source)' },
456
+ },
457
+ required: ['action'],
458
+ },
459
+ },
460
+ {
461
+ name: 'pg_install_url',
462
+ description: 'Install a skill directly from a GitHub URL or raw URL. Validates before saving.',
463
+ inputSchema: {
464
+ type: 'object',
465
+ properties: { url: { type: 'string', description: 'GitHub blob URL or raw URL of a .md skill file' } },
466
+ required: ['url'],
467
+ },
468
+ },
469
+ {
470
+ name: 'pg_bundle_publish',
471
+ description: 'Publish a bundle definition to GitHub Gist and get a registry submission link. Pass a JSON object or path to a .json file.',
472
+ inputSchema: {
473
+ type: 'object',
474
+ properties: {
475
+ bundle: {
476
+ description: 'Bundle definition object { id, name, description, skills[], tags[] } OR file path to .json',
477
+ oneOf: [
478
+ { type: 'object' },
479
+ { type: 'string' },
480
+ ],
481
+ },
482
+ },
483
+ required: ['bundle'],
484
+ },
485
+ },
389
486
  ],
390
487
  }));
391
488
 
@@ -413,6 +510,30 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
413
510
  case 'pg_marketplace_publish': result = await publishSkill(args.file_path); break;
414
511
  case 'pg_bundle_browse': result = await browseBundles(args.top_k || 20); break;
415
512
  case 'pg_bundle_install': result = await installBundle(args.bundle_id); break;
513
+ case 'pg_install_url': result = await installSkillFromUrl(args.url); break;
514
+ case 'pg_bundle_publish': result = await publishBundle(args.bundle); break;
515
+ case 'pg_config': {
516
+ const cfg = _loadConfig();
517
+ if (args.action === 'get') {
518
+ result = { sources: cfg.sources };
519
+ } else if (args.action === 'add_source') {
520
+ if (!args.dir || !args.source) throw new Error('dir and source required for add_source');
521
+ if (cfg.sources.find(s => s.source === args.source)) throw new Error(`Source "${args.source}" already exists`);
522
+ cfg.sources.push({ dir: args.dir, source: args.source });
523
+ _saveConfig(cfg);
524
+ result = { ok: true, sources: cfg.sources };
525
+ } else if (args.action === 'remove_source') {
526
+ if (!args.source) throw new Error('source required for remove_source');
527
+ const before = cfg.sources.length;
528
+ cfg.sources = cfg.sources.filter(s => s.source !== args.source);
529
+ if (cfg.sources.length === before) throw new Error(`Source "${args.source}" not found`);
530
+ _saveConfig(cfg);
531
+ result = { ok: true, sources: cfg.sources };
532
+ } else {
533
+ throw new Error(`Unknown action: ${args.action}`);
534
+ }
535
+ break;
536
+ }
416
537
  default: throw new Error(`Unknown tool: ${name}`);
417
538
  }
418
539
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
@@ -426,3 +547,11 @@ startWatcher();
426
547
  const transport = new StdioServerTransport();
427
548
  await server.connect(transport);
428
549
  console.error('[PromptGraph] MCP server running');
550
+
551
+ const { getDb: _getDb } = await import('./db.js');
552
+ const shutdown = () => {
553
+ try { _getDb().close(); } catch {}
554
+ process.exit(0);
555
+ };
556
+ process.on('SIGTERM', shutdown);
557
+ process.on('SIGINT', shutdown);
package/indexer.js CHANGED
@@ -4,7 +4,7 @@ import fs from 'fs';
4
4
  import path from 'path';
5
5
  import { parseSkillFile, isSkillFile } from './parser.js';
6
6
  import { embedBatch, BATCH_SIZE } from './embedder.js';
7
- import { getDb, skillId } from './db.js';
7
+ import { getDb, skillId, vecToBlob } from './db.js';
8
8
  import { loadConfig } from './config.js';
9
9
  import { chunkText } from './chunker.js';
10
10
  import { buildAnnIndex } from './ann.js';
@@ -54,7 +54,7 @@ async function indexBatch(db, skills) {
54
54
  }
55
55
  for (let i = 0; i < allChunks.length; i++) {
56
56
  const { id, chunkIndex, text } = allChunks[i];
57
- upsertChunk.run(id, chunkIndex, text, JSON.stringify(embeddings[i]));
57
+ upsertChunk.run(id, chunkIndex, text, vecToBlob(embeddings[i]));
58
58
  }
59
59
  })();
60
60
 
@@ -161,9 +161,9 @@ export async function indexAll() {
161
161
  const eta = count > 0 ? Math.round((total - count) * (Date.now() - start) / count / 1000) : '?';
162
162
  progress(count, total, { skipped, eta, errors });
163
163
  }
164
- } catch {
164
+ } catch (e) {
165
165
  errors++;
166
- // Remove stale record if the file was previously indexed but now fails to parse
166
+ console.error(`[PromptGraph] Error indexing ${file}: ${e.message}`);
167
167
  try {
168
168
  const stale = db.prepare('SELECT id FROM skills WHERE path = ?').get(file);
169
169
  if (stale) {
@@ -181,9 +181,6 @@ export async function indexAll() {
181
181
  count += batch.length;
182
182
  }
183
183
 
184
- // rebuild all edges for unchanged skills too (fixes edge loss bug)
185
- rebuildEdgesForUnchanged(db);
186
-
187
184
  progress(total, total, { skipped, errors });
188
185
  progressDone();
189
186
  const spin = spinner('Building ANN index...');
@@ -195,13 +192,6 @@ export async function indexAll() {
195
192
  info(chalk.gray(`Time: ${elapsed}s`));
196
193
  }
197
194
 
198
- function rebuildEdgesForUnchanged(db) {
199
- // For skills that were skipped (hash unchanged), their edges were not touched.
200
- // This is correct — we only delete+rebuild edges for skills that were re-indexed.
201
- // No action needed here: edges for unchanged skills remain intact.
202
- // The global DELETE FROM edges at the start was the bug — it's now removed.
203
- }
204
-
205
195
  export async function indexFile(filePath, source) {
206
196
  const db = getDb();
207
197
  const skill = parseSkillFile(filePath, source);
package/marketplace.js CHANGED
@@ -1,15 +1,43 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
- import os from 'os';
4
3
  import https from 'https';
5
4
  import { createHash } from 'crypto';
6
5
  import { spawnSync } from 'child_process';
6
+ import { createRequire } from 'module';
7
7
  import { getDb } from './db.js';
8
- import { validateSkill } from './validator.js';
9
- import { loadConfig, saveConfig } from './config.js';
8
+ import { validateSkill, validateBundle } from './validator.js';
9
+ import { loadConfig, saveConfig, PROMPTGRAPH_DIR, SKILLS_STORE_DIR } from './config.js';
10
10
 
11
11
  const REGISTRY_URL = 'https://raw.githubusercontent.com/NeiP4n/promptgraph-registry/main/registry.json';
12
- const SKILLS_DIR = path.join(os.homedir(), '.claude', 'skills-store', 'marketplace');
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}`;
13
41
 
14
42
  // Deterministic short code from an id. Same id always yields the same code,
15
43
  // so codes auto-generate — no need to assign them by hand.
@@ -20,7 +48,7 @@ export function codeFor(id) {
20
48
  // node:https GET — reliable and fast on Windows (undici fetch can hang ~10s there).
21
49
  function httpGet(url) {
22
50
  return new Promise((resolve, reject) => {
23
- const req = https.get(url, { headers: { 'User-Agent': 'promptgraph-mcp' }, timeout: 8000, family: 4 }, (res) => {
51
+ const req = https.get(url, { headers: { 'User-Agent': UA }, timeout: 8000, family: 4 }, (res) => {
24
52
  if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
25
53
  return httpGet(res.headers.location).then(resolve, reject);
26
54
  }
@@ -43,18 +71,17 @@ async function rawFetch(url) {
43
71
  try {
44
72
  return await httpGet(url);
45
73
  } catch {
46
- const res = await fetch(url, { headers: { 'User-Agent': 'promptgraph-mcp' } });
74
+ const res = await fetch(url, { headers: { 'User-Agent': UA } });
47
75
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
48
76
  return await res.text();
49
77
  }
50
78
  }
51
79
 
52
80
  // Disk cache for the registry (network to GitHub raw can be slow on some networks).
53
- const CACHE_DIR = path.join(os.homedir(), '.claude', '.promptgraph');
54
81
  const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
55
82
 
56
83
  async function fetchText(url) {
57
- const cacheFile = path.join(CACHE_DIR, 'registry-cache.json');
84
+ const cacheFile = path.join(PROMPTGRAPH_DIR, 'registry-cache.json');
58
85
  const isRegistry = url === REGISTRY_URL;
59
86
 
60
87
  if (isRegistry && fs.existsSync(cacheFile)) {
@@ -70,7 +97,7 @@ async function fetchText(url) {
70
97
 
71
98
  if (isRegistry) {
72
99
  try {
73
- fs.mkdirSync(CACHE_DIR, { recursive: true });
100
+ fs.mkdirSync(PROMPTGRAPH_DIR, { recursive: true });
74
101
  fs.writeFileSync(cacheFile, text);
75
102
  } catch {}
76
103
  }
@@ -91,8 +118,31 @@ export async function browseMarketplace(topK = 20) {
91
118
  }
92
119
  }
93
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
+
94
139
  export async function installSkill(query) {
95
140
  try {
141
+ // GitHub URL or raw URL → direct install
142
+ if (query.startsWith('http://') || query.startsWith('https://')) {
143
+ return installSkillFromUrl(query);
144
+ }
145
+
96
146
  const text = await fetchText(REGISTRY_URL);
97
147
  const registry = JSON.parse(text);
98
148
  const q = String(query).trim().toLowerCase();
@@ -107,7 +157,7 @@ export async function installSkill(query) {
107
157
  (b.code || codeFor(b.id)).toLowerCase() === q || b.id?.toLowerCase() === q
108
158
  );
109
159
  if (bundle) return { error: `"${query}" is a bundle. Use pg_bundle_install("${bundle.id}") instead.` };
110
- return { error: `No skill matching "${query}" (try a code like pg-xxxxxx, an id, or a name)` };
160
+ return { error: `No skill matching "${query}" (try a code, id, name, or GitHub URL)` };
111
161
  }
112
162
  if (!skill.raw_url) return { error: `Skill "${skill.id}" has no download URL` };
113
163
  const skillId = skill.id;
@@ -117,17 +167,8 @@ export async function installSkill(query) {
117
167
  const dest = path.join(SKILLS_DIR, `${skillId}.md`);
118
168
 
119
169
  const content = await fetchText(skill.raw_url);
120
-
121
- // Validate before writing reject malicious or junk downloads
122
- const tmpPath = dest + '.tmp';
123
- fs.writeFileSync(tmpPath, content);
124
- const validation = validateSkill(tmpPath);
125
- if (!validation.ok) {
126
- fs.unlinkSync(tmpPath);
127
- return { error: 'Downloaded skill failed validation', issues: validation.errors };
128
- }
129
- fs.renameSync(tmpPath, dest);
130
-
170
+ const v = writeSkillAtomic(dest, content);
171
+ if (!v.ok) return { error: 'Downloaded skill failed validation', issues: v.errors };
131
172
  return { success: true, path: dest, name: skill.name };
132
173
  } catch (e) {
133
174
  return { error: e.message };
@@ -173,17 +214,16 @@ export async function installBundle(bundleId) {
173
214
  const installed = [];
174
215
  const failed = [];
175
216
 
217
+ const delay = (ms) => new Promise(r => setTimeout(r, ms));
176
218
  for (const skillId of bundle.skills || []) {
177
219
  const skill = registry.skills?.find(s => s.id === skillId);
178
220
  if (!skill?.raw_url) { failed.push(skillId); continue; }
179
221
  try {
222
+ if (installed.length > 0) await delay(300); // rate limit: 300ms between requests
180
223
  const content = await fetchText(skill.raw_url);
181
224
  const dest = path.join(SKILLS_DIR, `${skillId}.md`);
182
- const tmpPath = dest + '.tmp';
183
- fs.writeFileSync(tmpPath, content);
184
- const validation = validateSkill(tmpPath);
185
- if (!validation.ok) { fs.unlinkSync(tmpPath); failed.push(skillId); continue; }
186
- fs.renameSync(tmpPath, dest);
225
+ const v = writeSkillAtomic(dest, content);
226
+ if (!v.ok) { failed.push(skillId); continue; }
187
227
  installed.push(skillId);
188
228
  } catch {
189
229
  failed.push(skillId);
@@ -196,34 +236,88 @@ export async function installBundle(bundleId) {
196
236
  }
197
237
  }
198
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
+
199
252
  export async function publishSkill(filePath) {
200
253
  if (!fs.existsSync(filePath)) return { error: `File not found: ${filePath}` };
201
254
 
202
- // validate before publishing — block junk and malicious skills
203
255
  const validation = validateSkill(filePath);
204
256
  if (!validation.ok) {
205
257
  return { error: 'Validation failed', issues: validation.errors, warnings: validation.warnings };
206
258
  }
207
259
 
208
260
  const name = path.basename(filePath, '.md');
261
+ const gh = ghPublish(filePath, `PromptGraph skill: ${name}`);
209
262
 
210
- try {
211
- const result = spawnSync(
212
- 'gh',
213
- ['gist', 'create', filePath, '--desc', `PromptGraph skill: ${name}`, '--public'],
214
- { encoding: 'utf8' }
215
- );
216
- if (result.status !== 0) {
217
- return { error: result.stderr?.trim() || 'gh CLI error. Run: gh auth login' };
218
- }
263
+ if (gh.no_gh) {
264
+ const content = fs.readFileSync(filePath, 'utf8');
219
265
  return {
220
266
  success: true,
221
- url: result.stdout.trim(),
222
- message: `Published! Submit to registry: https://github.com/NeiP4n/promptgraph-registry/issues/new`,
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,
223
316
  };
224
- } catch {
225
- return { error: 'gh CLI not found. Install from: https://cli.github.com' };
226
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}` };
227
321
  }
228
322
 
229
323
  export function getTopRated(topK = 10) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "promptgraph-mcp",
3
- "version": "1.5.25",
3
+ "version": "1.7.0",
4
4
  "main": "index.js",
5
5
  "type": "module",
6
6
  "bin": {
@@ -42,13 +42,12 @@
42
42
  "fastembed": "^2.1.0",
43
43
  "glob": "^13.0.6",
44
44
  "gray-matter": "^4.0.3",
45
- "ora": "^9.4.0",
46
- "vectra": "^0.15.0"
45
+ "ora": "^9.4.0"
47
46
  },
48
47
  "devDependencies": {
49
48
  "vitest": "^4.1.8"
50
49
  },
51
50
  "overrides": {
52
- "tar": "^7.5.16"
51
+ "tar": "^6.2.1"
53
52
  }
54
53
  }
package/pg-hook.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { embed, cosineSimilarity } from './embedder.js';
3
- import { getDb } from './db.js';
3
+ import { getDb, blobToVec } from './db.js';
4
+ import { annSearch } from './ann.js';
4
5
 
5
6
  let input = '';
6
7
  process.stdin.on('data', d => input += d);
@@ -13,19 +14,34 @@ process.stdin.on('end', async () => {
13
14
  const queryVec = await embed(prompt);
14
15
  const db = getDb();
15
16
 
16
- // search over chunks, deduplicate by skill
17
- const chunks = db.prepare('SELECT skill_id, embedding FROM chunks').all();
18
- const bestBySkill = new Map();
19
- for (const chunk of chunks) {
20
- const score = cosineSimilarity(queryVec, JSON.parse(chunk.embedding));
21
- const prev = bestBySkill.get(chunk.skill_id);
22
- if (!prev || score > prev) bestBySkill.set(chunk.skill_id, score);
23
- }
17
+ // Use ANN index (fast). Fall back to brute-force only if ANN unavailable.
18
+ let topIds;
19
+ const annResults = await annSearch(queryVec, 15);
24
20
 
25
- const topIds = [...bestBySkill.entries()]
26
- .sort((a, b) => b[1] - a[1])
27
- .slice(0, 3)
28
- .filter(([, score]) => score > 0.55);
21
+ if (annResults && annResults.length > 0) {
22
+ const bestBySkill = new Map();
23
+ for (const r of annResults) {
24
+ const prev = bestBySkill.get(r.skill_id);
25
+ if (!prev || r.score > prev) bestBySkill.set(r.skill_id, r.score);
26
+ }
27
+ topIds = [...bestBySkill.entries()]
28
+ .sort((a, b) => b[1] - a[1])
29
+ .slice(0, 3)
30
+ .filter(([, score]) => score > 0.55);
31
+ } else {
32
+ // Fallback: brute-force (only before first reindex)
33
+ const chunks = db.prepare('SELECT skill_id, embedding FROM chunks').all();
34
+ const bestBySkill = new Map();
35
+ for (const chunk of chunks) {
36
+ const score = cosineSimilarity(queryVec, blobToVec(chunk.embedding));
37
+ const prev = bestBySkill.get(chunk.skill_id);
38
+ if (!prev || score > prev) bestBySkill.set(chunk.skill_id, score);
39
+ }
40
+ topIds = [...bestBySkill.entries()]
41
+ .sort((a, b) => b[1] - a[1])
42
+ .slice(0, 3)
43
+ .filter(([, score]) => score > 0.55);
44
+ }
29
45
 
30
46
  if (topIds.length === 0) process.exit(0);
31
47
 
package/platform.js CHANGED
@@ -1,80 +1,102 @@
1
1
  import os from 'os';
2
2
  import path from 'path';
3
3
  import fs from 'fs';
4
+ import { spawnSync } from 'child_process';
4
5
 
5
6
  const HOME = os.homedir();
6
7
 
8
+ const STD_ENTRY = { command: 'npx', args: ['promptgraph-mcp'] };
9
+ const OC_ENTRY = { type: 'local', command: ['npx', 'promptgraph-mcp'], enabled: true };
10
+
11
+ // Shared helper: write standard mcpServers entry (Claude Code, Cursor, Windsurf, Codex format)
12
+ function addStdMcp(configPath) {
13
+ const json = readJson(configPath) || {};
14
+ json.mcpServers = json.mcpServers || {};
15
+ json.mcpServers.promptgraph = STD_ENTRY;
16
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
17
+ writeJson(configPath, json);
18
+ }
19
+
20
+ // Cline uses servers.promptgraph (not mcpServers)
21
+ function addClineMcp(configPath) {
22
+ const json = readJson(configPath) || {};
23
+ json.servers = json.servers || {};
24
+ json.servers.promptgraph = STD_ENTRY;
25
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
26
+ writeJson(configPath, json);
27
+ }
28
+
29
+ // OpenCode uses mcp.promptgraph with type/command/enabled
30
+ function addOpenCodeMcp(configPath) {
31
+ const json = readJson(configPath) || {};
32
+ json.mcp = json.mcp || {};
33
+ json.mcp.promptgraph = OC_ENTRY;
34
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
35
+ writeJson(configPath, json);
36
+ }
37
+
7
38
  export const PLATFORMS = {
8
39
  'claude-code': {
9
40
  name: 'Claude Code',
10
41
  configPath: path.join(HOME, '.claude', 'settings.json'),
11
- addMcp: (config, serverPath) => {
12
- const json = readJson(config.configPath);
13
- json.mcpServers = json.mcpServers || {};
14
- json.mcpServers.promptgraph = { command: 'npx', args: ['promptgraph-mcp'] };
15
- writeJson(config.configPath, json);
16
- },
42
+ addMcp: (config) => addStdMcp(config.configPath),
17
43
  },
18
44
  'claude-desktop': {
19
45
  name: 'Claude Desktop',
20
46
  configPath: getClaudeDesktopConfig(),
21
- addMcp: (config, serverPath) => {
22
- const json = readJson(config.configPath);
23
- json.mcpServers = json.mcpServers || {};
24
- json.mcpServers.promptgraph = { command: 'npx', args: ['promptgraph-mcp'] };
25
- writeJson(config.configPath, json);
26
- },
47
+ addMcp: (config) => addStdMcp(config.configPath),
27
48
  },
28
49
  'cline': {
29
50
  name: 'Cline (VS Code)',
30
51
  configPath: path.join(HOME, '.vscode', 'mcp.json'),
31
- addMcp: (config, serverPath) => {
32
- const json = readJson(config.configPath) || { servers: {} };
33
- json.servers = json.servers || {};
34
- json.servers.promptgraph = { command: 'npx', args: ['promptgraph-mcp'] };
35
- fs.mkdirSync(path.dirname(config.configPath), { recursive: true });
36
- writeJson(config.configPath, json);
37
- },
52
+ addMcp: (config) => addClineMcp(config.configPath),
38
53
  },
39
54
  'codex': {
40
55
  name: 'OpenAI Codex CLI',
41
56
  configPath: path.join(HOME, '.codex', 'config.json'),
42
- addMcp: (config, serverPath) => {
43
- const json = readJson(config.configPath) || {};
44
- json.mcpServers = json.mcpServers || {};
45
- json.mcpServers.promptgraph = { command: 'npx', args: ['promptgraph-mcp'] };
46
- fs.mkdirSync(path.dirname(config.configPath), { recursive: true });
47
- writeJson(config.configPath, json);
48
- },
57
+ addMcp: (config) => addStdMcp(config.configPath),
49
58
  },
50
59
  'cursor': {
51
60
  name: 'Cursor',
52
61
  configPath: path.join(HOME, '.cursor', 'mcp.json'),
53
- addMcp: (config, serverPath) => {
54
- const json = readJson(config.configPath) || { mcpServers: {} };
55
- json.mcpServers.promptgraph = { command: 'npx', args: ['promptgraph-mcp'] };
56
- fs.mkdirSync(path.dirname(config.configPath), { recursive: true });
57
- writeJson(config.configPath, json);
58
- },
62
+ addMcp: (config) => addStdMcp(config.configPath),
59
63
  },
60
64
  'windsurf': {
61
65
  name: 'Windsurf',
62
66
  configPath: path.join(HOME, '.codeium', 'windsurf', 'mcp_config.json'),
63
- addMcp: (config, serverPath) => {
64
- const json = readJson(config.configPath) || { mcpServers: {} };
65
- json.mcpServers.promptgraph = { command: 'npx', args: ['promptgraph-mcp'] };
66
- fs.mkdirSync(path.dirname(config.configPath), { recursive: true });
67
- writeJson(config.configPath, json);
68
- },
67
+ addMcp: (config) => addStdMcp(config.configPath),
68
+ },
69
+ 'opencode': {
70
+ name: 'OpenCode',
71
+ configPath: getOpenCodeConfig(),
72
+ addMcp: (config) => addOpenCodeMcp(config.configPath),
69
73
  },
70
74
  };
71
75
 
72
76
  export function detectPlatforms() {
73
77
  return Object.entries(PLATFORMS)
74
- .filter(([, p]) => p.configPath && fs.existsSync(path.dirname(p.configPath)))
78
+ .filter(([id, p]) => {
79
+ if (!p.configPath) return false;
80
+ if (fs.existsSync(path.dirname(p.configPath))) return true;
81
+ if (id === 'opencode') return isOpenCodeInstalled();
82
+ return false;
83
+ })
75
84
  .map(([id, p]) => ({ id, ...p }));
76
85
  }
77
86
 
87
+ function isOpenCodeInstalled() {
88
+ try {
89
+ const r = spawnSync('opencode', ['--version'], { encoding: 'utf8', timeout: 3000 });
90
+ return r.status === 0;
91
+ } catch { return false; }
92
+ }
93
+
94
+ function getOpenCodeConfig() {
95
+ if (process.platform === 'win32') return path.join(HOME, 'AppData', 'Roaming', 'opencode', 'opencode.json');
96
+ if (process.platform === 'darwin') return path.join(HOME, 'Library', 'Application Support', 'opencode', 'opencode.json');
97
+ return path.join(HOME, '.config', 'opencode', 'opencode.json');
98
+ }
99
+
78
100
  function getClaudeDesktopConfig() {
79
101
  if (process.platform === 'win32') {
80
102
  const base = process.env.LOCALAPPDATA || '';
package/search.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { embed, cosineSimilarity } from './embedder.js';
2
- import { getDb } from './db.js';
2
+ import { getDb, blobToVec } from './db.js';
3
3
  import { annSearch } from './ann.js';
4
4
 
5
5
  function applyRatingBoost(db, id, score) {
@@ -28,10 +28,7 @@ export async function search(query, topK = 5) {
28
28
  .map(([id, score]) => ({ id, score: applyRatingBoost(db, id, score) }))
29
29
  .sort((a, b) => b.score - a.score)
30
30
  .slice(0, topK)
31
- .map(({ id, score }) => {
32
- const skill = db.prepare('SELECT id, name, description, path, source FROM skills WHERE id = ?').get(id);
33
- return skill ? { ...skill, score } : null;
34
- })
31
+ .map(({ id, score }) => skillWithSnippet(db, id, score))
35
32
  .filter(Boolean);
36
33
  }
37
34
 
@@ -39,7 +36,7 @@ export async function search(query, topK = 5) {
39
36
  const chunks = db.prepare('SELECT skill_id, embedding FROM chunks').all();
40
37
  const bestBySkill = new Map();
41
38
  for (const chunk of chunks) {
42
- const score = cosineSimilarity(queryVec, JSON.parse(chunk.embedding));
39
+ const score = cosineSimilarity(queryVec, blobToVec(chunk.embedding));
43
40
  const prev = bestBySkill.get(chunk.skill_id);
44
41
  if (!prev || score > prev) bestBySkill.set(chunk.skill_id, score);
45
42
  }
@@ -47,13 +44,18 @@ export async function search(query, topK = 5) {
47
44
  .map(([id, score]) => ({ id, score: applyRatingBoost(db, id, score) }))
48
45
  .sort((a, b) => b.score - a.score)
49
46
  .slice(0, topK)
50
- .map(({ id, score }) => {
51
- const skill = db.prepare('SELECT id, name, description, path, source FROM skills WHERE id = ?').get(id);
52
- return skill ? { ...skill, score } : null;
53
- })
47
+ .map(({ id, score }) => skillWithSnippet(db, id, score))
54
48
  .filter(Boolean);
55
49
  }
56
50
 
51
+ function skillWithSnippet(db, id, score) {
52
+ const skill = db.prepare('SELECT id, name, description, path, source, content FROM skills WHERE id = ?').get(id);
53
+ if (!skill) return null;
54
+ const { content, ...rest } = skill;
55
+ const snippet = content?.replace(/^---[\s\S]*?---\n?/, '').trim().slice(0, 400) || '';
56
+ return { ...rest, score, snippet };
57
+ }
58
+
57
59
  export function getContext(nameOrId) {
58
60
  const db = getDb();
59
61
  let skill = db.prepare('SELECT * FROM skills WHERE id = ?').get(nameOrId);
@@ -108,7 +110,7 @@ export function getImpact(nameOrId) {
108
110
  const callers = db.prepare('SELECT from_skill FROM edges WHERE to_skill = ?').all(cur).map(r => r.from_skill);
109
111
  queue.push(...callers);
110
112
  }
111
- visited.delete(id);
113
+ visited.delete(res.id);
112
114
  return [...visited];
113
115
  }
114
116
 
package/validator.js CHANGED
@@ -87,6 +87,47 @@ export function validateSkill(filePath) {
87
87
  return { ok: errors.length === 0, errors, warnings };
88
88
  }
89
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
+
90
131
  // CLI: node validator.js <file>
91
132
  if (import.meta.url === `file://${process.argv[1]}`) {
92
133
  const file = process.argv[2];
package/watcher.js CHANGED
@@ -25,8 +25,11 @@ export function startWatcher() {
25
25
  }
26
26
 
27
27
  function getSource(filePath, config) {
28
- for (const { dir, source } of config.sources) {
29
- if (filePath.startsWith(dir)) return source;
28
+ const normFile = path.resolve(filePath);
29
+ // Sort longest-first so skills-store/marketplace wins over skills-store
30
+ const sorted = [...config.sources].sort((a, b) => b.dir.length - a.dir.length);
31
+ for (const { dir, source } of sorted) {
32
+ if (normFile.startsWith(path.resolve(dir))) return source;
30
33
  }
31
34
  return 'unknown';
32
35
  }