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 +138 -42
- package/index.js +39 -1
- package/package.json +1 -1
- package/tui.js +42 -4
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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: `/${
|
|
17
|
-
|
|
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
|
-
//
|
|
25
|
-
|
|
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
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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,
|
|
111
|
+
if (files.length >= 2) return { dir: candidate, label: dir, sparse: true };
|
|
36
112
|
}
|
|
37
113
|
}
|
|
38
|
-
return { dir: repoRoot,
|
|
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
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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(`
|
|
74
|
-
|
|
75
|
-
|
|
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,
|
|
178
|
+
const { dir: skillsDir, label } = detectSkillsDirLocal(dest);
|
|
79
179
|
const mdFiles = globSync(`${skillsDir}/**/*.md`);
|
|
80
180
|
|
|
81
|
-
if (
|
|
82
|
-
console.log(`
|
|
181
|
+
if (skillsSubdir) {
|
|
182
|
+
console.log(`Sparse-checkout: ${label}/ only (${mdFiles.length} .md files, no other repo files)`);
|
|
83
183
|
} else {
|
|
84
|
-
console.log(`
|
|
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
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
|
|
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;
|