@ian2018cs/agenthub 0.1.74 → 0.1.75

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/server/index.js CHANGED
@@ -670,8 +670,9 @@ app.put('/api/projects/:projectName/file', authenticateToken, async (req, res) =
670
670
  app.get('/api/projects/:projectName/files', authenticateToken, async (req, res) => {
671
671
  try {
672
672
  // Query parameters for lazy loading
673
- const { dirPath, depth = '1' } = req.query;
673
+ const { dirPath, depth = '1', showHidden = 'false' } = req.query;
674
674
  const maxDepth = Math.min(parseInt(depth) || 1, 10); // Limit max depth to 10
675
+ const showHiddenFiles = showHidden === 'true';
675
676
 
676
677
  // Use extractProjectDirectory to get the actual project path
677
678
  let projectPath;
@@ -701,7 +702,7 @@ app.get('/api/projects/:projectName/files', authenticateToken, async (req, res)
701
702
  return res.status(404).json({ error: `Path not found: ${targetPath}` });
702
703
  }
703
704
 
704
- const files = await getFileTree(targetPath, maxDepth, 0, true);
705
+ const files = await getFileTree(targetPath, maxDepth, 0, showHiddenFiles);
705
706
  res.json(files);
706
707
  } catch (error) {
707
708
  console.error('[ERROR] File tree error:', error.message);
@@ -2361,14 +2362,36 @@ async function getFileTree(dirPath, maxDepth = 1, currentDepth = 0, showHidden =
2361
2362
  for (const entry of entries) {
2362
2363
  // Skip heavy build directories and VCS directories
2363
2364
  if (SKIP_DIRECTORIES.has(entry.name)) continue;
2365
+ // Skip hidden files/dirs when showHidden is false
2366
+ if (!showHidden && entry.name.startsWith('.')) continue;
2364
2367
 
2365
2368
  const itemPath = path.join(dirPath, entry.name);
2369
+ const isSymlink = entry.isSymbolicLink();
2366
2370
  const item = {
2367
2371
  name: entry.name,
2368
2372
  path: itemPath,
2369
- type: entry.isDirectory() ? 'directory' : 'file'
2373
+ type: entry.isDirectory() ? 'directory' : 'file',
2374
+ isSymlink,
2370
2375
  };
2371
2376
 
2377
+ // Resolve symlink target and actual type
2378
+ if (isSymlink) {
2379
+ try {
2380
+ item.symlinkTarget = await fsPromises.readlink(itemPath);
2381
+ } catch {
2382
+ item.symlinkTarget = null;
2383
+ }
2384
+ try {
2385
+ // stat() follows the symlink to get target info
2386
+ const targetStats = await fsPromises.stat(itemPath);
2387
+ item.type = targetStats.isDirectory() ? 'directory' : 'file';
2388
+ } catch {
2389
+ // Broken symlink — keep as 'file', mark broken
2390
+ item.type = 'file';
2391
+ item.isBrokenSymlink = true;
2392
+ }
2393
+ }
2394
+
2372
2395
  // Get file stats for additional metadata
2373
2396
  try {
2374
2397
  const stats = await fsPromises.stat(itemPath);
@@ -2383,14 +2406,14 @@ async function getFileTree(dirPath, maxDepth = 1, currentDepth = 0, showHidden =
2383
2406
  item.permissions = ((mode >> 6) & 7).toString() + ((mode >> 3) & 7).toString() + (mode & 7).toString();
2384
2407
  item.permissionsRwx = permToRwx(ownerPerm) + permToRwx(groupPerm) + permToRwx(otherPerm);
2385
2408
  } catch (statError) {
2386
- // If stat fails, provide default values
2409
+ // If stat fails (e.g. broken symlink), provide default values
2387
2410
  item.size = 0;
2388
2411
  item.modified = null;
2389
2412
  item.permissions = '000';
2390
2413
  item.permissionsRwx = '---------';
2391
2414
  }
2392
2415
 
2393
- if (entry.isDirectory()) {
2416
+ if (item.type === 'directory') {
2394
2417
  // For lazy loading: check if directory has children without loading them
2395
2418
  if (currentDepth >= maxDepth) {
2396
2419
  // Don't load children, just check if they exist
@@ -903,6 +903,9 @@ async function deleteProject(projectName, userUuid, options = {}) {
903
903
  actualProjectDir = await extractProjectDirectory(projectName, userUuid);
904
904
  }
905
905
 
906
+ // Also resolve the actual path to clean up .claude.json project-scoped MCPs
907
+ const resolvedProjectDir = actualProjectDir || await extractProjectDirectory(projectName, userUuid).catch(() => null);
908
+
906
909
  // Remove the Claude sessions directory
907
910
  try {
908
911
  await fs.rm(sessionDir, { recursive: true, force: true });
@@ -917,6 +920,21 @@ async function deleteProject(projectName, userUuid, options = {}) {
917
920
  delete config[projectName];
918
921
  await saveProjectConfig(config, userUuid);
919
922
 
923
+ // Clean up project-scoped MCP servers from .claude.json
924
+ if (resolvedProjectDir) {
925
+ try {
926
+ const claudeJsonPath = path.join(getUserPaths(userUuid).claudeDir, '.claude.json');
927
+ const claudeConfig = JSON.parse(await fs.readFile(claudeJsonPath, 'utf-8'));
928
+ if (claudeConfig.projects?.[resolvedProjectDir]) {
929
+ delete claudeConfig.projects[resolvedProjectDir];
930
+ await fs.writeFile(claudeJsonPath, JSON.stringify(claudeConfig, null, 2), 'utf-8');
931
+ }
932
+ } catch (err) {
933
+ // Non-fatal: log and continue
934
+ console.error(`[deleteProject] Failed to clean up .claude.json for ${resolvedProjectDir}:`, err.message);
935
+ }
936
+ }
937
+
920
938
  // Optionally delete the actual project folder
921
939
  if (deleteFolder && actualProjectDir) {
922
940
  try {
@@ -197,7 +197,7 @@ async function ensureSkillRepo(repoUrl, userUuid) {
197
197
  /**
198
198
  * Install a single skill by name from a given repo URL.
199
199
  */
200
- async function installSkill(skillName, repoUrl, userUuid) {
200
+ async function installSkill(skillName, repoUrl, userUuid, projectPath) {
201
201
  const publicRepoPath = await ensureSkillRepo(repoUrl, userUuid);
202
202
  const userPaths = getUserPaths(userUuid);
203
203
 
@@ -208,7 +208,11 @@ async function installSkill(skillName, repoUrl, userUuid) {
208
208
  return false;
209
209
  }
210
210
 
211
- const linkPath = path.join(userPaths.skillsDir, skillName);
211
+ const targetDir = projectPath
212
+ ? path.join(projectPath, '.claude', 'skills')
213
+ : userPaths.skillsDir;
214
+ await fs.mkdir(targetDir, { recursive: true });
215
+ const linkPath = path.join(targetDir, skillName);
212
216
  try { await fs.unlink(linkPath); } catch {}
213
217
  await fs.symlink(skillPath, linkPath);
214
218
  return true;
@@ -359,8 +363,9 @@ async function ensureMcpRepo(repoUrl, userUuid) {
359
363
 
360
364
  /**
361
365
  * Install a single MCP server by service dir name from a given repo URL.
366
+ * If projectPath is provided, installs as project-scoped (local); otherwise user-scoped.
362
367
  */
363
- async function installMcp(mcpName, repoUrl, userUuid) {
368
+ async function installMcp(mcpName, repoUrl, userUuid, projectPath) {
364
369
  const publicRepoPath = await ensureMcpRepo(repoUrl, userUuid);
365
370
  const userPaths = getUserPaths(userUuid);
366
371
 
@@ -383,7 +388,18 @@ async function installMcp(mcpName, repoUrl, userUuid) {
383
388
  } catch {
384
389
  claudeConfig = { hasCompletedOnboarding: true };
385
390
  }
386
- claudeConfig.mcpServers = { ...(claudeConfig.mcpServers || {}), ...mcpJson };
391
+
392
+ if (projectPath) {
393
+ // Project-scoped: write to projects[projectPath].mcpServers
394
+ if (!claudeConfig.projects) claudeConfig.projects = {};
395
+ if (!claudeConfig.projects[projectPath]) claudeConfig.projects[projectPath] = {};
396
+ if (!claudeConfig.projects[projectPath].mcpServers) claudeConfig.projects[projectPath].mcpServers = {};
397
+ Object.assign(claudeConfig.projects[projectPath].mcpServers, mcpJson);
398
+ } else {
399
+ // User-scoped (fallback)
400
+ claudeConfig.mcpServers = { ...(claudeConfig.mcpServers || {}), ...mcpJson };
401
+ }
402
+
387
403
  await fs.writeFile(claudeJsonPath, JSON.stringify(claudeConfig, null, 2), 'utf-8');
388
404
  return true;
389
405
  }
@@ -549,7 +565,7 @@ router.post('/install', async (req, res) => {
549
565
  for (const skill of agent.skills || []) {
550
566
  if (!skill.name || !skill.repo) continue;
551
567
  try {
552
- const ok = await installSkill(skill.name, skill.repo, userUuid);
568
+ const ok = await installSkill(skill.name, skill.repo, userUuid, projectDir);
553
569
  skillResults.push({ name: skill.name, success: ok });
554
570
  } catch (err) {
555
571
  console.error(`[AgentInstall] Failed to install skill "${skill.name}":`, err.message);
@@ -557,12 +573,12 @@ router.post('/install', async (req, res) => {
557
573
  }
558
574
  }
559
575
 
560
- // Install MCPs
576
+ // Install MCPs (project-scoped to the newly created project directory)
561
577
  const mcpResults = [];
562
578
  for (const mcp of agent.mcps || []) {
563
579
  if (!mcp.name || !mcp.repo) continue;
564
580
  try {
565
- const ok = await installMcp(mcp.name, mcp.repo, userUuid);
581
+ const ok = await installMcp(mcp.name, mcp.repo, userUuid, projectDir);
566
582
  mcpResults.push({ name: mcp.name, success: ok });
567
583
  } catch (err) {
568
584
  console.error(`[AgentInstall] Failed to install MCP "${mcp.name}":`, err.message);
@@ -451,7 +451,7 @@ router.post('/install', async (req, res) => {
451
451
  const userUuid = req.user?.uuid;
452
452
  if (!userUuid) return res.status(401).json({ error: 'User authentication required' });
453
453
 
454
- const { servicePath, scope = 'user' } = req.body;
454
+ const { servicePath, scope = 'user', projectPath } = req.body;
455
455
  if (!servicePath) return res.status(400).json({ error: 'servicePath is required' });
456
456
 
457
457
  // Security: ensure servicePath is within mcp-repo directory (public or user symlink dir)
@@ -476,11 +476,29 @@ router.post('/install', async (req, res) => {
476
476
  const installedServers = [];
477
477
  const errors = [];
478
478
 
479
- // Install each server entry using claude mcp add-json
479
+ // Install each server entry
480
480
  for (const [serverName, serverConfig] of Object.entries(mcpJson)) {
481
481
  try {
482
- await addMcpServerViaJson(serverName, serverConfig, scope, userPaths.claudeDir);
483
- installedServers.push(serverName);
482
+ if (scope === 'local' && projectPath) {
483
+ // Bypass Claude CLI for local scope: directly write to .claude.json projects[projectPath].mcpServers
484
+ // (Claude CLI --scope local uses git root as key, which breaks in multi-user deployments)
485
+ const claudeJsonPath = path.join(userPaths.claudeDir, '.claude.json');
486
+ let claudeConfig = {};
487
+ try {
488
+ claudeConfig = JSON.parse(await fs.readFile(claudeJsonPath, 'utf-8'));
489
+ } catch {
490
+ claudeConfig = { hasCompletedOnboarding: true };
491
+ }
492
+ if (!claudeConfig.projects) claudeConfig.projects = {};
493
+ if (!claudeConfig.projects[projectPath]) claudeConfig.projects[projectPath] = {};
494
+ if (!claudeConfig.projects[projectPath].mcpServers) claudeConfig.projects[projectPath].mcpServers = {};
495
+ claudeConfig.projects[projectPath].mcpServers[serverName] = serverConfig;
496
+ await fs.writeFile(claudeJsonPath, JSON.stringify(claudeConfig, null, 2), 'utf-8');
497
+ installedServers.push(serverName);
498
+ } else {
499
+ await addMcpServerViaJson(serverName, serverConfig, scope, userPaths.claudeDir);
500
+ installedServers.push(serverName);
501
+ }
484
502
  } catch (err) {
485
503
  console.error(`[MCP Repos] CLI install failed for "${serverName}", using fallback:`, err.message);
486
504
  // Fallback: directly write to .claude.json
@@ -6,6 +6,7 @@ import multer from 'multer';
6
6
  import AdmZip from 'adm-zip';
7
7
  import { getUserPaths, getPublicPaths, DATA_DIR } from '../services/user-directories.js';
8
8
  import { ensureSystemRepo, SYSTEM_REPO_OWNER, SYSTEM_REPO_NAME, SYSTEM_REPO_URL } from '../services/system-repo.js';
9
+ import { loadProjectConfig, extractProjectDirectory } from '../projects.js';
9
10
 
10
11
  const router = express.Router();
11
12
 
@@ -443,13 +444,54 @@ router.get('/', async (req, res) => {
443
444
  enabled: true,
444
445
  source,
445
446
  repository,
446
- path: realPath
447
+ path: realPath,
448
+ scope: 'user'
447
449
  });
448
450
  } catch (err) {
449
451
  console.error(`Error reading skill ${entry.name}:`, err.message);
450
452
  }
451
453
  }
452
454
 
455
+ // Also scan project-scoped skills from each project's .claude/skills/
456
+ try {
457
+ const projectConfig = await loadProjectConfig(userUuid);
458
+ for (const projectName of Object.keys(projectConfig)) {
459
+ let projectDir = null;
460
+ try { projectDir = await extractProjectDirectory(projectName, userUuid); } catch { continue; }
461
+ if (!projectDir) continue;
462
+ const projectSkillsDir = path.join(projectDir, '.claude', 'skills');
463
+ let projectEntries;
464
+ try { projectEntries = await fs.readdir(projectSkillsDir, { withFileTypes: true }); } catch { continue; }
465
+ for (const entry of projectEntries) {
466
+ if (entry.name.startsWith('.') || entry.name.toLowerCase().startsWith('readme')) continue;
467
+ const skillPath = path.join(projectSkillsDir, entry.name);
468
+ let realPath = skillPath;
469
+ let source = 'repo';
470
+ let repository = null;
471
+ try {
472
+ const stat = await fs.lstat(skillPath);
473
+ if (stat.isSymbolicLink()) {
474
+ try { realPath = await fs.realpath(skillPath); }
475
+ catch (e) { if (e.code === 'ENOENT') await fs.unlink(skillPath).catch(() => {}); continue; }
476
+ if (realPath.includes('/skills-import/')) source = 'imported';
477
+ else if (realPath.includes('/skills-repo/')) {
478
+ source = 'repo';
479
+ const repoMatch = realPath.match(/skills-repo\/([^/]+)\/([^/]+)/);
480
+ if (repoMatch) repository = `${repoMatch[1]}/${repoMatch[2]}`;
481
+ }
482
+ }
483
+ if (!await isValidSkill(realPath)) continue;
484
+ const metadata = await parseSkillMetadata(realPath);
485
+ skills.push({ name: entry.name, title: metadata.title, description: metadata.description, enabled: true, source, repository, path: realPath, scope: 'local', projectPath: projectDir });
486
+ } catch (err) {
487
+ console.error(`Error reading project skill ${entry.name}:`, err.message);
488
+ }
489
+ }
490
+ }
491
+ } catch (err) {
492
+ console.error('[Skills] Error scanning project-scoped skills:', err.message);
493
+ }
494
+
453
495
  // Compute sync status for each skill
454
496
  // For builtin skills, batch-check git status once
455
497
  const builtinChangedSkills = new Set();
@@ -472,6 +514,11 @@ router.get('/', async (req, res) => {
472
514
  }
473
515
 
474
516
  for (const skill of skills) {
517
+ if (skill.scope === 'local') {
518
+ skill.syncable = false;
519
+ skill.syncType = null;
520
+ continue;
521
+ }
475
522
  if (skill.source === 'imported' || skill.source === 'unknown') {
476
523
  skill.syncable = true;
477
524
  skill.syncType = 'local';
@@ -814,13 +861,16 @@ router.delete('/:name', async (req, res) => {
814
861
  }
815
862
 
816
863
  const { name } = req.params;
864
+ const { projectPath } = req.query;
817
865
 
818
866
  if (!SKILL_NAME_REGEX.test(name)) {
819
867
  return res.status(400).json({ error: 'Invalid skill name' });
820
868
  }
821
869
 
822
870
  const userPaths = getUserPaths(userUuid);
823
- const linkPath = path.join(userPaths.skillsDir, name);
871
+ const linkPath = projectPath
872
+ ? path.join(projectPath, '.claude', 'skills', name)
873
+ : path.join(userPaths.skillsDir, name);
824
874
 
825
875
  // Check the symlink target to determine source
826
876
  let realPath = null;
@@ -1057,7 +1107,7 @@ router.post('/install/:name', async (req, res) => {
1057
1107
  }
1058
1108
 
1059
1109
  const { name } = req.params;
1060
- const { skillPath } = req.body;
1110
+ const { skillPath, projectPath } = req.body;
1061
1111
 
1062
1112
  if (!SKILL_NAME_REGEX.test(name)) {
1063
1113
  return res.status(400).json({ error: 'Invalid skill name' });
@@ -1074,8 +1124,12 @@ router.post('/install/:name', async (req, res) => {
1074
1124
  return res.status(404).json({ error: 'Skill not found or invalid' });
1075
1125
  }
1076
1126
 
1077
- // Create symlink directly from user's skills directory to skill in repo
1078
- const userSkillLink = path.join(userPaths.skillsDir, name);
1127
+ // Determine target directory: project-scoped or user global
1128
+ const targetDir = projectPath
1129
+ ? path.join(projectPath, '.claude', 'skills')
1130
+ : userPaths.skillsDir;
1131
+ await fs.mkdir(targetDir, { recursive: true });
1132
+ const userSkillLink = path.join(targetDir, name);
1079
1133
 
1080
1134
  try {
1081
1135
  await fs.unlink(userSkillLink);