@ian2018cs/agenthub 0.1.25 → 0.1.27

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.
@@ -29,31 +29,24 @@ const upload = multer({
29
29
  });
30
30
 
31
31
  /**
32
- * Parse skill metadata from SKILLS.md file
32
+ * Parse skill metadata from SKILL.md YAML frontmatter
33
33
  */
34
34
  async function parseSkillMetadata(skillPath) {
35
35
  try {
36
- const skillsFile = path.join(skillPath, 'SKILLS.md');
36
+ const skillsFile = path.join(skillPath, 'SKILL.md');
37
37
  const content = await fs.readFile(skillsFile, 'utf-8');
38
38
 
39
- // Extract title from first # heading
40
- const titleMatch = content.match(/^#\s+(.+)$/m);
41
- const title = titleMatch ? titleMatch[1].trim() : path.basename(skillPath);
42
-
43
- // Extract description from content after title (first paragraph)
44
- const lines = content.split('\n');
39
+ let title = path.basename(skillPath);
45
40
  let description = '';
46
- let foundTitle = false;
47
- for (const line of lines) {
48
- if (line.startsWith('#')) {
49
- if (foundTitle) break;
50
- foundTitle = true;
51
- continue;
52
- }
53
- if (foundTitle && line.trim()) {
54
- description = line.trim();
55
- break;
56
- }
41
+
42
+ // Parse YAML frontmatter (between --- delimiters)
43
+ const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
44
+ if (fmMatch) {
45
+ const fm = fmMatch[1];
46
+ const nameMatch = fm.match(/^name:\s*(.+)$/m);
47
+ const descMatch = fm.match(/^description:\s*(.+)$/m);
48
+ if (nameMatch) title = nameMatch[1].trim();
49
+ if (descMatch) description = descMatch[1].trim();
57
50
  }
58
51
 
59
52
  return { title, description };
@@ -63,27 +56,64 @@ async function parseSkillMetadata(skillPath) {
63
56
  }
64
57
 
65
58
  /**
66
- * Check if a path is a valid skill directory
59
+ * Check if a path is a valid skill directory (must contain SKILL.md)
67
60
  */
68
61
  async function isValidSkill(skillPath) {
69
62
  try {
70
63
  const stat = await fs.stat(skillPath);
71
64
  if (!stat.isDirectory()) return false;
72
65
 
73
- // Check for SKILLS.md
74
- try {
75
- await fs.access(path.join(skillPath, 'SKILLS.md'));
76
- return true;
77
- } catch {
78
- // Fallback: check for any .md files
79
- const files = await fs.readdir(skillPath);
80
- return files.some(f => f.endsWith('.md'));
81
- }
66
+ await fs.access(path.join(skillPath, 'SKILL.md'));
67
+ return true;
82
68
  } catch {
83
69
  return false;
84
70
  }
85
71
  }
86
72
 
73
+ /**
74
+ * Recursively scan a directory for valid skill subdirectories.
75
+ * Supports nested structures like skills/XXX (maxDepth controls recursion depth).
76
+ * If a directory is itself a valid skill it is collected and not recursed into.
77
+ */
78
+ async function scanDirForSkills(dirPath, repository, installedSkills, maxDepth = 2) {
79
+ const found = [];
80
+
81
+ let entries;
82
+ try {
83
+ entries = await fs.readdir(dirPath, { withFileTypes: true });
84
+ } catch {
85
+ return found;
86
+ }
87
+
88
+ for (const entry of entries) {
89
+ if (entry.name.startsWith('.') ||
90
+ entry.name.toLowerCase().startsWith('readme') ||
91
+ !entry.isDirectory()) {
92
+ continue;
93
+ }
94
+
95
+ const skillPath = path.join(dirPath, entry.name);
96
+
97
+ if (await isValidSkill(skillPath)) {
98
+ const metadata = await parseSkillMetadata(skillPath);
99
+ found.push({
100
+ name: entry.name,
101
+ title: metadata.title,
102
+ description: metadata.description,
103
+ repository,
104
+ installed: installedSkills ? installedSkills.has(entry.name) : false,
105
+ path: skillPath
106
+ });
107
+ } else if (maxDepth > 0) {
108
+ // Not a skill itself — recurse into it looking for nested skills
109
+ const nested = await scanDirForSkills(skillPath, repository, installedSkills, maxDepth - 1);
110
+ found.push(...nested);
111
+ }
112
+ }
113
+
114
+ return found;
115
+ }
116
+
87
117
  /**
88
118
  * Check if URL is SSH format (git@host:path)
89
119
  */
@@ -497,18 +527,18 @@ router.post('/import', upload.single('skillZip'), async (req, res) => {
497
527
  }
498
528
 
499
529
  // Determine skill name from zip structure
500
- // Look for the root directory or first directory containing SKILLS.md
530
+ // Look for the root directory or first directory containing SKILL.md
501
531
  let skillName = null;
502
532
  let rootDir = '';
503
533
 
504
534
  for (const entry of zipEntries) {
505
- if (entry.entryName.endsWith('SKILLS.md')) {
535
+ if (entry.entryName.endsWith('SKILL.md')) {
506
536
  const parts = entry.entryName.split('/');
507
537
  if (parts.length >= 2) {
508
538
  skillName = parts[0];
509
539
  rootDir = parts[0] + '/';
510
540
  } else {
511
- // SKILLS.md is at root, use original zip filename
541
+ // SKILL.md is at root, use original zip filename
512
542
  skillName = path.basename(req.file.originalname, '.zip');
513
543
  }
514
544
  break;
@@ -638,34 +668,9 @@ router.get('/available', async (req, res) => {
638
668
  continue;
639
669
  }
640
670
 
641
- // Scan for skills in the repo
642
- const entries = await fs.readdir(realRepoPath, { withFileTypes: true });
643
-
644
- for (const entry of entries) {
645
- // Skip hidden dirs, READMEs, and files
646
- if (entry.name.startsWith('.') ||
647
- entry.name.toLowerCase().startsWith('readme') ||
648
- !entry.isDirectory()) {
649
- continue;
650
- }
651
-
652
- const skillPath = path.join(realRepoPath, entry.name);
653
-
654
- if (!await isValidSkill(skillPath)) {
655
- continue;
656
- }
657
-
658
- const metadata = await parseSkillMetadata(skillPath);
659
-
660
- skills.push({
661
- name: entry.name,
662
- title: metadata.title,
663
- description: metadata.description,
664
- repository: `${owner}/${repo}`,
665
- installed: installedSkills.has(entry.name),
666
- path: skillPath
667
- });
668
- }
671
+ // Scan for skills in the repo (supports nested dirs like skills/XXX)
672
+ const repoSkills = await scanDirForSkills(realRepoPath, `${owner}/${repo}`, installedSkills);
673
+ skills.push(...repoSkills);
669
674
  }
670
675
  }
671
676
  } catch (err) {
@@ -778,19 +783,9 @@ router.get('/repos', async (req, res) => {
778
783
  continue;
779
784
  }
780
785
 
781
- // Count skills in repo
782
- let skillCount = 0;
783
- try {
784
- const entries = await fs.readdir(realPath, { withFileTypes: true });
785
- for (const entry of entries) {
786
- if (entry.name.startsWith('.') || !entry.isDirectory()) continue;
787
- if (await isValidSkill(path.join(realPath, entry.name))) {
788
- skillCount++;
789
- }
790
- }
791
- } catch {
792
- // Ignore
793
- }
786
+ // Count skills in repo (supports nested dirs like skills/XXX)
787
+ const repoSkills = await scanDirForSkills(realPath, `${owner}/${repo}`, null);
788
+ const skillCount = repoSkills.length;
794
789
 
795
790
  repos.push({
796
791
  owner,
@@ -882,16 +877,11 @@ router.post('/repos', async (req, res) => {
882
877
 
883
878
  await fs.symlink(publicRepoPath, userRepoPath);
884
879
 
885
- // Count skills
880
+ // Count skills (supports nested dirs like skills/XXX)
886
881
  let skillCount = 0;
887
882
  try {
888
- const entries = await fs.readdir(publicRepoPath, { withFileTypes: true });
889
- for (const entry of entries) {
890
- if (entry.name.startsWith('.') || !entry.isDirectory()) continue;
891
- if (await isValidSkill(path.join(publicRepoPath, entry.name))) {
892
- skillCount++;
893
- }
894
- }
883
+ const repoSkills = await scanDirForSkills(publicRepoPath, `${owner}/${repo}`, null);
884
+ skillCount = repoSkills.length;
895
885
  } catch {
896
886
  // Ignore
897
887
  }
@@ -912,6 +902,68 @@ router.post('/repos', async (req, res) => {
912
902
  }
913
903
  });
914
904
 
905
+ /**
906
+ * POST /api/skills/repos/refresh
907
+ * Pull latest updates for all user's skill repositories
908
+ */
909
+ router.post('/repos/refresh', async (req, res) => {
910
+ try {
911
+ const userUuid = req.user?.uuid;
912
+ if (!userUuid) {
913
+ return res.status(401).json({ error: 'User authentication required' });
914
+ }
915
+
916
+ const userPaths = getUserPaths(userUuid);
917
+ const results = [];
918
+
919
+ try {
920
+ await fs.mkdir(userPaths.skillsRepoDir, { recursive: true });
921
+ const owners = await fs.readdir(userPaths.skillsRepoDir);
922
+
923
+ for (const owner of owners) {
924
+ if (owner.startsWith('.')) continue;
925
+
926
+ const ownerPath = path.join(userPaths.skillsRepoDir, owner);
927
+ const stat = await fs.stat(ownerPath);
928
+ if (!stat.isDirectory()) continue;
929
+
930
+ const repoNames = await fs.readdir(ownerPath);
931
+
932
+ for (const repo of repoNames) {
933
+ if (repo.startsWith('.')) continue;
934
+
935
+ const repoPath = path.join(ownerPath, repo);
936
+ let realPath = repoPath;
937
+
938
+ try {
939
+ const repoStat = await fs.lstat(repoPath);
940
+ if (repoStat.isSymbolicLink()) {
941
+ realPath = await fs.realpath(repoPath);
942
+ }
943
+ } catch {
944
+ continue;
945
+ }
946
+
947
+ try {
948
+ await updateRepository(realPath);
949
+ results.push({ owner, repo, status: 'updated' });
950
+ } catch (err) {
951
+ console.log(`Failed to update ${owner}/${repo}:`, err.message);
952
+ results.push({ owner, repo, status: 'failed', error: err.message });
953
+ }
954
+ }
955
+ }
956
+ } catch (err) {
957
+ console.error('Error refreshing repos:', err);
958
+ }
959
+
960
+ res.json({ success: true, results });
961
+ } catch (error) {
962
+ console.error('Error refreshing repos:', error);
963
+ res.status(500).json({ error: error.message });
964
+ }
965
+ });
966
+
915
967
  /**
916
968
  * DELETE /api/skills/repos/:owner/:repo
917
969
  * Remove a skill repository (user's symlink only)