mcp-config-manager 2.1.1 → 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.
@@ -0,0 +1,555 @@
1
+ import * as fs from 'fs/promises';
2
+ import path from 'path';
3
+ import { CLIENT_SKILLS, getClientSkillTabs, getClientSkillTab, clientHasSkills } from './skills-scopes.js';
4
+
5
+ // Mock support for testing
6
+ const USE_MOCK_SKILLS = process.env.MCP_USE_MOCK_SKILLS === 'true';
7
+
8
+ /**
9
+ * Parse YAML frontmatter from a markdown file
10
+ */
11
+ function parseFrontmatter(content) {
12
+ const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
13
+
14
+ if (!frontmatterMatch) {
15
+ return { metadata: {}, content: content.trim() };
16
+ }
17
+
18
+ const [, yamlPart, bodyPart] = frontmatterMatch;
19
+ const metadata = {};
20
+
21
+ const lines = yamlPart.split('\n');
22
+ for (const line of lines) {
23
+ const match = line.match(/^(\w+):\s*(.*)$/);
24
+ if (match) {
25
+ const [, key, value] = match;
26
+ if (value.includes(',')) {
27
+ metadata[key] = value.split(',').map(v => v.trim());
28
+ } else if (value.startsWith('"') && value.endsWith('"')) {
29
+ metadata[key] = value.slice(1, -1);
30
+ } else if (value.startsWith("'") && value.endsWith("'")) {
31
+ metadata[key] = value.slice(1, -1);
32
+ } else {
33
+ metadata[key] = value;
34
+ }
35
+ }
36
+ }
37
+
38
+ return { metadata, content: bodyPart.trim() };
39
+ }
40
+
41
+ /**
42
+ * Generate YAML frontmatter string from metadata object
43
+ */
44
+ function generateFrontmatter(metadata) {
45
+ if (!metadata || Object.keys(metadata).length === 0) {
46
+ return '';
47
+ }
48
+
49
+ const lines = ['---'];
50
+ for (const [key, value] of Object.entries(metadata)) {
51
+ if (Array.isArray(value)) {
52
+ lines.push(`${key}: ${value.join(', ')}`);
53
+ } else if (typeof value === 'string' && (value.includes(':') || value.includes('#') || value.includes('\n'))) {
54
+ lines.push(`${key}: "${value.replace(/"/g, '\\"')}"`);
55
+ } else {
56
+ lines.push(`${key}: ${value}`);
57
+ }
58
+ }
59
+ lines.push('---');
60
+ lines.push('');
61
+
62
+ return lines.join('\n');
63
+ }
64
+
65
+ /**
66
+ * Validate item name to prevent path traversal attacks
67
+ */
68
+ function validateItemName(name) {
69
+ if (!name || name.includes('..') || name.includes('/') || name.includes('\\') || name.includes('\0')) {
70
+ throw new Error(`Invalid item name: '${name}'`);
71
+ }
72
+ }
73
+
74
+ /**
75
+ * SkillsManager handles CRUD operations for client-specific skills/prompts/rules
76
+ */
77
+ export class SkillsManager {
78
+ constructor() {
79
+ this.mockSkillsInitialized = false;
80
+ this.clientSkills = { ...CLIENT_SKILLS };
81
+ }
82
+
83
+ async initializeMockSkills() {
84
+ if (USE_MOCK_SKILLS && !this.mockSkillsInitialized) {
85
+ try {
86
+ const mockModule = await import('../test/mock-skills.js');
87
+ if (mockModule.MOCK_CLIENT_SKILLS) {
88
+ this.clientSkills = mockModule.MOCK_CLIENT_SKILLS;
89
+ }
90
+ this.mockSkillsInitialized = true;
91
+ } catch (error) {
92
+ console.warn('Mock skills not available, using production paths');
93
+ }
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Get skill tabs for a client
99
+ */
100
+ async getClientTabs(clientId) {
101
+ await this.initializeMockSkills();
102
+ const config = this.clientSkills[clientId];
103
+ return config ? config.tabs : [];
104
+ }
105
+
106
+ /**
107
+ * Get a specific tab for a client
108
+ */
109
+ async getClientTab(clientId, tabId) {
110
+ const tabs = await this.getClientTabs(clientId);
111
+ return tabs.find(t => t.id === tabId);
112
+ }
113
+
114
+ /**
115
+ * Check if client has skill support
116
+ */
117
+ async clientHasSkills(clientId) {
118
+ const tabs = await this.getClientTabs(clientId);
119
+ return tabs && tabs.length > 0;
120
+ }
121
+
122
+ /**
123
+ * List all items in a client's skill tab
124
+ */
125
+ async listItems(clientId, tabId) {
126
+ const tab = await this.getClientTab(clientId, tabId);
127
+ if (!tab) {
128
+ throw new Error(`Tab '${tabId}' not found for client '${clientId}'`);
129
+ }
130
+
131
+ const items = [];
132
+
133
+ try {
134
+ await fs.access(tab.path);
135
+ } catch {
136
+ return items; // Directory doesn't exist
137
+ }
138
+
139
+ // Handle single-file tabs (like GEMINI.md, AGENTS.md)
140
+ if (tab.singleFile) {
141
+ const filePath = path.join(tab.path, tab.filePattern);
142
+ try {
143
+ const content = await fs.readFile(filePath, 'utf-8');
144
+ const { metadata } = parseFrontmatter(content);
145
+ items.push({
146
+ name: tab.filePattern.replace('.md', ''),
147
+ type: 'single',
148
+ path: filePath,
149
+ metadata,
150
+ exists: true
151
+ });
152
+ } catch {
153
+ items.push({
154
+ name: tab.filePattern.replace('.md', ''),
155
+ type: 'single',
156
+ path: filePath,
157
+ metadata: {},
158
+ exists: false
159
+ });
160
+ }
161
+ return items;
162
+ }
163
+
164
+ // Handle directory-based tabs
165
+ const entries = await fs.readdir(tab.path, { withFileTypes: true });
166
+ const ext = tab.filePattern.replace('*', '');
167
+
168
+ for (const entry of entries) {
169
+ if (entry.name.startsWith('.')) continue;
170
+
171
+ const entryPath = path.join(tab.path, entry.name);
172
+
173
+ try {
174
+ if (entry.isDirectory() && tab.supportsDirectories) {
175
+ // Complex item - directory with main file
176
+ const mainFile = path.join(entryPath, 'SKILL.md');
177
+ try {
178
+ const content = await fs.readFile(mainFile, 'utf-8');
179
+ const { metadata } = parseFrontmatter(content);
180
+
181
+ const subEntries = await fs.readdir(entryPath, { withFileTypes: true });
182
+ const hasWorkflows = subEntries.some(e => e.name === 'workflows' && e.isDirectory());
183
+ const hasReferences = subEntries.some(e => e.name === 'references' && e.isDirectory());
184
+
185
+ items.push({
186
+ name: entry.name,
187
+ type: 'complex',
188
+ path: entryPath,
189
+ metadata: {
190
+ name: metadata.name || entry.name,
191
+ description: metadata.description || '',
192
+ ...metadata
193
+ },
194
+ structure: { hasWorkflows, hasReferences }
195
+ });
196
+ } catch {
197
+ // Directory doesn't have SKILL.md, skip
198
+ }
199
+ } else if (entry.isFile() && entry.name.endsWith(ext) && entry.name !== 'README.md') {
200
+ // Simple item - single file
201
+ const itemName = entry.name.replace(ext, '');
202
+ const content = await fs.readFile(entryPath, 'utf-8');
203
+ const { metadata } = parseFrontmatter(content);
204
+
205
+ items.push({
206
+ name: itemName,
207
+ type: 'simple',
208
+ path: entryPath,
209
+ metadata: {
210
+ name: metadata.name || itemName,
211
+ description: metadata.description || '',
212
+ ...metadata
213
+ }
214
+ });
215
+ }
216
+ } catch (error) {
217
+ console.warn(`Error reading item ${entry.name}:`, error.message);
218
+ }
219
+ }
220
+
221
+ return items.sort((a, b) => a.name.localeCompare(b.name));
222
+ }
223
+
224
+ /**
225
+ * Read a single item's content
226
+ */
227
+ async readItem(clientId, tabId, itemName) {
228
+ validateItemName(itemName);
229
+ const tab = await this.getClientTab(clientId, tabId);
230
+ if (!tab) {
231
+ throw new Error(`Tab '${tabId}' not found for client '${clientId}'`);
232
+ }
233
+
234
+ // Handle single-file tabs
235
+ if (tab.singleFile) {
236
+ const filePath = path.join(tab.path, tab.filePattern);
237
+ try {
238
+ const content = await fs.readFile(filePath, 'utf-8');
239
+ const { metadata, content: bodyContent } = parseFrontmatter(content);
240
+ return {
241
+ name: itemName,
242
+ type: 'single',
243
+ path: filePath,
244
+ metadata,
245
+ content: bodyContent,
246
+ fullContent: content
247
+ };
248
+ } catch (error) {
249
+ if (error.code === 'ENOENT') {
250
+ return {
251
+ name: itemName,
252
+ type: 'single',
253
+ path: filePath,
254
+ metadata: {},
255
+ content: '',
256
+ fullContent: '',
257
+ exists: false
258
+ };
259
+ }
260
+ throw error;
261
+ }
262
+ }
263
+
264
+ const ext = tab.filePattern.replace('*', '');
265
+
266
+ // Try complex item first (directory)
267
+ if (tab.supportsDirectories) {
268
+ const dirPath = path.join(tab.path, itemName);
269
+ try {
270
+ const stat = await fs.stat(dirPath);
271
+ if (stat.isDirectory()) {
272
+ const mainFile = path.join(dirPath, 'SKILL.md');
273
+ const content = await fs.readFile(mainFile, 'utf-8');
274
+ const { metadata, content: bodyContent } = parseFrontmatter(content);
275
+
276
+ return {
277
+ name: itemName,
278
+ type: 'complex',
279
+ path: dirPath,
280
+ metadata,
281
+ content: bodyContent,
282
+ fullContent: content
283
+ };
284
+ }
285
+ } catch {
286
+ // Not a directory or doesn't exist
287
+ }
288
+ }
289
+
290
+ // Try simple item (file)
291
+ const filePath = path.join(tab.path, `${itemName}${ext}`);
292
+ try {
293
+ const content = await fs.readFile(filePath, 'utf-8');
294
+ const { metadata, content: bodyContent } = parseFrontmatter(content);
295
+
296
+ return {
297
+ name: itemName,
298
+ type: 'simple',
299
+ path: filePath,
300
+ metadata,
301
+ content: bodyContent,
302
+ fullContent: content
303
+ };
304
+ } catch (error) {
305
+ throw new Error(`Item '${itemName}' not found in ${clientId}/${tabId}`);
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Write/create an item
311
+ */
312
+ async writeItem(clientId, tabId, itemName, data) {
313
+ validateItemName(itemName);
314
+ const tab = await this.getClientTab(clientId, tabId);
315
+ if (!tab) {
316
+ throw new Error(`Tab '${tabId}' not found for client '${clientId}'`);
317
+ }
318
+
319
+ // Ensure directory exists
320
+ await fs.mkdir(tab.path, { recursive: true });
321
+
322
+ const { type = 'simple', metadata = {}, content = '' } = data;
323
+ const frontmatter = generateFrontmatter(metadata);
324
+ const fullContent = frontmatter + content;
325
+
326
+ // Handle single-file tabs
327
+ if (tab.singleFile) {
328
+ const filePath = path.join(tab.path, tab.filePattern);
329
+ await fs.writeFile(filePath, fullContent, 'utf-8');
330
+ return { path: filePath, type: 'single' };
331
+ }
332
+
333
+ const ext = tab.filePattern.replace('*', '');
334
+
335
+ if (type === 'simple') {
336
+ const filePath = path.join(tab.path, `${itemName}${ext}`);
337
+ await fs.writeFile(filePath, fullContent, 'utf-8');
338
+ return { path: filePath, type: 'simple' };
339
+ } else if (type === 'complex' && tab.supportsDirectories) {
340
+ const dirPath = path.join(tab.path, itemName);
341
+ await fs.mkdir(dirPath, { recursive: true });
342
+
343
+ const mainFile = path.join(dirPath, 'SKILL.md');
344
+ await fs.writeFile(mainFile, fullContent, 'utf-8');
345
+
346
+ if (data.createWorkflows) {
347
+ await fs.mkdir(path.join(dirPath, 'workflows'), { recursive: true });
348
+ }
349
+ if (data.createReferences) {
350
+ await fs.mkdir(path.join(dirPath, 'references'), { recursive: true });
351
+ }
352
+
353
+ return { path: dirPath, type: 'complex' };
354
+ }
355
+
356
+ throw new Error(`Invalid item type '${type}' for tab '${tabId}'`);
357
+ }
358
+
359
+ /**
360
+ * Delete an item
361
+ */
362
+ async deleteItem(clientId, tabId, itemName) {
363
+ validateItemName(itemName);
364
+ const tab = await this.getClientTab(clientId, tabId);
365
+ if (!tab) {
366
+ throw new Error(`Tab '${tabId}' not found for client '${clientId}'`);
367
+ }
368
+
369
+ // Cannot delete single-file items, only clear content
370
+ if (tab.singleFile) {
371
+ const filePath = path.join(tab.path, tab.filePattern);
372
+ await fs.writeFile(filePath, '', 'utf-8');
373
+ return { deleted: false, cleared: true, path: filePath };
374
+ }
375
+
376
+ const ext = tab.filePattern.replace('*', '');
377
+
378
+ // Try complex item first (directory)
379
+ if (tab.supportsDirectories) {
380
+ const dirPath = path.join(tab.path, itemName);
381
+ try {
382
+ const stat = await fs.stat(dirPath);
383
+ if (stat.isDirectory()) {
384
+ await fs.rm(dirPath, { recursive: true });
385
+ return { deleted: true, type: 'complex', path: dirPath };
386
+ }
387
+ } catch {
388
+ // Not a directory
389
+ }
390
+ }
391
+
392
+ // Try simple item (file)
393
+ const filePath = path.join(tab.path, `${itemName}${ext}`);
394
+ try {
395
+ await fs.unlink(filePath);
396
+ return { deleted: true, type: 'simple', path: filePath };
397
+ } catch (error) {
398
+ throw new Error(`Item '${itemName}' not found in ${clientId}/${tabId}`);
399
+ }
400
+ }
401
+
402
+ /**
403
+ * List files in a complex item
404
+ */
405
+ async listItemFiles(clientId, tabId, itemName) {
406
+ validateItemName(itemName);
407
+ const tab = await this.getClientTab(clientId, tabId);
408
+ if (!tab || !tab.supportsDirectories) {
409
+ throw new Error(`Tab '${tabId}' does not support directories`);
410
+ }
411
+
412
+ const itemPath = path.join(tab.path, itemName);
413
+ const stat = await fs.stat(itemPath);
414
+ if (!stat.isDirectory()) {
415
+ throw new Error(`Item '${itemName}' is not a complex item`);
416
+ }
417
+
418
+ const files = [];
419
+
420
+ async function walkDir(dir, relativePath = '') {
421
+ const entries = await fs.readdir(dir, { withFileTypes: true });
422
+
423
+ for (const entry of entries) {
424
+ if (entry.name.startsWith('.')) continue;
425
+
426
+ const fullPath = path.join(dir, entry.name);
427
+ const relPath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
428
+
429
+ if (entry.isDirectory()) {
430
+ files.push({ name: entry.name, path: relPath, type: 'directory' });
431
+ await walkDir(fullPath, relPath);
432
+ } else {
433
+ const fileStat = await fs.stat(fullPath);
434
+ files.push({
435
+ name: entry.name,
436
+ path: relPath,
437
+ type: 'file',
438
+ size: fileStat.size,
439
+ modified: fileStat.mtime
440
+ });
441
+ }
442
+ }
443
+ }
444
+
445
+ await walkDir(itemPath);
446
+ return files;
447
+ }
448
+
449
+ /**
450
+ * Read a file from a complex item
451
+ */
452
+ async readItemFile(clientId, tabId, itemName, filePath) {
453
+ validateItemName(itemName);
454
+ const tab = await this.getClientTab(clientId, tabId);
455
+ if (!tab) {
456
+ throw new Error(`Tab '${tabId}' not found`);
457
+ }
458
+
459
+ const fullPath = path.join(tab.path, itemName, filePath);
460
+ const realItemDir = await fs.realpath(path.join(tab.path, itemName));
461
+ const realPath = await fs.realpath(fullPath);
462
+
463
+ if (!realPath.startsWith(realItemDir + path.sep) && realPath !== realItemDir) {
464
+ throw new Error('Invalid file path');
465
+ }
466
+
467
+ const content = await fs.readFile(fullPath, 'utf-8');
468
+ const stat = await fs.stat(fullPath);
469
+
470
+ return { path: filePath, content, size: stat.size, modified: stat.mtime };
471
+ }
472
+
473
+ /**
474
+ * Write a file in a complex item
475
+ */
476
+ async writeItemFile(clientId, tabId, itemName, filePath, content) {
477
+ validateItemName(itemName);
478
+ const tab = await this.getClientTab(clientId, tabId);
479
+ if (!tab) {
480
+ throw new Error(`Tab '${tabId}' not found`);
481
+ }
482
+
483
+ const fullPath = path.join(tab.path, itemName, filePath);
484
+ await fs.mkdir(path.dirname(fullPath), { recursive: true });
485
+
486
+ const realItemDir = await fs.realpath(path.join(tab.path, itemName));
487
+ const realPath = await fs.realpath(fullPath);
488
+
489
+ if (!realPath.startsWith(realItemDir + path.sep) && realPath !== realItemDir) {
490
+ throw new Error('Invalid file path');
491
+ }
492
+
493
+ await fs.writeFile(fullPath, content, 'utf-8');
494
+
495
+ return { path: filePath, written: true };
496
+ }
497
+
498
+ /**
499
+ * Copy an item within the same tab
500
+ */
501
+ async copyItem(clientId, tabId, itemName, newName) {
502
+ validateItemName(itemName);
503
+ validateItemName(newName);
504
+ const item = await this.readItem(clientId, tabId, itemName);
505
+
506
+ await this.writeItem(clientId, tabId, newName, {
507
+ type: item.type,
508
+ metadata: item.metadata,
509
+ content: item.content
510
+ });
511
+
512
+ return { copied: true, from: itemName, to: newName };
513
+ }
514
+
515
+ // Legacy compatibility methods
516
+ async listScopes() {
517
+ return [];
518
+ }
519
+
520
+ async listSkills(scopeId) {
521
+ return [];
522
+ }
523
+
524
+ async listAllSkills() {
525
+ return [];
526
+ }
527
+
528
+ async readSkill(scopeId, skillName) {
529
+ return null;
530
+ }
531
+
532
+ async writeSkill(scopeId, skillName, data) {
533
+ throw new Error('Use writeItem instead');
534
+ }
535
+
536
+ async deleteSkill(scopeId, skillName) {
537
+ throw new Error('Use deleteItem instead');
538
+ }
539
+
540
+ async readRegistry() {
541
+ return { skills: [] };
542
+ }
543
+
544
+ async importFromGitHub(url, toScope) {
545
+ return { imported: false, message: 'GitHub import not supported in per-client mode' };
546
+ }
547
+
548
+ async getClientsWithScopes() {
549
+ return [];
550
+ }
551
+
552
+ async listScopesByClient(clientId) {
553
+ return [];
554
+ }
555
+ }