@ian2018cs/agenthub 0.1.69 → 0.1.71

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.
@@ -5,7 +5,7 @@ import { spawn } from 'child_process';
5
5
  import multer from 'multer';
6
6
  import AdmZip from 'adm-zip';
7
7
  import { getUserPaths, getPublicPaths, DATA_DIR } from '../services/user-directories.js';
8
- import { SYSTEM_REPO_OWNER, SYSTEM_REPO_NAME } from '../services/system-repo.js';
8
+ import { ensureSystemRepo, SYSTEM_REPO_OWNER, SYSTEM_REPO_NAME, SYSTEM_REPO_URL } from '../services/system-repo.js';
9
9
 
10
10
  const router = express.Router();
11
11
 
@@ -292,6 +292,83 @@ function updateRepository(repoPath) {
292
292
  });
293
293
  }
294
294
 
295
+ /**
296
+ * Run a git command and return a promise
297
+ */
298
+ function runGit(args, cwd) {
299
+ return new Promise((resolve, reject) => {
300
+ const opts = { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } };
301
+ if (cwd) opts.cwd = cwd;
302
+ const proc = spawn('git', args, opts);
303
+ let stderr = '';
304
+ proc.stderr.on('data', d => { stderr += d.toString(); });
305
+ proc.on('close', code => {
306
+ if (code === 0) resolve();
307
+ else reject(new Error(stderr || `git ${args[0]} failed with code ${code}`));
308
+ });
309
+ proc.on('error', err => reject(err));
310
+ });
311
+ }
312
+
313
+ /**
314
+ * Run a git command and return stdout as a string
315
+ */
316
+ function runGitOutput(args, cwd) {
317
+ return new Promise((resolve, reject) => {
318
+ const opts = { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } };
319
+ if (cwd) opts.cwd = cwd;
320
+ const proc = spawn('git', args, opts);
321
+ let stdout = '';
322
+ let stderr = '';
323
+ proc.stdout.on('data', d => { stdout += d.toString(); });
324
+ proc.stderr.on('data', d => { stderr += d.toString(); });
325
+ proc.on('close', code => {
326
+ if (code === 0) resolve(stdout);
327
+ else reject(new Error(stderr || `git ${args[0]} failed with code ${code}`));
328
+ });
329
+ proc.on('error', err => reject(err));
330
+ });
331
+ }
332
+
333
+ /**
334
+ * Recursively copy all files and directories from src to dst.
335
+ */
336
+ async function copyDirRecursive(src, dst) {
337
+ await fs.mkdir(dst, { recursive: true });
338
+ const entries = await fs.readdir(src, { withFileTypes: true });
339
+ for (const entry of entries) {
340
+ const srcPath = path.join(src, entry.name);
341
+ const dstPath = path.join(dst, entry.name);
342
+ if (entry.isDirectory()) {
343
+ await copyDirRecursive(srcPath, dstPath);
344
+ } else {
345
+ await fs.copyFile(srcPath, dstPath);
346
+ }
347
+ }
348
+ }
349
+
350
+ /**
351
+ * Find a skill directory in the system repo (supports direct and nested structures).
352
+ * Returns absolute path to skill directory, or null if not found.
353
+ */
354
+ async function findSkillInSystemRepo(systemRepoPath, skillName) {
355
+ // Direct: {systemRepoPath}/{skillName}/SKILL.md
356
+ const directPath = path.join(systemRepoPath, skillName);
357
+ try {
358
+ await fs.access(path.join(directPath, 'SKILL.md'));
359
+ return directPath;
360
+ } catch {}
361
+
362
+ // Nested: {systemRepoPath}/skills/{skillName}/SKILL.md
363
+ const nestedPath = path.join(systemRepoPath, 'skills', skillName);
364
+ try {
365
+ await fs.access(path.join(nestedPath, 'SKILL.md'));
366
+ return nestedPath;
367
+ } catch {}
368
+
369
+ return null;
370
+ }
371
+
295
372
  /**
296
373
  * GET /api/skills
297
374
  * List user's installed skills
@@ -373,6 +450,43 @@ router.get('/', async (req, res) => {
373
450
  }
374
451
  }
375
452
 
453
+ // Compute sync status for each skill
454
+ // For builtin skills, batch-check git status once
455
+ const builtinChangedSkills = new Set();
456
+ const systemRepoTag = `${SYSTEM_REPO_OWNER}/${SYSTEM_REPO_NAME}`;
457
+ const hasBuiltinSkills = skills.some(s => s.repository === systemRepoTag);
458
+ if (hasBuiltinSkills) {
459
+ const publicPaths = getPublicPaths();
460
+ const systemRepoPath = path.join(publicPaths.skillsRepoDir, SYSTEM_REPO_OWNER, SYSTEM_REPO_NAME);
461
+ try {
462
+ const statusOutput = await runGitOutput(['status', '--porcelain'], systemRepoPath);
463
+ for (const line of statusOutput.split('\n')) {
464
+ if (!line) continue;
465
+ const filePath = line.slice(3).trim();
466
+ const topDir = filePath.split('/')[0];
467
+ if (topDir) builtinChangedSkills.add(topDir);
468
+ }
469
+ } catch {
470
+ // System repo may not exist locally or git may fail — skip sync detection
471
+ }
472
+ }
473
+
474
+ for (const skill of skills) {
475
+ if (skill.source === 'imported' || skill.source === 'unknown') {
476
+ skill.syncable = true;
477
+ skill.syncType = 'local';
478
+ } else if (skill.repository === systemRepoTag) {
479
+ skill.syncable = builtinChangedSkills.has(skill.name);
480
+ skill.syncType = 'builtin';
481
+ } else if (skill.source === 'repo') {
482
+ skill.syncable = true;
483
+ skill.syncType = 'external';
484
+ } else {
485
+ skill.syncable = false;
486
+ skill.syncType = null;
487
+ }
488
+ }
489
+
376
490
  res.json({ skills, count: skills.length });
377
491
  } catch (error) {
378
492
  console.error('Error listing skills:', error);
@@ -423,6 +537,232 @@ router.post('/enable/:name', async (req, res) => {
423
537
  }
424
538
  });
425
539
 
540
+ /**
541
+ * Recursively add directory contents to a zip archive.
542
+ * Uses fs.stat (follows symlinks) so symlinked files are included transparently.
543
+ */
544
+ async function addDirToZip(zip, dirPath, zipPrefix) {
545
+ let names;
546
+ try {
547
+ names = await fs.readdir(dirPath);
548
+ } catch {
549
+ return;
550
+ }
551
+
552
+ for (const name of names) {
553
+ if (name.startsWith('.')) continue;
554
+
555
+ const fullPath = path.join(dirPath, name);
556
+ const zipPath = zipPrefix + name;
557
+
558
+ let stat;
559
+ try {
560
+ stat = await fs.stat(fullPath); // follows symlinks
561
+ } catch {
562
+ continue;
563
+ }
564
+
565
+ if (stat.isDirectory()) {
566
+ await addDirToZip(zip, fullPath, zipPath + '/');
567
+ } else if (stat.isFile()) {
568
+ const data = await fs.readFile(fullPath);
569
+ zip.addFile(zipPath, data);
570
+ }
571
+ }
572
+ }
573
+
574
+ /**
575
+ * GET /api/skills/:name/download
576
+ * Download a skill as a zip file (symlinks are resolved transparently)
577
+ */
578
+ router.get('/:name/download', async (req, res) => {
579
+ try {
580
+ const userUuid = req.user?.uuid;
581
+ if (!userUuid) {
582
+ return res.status(401).json({ error: 'User authentication required' });
583
+ }
584
+
585
+ const { name } = req.params;
586
+
587
+ if (!SKILL_NAME_REGEX.test(name)) {
588
+ return res.status(400).json({ error: 'Invalid skill name' });
589
+ }
590
+
591
+ const userPaths = getUserPaths(userUuid);
592
+ const skillPath = path.join(userPaths.skillsDir, name);
593
+
594
+ // Resolve symlink to real directory
595
+ let realPath = skillPath;
596
+ try {
597
+ const stat = await fs.lstat(skillPath);
598
+ if (stat.isSymbolicLink()) {
599
+ realPath = await fs.realpath(skillPath);
600
+ }
601
+ } catch {
602
+ return res.status(404).json({ error: 'Skill not found' });
603
+ }
604
+
605
+ if (!await isValidSkill(realPath)) {
606
+ return res.status(404).json({ error: 'Skill not found or invalid' });
607
+ }
608
+
609
+ const zip = new AdmZip();
610
+ await addDirToZip(zip, realPath, `${name}/`);
611
+ const zipBuffer = zip.toBuffer();
612
+
613
+ res.setHeader('Content-Type', 'application/zip');
614
+ res.setHeader('Content-Disposition', `attachment; filename="${name}.zip"`);
615
+ res.setHeader('Content-Length', zipBuffer.length);
616
+ res.send(zipBuffer);
617
+ } catch (error) {
618
+ console.error('Error downloading skill:', error);
619
+ res.status(500).json({ error: error.message });
620
+ }
621
+ });
622
+
623
+ /**
624
+ * POST /api/skills/:name/sync
625
+ * Sync a skill to the built-in system repository
626
+ */
627
+ router.post('/:name/sync', async (req, res) => {
628
+ try {
629
+ const userUuid = req.user?.uuid;
630
+ if (!userUuid) {
631
+ return res.status(401).json({ error: 'User authentication required' });
632
+ }
633
+
634
+ const { name } = req.params;
635
+ if (!SKILL_NAME_REGEX.test(name)) {
636
+ return res.status(400).json({ error: 'Invalid skill name' });
637
+ }
638
+
639
+ const userPaths = getUserPaths(userUuid);
640
+ const publicPaths = getPublicPaths();
641
+ const linkPath = path.join(userPaths.skillsDir, name);
642
+
643
+ // Resolve skill info
644
+ let realPath;
645
+ let source = 'unknown';
646
+ let repository = null;
647
+ let isSymlink = false;
648
+
649
+ try {
650
+ const stat = await fs.lstat(linkPath);
651
+ isSymlink = stat.isSymbolicLink();
652
+ if (isSymlink) {
653
+ realPath = await fs.realpath(linkPath);
654
+ if (realPath.includes('/skills-import/')) {
655
+ source = 'imported';
656
+ } else if (realPath.includes('/skills-repo/')) {
657
+ source = 'repo';
658
+ const repoMatch = realPath.match(/skills-repo\/([^/]+)\/([^/]+)/);
659
+ if (repoMatch) repository = `${repoMatch[1]}/${repoMatch[2]}`;
660
+ }
661
+ } else {
662
+ realPath = linkPath;
663
+ }
664
+ } catch {
665
+ return res.status(404).json({ error: 'Skill not found' });
666
+ }
667
+
668
+ if (!await isValidSkill(realPath)) {
669
+ return res.status(400).json({ error: 'Invalid skill: missing SKILL.md' });
670
+ }
671
+
672
+ const systemRepoTag = `${SYSTEM_REPO_OWNER}/${SYSTEM_REPO_NAME}`;
673
+ const username = req.user?.email || req.user?.username || req.user?.uuid;
674
+ const now = new Date();
675
+ const timestamp = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
676
+
677
+ // === Case 2: Built-in system repo skill — push local changes ===
678
+ if (source === 'repo' && repository === systemRepoTag) {
679
+ const systemRepoPath = path.join(publicPaths.skillsRepoDir, SYSTEM_REPO_OWNER, SYSTEM_REPO_NAME);
680
+ try {
681
+ await runGit(['add', name], systemRepoPath);
682
+ await runGit(['commit', '-m', `feat: update skill ${name} by user ${username} at ${timestamp}`], systemRepoPath);
683
+ await runGit(['push'], systemRepoPath);
684
+ console.log(`[SkillSync] Pushed changes for built-in skill "${name}" by ${username}`);
685
+ } catch (e) {
686
+ console.error(`[SkillSync] Failed to push skill "${name}":`, e.message);
687
+ return res.status(500).json({ error: `Git push failed: ${e.message}` });
688
+ }
689
+ return res.json({ success: true, message: '技能修改已同步到云端' });
690
+ }
691
+
692
+ // === Case 1 & 3: Local/imported or external repo skill — migrate to system repo ===
693
+ let skillRepoPath;
694
+ try {
695
+ skillRepoPath = await ensureSystemRepo();
696
+ } catch (e) {
697
+ console.error('[SkillSync] Failed to ensure system repo:', e.message);
698
+ return res.status(500).json({ error: `无法访问系统仓库: ${e.message}` });
699
+ }
700
+
701
+ const destSkillDir = path.join(skillRepoPath, name);
702
+
703
+ // Copy skill files to system repo (overwrite if exists)
704
+ await fs.rm(destSkillDir, { recursive: true, force: true });
705
+ await copyDirRecursive(realPath, destSkillDir);
706
+ console.log(`[SkillSync] Copied skill "${name}" to system repo`);
707
+
708
+ // Git: add, commit, push
709
+ try {
710
+ await runGit(['add', name], skillRepoPath);
711
+ await runGit(['commit', '-m', `feat: add skill ${name} by user ${username} at ${timestamp}`], skillRepoPath);
712
+ await runGit(['push'], skillRepoPath);
713
+ console.log(`[SkillSync] Pushed skill "${name}" to system repo`);
714
+ } catch (e) {
715
+ // Rollback: remove the files we just copied
716
+ await fs.rm(destSkillDir, { recursive: true, force: true }).catch(() => {});
717
+ console.error(`[SkillSync] Git push failed for skill "${name}":`, e.message);
718
+ return res.status(500).json({ error: `Git push failed: ${e.message}` });
719
+ }
720
+
721
+ // For external repo: restore original files via git checkout
722
+ if (source === 'repo' && repository && repository !== systemRepoTag) {
723
+ const repoMatch = repository.match(/^([^/]+)\/(.+)$/);
724
+ if (repoMatch) {
725
+ const externalRepoPath = path.join(publicPaths.skillsRepoDir, repoMatch[1], repoMatch[2]);
726
+ const skillRelPath = path.relative(externalRepoPath, realPath);
727
+ try {
728
+ await runGit(['checkout', '--', skillRelPath], externalRepoPath);
729
+ console.log(`[SkillSync] Restored external repo skill "${name}" via git checkout`);
730
+ } catch (e) {
731
+ console.warn(`[SkillSync] git checkout on external repo failed (non-fatal):`, e.message);
732
+ }
733
+ }
734
+ }
735
+
736
+ // Clean up local import directory (Case 1: imported)
737
+ if (source === 'imported') {
738
+ try {
739
+ await fs.rm(path.join(userPaths.skillsImportDir, name), { recursive: true, force: true });
740
+ } catch {}
741
+ }
742
+
743
+ // Remove current symlink/directory
744
+ if (isSymlink) {
745
+ await fs.unlink(linkPath);
746
+ } else {
747
+ await fs.rm(linkPath, { recursive: true, force: true });
748
+ }
749
+
750
+ // Re-install from system repo: find skill path and create symlink
751
+ const newSkillPath = await findSkillInSystemRepo(skillRepoPath, name);
752
+ if (newSkillPath) {
753
+ await fs.symlink(newSkillPath, linkPath);
754
+ console.log(`[SkillSync] Re-installed skill "${name}" from system repo`);
755
+ } else {
756
+ console.warn(`[SkillSync] Could not find skill "${name}" in system repo after push — skipping re-install`);
757
+ }
758
+
759
+ return res.json({ success: true, message: '技能已成功同步到共享仓库' });
760
+ } catch (error) {
761
+ console.error('Error syncing skill:', error);
762
+ res.status(500).json({ error: error.message });
763
+ }
764
+ });
765
+
426
766
  /**
427
767
  * DELETE /api/skills/disable/:name
428
768
  * 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