mcp-config-manager 2.1.0 → 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/README.md +206 -0
- package/package.json +1 -1
- package/public/index.html +98 -2
- package/public/js/clientView.js +437 -3
- package/public/js/main.js +2 -2
- package/public/js/skillsApi.js +135 -0
- package/public/style.css +877 -26
- package/src/commands-manager.js +268 -0
- package/src/config-manager.js +3 -5
- package/src/server.js +300 -0
- package/src/skills-manager.js +555 -0
- package/src/skills-scopes.js +809 -0
|
@@ -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
|
+
}
|