promptgraph-mcp 2.2.8 → 2.3.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/github-import.js CHANGED
@@ -1,43 +1,121 @@
1
1
  import { spawnSync } from 'child_process';
2
2
  import path from 'path';
3
- import os from 'os';
4
3
  import fs from 'fs';
5
4
  import https from 'https';
6
5
  import { globSync } from 'glob';
7
6
  import { indexAll, indexSource } from './indexer.js';
8
7
  import { loadConfig, saveConfig, SKILLS_STORE_DIR } from './config.js';
9
8
 
10
- function repoExists(repoUrl) {
11
- const owner_repo = repoUrl.startsWith('http')
12
- ? repoUrl.replace(/^https?:\/\/github\.com\//, '').replace(/\.git$/, '')
13
- : repoUrl;
9
+ const SKILL_DIRS = ['skills', 'commands', 'prompts', 'agents', 'skills-store', 'slash-commands', 'custom-commands', 'templates'];
10
+
11
+ // ── helpers ───────────────────────────────────────────────────────────────────
12
+
13
+ function httpsGet(url) {
14
+ return new Promise((res, rej) => {
15
+ const req = https.get(url, { headers: { 'User-Agent': 'promptgraph-mcp' } }, r => {
16
+ if (r.statusCode >= 300 && r.statusCode < 400 && r.headers.location)
17
+ return httpsGet(r.headers.location).then(res, rej);
18
+ if (r.statusCode !== 200) { r.resume(); return rej(new Error(`HTTP ${r.statusCode}`)); }
19
+ let d = ''; r.setEncoding('utf8'); r.on('data', c => d += c); r.on('end', () => res(d));
20
+ });
21
+ req.on('error', rej);
22
+ });
23
+ }
24
+
25
+ function repoExists(ownerRepo) {
14
26
  return new Promise(resolve => {
15
27
  const req = https.request(
16
- { host: 'github.com', path: `/${owner_repo}`, method: 'HEAD', headers: { 'User-Agent': 'promptgraph-mcp' } },
17
- res => resolve(res.statusCode < 400)
28
+ { host: 'github.com', path: `/${ownerRepo}`, method: 'HEAD', headers: { 'User-Agent': 'promptgraph-mcp' } },
29
+ r => resolve(r.statusCode < 400)
18
30
  );
19
31
  req.on('error', () => resolve(false));
20
32
  req.end();
21
33
  });
22
34
  }
23
35
 
24
- // Directories likely to contain skills checked in priority order
25
- const SKILL_DIRS = ['skills', 'commands', 'prompts', 'agents', 'skills-store', 'slash-commands', 'custom-commands', 'templates'];
36
+ // Ask GitHub API which skill subdir exists (without cloning anything)
37
+ async function detectSkillsDirFromAPI(ownerRepo) {
38
+ try {
39
+ const json = await httpsGet(`https://api.github.com/repos/${ownerRepo}/contents`);
40
+ const entries = JSON.parse(json);
41
+ const dirs = new Set(entries.filter(e => e.type === 'dir').map(e => e.name.toLowerCase()));
42
+ for (const d of SKILL_DIRS) {
43
+ if (dirs.has(d)) return d;
44
+ }
45
+ } catch {}
46
+ return null; // use repo root
47
+ }
48
+
49
+ function git(args, cwd, stdio = 'inherit') {
50
+ return spawnSync('git', args, { cwd, stdio });
51
+ }
52
+
53
+ // Clone only the skills subdir via sparse-checkout
54
+ function sparseClone(url, dest, subdir) {
55
+ fs.mkdirSync(dest, { recursive: true });
56
+
57
+ // 1. init + add remote
58
+ if (git(['init'], dest, 'pipe').status !== 0) return false;
59
+ if (git(['remote', 'add', 'origin', url], dest, 'pipe').status !== 0) return false;
60
+
61
+ // 2. sparse-checkout config
62
+ git(['sparse-checkout', 'init', '--cone'], dest, 'pipe');
63
+ git(['sparse-checkout', 'set', subdir], dest, 'pipe');
64
+
65
+ // 3. fetch + checkout (depth=1)
66
+ const fetch = git(['fetch', '--depth=1', 'origin'], dest);
67
+ if (fetch.status !== 0) return false;
68
+
69
+ // Try HEAD, then main, then master
70
+ for (const branch of ['HEAD', 'main', 'master']) {
71
+ const r = git(['checkout', branch === 'HEAD' ? 'FETCH_HEAD' : branch], dest, 'pipe');
72
+ if (r.status === 0) return true;
73
+ }
74
+ return false;
75
+ }
76
+
77
+ // Update sparse repo — fetch + reset
78
+ function sparseUpdate(dest, subdir) {
79
+ const fetch = git(['fetch', '--depth=1', 'origin'], dest);
80
+ if (fetch.status !== 0) return false;
26
81
 
27
- // Find the best subdirectory to index in the cloned repo.
28
- // Returns the subdir path if a known skills dir exists with 2+ .md files,
29
- // otherwise returns the repo root (full scan with isSkillFile filtering).
30
- function detectSkillsDir(repoRoot) {
82
+ // Ensure sparse-checkout still set correctly
83
+ git(['sparse-checkout', 'set', subdir], dest, 'pipe');
84
+
85
+ for (const ref of ['origin/HEAD', 'origin/main', 'origin/master']) {
86
+ const r = git(['reset', '--hard', ref], dest, 'pipe');
87
+ if (r.status === 0) return true;
88
+ }
89
+ return false;
90
+ }
91
+
92
+ // Fallback: full clone (when no subdir found)
93
+ function fullClone(url, dest) {
94
+ if (fs.existsSync(dest)) {
95
+ const fetch = git(['fetch', '--depth=1', 'origin'], dest);
96
+ if (fetch.status !== 0) return false;
97
+ for (const ref of ['origin/HEAD', 'origin/main', 'origin/master']) {
98
+ if (git(['reset', '--hard', ref], dest, 'pipe').status === 0) return true;
99
+ }
100
+ return false;
101
+ }
102
+ return git(['clone', '--depth=1', url, dest]).status === 0;
103
+ }
104
+
105
+ // After clone: detect actual skills dir on disk
106
+ function detectSkillsDirLocal(repoRoot) {
31
107
  for (const dir of SKILL_DIRS) {
32
108
  const candidate = path.join(repoRoot, dir);
33
109
  if (fs.existsSync(candidate)) {
34
110
  const files = globSync(`${candidate}/**/*.md`);
35
- if (files.length >= 2) return { dir: candidate, auto: true, label: dir };
111
+ if (files.length >= 2) return { dir: candidate, label: dir, sparse: true };
36
112
  }
37
113
  }
38
- return { dir: repoRoot, auto: false, label: '(root)' };
114
+ return { dir: repoRoot, label: '(root)', sparse: false };
39
115
  }
40
116
 
117
+ // ── main export ───────────────────────────────────────────────────────────────
118
+
41
119
  export async function importFromGitHub(repoUrl) {
42
120
  if (!repoUrl) {
43
121
  console.error('Usage: promptgraph-mcp import <github-url-or-owner/repo>');
@@ -45,58 +123,76 @@ export async function importFromGitHub(repoUrl) {
45
123
  }
46
124
 
47
125
  const url = repoUrl.startsWith('http') ? repoUrl : `https://github.com/${repoUrl}`;
48
- const repoName = url.split('/').slice(-2).join('-').replace('.git', '');
126
+ const ownerRepo = url.replace(/^https?:\/\/github\.com\//, '').replace(/\.git$/, '');
127
+ const repoName = ownerRepo.replace('/', '-');
49
128
  const dest = path.join(SKILLS_STORE_DIR, 'github', repoName);
50
129
 
51
- if (!fs.existsSync(dest)) {
52
- const exists = await repoExists(repoUrl);
130
+ const isNew = !fs.existsSync(dest);
131
+
132
+ if (isNew) {
133
+ const exists = await repoExists(ownerRepo);
53
134
  if (!exists) throw new Error(`Repository not found (404): ${url}`);
54
135
  }
55
136
 
56
137
  fs.mkdirSync(path.dirname(dest), { recursive: true });
57
138
 
58
- if (fs.existsSync(dest)) {
59
- console.log(`Updating ${repoName}...`);
60
- // fetch + reset handles force-pushes and unrelated histories without re-cloning
61
- const fetch = spawnSync('git', ['-C', dest, 'fetch', '--depth=1', 'origin'], { stdio: 'inherit' });
62
- if (fetch.status !== 0) throw new Error(`git fetch failed for ${repoName}`);
63
- const reset = spawnSync('git', ['-C', dest, 'reset', '--hard', 'origin/HEAD'], { stdio: 'pipe' });
64
- if (reset.status !== 0) {
65
- // fallback: try origin/main then origin/master
66
- const main = spawnSync('git', ['-C', dest, 'reset', '--hard', 'origin/main'], { stdio: 'pipe' });
67
- if (main.status !== 0) {
68
- const master = spawnSync('git', ['-C', dest, 'reset', '--hard', 'origin/master'], { stdio: 'inherit' });
69
- if (master.status !== 0) throw new Error(`git reset failed for ${repoName}`);
139
+ let skillsSubdir = null;
140
+ let cloneOk = false;
141
+
142
+ if (isNew) {
143
+ // Detect skills dir via API before cloning
144
+ console.log(`Detecting skills directory for ${ownerRepo}...`);
145
+ skillsSubdir = await detectSkillsDirFromAPI(ownerRepo);
146
+
147
+ if (skillsSubdir) {
148
+ console.log(`Sparse-cloning ${url} (${skillsSubdir}/ only)...`);
149
+ cloneOk = sparseClone(url, dest, skillsSubdir);
150
+ if (!cloneOk) {
151
+ // Sparse failed — fall back to full clone
152
+ console.log('Sparse-checkout failed, falling back to full clone...');
153
+ fs.rmSync(dest, { recursive: true, force: true });
154
+ cloneOk = fullClone(url, dest);
155
+ skillsSubdir = null;
70
156
  }
157
+ } else {
158
+ console.log(`Cloning ${url}...`);
159
+ cloneOk = fullClone(url, dest);
71
160
  }
161
+
162
+ if (!cloneOk) throw new Error(`Clone failed for ${url}`);
72
163
  } else {
73
- console.log(`Cloning ${url}...`);
74
- const cloneResult = spawnSync('git', ['clone', '--depth=1', url, dest], { stdio: 'inherit' });
75
- if (cloneResult.status !== 0) throw new Error(`git clone failed for ${url}`);
164
+ console.log(`Updating ${repoName}...`);
165
+ // Detect existing sparse subdir
166
+ const isSparse = git(['sparse-checkout', 'list'], dest, 'pipe').status === 0;
167
+ const sparseList = isSparse
168
+ ? spawnSync('git', ['sparse-checkout', 'list'], { cwd: dest, encoding: 'utf8' }).stdout.trim()
169
+ : '';
170
+ skillsSubdir = sparseList.split('\n').find(l => SKILL_DIRS.includes(l.trim())) || null;
171
+
172
+ cloneOk = skillsSubdir
173
+ ? sparseUpdate(dest, skillsSubdir)
174
+ : fullClone(url, dest);
175
+ if (!cloneOk) throw new Error(`Update failed for ${repoName}`);
76
176
  }
77
177
 
78
- const { dir: skillsDir, auto, label } = detectSkillsDir(dest);
178
+ const { dir: skillsDir, label } = detectSkillsDirLocal(dest);
79
179
  const mdFiles = globSync(`${skillsDir}/**/*.md`);
80
180
 
81
- if (auto) {
82
- console.log(`Auto-detected skills directory: ${label}/ (${mdFiles.length} .md files)`);
181
+ if (skillsSubdir) {
182
+ console.log(`Sparse-checkout: ${label}/ only (${mdFiles.length} .md files, no other repo files)`);
83
183
  } else {
84
- console.log(`No skills/ dir found — scanning root (${mdFiles.length} .md files, non-skill files will be filtered)`);
184
+ console.log(`Full clone: scanning ${label} (${mdFiles.length} .md files)`);
85
185
  }
86
186
 
87
- if (mdFiles.length < 1) {
88
- console.warn('Warning: no .md files found');
89
- }
187
+ if (mdFiles.length < 1) console.warn('Warning: no .md files found');
90
188
 
91
189
  const config = loadConfig();
92
190
  const repoSource = `github:${repoName}`;
93
191
  if (!config.sources.find(s => s.dir === skillsDir)) {
94
- // Remove any old entry pointing at the full repo root if we now have a subdir
95
192
  const oldIdx = config.sources.findIndex(s => s.source === repoSource);
96
193
  if (oldIdx !== -1) config.sources.splice(oldIdx, 1);
97
194
  config.sources.push({ dir: skillsDir, source: repoSource });
98
195
  saveConfig(config);
99
- console.log(`Indexing from: ${skillsDir}`);
100
196
  }
101
197
 
102
198
  console.log();
package/index.js CHANGED
@@ -244,6 +244,9 @@ if (args[0] === 'marketplace') {
244
244
  } catch {}
245
245
 
246
246
  const { runTUI } = await import('./tui.js');
247
+ const { loadConfig: _lcR, saveConfig: _scR, SKILLS_STORE_DIR: _ssR } = await import('./config.js');
248
+ const { getDb: _getDbR } = await import('./db.js');
249
+
247
250
  await runTUI(
248
251
  Array.isArray(skills) ? skills : [],
249
252
  Array.isArray(bundles) ? bundles : [],
@@ -259,7 +262,42 @@ if (args[0] === 'marketplace') {
259
262
  if (item.code) installedSet.add(item.code);
260
263
  }
261
264
  },
262
- installedSet
265
+ installedSet,
266
+ async (item) => {
267
+ const cfg = _lcR();
268
+ const db = _getDbR();
269
+ if (item.type === 'bundle' && item.repo_url) {
270
+ // Remove cloned directory
271
+ const owner = item.repo_url.split('/')[0];
272
+ const repo = item.repo_url.split('/')[1];
273
+ const clonedName = `${owner}-${repo}`;
274
+ const clonedDir = path.join(_ssR, 'github', clonedName);
275
+ if (fs.existsSync(clonedDir)) fs.rmSync(clonedDir, { recursive: true, force: true });
276
+ // Remove from config + DB
277
+ const src = `github:${clonedName}`;
278
+ cfg.sources = cfg.sources.filter(s => s.source !== src && !s.dir.startsWith(clonedDir));
279
+ _scR(cfg);
280
+ db.prepare('DELETE FROM skills WHERE source = ?').run(src);
281
+ db.prepare('DELETE FROM chunks WHERE skill_id NOT IN (SELECT id FROM skills)').run();
282
+ } else if (item.type === 'bundle') {
283
+ // skill-list bundle — remove each skill file
284
+ const mktDir = path.join(_ssR, 'marketplace');
285
+ for (const sid of (item.skills || [])) {
286
+ const row = db.prepare('SELECT path FROM skills WHERE id = ?').get(sid);
287
+ if (row?.path && fs.existsSync(row.path)) fs.unlinkSync(row.path);
288
+ db.prepare('DELETE FROM skills WHERE id = ?').run(sid);
289
+ db.prepare('DELETE FROM chunks WHERE skill_id = ?').run(sid);
290
+ }
291
+ } else {
292
+ // individual skill
293
+ const row = db.prepare('SELECT path FROM skills WHERE id = ?').get(item.id);
294
+ if (row?.path && fs.existsSync(row.path)) fs.unlinkSync(row.path);
295
+ db.prepare('DELETE FROM skills WHERE id = ?').run(item.id);
296
+ db.prepare('DELETE FROM chunks WHERE skill_id = ?').run(item.id);
297
+ }
298
+ installedSet.delete(item.id);
299
+ if (item.code) installedSet.delete(item.code);
300
+ }
263
301
  );
264
302
  process.exit(0);
265
303
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "promptgraph-mcp",
3
- "version": "2.2.8",
3
+ "version": "2.3.0",
4
4
  "main": "index.js",
5
5
  "type": "module",
6
6
  "bin": {
package/tui.js CHANGED
@@ -171,17 +171,23 @@ function render(state, installedSet = new Set()) {
171
171
  // ── footer ─────────────────────────────────────────────────────────────────
172
172
  write(dim('─'.repeat(cols)) + CLEAR_EOL + '\n');
173
173
  const sel = items[cursor];
174
- if (sel && !searching) {
174
+ if (sel && !searching && !state.confirming) {
175
175
  const isInst = installedSet.has(sel.id) || (sel.code && installedSet.has(sel.code));
176
176
  const installCmd = sel.type === 'bundle' ? `bundle install ${sel.id}` : `install ${sel.code || sel.id}`;
177
- const instLabel = isInst ? green(' ✓ installed') + dim(' ') : dim(' Enter') + chalk.white(' install') + dim(' ');
177
+ const instLabel = isInst
178
+ ? green(' ✓ installed') + dim(' ') + dim('d') + chalk.red(' remove') + dim(' ')
179
+ : dim(' Enter') + chalk.white(' install') + dim(' ');
178
180
  write(instLabel + dim('Tab') + ' switch ' + dim('/') + ' search ' + dim('q') + ' quit' + CLEAR_EOL + '\n');
179
181
  write(dim(` → pg ${installCmd}`) + CLEAR_EOL + '\n');
182
+ } else if (state.confirming) {
183
+ write(chalk.red.bold(' Remove ') + chalk.white(state.confirming.name) + chalk.red('? ') +
184
+ chalk.white.bold('[y]') + chalk.gray('es ') + chalk.white.bold('[n]') + chalk.gray('o') + CLEAR_EOL + '\n');
185
+ write(CLEAR_EOL + '\n');
180
186
  } else if (searching) {
181
187
  write(dim(' Type to filter ') + cyan('Enter') + dim(' confirm ') + cyan('Esc') + dim(' cancel') + CLEAR_EOL + '\n');
182
188
  write(CLEAR_EOL + '\n');
183
189
  } else {
184
- write(dim(' ↑↓ navigate Enter install Tab switch / search q quit') + CLEAR_EOL + '\n');
190
+ write(dim(' ↑↓ navigate Enter install d remove Tab switch / search q quit') + CLEAR_EOL + '\n');
185
191
  write(CLEAR_EOL + '\n');
186
192
  }
187
193
  }
@@ -224,13 +230,14 @@ function clampScroll(state) {
224
230
 
225
231
  // ── main ─────────────────────────────────────────────────────────────────────
226
232
 
227
- export async function runTUI(allSkills, allBundles, installFn, installedSet = new Set()) {
233
+ export async function runTUI(allSkills, allBundles, installFn, installedSet = new Set(), removeFn = async () => {}) {
228
234
  const allItems = buildItems(allSkills, allBundles);
229
235
 
230
236
  const state = {
231
237
  tab: 'all',
232
238
  query: '',
233
239
  searching: false,
240
+ confirming: null, // { id, name, type, repoUrl }
234
241
  cursor: 0,
235
242
  scroll: 0,
236
243
  items: allItems,
@@ -293,6 +300,27 @@ export async function runTUI(allSkills, allBundles, installFn, installedSet = ne
293
300
  return;
294
301
  }
295
302
 
303
+ // Confirm-delete mode
304
+ if (state.confirming) {
305
+ if (ch === 'y' || ch === 'Y') {
306
+ const item = state.confirming;
307
+ state.confirming = null;
308
+ setStatus(null, `Removing ${item.name}…`);
309
+ try {
310
+ await removeFn(item);
311
+ installedSet.delete(item.id);
312
+ if (item.code) installedSet.delete(item.code);
313
+ setStatus(true, `Removed ${item.name}`);
314
+ } catch (e) {
315
+ setStatus(false, e.message.slice(0, 60));
316
+ }
317
+ } else {
318
+ state.confirming = null;
319
+ render(state, installedSet);
320
+ }
321
+ return;
322
+ }
323
+
296
324
  // Normal mode
297
325
  if (key.name === 'q' || (key.ctrl && key.name === 'c')) {
298
326
  cleanup();
@@ -345,6 +373,16 @@ export async function runTUI(allSkills, allBundles, installFn, installedSet = ne
345
373
  if (key.name === 'home') { state.cursor = 0; state.scroll = 0; render(state, installedSet); return; }
346
374
  if (key.name === 'end') { state.cursor = state.items.length - 1; clampScroll(state); render(state, installedSet); return; }
347
375
 
376
+ if (ch === 'd' || ch === 'D') {
377
+ const sel = state.items[state.cursor];
378
+ if (!sel) return;
379
+ const isInst = installedSet.has(sel.id) || (sel.code && installedSet.has(sel.code));
380
+ if (!isInst) { setStatus(false, 'Not installed'); return; }
381
+ state.confirming = { id: sel.id, name: sel.name, type: sel.type, code: sel.code, repo_url: sel.repo_url };
382
+ render(state, installedSet);
383
+ return;
384
+ }
385
+
348
386
  if (key.name === 'return' || key.name === 'i') {
349
387
  const sel = state.items[state.cursor];
350
388
  if (!sel || state.installing) return;