@ian2018cs/agenthub 0.1.69 → 0.1.70

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.
@@ -70,67 +70,67 @@ router.post('/cli/add', async (req, res) => {
70
70
  return res.status(401).json({ error: 'User authentication required' });
71
71
  }
72
72
 
73
- console.log(`➕ Adding MCP server using Claude CLI (${scope} scope):`, name);
73
+ console.log(`➕ Adding MCP server (${scope} scope):`, name);
74
74
 
75
75
  const userPaths = getUserPaths(userUuid);
76
+
77
+ // For local scope, bypass Claude CLI and directly write to .claude.json.
78
+ // Claude CLI resolves the project key by walking up to the git root, which in
79
+ // our multi-user deployment always resolves to the app root — not the user's
80
+ // actual project directory. We write directly to avoid this mis-keying.
81
+ if (scope === 'local' && projectPath) {
82
+ const claudeJsonPath = path.join(userPaths.claudeDir, '.claude.json');
83
+ let claudeConfig = {};
84
+ try {
85
+ claudeConfig = JSON.parse(await fs.readFile(claudeJsonPath, 'utf-8'));
86
+ } catch { /* start fresh if missing */ }
87
+
88
+ // Build the server entry in the same shape Claude CLI would write
89
+ let serverEntry = {};
90
+ if (type === 'http' || type === 'sse') {
91
+ serverEntry = { transport: type, url, ...(Object.keys(headers).length ? { headers } : {}) };
92
+ } else {
93
+ serverEntry = { command, ...(args?.length ? { args } : {}), ...(Object.keys(env).length ? { env } : {}) };
94
+ }
95
+
96
+ if (!claudeConfig.projects) claudeConfig.projects = {};
97
+ if (!claudeConfig.projects[projectPath]) claudeConfig.projects[projectPath] = {};
98
+ if (!claudeConfig.projects[projectPath].mcpServers) claudeConfig.projects[projectPath].mcpServers = {};
99
+ claudeConfig.projects[projectPath].mcpServers[name] = serverEntry;
100
+
101
+ await fs.writeFile(claudeJsonPath, JSON.stringify(claudeConfig, null, 2), 'utf-8');
102
+ console.log(`[MCP] Wrote local-scoped MCP "${name}" for project ${projectPath}`);
103
+ return res.json({ success: true, message: `MCP server "${name}" added successfully` });
104
+ }
105
+
76
106
  const { spawn } = await import('child_process');
77
-
78
- let cliArgs = ['mcp', 'add'];
79
-
80
- // Add scope flag
81
- cliArgs.push('--scope', scope);
82
-
107
+
108
+ let cliArgs = ['mcp', 'add', '--scope', scope];
109
+
83
110
  if (type === 'http') {
84
111
  cliArgs.push('--transport', 'http', name, url);
85
- // Add headers if provided
86
- Object.entries(headers).forEach(([key, value]) => {
87
- cliArgs.push('--header', `${key}: ${value}`);
88
- });
112
+ Object.entries(headers).forEach(([key, value]) => { cliArgs.push('--header', `${key}: ${value}`); });
89
113
  } else if (type === 'sse') {
90
114
  cliArgs.push('--transport', 'sse', name, url);
91
- // Add headers if provided
92
- Object.entries(headers).forEach(([key, value]) => {
93
- cliArgs.push('--header', `${key}: ${value}`);
94
- });
115
+ Object.entries(headers).forEach(([key, value]) => { cliArgs.push('--header', `${key}: ${value}`); });
95
116
  } else {
96
- // stdio (default): claude mcp add --scope user <name> <command> [args...]
97
117
  cliArgs.push(name);
98
- // Add environment variables
99
- Object.entries(env).forEach(([key, value]) => {
100
- cliArgs.push('-e', `${key}=${value}`);
101
- });
118
+ Object.entries(env).forEach(([key, value]) => { cliArgs.push('-e', `${key}=${value}`); });
102
119
  cliArgs.push(command);
103
- if (args && args.length > 0) {
104
- cliArgs.push(...args);
105
- }
120
+ if (args?.length) cliArgs.push(...args);
106
121
  }
107
-
122
+
108
123
  console.log('🔧 Running Claude CLI command:', 'claude', cliArgs.join(' '));
109
124
 
110
- // For local scope, we need to run the command in the project directory
111
- const spawnOptions = {
125
+ const cliProcess = spawn('claude', cliArgs, {
112
126
  stdio: ['pipe', 'pipe', 'pipe'],
113
127
  env: { ...process.env, CLAUDE_CONFIG_DIR: userPaths.claudeDir }
114
- };
115
-
116
- if (scope === 'local' && projectPath) {
117
- spawnOptions.cwd = projectPath;
118
- console.log('📁 Running in project directory:', projectPath);
119
- }
120
-
121
- const cliProcess = spawn('claude', cliArgs, spawnOptions);
128
+ });
122
129
 
123
130
  let stdout = '';
124
131
  let stderr = '';
125
-
126
- cliProcess.stdout.on('data', (data) => {
127
- stdout += data.toString();
128
- });
129
-
130
- cliProcess.stderr.on('data', (data) => {
131
- stderr += data.toString();
132
- });
133
-
132
+ cliProcess.stdout.on('data', (data) => { stdout += data.toString(); });
133
+ cliProcess.stderr.on('data', (data) => { stderr += data.toString(); });
134
134
  cliProcess.on('close', (code) => {
135
135
  if (code === 0) {
136
136
  res.json({ success: true, output: stdout, message: `MCP server "${name}" added successfully` });
@@ -139,9 +139,7 @@ router.post('/cli/add', async (req, res) => {
139
139
  res.status(400).json({ error: 'Claude CLI command failed', details: stderr });
140
140
  }
141
141
  });
142
-
143
142
  cliProcess.on('error', (error) => {
144
- console.error('Error running Claude CLI:', error);
145
143
  res.status(500).json({ error: 'Failed to run Claude CLI', details: error.message });
146
144
  });
147
145
  } catch (error) {
@@ -150,6 +148,7 @@ router.post('/cli/add', async (req, res) => {
150
148
  }
151
149
  });
152
150
 
151
+ // POST /api/mcp/cli/add-json - Add MCP server using JSON format
153
152
  // POST /api/mcp/cli/add-json - Add MCP server using JSON format
154
153
  router.post('/cli/add-json', async (req, res) => {
155
154
  try {
@@ -167,74 +166,62 @@ router.post('/cli/add-json', async (req, res) => {
167
166
  try {
168
167
  parsedConfig = typeof jsonConfig === 'string' ? JSON.parse(jsonConfig) : jsonConfig;
169
168
  } catch (parseError) {
170
- return res.status(400).json({
171
- error: 'Invalid JSON configuration',
172
- details: parseError.message
173
- });
169
+ return res.status(400).json({ error: 'Invalid JSON configuration', details: parseError.message });
174
170
  }
175
171
 
176
- // Validate required fields
177
172
  if (!parsedConfig.type) {
178
- return res.status(400).json({
179
- error: 'Invalid configuration',
180
- details: 'Missing required field: type'
181
- });
173
+ return res.status(400).json({ error: 'Invalid configuration', details: 'Missing required field: type' });
182
174
  }
183
-
184
175
  if (parsedConfig.type === 'stdio' && !parsedConfig.command) {
185
- return res.status(400).json({
186
- error: 'Invalid configuration',
187
- details: 'stdio type requires a command field'
188
- });
176
+ return res.status(400).json({ error: 'Invalid configuration', details: 'stdio type requires a command field' });
189
177
  }
190
-
191
178
  if ((parsedConfig.type === 'http' || parsedConfig.type === 'sse') && !parsedConfig.url) {
192
- return res.status(400).json({
193
- error: 'Invalid configuration',
194
- details: `${parsedConfig.type} type requires a url field`
195
- });
179
+ return res.status(400).json({ error: 'Invalid configuration', details: `${parsedConfig.type} type requires a url field` });
196
180
  }
197
181
 
198
182
  const userPaths = getUserPaths(userUuid);
199
- const { spawn } = await import('child_process');
200
183
 
201
- // Build the command: claude mcp add-json --scope <scope> <name> '<json>'
202
- const cliArgs = ['mcp', 'add-json', '--scope', scope, name];
184
+ // For local scope, bypass Claude CLI (same reason as cli/add git root mis-keying).
185
+ if (scope === 'local' && projectPath) {
186
+ const claudeJsonPath = path.join(userPaths.claudeDir, '.claude.json');
187
+ let claudeConfig = {};
188
+ try {
189
+ claudeConfig = JSON.parse(await fs.readFile(claudeJsonPath, 'utf-8'));
190
+ } catch { /* start fresh if missing */ }
191
+
192
+ if (!claudeConfig.projects) claudeConfig.projects = {};
193
+ if (!claudeConfig.projects[projectPath]) claudeConfig.projects[projectPath] = {};
194
+ if (!claudeConfig.projects[projectPath].mcpServers) claudeConfig.projects[projectPath].mcpServers = {};
195
+ claudeConfig.projects[projectPath].mcpServers[name] = parsedConfig;
196
+
197
+ await fs.writeFile(claudeJsonPath, JSON.stringify(claudeConfig, null, 2), 'utf-8');
198
+ console.log(`[MCP] Wrote local-scoped MCP "${name}" (JSON) for project ${projectPath}`);
199
+
200
+ // Sync to Codex config.toml (fire-and-forget)
201
+ const codexConfigPath = path.join(userPaths.codexHomeDir, '.codex', 'config.toml');
202
+ addToCodexConfig(codexConfigPath, { [name]: parsedConfig }).catch(err =>
203
+ console.error('[MCP] Failed to sync to Codex config.toml:', err.message)
204
+ );
205
+ return res.json({ success: true, message: `MCP server "${name}" added successfully via JSON` });
206
+ }
203
207
 
204
- // Add the JSON config as a properly formatted string
205
- const jsonString = JSON.stringify(parsedConfig);
206
- cliArgs.push(jsonString);
208
+ const { spawn } = await import('child_process');
207
209
 
208
- console.log('🔧 Running Claude CLI command:', 'claude', cliArgs[0], cliArgs[1], cliArgs[2], cliArgs[3], cliArgs[4], jsonString);
210
+ const cliArgs = ['mcp', 'add-json', '--scope', scope, name, JSON.stringify(parsedConfig)];
211
+ console.log('🔧 Running Claude CLI command:', 'claude', cliArgs[0], cliArgs[1], cliArgs[2], cliArgs[3], cliArgs[4]);
209
212
 
210
- // For local scope, we need to run the command in the project directory
211
- const spawnOptions = {
213
+ const cliProcess = spawn('claude', cliArgs, {
212
214
  stdio: ['pipe', 'pipe', 'pipe'],
213
215
  env: { ...process.env, CLAUDE_CONFIG_DIR: userPaths.claudeDir }
214
- };
215
-
216
- if (scope === 'local' && projectPath) {
217
- spawnOptions.cwd = projectPath;
218
- console.log('📁 Running in project directory:', projectPath);
219
- }
220
-
221
- const cliProcess = spawn('claude', cliArgs, spawnOptions);
216
+ });
222
217
 
223
218
  let stdout = '';
224
219
  let stderr = '';
225
-
226
- cliProcess.stdout.on('data', (data) => {
227
- stdout += data.toString();
228
- });
229
-
230
- cliProcess.stderr.on('data', (data) => {
231
- stderr += data.toString();
232
- });
233
-
220
+ cliProcess.stdout.on('data', (data) => { stdout += data.toString(); });
221
+ cliProcess.stderr.on('data', (data) => { stderr += data.toString(); });
234
222
  cliProcess.on('close', (code) => {
235
223
  if (code === 0) {
236
224
  res.json({ success: true, output: stdout, message: `MCP server "${name}" added successfully via JSON` });
237
- // Sync to Codex config.toml (fire-and-forget)
238
225
  const codexConfigPath = path.join(userPaths.codexHomeDir, '.codex', 'config.toml');
239
226
  addToCodexConfig(codexConfigPath, { [name]: parsedConfig }).catch(err =>
240
227
  console.error('[MCP] Failed to sync to Codex config.toml:', err.message)
@@ -244,9 +231,7 @@ router.post('/cli/add-json', async (req, res) => {
244
231
  res.status(400).json({ error: 'Claude CLI command failed', details: stderr });
245
232
  }
246
233
  });
247
-
248
234
  cliProcess.on('error', (error) => {
249
- console.error('Error running Claude CLI:', error);
250
235
  res.status(500).json({ error: 'Failed to run Claude CLI', details: error.message });
251
236
  });
252
237
  } catch (error) {
@@ -259,42 +244,44 @@ router.post('/cli/add-json', async (req, res) => {
259
244
  router.delete('/cli/remove/:name', async (req, res) => {
260
245
  try {
261
246
  const { name } = req.params;
262
- const { scope } = req.query; // Get scope from query params
247
+ const { scope, projectPath } = req.query;
263
248
 
264
249
  const userUuid = req.user?.uuid;
265
250
  if (!userUuid) {
266
251
  return res.status(401).json({ error: 'User authentication required' });
267
252
  }
268
253
 
269
- // Handle the ID format (remove scope prefix if present)
270
- let actualName = name;
271
- let actualScope = scope;
272
-
273
- // If the name includes a scope prefix like "local:test", extract it
274
- if (name.includes(':')) {
275
- const [prefix, serverName] = name.split(':');
276
- actualName = serverName;
277
- actualScope = actualScope || prefix; // Use prefix as scope if not provided in query
278
- }
279
-
280
- console.log('🗑️ Removing MCP server using Claude CLI:', actualName, 'scope:', actualScope);
254
+ const actualScope = scope || 'user';
255
+ console.log('🗑️ Removing MCP server:', name, 'scope:', actualScope);
281
256
 
282
257
  const userPaths = getUserPaths(userUuid);
283
- const { spawn } = await import('child_process');
284
-
285
- // Build command args based on scope
286
- let cliArgs = ['mcp', 'remove'];
287
258
 
288
- // Add scope flag if it's local scope
259
+ // For local scope, bypass Claude CLI and directly edit .claude.json.
289
260
  if (actualScope === 'local') {
290
- cliArgs.push('--scope', 'local');
291
- } else if (actualScope === 'user' || !actualScope) {
292
- // User scope is default, but we can be explicit
293
- cliArgs.push('--scope', 'user');
261
+ if (!projectPath) {
262
+ return res.status(400).json({ error: 'projectPath is required for local-scoped MCP removal' });
263
+ }
264
+ const claudeJsonPath = path.join(userPaths.claudeDir, '.claude.json');
265
+ try {
266
+ const claudeConfig = JSON.parse(await fs.readFile(claudeJsonPath, 'utf-8'));
267
+ const projectMcps = claudeConfig.projects?.[projectPath]?.mcpServers;
268
+ if (projectMcps && name in projectMcps) {
269
+ delete projectMcps[name];
270
+ await fs.writeFile(claudeJsonPath, JSON.stringify(claudeConfig, null, 2), 'utf-8');
271
+ }
272
+ } catch (e) {
273
+ return res.status(500).json({ error: 'Failed to update .claude.json', details: e.message });
274
+ }
275
+ // Sync removal to Codex (fire-and-forget)
276
+ const codexConfigPath = path.join(userPaths.codexHomeDir, '.codex', 'config.toml');
277
+ removeFromCodexConfig(codexConfigPath, name).catch(err =>
278
+ console.error('[MCP] Failed to sync removal to Codex config.toml:', err.message)
279
+ );
280
+ return res.json({ success: true, message: `MCP server "${name}" removed successfully` });
294
281
  }
295
282
 
296
- cliArgs.push(actualName);
297
-
283
+ const { spawn } = await import('child_process');
284
+ const cliArgs = ['mcp', 'remove', '--scope', actualScope, name];
298
285
  console.log('🔧 Running Claude CLI command:', 'claude', cliArgs.join(' '));
299
286
 
300
287
  const cliProcess = spawn('claude', cliArgs, {
@@ -304,21 +291,13 @@ router.delete('/cli/remove/:name', async (req, res) => {
304
291
 
305
292
  let stdout = '';
306
293
  let stderr = '';
307
-
308
- cliProcess.stdout.on('data', (data) => {
309
- stdout += data.toString();
310
- });
311
-
312
- cliProcess.stderr.on('data', (data) => {
313
- stderr += data.toString();
314
- });
315
-
294
+ cliProcess.stdout.on('data', (data) => { stdout += data.toString(); });
295
+ cliProcess.stderr.on('data', (data) => { stderr += data.toString(); });
316
296
  cliProcess.on('close', (code) => {
317
297
  if (code === 0) {
318
298
  res.json({ success: true, output: stdout, message: `MCP server "${name}" removed successfully` });
319
- // Sync removal to Codex config.toml (fire-and-forget)
320
299
  const codexConfigPath = path.join(userPaths.codexHomeDir, '.codex', 'config.toml');
321
- removeFromCodexConfig(codexConfigPath, actualName).catch(err =>
300
+ removeFromCodexConfig(codexConfigPath, name).catch(err =>
322
301
  console.error('[MCP] Failed to sync removal to Codex config.toml:', err.message)
323
302
  );
324
303
  } else {
@@ -326,9 +305,7 @@ router.delete('/cli/remove/:name', async (req, res) => {
326
305
  res.status(400).json({ error: 'Claude CLI command failed', details: stderr });
327
306
  }
328
307
  });
329
-
330
308
  cliProcess.on('error', (error) => {
331
- console.error('Error running Claude CLI:', error);
332
309
  res.status(500).json({ error: 'Failed to run Claude CLI', details: error.message });
333
310
  });
334
311
  } catch (error) {
@@ -464,26 +441,24 @@ router.get('/config/read', async (req, res) => {
464
441
  }
465
442
  }
466
443
 
467
- // Check for local-scoped MCP servers (project-specific)
468
- const currentProjectPath = process.cwd();
469
-
470
- // Check under 'projects' key
471
- if (configData.projects && configData.projects[currentProjectPath]) {
472
- const projectConfig = configData.projects[currentProjectPath];
473
- if (projectConfig.mcpServers && typeof projectConfig.mcpServers === 'object' && Object.keys(projectConfig.mcpServers).length > 0) {
474
- console.log(`🔍 Found local-scoped MCP servers for ${currentProjectPath}:`, Object.keys(projectConfig.mcpServers));
444
+ // Check for local-scoped MCP servers (project-specific) — iterate all projects
445
+ if (configData.projects && typeof configData.projects === 'object') {
446
+ for (const [projectPath, projectConfig] of Object.entries(configData.projects)) {
447
+ if (!projectConfig?.mcpServers || typeof projectConfig.mcpServers !== 'object') continue;
448
+ const mcpNames = Object.keys(projectConfig.mcpServers);
449
+ if (mcpNames.length === 0) continue;
450
+ console.log(`🔍 Found local-scoped MCP servers for ${projectPath}:`, mcpNames);
475
451
  for (const [name, config] of Object.entries(projectConfig.mcpServers)) {
476
452
  const server = {
477
- id: `local:${name}`, // Prefix with scope for uniqueness
478
- name: name, // Keep original name
479
- type: 'stdio', // Default type
480
- scope: 'local', // Local scope - only for this project
481
- projectPath: currentProjectPath,
453
+ id: name,
454
+ name: name,
455
+ type: 'stdio',
456
+ scope: 'local',
457
+ projectPath: projectPath,
482
458
  config: {},
483
- raw: config // Include raw config for full details
459
+ raw: config
484
460
  };
485
-
486
- // Determine transport type and extract config
461
+
487
462
  if (config.command) {
488
463
  server.type = 'stdio';
489
464
  server.config.command = config.command;
@@ -494,7 +469,7 @@ router.get('/config/read', async (req, res) => {
494
469
  server.config.url = config.url;
495
470
  server.config.headers = config.headers || {};
496
471
  }
497
-
472
+
498
473
  servers.push(server);
499
474
  }
500
475
  }
@@ -423,6 +423,89 @@ router.post('/enable/:name', async (req, res) => {
423
423
  }
424
424
  });
425
425
 
426
+ /**
427
+ * Recursively add directory contents to a zip archive.
428
+ * Uses fs.stat (follows symlinks) so symlinked files are included transparently.
429
+ */
430
+ async function addDirToZip(zip, dirPath, zipPrefix) {
431
+ let names;
432
+ try {
433
+ names = await fs.readdir(dirPath);
434
+ } catch {
435
+ return;
436
+ }
437
+
438
+ for (const name of names) {
439
+ if (name.startsWith('.')) continue;
440
+
441
+ const fullPath = path.join(dirPath, name);
442
+ const zipPath = zipPrefix + name;
443
+
444
+ let stat;
445
+ try {
446
+ stat = await fs.stat(fullPath); // follows symlinks
447
+ } catch {
448
+ continue;
449
+ }
450
+
451
+ if (stat.isDirectory()) {
452
+ await addDirToZip(zip, fullPath, zipPath + '/');
453
+ } else if (stat.isFile()) {
454
+ const data = await fs.readFile(fullPath);
455
+ zip.addFile(zipPath, data);
456
+ }
457
+ }
458
+ }
459
+
460
+ /**
461
+ * GET /api/skills/:name/download
462
+ * Download a skill as a zip file (symlinks are resolved transparently)
463
+ */
464
+ router.get('/:name/download', async (req, res) => {
465
+ try {
466
+ const userUuid = req.user?.uuid;
467
+ if (!userUuid) {
468
+ return res.status(401).json({ error: 'User authentication required' });
469
+ }
470
+
471
+ const { name } = req.params;
472
+
473
+ if (!SKILL_NAME_REGEX.test(name)) {
474
+ return res.status(400).json({ error: 'Invalid skill name' });
475
+ }
476
+
477
+ const userPaths = getUserPaths(userUuid);
478
+ const skillPath = path.join(userPaths.skillsDir, name);
479
+
480
+ // Resolve symlink to real directory
481
+ let realPath = skillPath;
482
+ try {
483
+ const stat = await fs.lstat(skillPath);
484
+ if (stat.isSymbolicLink()) {
485
+ realPath = await fs.realpath(skillPath);
486
+ }
487
+ } catch {
488
+ return res.status(404).json({ error: 'Skill not found' });
489
+ }
490
+
491
+ if (!await isValidSkill(realPath)) {
492
+ return res.status(404).json({ error: 'Skill not found or invalid' });
493
+ }
494
+
495
+ const zip = new AdmZip();
496
+ await addDirToZip(zip, realPath, `${name}/`);
497
+ const zipBuffer = zip.toBuffer();
498
+
499
+ res.setHeader('Content-Type', 'application/zip');
500
+ res.setHeader('Content-Disposition', `attachment; filename="${name}.zip"`);
501
+ res.setHeader('Content-Length', zipBuffer.length);
502
+ res.send(zipBuffer);
503
+ } catch (error) {
504
+ console.error('Error downloading skill:', error);
505
+ res.status(500).json({ error: error.message });
506
+ }
507
+ });
508
+
426
509
  /**
427
510
  * DELETE /api/skills/disable/:name
428
511
  * Disable a skill by removing symlink
@@ -42,7 +42,7 @@ function updateRepository(repoPath) {
42
42
  });
43
43
  }
44
44
 
45
- async function ensureSystemMcpRepo() {
45
+ export async function ensureSystemMcpRepo() {
46
46
  const publicPaths = getPublicPaths();
47
47
  const publicRepoPath = path.join(publicPaths.mcpRepoDir, SYSTEM_MCP_REPO_OWNER, SYSTEM_MCP_REPO_NAME);
48
48
 
@@ -51,7 +51,7 @@ function updateRepository(repoPath) {
51
51
  * If already cloned, attempts to pull latest changes.
52
52
  * Returns the path to the public clone.
53
53
  */
54
- async function ensureSystemRepo() {
54
+ export async function ensureSystemRepo() {
55
55
  const publicPaths = getPublicPaths();
56
56
  const publicRepoPath = path.join(publicPaths.skillsRepoDir, SYSTEM_REPO_OWNER, SYSTEM_REPO_NAME);
57
57