promptgraph-mcp 2.1.4 → 2.1.5

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/README.md +80 -79
  2. package/index.js +25 -174
  3. package/package.json +1 -1
  4. package/tui.js +331 -0
package/README.md CHANGED
@@ -1,54 +1,59 @@
1
1
  # PromptGraph
2
2
 
3
- **Stop burning 50,000 tokens on skills you won't use.**
3
+ **Semantic skill router and marketplace for Claude Code.**
4
4
 
5
- PromptGraph is an MCP server that gives Claude Code a semantic skill index — vector search, skill graph, and a community marketplace. Instead of cramming every `.md` into your system prompt, Claude finds and loads only the one skill it needs.
5
+ Instead of loading every `.md` skill into your context, Claude calls `pg_search` and loads only the one skill it needs.
6
6
 
7
7
  [![npm](https://img.shields.io/npm/v/promptgraph-mcp?color=7C3AED&label=npm)](https://www.npmjs.com/package/promptgraph-mcp)
8
8
  [![license](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
9
9
 
10
10
  ---
11
11
 
12
- ## Why
13
-
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
-
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.
12
+ ## How it works
17
13
 
18
14
  ```
19
- Before: route across all 40 skills → 40 × read overhead
20
- After: 1 pg_search call + snippet read only what you need
15
+ pg_search("refactor without breaking tests")
16
+ embed query (BGE-Small-EN, 384-dim)
17
+ → flat cosine similarity over in-memory index
18
+ → return top skill path + snippet
19
+ → Claude reads only that file
21
20
  ```
22
21
 
22
+ **Index:** SQLite + Float32 BLOB embeddings, flat cosine search in-memory.
23
+ No external vector DB, no API key, no cloud.
24
+
25
+ **File watcher:** `chokidar` detects `.md` changes and reindexes automatically (MCP server mode only).
26
+
23
27
  ---
24
28
 
25
- ## Features
29
+ ## Benchmarks (measured on real hardware)
26
30
 
27
- - 🔍 **Semantic search** — finds skills by meaning, not just keywords (HNSW, O(log N))
28
- - 📦 **Marketplace** — browse and install community skills with one command
29
- - 🧩 **Skill bundles** install curated packs (e.g. `engineering-essentials`)
30
- - 🔗 **Dependency graph** tracks which skills call other skills (`pg_callers`, `pg_impact`)
31
- - **Local embeddings** `fastembed` BGE-Small-EN, 23 MB, no API key needed
32
- - 👁️ **File watcher** auto-reindexes when you add or edit skills
33
- - 🛡️ **Validator** blocks malicious/junk skills before they reach your machine
34
- - 🌐 **MCP-native** — works with Claude Code, Claude Desktop, Cline, OpenCode, Cursor, Windsurf, and any MCP client
31
+ | Operation | Result |
32
+ |---|---|
33
+ | 88 new skills indexed (first time, cold ONNX) | **49.5 s** |
34
+ | 88 skills reindexed (unchanged, hash match) | **< 1 s** |
35
+ | `pg reindex --fast` (3000 files, keyword only) | **~30 s** |
36
+ | `pg reindex` full embed (3000 files) | **~30 min** |
37
+ | Semantic search query | **< 50 ms** |
38
+ | Model size (BGE-Small-EN-v1.5, one-time download) | **23 MB** |
39
+ | Embedding dimensions | **384** |
40
+ | Max chunks per skill | **2** |
41
+ | Embedding batch size | **256** |
42
+
43
+ > ONNX model initialization (~2–3 min) happens once on first use and is cached in `~/.claude/.promptgraph/model-cache/`.
35
44
 
36
45
  ---
37
46
 
38
47
  ## Quick Start
39
48
 
40
49
  ```bash
41
- # one-time global install (recommended — faster than npx every time)
42
50
  npm install -g promptgraph-mcp@latest
43
51
  pg init
44
-
45
- # or without installing
46
- npx promptgraph-mcp init
47
52
  ```
48
53
 
49
- `init` downloads the embedding model (~23 MB, once), indexes your skills, and prints the config to paste into `settings.json`.
54
+ `pg init` downloads the model (~23 MB, once), indexes your local skills, and prints the config snippet.
50
55
 
51
- ### Add to Claude Code (`~/.claude/settings.json`)
56
+ ### Claude Code (`~/.claude/settings.json`)
52
57
 
53
58
  ```json
54
59
  {
@@ -61,7 +66,11 @@ npx promptgraph-mcp init
61
66
  }
62
67
  ```
63
68
 
64
- ### Add to OpenCode (`~/.config/opencode/opencode.json`)
69
+ ### Claude Desktop / Cursor / Windsurf / Cline
70
+
71
+ Same config — any MCP-compatible client works.
72
+
73
+ ### OpenCode (`~/.config/opencode/opencode.json`)
65
74
 
66
75
  ```json
67
76
  {
@@ -75,112 +84,104 @@ npx promptgraph-mcp init
75
84
  }
76
85
  ```
77
86
 
78
- > `pg setup` auto-detects OpenCode and writes this config for you.
87
+ > `pg setup` auto-detects installed clients and writes config automatically.
79
88
 
80
- ### Move your skills out of `commands/`
89
+ ---
90
+
91
+ ## CLI
81
92
 
82
93
  ```bash
83
- mkdir -p ~/.claude/skills-store
84
- mv ~/.claude/commands/*.md ~/.claude/skills-store/
85
- mv ~/.claude/skills-store/pg.md ~/.claude/commands/ # keep only the router
94
+ pg init # first-time setup
95
+ pg status # show indexed sources, repos, installed bundles
96
+ pg reindex # full reindex (semantic search, slow)
97
+ pg reindex --fast # keyword-only reindex (~30s, no embeddings)
98
+ pg search "deploy" # search from terminal
99
+ pg import owner/repo # clone and index any GitHub repo of .md skills
100
+ pg marketplace # browse skills by category
101
+ pg marketplace bundles # browse curated bundles
102
+ pg bundle install <id> # install a bundle
103
+ pg validate my-skill.md # validate before publishing
104
+ pg doctor # clean orphaned DB rows
105
+ pg update # update to latest version
86
106
  ```
87
107
 
88
108
  ---
89
109
 
90
110
  ## Marketplace
91
111
 
92
- Browse and install community skills without leaving your terminal:
93
-
94
112
  ```bash
95
- pg marketplace # browse skills
96
- pg marketplace bundles # browse bundles
97
- ```
98
-
99
- Or ask Claude directly:
100
-
101
- ```
102
- install pg-a1b2c3 # by code
103
- install systematic-debugging # by name
113
+ pg marketplace # 🛠 Engineering 💻 Coding 🤖 AI Tools 🔒 Security 🎨 Creative
114
+ pg marketplace Engineering # filter by category
115
+ pg marketplace bundles # install whole repos as skill bundles
104
116
  ```
105
117
 
106
- **Publish your own skill:**
118
+ **Bundles** install an entire GitHub repo as a skill source — auto-detects `skills/`, `commands/`, `prompts/` subdirectory.
107
119
 
120
+ Example:
108
121
  ```bash
109
- pg publish ~/.claude/skills-store/my-skill.md
122
+ pg bundle install elementalsouls-claude-bughunter # 88 security skills from GitHub
123
+ pg bundle install engineering-essentials # 4 curated workflow skills
110
124
  ```
111
125
 
112
- > Skills are validated automatically — dangerous patterns, prompt injection, and junk are rejected by CI before they enter the registry.
113
-
114
- ---
126
+ **Publish your skill** (auto-validated, no manual review):
115
127
 
116
- ## CLI
128
+ Open an issue on [promptgraph-registry](https://github.com/NeiP4n/promptgraph-registry) with label `skill-submission`. The bot fetches, validates, commits, and closes the issue automatically.
117
129
 
118
- ```bash
119
- pg init # first-time setup
120
- pg reindex # re-index all skills
121
- pg search "deploy" # search from terminal
122
- pg list # list all indexed skills
123
- pg marketplace # browse registry
124
- pg import owner/repo # import any GitHub repo full of .md skills
125
- pg validate my-skill.md
126
- pg doctor # clean up orphaned data
127
- ```
130
+ Anti-spam checks: min 200 chars, 2+ headers, code/bullets required, prompt injection detection, duplicate URL/description check, 3 submissions per user per 24h.
128
131
 
129
132
  ---
130
133
 
131
- ## MCP Tools (used by Claude automatically)
134
+ ## MCP Tools
135
+
136
+ Claude uses these automatically when the MCP server is running:
132
137
 
133
138
  | Tool | What it does |
134
139
  |---|---|
135
- | `pg_search` | Semantic skill search by task description |
140
+ | `pg_search` | Semantic search by task description |
136
141
  | `pg_list` | List all indexed skills |
137
142
  | `pg_context` | Full skill details + callers/callees |
138
143
  | `pg_callers` | Which skills reference this one |
139
144
  | `pg_callees` | Which skills this one calls |
140
145
  | `pg_impact` | What breaks if this skill changes |
141
146
  | `pg_marketplace_browse` | Browse community registry |
142
- | `pg_marketplace_install` | Install a skill by code, id, or name |
147
+ | `pg_marketplace_install` | Install a skill by code or name |
143
148
  | `pg_bundle_browse` | Browse skill bundles |
144
149
  | `pg_bundle_install` | Install a bundle |
145
- | `pg_top_rated` | Highest-rated local skills |
146
- | `pg_rate` | Rate a skill (success/fail) |
150
+ | `pg_top_rated` | Highest-rated skills |
151
+ | `pg_rate` | Rate a skill after use |
147
152
 
148
153
  ---
149
154
 
150
- ## Token Savings
155
+ ## Search modes
151
156
 
152
- | | Before PromptGraph | After PromptGraph |
157
+ | Mode | How | Quality |
153
158
  |---|---|---|
154
- | Skills in system prompt | All 40+ every session | 1 router (~150 tokens) |
155
- | Tokens per session | 20,000 50,000 | ~300 + 1 skill on demand |
156
- | Skills you can have | ~30 before it gets painful | Unlimited |
159
+ | After `pg reindex` | Cosine similarity on 384-dim BGE vectors | Semantic finds by meaning |
160
+ | After `pg reindex --fast` | SQLite FTS5 BM25 | Keyword exact word match |
157
161
 
158
- ---
162
+ Search falls back to FTS5 automatically if no embeddings exist.
159
163
 
160
- ## How It Works
164
+ ---
161
165
 
162
- ```
163
- pg_search("refactor without breaking tests")
164
- → embed query → HNSW ANN search → rank by cosine + rating boost
165
- → return top skill path
166
- → Claude reads only that file
167
- ```
166
+ ## Skill filtering
168
167
 
169
- Embeddings are stored in SQLite. The HNSW index ([vectra](https://github.com/Stevenic/vectra)) keeps search sub-millisecond even at thousands of skills. Skills are re-indexed automatically via `chokidar` file watcher.
168
+ When importing a GitHub repo, PromptGraph:
169
+ 1. Looks for a dedicated subdir (`skills/`, `commands/`, `prompts/`, `agents/`, `templates/`, etc.) — indexes only that dir if found with 2+ `.md` files
170
+ 2. Falls back to repo root with content quality filter: min 150 chars, 2+ headers, must have code blocks or bullet points, skips readme/changelog/license/docs
170
171
 
171
172
  ---
172
173
 
173
174
  ## Requirements
174
175
 
175
176
  - Node.js 18+
176
- - Claude Code or Claude Desktop (any MCP-compatible client)
177
+ - Any MCP-compatible client (Claude Code, Claude Desktop, Cline, OpenCode, Cursor, Windsurf…)
177
178
 
178
179
  ---
179
180
 
180
181
  ## Related
181
182
 
182
- - 📋 [promptgraph-registry](https://github.com/NeiP4n/promptgraph-registry) — community skill registry
183
+ - 📋 [promptgraph-registry](https://github.com/NeiP4n/promptgraph-registry) — community skill registry and auto-publish bot
183
184
 
184
185
  ---
185
186
 
186
- *Built with [Claude](https://claude.com/claude-code) by Anthropic.*
187
+ *Built with [Claude Code](https://claude.com/claude-code).*
package/index.js CHANGED
@@ -33,8 +33,7 @@ function showHelp() {
33
33
  ['search <query>', 'Search skills from the terminal'],
34
34
  ['import <owner/repo>', 'Import skills from GitHub'],
35
35
  ['status', 'Show installed skills, repos, and bundles'],
36
- ['marketplace [cat]', 'Browse skills by category (Engineering, Coding, …)'],
37
- ['marketplace bundles', 'Browse skill bundles grouped by category'],
36
+ ['marketplace', 'Interactive TUI: browse + search + install skills & bundles'],
38
37
  ['validate <file.md>', 'Validate a skill before publishing'],
39
38
  ['doctor', 'Clean orphaned chunks/edges/ratings'],
40
39
  ['update', 'Update to the latest version from npm'],
@@ -177,182 +176,34 @@ if (args[0] === 'status') {
177
176
  process.exit(0);
178
177
  }
179
178
 
180
- if (args[0] === 'marketplace' && (args[1] === 'bundles' || args[1] === 'bundle')) {
181
- const { browseBundles } = await import('./marketplace.js');
182
- const purple = chalk.hex('#7C3AED');
183
- const spin = (await import('./cli.js')).spinner('Fetching bundles...');
184
- spin.start();
185
- const bundles = await browseBundles(1000);
186
- spin.stop();
187
- (await import('./cli.js')).clearScreen();
188
-
189
- if (bundles?.error) { error(bundles.error); process.exit(1); }
190
-
191
- console.log();
192
- console.log(' ' + purple.bold('PromptGraph Bundles') + chalk.gray(' curated skill sets'));
193
- console.log(' ' + chalk.gray(`${bundles.length} bundle${bundles.length === 1 ? '' : 's'}`));
194
- console.log(' ' + chalk.gray('─'.repeat(54)));
195
- console.log();
196
-
197
- if (!bundles.length) {
198
- info('No bundles yet.');
199
- console.log(chalk.gray(' github.com/NeiP4n/promptgraph-registry\n'));
200
- process.exit(0);
201
- }
202
-
203
- const CATEGORY_ICONS = { Engineering: '🛠', 'AI Tools': '🤖', Coding: '💻', Creative: '🎨', Security: '🔒', Community: '🌐' };
204
-
205
- const wrapB = (t, w, ind) => {
206
- const words = (t || '').split(/\s+/); const lines = []; let line = '';
207
- for (const x of words) { if ((line + ' ' + x).trim().length > w) { lines.push(line.trim()); line = x; } else line += ' ' + x; }
208
- if (line.trim()) lines.push(line.trim());
209
- return lines.map(l => ind + chalk.gray(l)).join('\n');
210
- };
211
-
212
- // Group by category
213
- const byCategory = {};
214
- for (const b of bundles) {
215
- const cat = b.category || 'Community';
216
- (byCategory[cat] = byCategory[cat] || []).push(b);
217
- }
218
-
219
- let globalIdx = 1;
220
- for (const [cat, items] of Object.entries(byCategory)) {
221
- const icon = CATEGORY_ICONS[cat] || '📦';
222
- console.log(' ' + chalk.hex('#7C3AED').bold(`${icon} ${cat}`));
223
- console.log(' ' + chalk.gray('─'.repeat(50)));
224
- for (const b of items) {
225
- const stars = b.stars > 0 ? chalk.yellow('★ ' + b.stars) : chalk.gray('★ 0');
226
- const countLabel = b.repo_url
227
- ? chalk.blue((b.skillCount ? b.skillCount + ' skills · ' : '') + 'GitHub')
228
- : chalk.gray((b.skills?.length || 0) + ' skills');
229
- console.log(' ' + chalk.gray(globalIdx++ + '.') + ' ' + chalk.white.bold(b.id) + ' ' + stars + ' ' + countLabel);
230
- console.log(wrapB(b.description, 64, ' '));
231
- console.log(' ' + chalk.gray('includes: ') + (b.repo_url ? chalk.blue(b.repo_url) : chalk.gray((b.skills || []).join(', '))));
232
- if (b.tags?.length) console.log(' ' + purple(b.tags.map(t => '#' + t).join(' ')));
233
- console.log(' ' + chalk.gray('install: ') + chalk.cyan(`pg bundle install ${b.id}`));
234
- console.log();
235
- }
236
- }
237
-
238
- console.log(
239
- boxen(
240
- chalk.dim('install bundle ') + chalk.cyan(`pg bundle install <id>`) + '\n' +
241
- chalk.dim('add repo ') + chalk.cyan(`pg bundle add-repo <owner/repo>`) + '\n' +
242
- chalk.dim('browse skills ') + chalk.cyan(`${bin} marketplace`),
243
- { padding: { top: 0, bottom: 0, left: 1, right: 1 }, borderStyle: 'round', borderColor: '#4B5563', dimBorder: true }
244
- )
245
- );
246
- console.log();
247
- process.exit(0);
248
- }
249
-
250
179
  if (args[0] === 'marketplace') {
251
- const { browseMarketplace } = await import('./marketplace.js');
252
- const purple = chalk.hex('#7C3AED');
253
- const W = 60;
254
-
255
- // Support: pg marketplace <category> or pg marketplace <page>
256
- const arg1 = args[1];
257
- const pageArg = parseInt(arg1);
258
- const categoryFilter = (!arg1 || isNaN(pageArg)) ? (arg1 || null) : null;
259
- const PER_PAGE = categoryFilter ? 1000 : 10;
260
- const page = categoryFilter ? 1 : Math.max(1, pageArg || 1);
261
-
262
- const { clearScreen } = await import('./cli.js');
263
- const spin = (await import('./cli.js')).spinner('Fetching registry...');
264
- spin.start();
265
- let all = await browseMarketplace(1000);
266
- spin.stop();
267
- clearScreen();
268
-
269
- if (all?.error) { error(all.error); process.exit(1); }
270
- if (!all.length) {
271
- info('Registry is empty. Be the first to contribute!');
272
- console.log(chalk.gray(' github.com/NeiP4n/promptgraph-registry\n'));
273
- process.exit(0);
274
- }
275
-
276
- const SKILL_CAT_ICONS = { Engineering: '🛠', 'AI Tools': '🤖', Coding: '💻', Creative: '🎨', Security: '🔒', Community: '🌐' };
277
-
278
- const wrap = (text, width, indent) => {
279
- const words = (text || '').split(/\s+/);
280
- const lines = []; let line = '';
281
- for (const w of words) {
282
- if ((line + ' ' + w).trim().length > width) { lines.push(line.trim()); line = w; }
283
- else line += ' ' + w;
284
- }
285
- if (line.trim()) lines.push(line.trim());
286
- return lines.map(l => indent + chalk.dim(l)).join('\n');
287
- };
288
-
289
- // Filter by category if provided
290
- if (categoryFilter) {
291
- const matched = categoryFilter.toLowerCase();
292
- all = all.filter(s => (s.category || 'community').toLowerCase().includes(matched));
293
- if (!all.length) {
294
- error(`No skills in category "${categoryFilter}"`);
295
- process.exit(1);
296
- }
297
- }
298
-
299
- // Group by category
300
- const byCategory = {};
301
- for (const s of all) {
302
- const cat = s.category || 'Community';
303
- (byCategory[cat] = byCategory[cat] || []).push(s);
304
- }
305
-
306
- // header
307
- console.log();
308
- console.log(' ' + purple.bold('◆ PromptGraph') + chalk.dim(' · marketplace'));
309
- if (categoryFilter) {
310
- console.log(' ' + chalk.dim(`${all.length} skills in "${categoryFilter}"`));
311
- } else {
312
- const cats = Object.keys(byCategory);
313
- console.log(' ' + chalk.dim(`${all.length} skills · ${cats.length} categories`));
180
+ if (!process.stdout.isTTY) {
181
+ error('marketplace TUI requires an interactive terminal');
182
+ process.exit(1);
314
183
  }
315
- console.log(chalk.dim(' ' + ''.repeat(W)));
316
- console.log();
317
-
318
- // Display grouped by category
319
- for (const [cat, items] of Object.entries(byCategory)) {
320
- const icon = SKILL_CAT_ICONS[cat] || '📦';
321
- console.log(' ' + purple.bold(`${icon} ${cat}`) + chalk.dim(` (${items.length})`));
322
-
323
- // If not filtering and category has many, paginate within category
324
- const showItems = (categoryFilter || items.length <= 5) ? items : items.slice(0, 5);
325
- for (const s of showItems) {
326
- const code = s.code ? chalk.hex('#A78BFA')(s.code) : '';
327
- const stars = chalk.yellow('★') + chalk.dim(' ' + (s.stars || 0));
328
- console.log(' ' + chalk.bold.white(s.id) + ' ' + code + ' ' + stars);
329
- console.log(wrap(s.description, W - 6, ' '));
330
- if (s.tags?.length) console.log(' ' + chalk.dim(s.tags.map(t => '#' + t).join(' ')));
331
- console.log(' ' + chalk.dim('install: ') + chalk.cyan(`${bin} install ${s.code || s.id}`));
332
- console.log();
333
- }
334
- if (!categoryFilter && items.length > 5) {
335
- console.log(' ' + chalk.dim(`... and ${items.length - 5} more · `) + chalk.cyan(`${bin} marketplace ${cat}`));
336
- console.log();
184
+ const { browseMarketplace, browseBundles, installSkill, installBundle } = await import('./marketplace.js');
185
+ const { spinner: spin2 } = await import('./cli.js');
186
+ const sp = spin2('Fetching marketplace...');
187
+ sp.start();
188
+ const [skills, bundles] = await Promise.all([browseMarketplace(1000), browseBundles(1000)]);
189
+ sp.stop();
190
+
191
+ if (skills?.error) { error(skills.error); process.exit(1); }
192
+
193
+ const { runTUI } = await import('./tui.js');
194
+ await runTUI(
195
+ Array.isArray(skills) ? skills : [],
196
+ Array.isArray(bundles) ? bundles : [],
197
+ async (item) => {
198
+ if (item.type === 'bundle') {
199
+ const r = await installBundle(item.id);
200
+ if (r?.error) throw new Error(r.error);
201
+ } else {
202
+ const r = await installSkill(item.code || item.id);
203
+ if (r?.error) throw new Error(r.error);
204
+ }
337
205
  }
338
- }
339
-
340
- console.log(chalk.dim(' ' + '━'.repeat(W)));
341
- console.log();
342
-
343
- const exCode = all[0]?.code || all[0]?.id || 'pg-xxxxxx';
344
- const cats = Object.keys(byCategory).map(c => chalk.cyan(`${bin} marketplace ${c}`)).join(' ');
345
- console.log(
346
- boxen(
347
- chalk.dim('categories ') + cats + '\n' +
348
- chalk.dim('install skill ') + chalk.white('install ') + chalk.hex('#A78BFA')(exCode) + '\n' +
349
- chalk.dim('from GitHub ') + chalk.white('install ') + chalk.hex('#A78BFA')('https://github.com/owner/repo/blob/main/skill.md') + '\n' +
350
- chalk.dim('publish skill ') + chalk.white('/pg-publish ') + chalk.hex('#A78BFA')('<file.md>') + '\n' +
351
- chalk.dim('view bundles ') + chalk.cyan(`${bin} marketplace bundles`),
352
- { padding: { top: 0, bottom: 0, left: 1, right: 1 }, borderStyle: 'round', borderColor: '#4B5563', dimBorder: true }
353
- )
354
206
  );
355
- console.log();
356
207
  process.exit(0);
357
208
  }
358
209
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "promptgraph-mcp",
3
- "version": "2.1.4",
3
+ "version": "2.1.5",
4
4
  "main": "index.js",
5
5
  "type": "module",
6
6
  "bin": {
package/tui.js ADDED
@@ -0,0 +1,331 @@
1
+ /**
2
+ * Interactive marketplace TUI — nano-style keyboard navigation
3
+ * Arrow keys, search, install, categories — all in one screen
4
+ */
5
+ import readline from 'readline';
6
+ import chalk from 'chalk';
7
+ import process from 'process';
8
+
9
+ const ESC = '\x1b';
10
+ const HIDE = '\x1b[?25l';
11
+ const SHOW = '\x1b[?25h';
12
+ const HOME = '\x1b[H';
13
+ const CLEAR = '\x1b[2J\x1b[H';
14
+ const CLEAR_EOL = '\x1b[K';
15
+
16
+ const purple = chalk.hex('#7C3AED');
17
+ const dim = chalk.dim;
18
+ const bold = chalk.bold;
19
+ const cyan = chalk.cyan;
20
+ const yellow = chalk.yellow;
21
+ const green = chalk.green;
22
+ const red = chalk.red;
23
+ const blue = chalk.blue;
24
+ const white = chalk.white;
25
+
26
+ const CAT_ICON = { Engineering:'🛠', 'AI Tools':'🤖', Coding:'💻', Creative:'🎨', Security:'🔒', Community:'🌐' };
27
+
28
+ // ── helpers ──────────────────────────────────────────────────────────────────
29
+
30
+ function termSize() {
31
+ return { cols: process.stdout.columns || 100, rows: process.stdout.rows || 30 };
32
+ }
33
+
34
+ function write(s) { process.stdout.write(s); }
35
+ function moveTo(row, col) { write(`\x1b[${row};${col}H`); }
36
+ function clearLine() { write('\x1b[2K\r'); }
37
+
38
+ function truncate(s, n) {
39
+ s = String(s || '');
40
+ return s.length > n ? s.slice(0, n - 1) + '…' : s;
41
+ }
42
+
43
+ // ── build item list ───────────────────────────────────────────────────────────
44
+
45
+ function buildItems(skills, bundles) {
46
+ const items = [];
47
+ // skills
48
+ for (const s of skills) {
49
+ items.push({ type: 'skill', id: s.id, name: s.name || s.id, description: s.description || '', category: s.category || 'Community', tags: s.tags || [], stars: s.stars || 0, code: s.code });
50
+ }
51
+ // bundles
52
+ for (const b of bundles) {
53
+ items.push({ type: 'bundle', id: b.id, name: b.name || b.id, description: b.description || '', category: b.category || 'Community', tags: b.tags || [], stars: b.stars || 0, skillCount: b.skillCount, repo_url: b.repo_url, skills: b.skills });
54
+ }
55
+ return items;
56
+ }
57
+
58
+ function filterItems(items, query, tab) {
59
+ let filtered = items;
60
+ if (tab === 'skills') filtered = items.filter(i => i.type === 'skill');
61
+ if (tab === 'bundles') filtered = items.filter(i => i.type === 'bundle');
62
+ if (query) {
63
+ const q = query.toLowerCase();
64
+ filtered = filtered.filter(i =>
65
+ i.id.includes(q) || i.name.toLowerCase().includes(q) ||
66
+ i.description.toLowerCase().includes(q) ||
67
+ i.category.toLowerCase().includes(q) ||
68
+ (i.tags || []).some(t => t.includes(q))
69
+ );
70
+ }
71
+ return filtered;
72
+ }
73
+
74
+ // ── render ────────────────────────────────────────────────────────────────────
75
+
76
+ function render(state) {
77
+ const { cols, rows } = termSize();
78
+ const HEADER_ROWS = 4;
79
+ const FOOTER_ROWS = 3;
80
+ const LIST_ROWS = rows - HEADER_ROWS - FOOTER_ROWS;
81
+
82
+ const { items, cursor, scroll, query, searching, tab, status } = state;
83
+
84
+ write(HOME);
85
+
86
+ // ── header ─────────────────────────────────────────────────────────────────
87
+ const title = purple.bold(' ◆ PromptGraph Marketplace ');
88
+ const tabs = ['all', 'skills', 'bundles'].map(t =>
89
+ t === tab ? cyan.bold(`[${t}]`) : dim(`[${t}]`)
90
+ ).join(' ');
91
+ write(truncate(title + ' '.repeat(4) + tabs, cols) + CLEAR_EOL + '\n');
92
+
93
+ // search bar
94
+ const searchLabel = searching ? green('/ ') : dim('/ ');
95
+ const searchVal = searching ? white(query) + (Math.floor(Date.now()/500)%2 ? '▌' : ' ') : dim(query || 'type / to search');
96
+ write(' ' + searchLabel + truncate(searchVal, cols - 4) + CLEAR_EOL + '\n');
97
+
98
+ const countLabel = dim(` ${items.length} items`);
99
+ const hint = dim(status ? (status.ok ? green(' ✓ ' + status.msg) : red(' ✗ ' + status.msg)) : '');
100
+ write(countLabel + hint + CLEAR_EOL + '\n');
101
+ write(dim('─'.repeat(cols)) + CLEAR_EOL + '\n');
102
+
103
+ // ── list ───────────────────────────────────────────────────────────────────
104
+ let lastCat = null;
105
+ let lineIdx = 0;
106
+ let rendered = 0;
107
+
108
+ for (let i = scroll; i < items.length && rendered < LIST_ROWS; i++) {
109
+ const item = items[i];
110
+ const selected = i === cursor;
111
+ const bg = selected ? '\x1b[48;2;60;40;120m' : '';
112
+ const reset = selected ? '\x1b[0m' : '';
113
+
114
+ // category header
115
+ if (item.category !== lastCat) {
116
+ if (rendered >= LIST_ROWS) break;
117
+ const icon = CAT_ICON[item.category] || '📦';
118
+ write(bg + ' ' + purple(icon + ' ' + item.category) + reset + CLEAR_EOL + '\n');
119
+ lastCat = item.category;
120
+ rendered++;
121
+ }
122
+
123
+ if (rendered >= LIST_ROWS) break;
124
+
125
+ // item row
126
+ const sel = selected ? cyan('▶') : ' ';
127
+ const type = item.type === 'bundle' ? blue('⊞') : dim('·');
128
+ const name = selected ? white.bold(item.name) : white(item.name);
129
+ const stars = item.stars > 0 ? yellow('★' + item.stars) : dim('★0');
130
+ const extra = item.type === 'bundle'
131
+ ? (item.skillCount ? blue(item.skillCount + ' skills') : blue('GitHub'))
132
+ : (item.code ? dim(item.code) : '');
133
+ const desc = dim(truncate(item.description, cols - 42));
134
+
135
+ const left = ` ${sel} ${type} ${truncate(item.name, 28).padEnd(28)} ${stars} ${extra.padEnd(12)}`;
136
+ write(bg + truncate(left, cols - desc.length - 2) + ' ' + desc + reset + CLEAR_EOL + '\n');
137
+ rendered++;
138
+ }
139
+
140
+ // fill empty rows
141
+ while (rendered < LIST_ROWS) {
142
+ write(CLEAR_EOL + '\n');
143
+ rendered++;
144
+ }
145
+
146
+ // ── footer ─────────────────────────────────────────────────────────────────
147
+ write(dim('─'.repeat(cols)) + CLEAR_EOL + '\n');
148
+ const sel = items[cursor];
149
+ if (sel && !searching) {
150
+ const installCmd = sel.type === 'bundle' ? `bundle install ${sel.id}` : `install ${sel.code || sel.id}`;
151
+ write(dim(` Enter`) + ' install ' + dim('Tab') + ' switch ' + dim('/') + ' search ' + dim('q') + ' quit' + CLEAR_EOL + '\n');
152
+ write(dim(` → pg ${installCmd}`) + CLEAR_EOL + '\n');
153
+ } else if (searching) {
154
+ write(dim(' Type to filter ') + cyan('Enter') + dim(' confirm ') + cyan('Esc') + dim(' cancel') + CLEAR_EOL + '\n');
155
+ write(CLEAR_EOL + '\n');
156
+ } else {
157
+ write(dim(' ↑↓ navigate Enter install Tab switch / search q quit') + CLEAR_EOL + '\n');
158
+ write(CLEAR_EOL + '\n');
159
+ }
160
+ }
161
+
162
+ // ── clamp scroll ─────────────────────────────────────────────────────────────
163
+
164
+ function clampScroll(state) {
165
+ const { rows } = termSize();
166
+ const HEADER_ROWS = 4, FOOTER_ROWS = 3;
167
+ const LIST_ROWS = rows - HEADER_ROWS - FOOTER_ROWS;
168
+ const { cursor, items } = state;
169
+
170
+ // ensure cursor visible — approximate (category headers add extra rows)
171
+ if (cursor < state.scroll) state.scroll = cursor;
172
+ if (cursor >= state.scroll + LIST_ROWS - 2) state.scroll = cursor - LIST_ROWS + 3;
173
+ if (state.scroll < 0) state.scroll = 0;
174
+ if (state.scroll >= items.length) state.scroll = Math.max(0, items.length - 1);
175
+ }
176
+
177
+ // ── main ─────────────────────────────────────────────────────────────────────
178
+
179
+ export async function runTUI(allSkills, allBundles, installFn) {
180
+ const allItems = buildItems(allSkills, allBundles);
181
+
182
+ const state = {
183
+ tab: 'all',
184
+ query: '',
185
+ searching: false,
186
+ cursor: 0,
187
+ scroll: 0,
188
+ items: allItems,
189
+ status: null,
190
+ installing: false,
191
+ };
192
+
193
+ function refresh(q, t) {
194
+ state.items = filterItems(allItems, q ?? state.query, t ?? state.tab);
195
+ if (state.cursor >= state.items.length) state.cursor = Math.max(0, state.items.length - 1);
196
+ clampScroll(state);
197
+ render(state);
198
+ }
199
+
200
+ // Setup terminal
201
+ write(HIDE + CLEAR);
202
+ readline.emitKeypressEvents(process.stdin);
203
+ if (process.stdin.isTTY) process.stdin.setRawMode(true);
204
+
205
+ function cleanup() {
206
+ if (process.stdin.isTTY) process.stdin.setRawMode(false);
207
+ write(SHOW + CLEAR);
208
+ process.stdin.pause();
209
+ }
210
+
211
+ process.on('SIGINT', cleanup);
212
+ process.on('exit', cleanup);
213
+
214
+ // Initial render
215
+ refresh();
216
+
217
+ // Status blink timer
218
+ let statusTimer = null;
219
+ function setStatus(ok, msg) {
220
+ state.status = { ok, msg };
221
+ clearTimeout(statusTimer);
222
+ render(state);
223
+ statusTimer = setTimeout(() => { state.status = null; render(state); }, 3000);
224
+ }
225
+
226
+ // Keypress handler
227
+ process.stdin.on('keypress', async (ch, key) => {
228
+ if (!key) return;
229
+
230
+ if (state.searching) {
231
+ if (key.name === 'escape') {
232
+ state.searching = false;
233
+ state.query = '';
234
+ refresh();
235
+ } else if (key.name === 'return') {
236
+ state.searching = false;
237
+ refresh();
238
+ } else if (key.name === 'backspace') {
239
+ state.query = state.query.slice(0, -1);
240
+ refresh();
241
+ } else if (ch && !key.ctrl && !key.meta && ch.length === 1) {
242
+ state.query += ch;
243
+ refresh();
244
+ }
245
+ return;
246
+ }
247
+
248
+ // Normal mode
249
+ if (key.name === 'q' || (key.ctrl && key.name === 'c')) {
250
+ cleanup();
251
+ return;
252
+ }
253
+
254
+ if (key.name === 'slash' || ch === '/') {
255
+ state.searching = true;
256
+ render(state);
257
+ return;
258
+ }
259
+
260
+ if (key.name === 'tab') {
261
+ const tabs = ['all', 'skills', 'bundles'];
262
+ state.tab = tabs[(tabs.indexOf(state.tab) + 1) % tabs.length];
263
+ state.cursor = 0;
264
+ state.scroll = 0;
265
+ refresh(state.query, state.tab);
266
+ return;
267
+ }
268
+
269
+ if (key.name === 'up') {
270
+ if (state.cursor > 0) state.cursor--;
271
+ clampScroll(state);
272
+ render(state);
273
+ return;
274
+ }
275
+
276
+ if (key.name === 'down') {
277
+ if (state.cursor < state.items.length - 1) state.cursor++;
278
+ clampScroll(state);
279
+ render(state);
280
+ return;
281
+ }
282
+
283
+ if (key.name === 'pageup') {
284
+ state.cursor = Math.max(0, state.cursor - 10);
285
+ clampScroll(state);
286
+ render(state);
287
+ return;
288
+ }
289
+
290
+ if (key.name === 'pagedown') {
291
+ state.cursor = Math.min(state.items.length - 1, state.cursor + 10);
292
+ clampScroll(state);
293
+ render(state);
294
+ return;
295
+ }
296
+
297
+ if (key.name === 'home') { state.cursor = 0; state.scroll = 0; render(state); return; }
298
+ if (key.name === 'end') { state.cursor = state.items.length - 1; clampScroll(state); render(state); return; }
299
+
300
+ if (key.name === 'return' || key.name === 'i') {
301
+ const sel = state.items[state.cursor];
302
+ if (!sel || state.installing) return;
303
+ state.installing = true;
304
+ setStatus(null, `Installing ${sel.id}…`);
305
+ try {
306
+ await installFn(sel);
307
+ setStatus(true, `Installed ${sel.id}`);
308
+ } catch (e) {
309
+ setStatus(false, e.message.slice(0, 60));
310
+ } finally {
311
+ state.installing = false;
312
+ }
313
+ return;
314
+ }
315
+
316
+ if (key.name === 'escape') {
317
+ state.query = '';
318
+ refresh();
319
+ return;
320
+ }
321
+ });
322
+
323
+ // Resize handler
324
+ process.stdout.on('resize', () => { clampScroll(state); render(state); });
325
+
326
+ // Keep alive
327
+ return new Promise(resolve => {
328
+ process.stdin.once('close', resolve);
329
+ process.on('SIGINT', resolve);
330
+ });
331
+ }