@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.
- package/dist/assets/index-CyYbCDk1.js +192 -0
- package/dist/assets/index-DQaPJRqa.css +32 -0
- package/dist/assets/{vendor-icons-DxBNDMja.js → vendor-icons-CFgKYN6c.js} +77 -72
- package/dist/index.html +3 -3
- package/package.json +2 -2
- package/server/claude-sdk.js +231 -15
- package/server/index.js +33 -1
- package/server/routes/agents.js +336 -5
- package/server/routes/mcp.js +122 -147
- package/server/routes/skills.js +341 -1
- package/server/services/system-mcp-repo.js +1 -1
- package/server/services/system-repo.js +1 -1
- package/dist/assets/index-HOTjBpXH.css +0 -32
- package/dist/assets/index-u5cEXvaS.js +0 -184
package/server/routes/skills.js
CHANGED
|
@@ -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
|
|