package hub import ( "archive/zip" "fmt" "os" "os/exec" "path/filepath" "strings" "time" "gopkg.in/yaml.v3" ) type Team struct { Name string `yaml:"name"` Description string `yaml:"description"` Author string `yaml:"author"` RepoURL string `yaml:"repo_url"` Agents []string `yaml:"agents"` Skills []string `yaml:"skills"` InstalledAt string `yaml:"installed_at"` UpdatedAt string `yaml:"updated_at"` } // Install clones a git repo (any URL) and installs the team. func Install(repoRef, agentsDir, skillsDir, teamsDir string) (*Team, error) { url := repoRef if !strings.HasPrefix(repoRef, "http") && !strings.HasPrefix(repoRef, "git@") { url = "https://github.com/" + repoRef } // Extract repo name for team name repoName := extractRepoName(url) tmp, err := os.MkdirTemp("", "agent-team-hub-*") if err != nil { return nil, err } defer os.RemoveAll(tmp) cmd := exec.Command("git", "clone", "--depth=1", url, tmp) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { return nil, fmt.Errorf("git clone: %w", err) } return installFromDir(tmp, repoName, url, agentsDir, skillsDir, teamsDir) } // InstallZIP extracts a ZIP file and installs the team. func InstallZIP(zipPath, agentsDir, skillsDir, teamsDir string) (*Team, error) { // Extract team name from zip filename teamName := strings.TrimSuffix(filepath.Base(zipPath), ".zip") teamName = strings.TrimSuffix(teamName, "-main") teamName = strings.TrimSuffix(teamName, "-master") tmp, err := os.MkdirTemp("", "agent-team-hub-*") if err != nil { return nil, err } defer os.RemoveAll(tmp) // Extract ZIP if err := ExtractZIP(zipPath, tmp); err != nil { return nil, fmt.Errorf("extract zip: %w", err) } // Find the root directory (might be wrapped in a subfolder) entries, _ := os.ReadDir(tmp) if len(entries) == 1 && entries[0].IsDir() { tmp = filepath.Join(tmp, entries[0].Name()) } return installFromDir(tmp, teamName, "", agentsDir, skillsDir, teamsDir) } func installFromDir(tmp, teamName, repoURL, agentsDir, skillsDir, teamsDir string) (*Team, error) { // Copy agents installedAgents := []string{} srcAgentsDir := filepath.Join(tmp, "agents") if _, err := os.Stat(srcAgentsDir); err == nil { // Copy to team-specific directory dstAgentsDir := filepath.Join(agentsDir, teamName) if err := CopyDir(srcAgentsDir, dstAgentsDir); err == nil { entries, _ := os.ReadDir(srcAgentsDir) for _, e := range entries { if e.IsDir() { installedAgents = append(installedAgents, e.Name()) } } } } // Copy skills installedSkills := []string{} srcSkillsDir := filepath.Join(tmp, "skills") if _, err := os.Stat(srcSkillsDir); err == nil { dstSkillsDir := filepath.Join(skillsDir, teamName) if err := CopyDir(srcSkillsDir, dstSkillsDir); err == nil { entries, _ := os.ReadDir(srcSkillsDir) for _, e := range entries { if e.IsDir() { installedSkills = append(installedSkills, e.Name()) } } } } // Create knowledge dir os.MkdirAll(filepath.Join(agentsDir, teamName, "knowledge"), 0755) // Read TEAM.md for metadata teamMeta := readTeamMeta(tmp) // Create team record team := &Team{ Name: teamMeta.Name, Description: teamMeta.Description, Author: teamMeta.Author, RepoURL: repoURL, Agents: installedAgents, Skills: installedSkills, InstalledAt: time.Now().Format("2006-01-02"), } // Save team record teamDir := filepath.Join(teamsDir, teamName) os.MkdirAll(teamDir, 0755) team.Save(filepath.Join(teamDir, "team.yaml")) return team, nil } // Update 从 git 仓库拉取最新版本并更新团队文件,保留 memory/ 等用户数据。 func Update(teamName, agentsDir, skillsDir, teamsDir string) (*Team, error) { // 读取现有 team.yaml 获取 repo_url teamFile := filepath.Join(teamsDir, teamName, "team.yaml") existingData, err := os.ReadFile(teamFile) if err != nil { return nil, fmt.Errorf("团队 %s 不存在", teamName) } var existing Team // 简单解析(复用 server.go 中的解析逻辑风格) lines := strings.Split(string(existingData), "\n") for _, line := range lines { if strings.HasPrefix(line, "repo_url: ") { existing.RepoURL = strings.TrimSpace(strings.TrimPrefix(line, "repo_url: ")) } if strings.HasPrefix(line, "installed_at: ") { existing.InstalledAt = strings.TrimSpace(strings.TrimPrefix(line, "installed_at: ")) } } if existing.RepoURL == "" { return nil, fmt.Errorf("团队 %s 没有 repo_url,无法更新(可能是 ZIP 安装的)", teamName) } // Clone 最新版本到临时目录 tmp, err := os.MkdirTemp("", "agent-team-update-*") if err != nil { return nil, err } defer os.RemoveAll(tmp) cmd := exec.Command("git", "clone", "--depth=1", existing.RepoURL, tmp) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { return nil, fmt.Errorf("git clone: %w", err) } // 更新 agents:逐个 agent 目录,跳过 memory/ srcAgentsDir := filepath.Join(tmp, "agents") dstAgentsDir := filepath.Join(agentsDir, teamName) updatedAgents := []string{} if _, err := os.Stat(srcAgentsDir); err == nil { entries, _ := os.ReadDir(srcAgentsDir) for _, e := range entries { if !e.IsDir() { continue } updatedAgents = append(updatedAgents, e.Name()) srcAgent := filepath.Join(srcAgentsDir, e.Name()) dstAgent := filepath.Join(dstAgentsDir, e.Name()) if err := updateAgentDir(srcAgent, dstAgent); err != nil { return nil, fmt.Errorf("更新 agent %s 失败: %w", e.Name(), err) } } } // 更新 knowledge/(合并,不删除用户已有文件) srcKnowledge := filepath.Join(tmp, "knowledge") dstKnowledge := filepath.Join(agentsDir, teamName, "knowledge") if _, err := os.Stat(srcKnowledge); err == nil { CopyDir(srcKnowledge, dstKnowledge) } // 更新 skills updatedSkills := []string{} srcSkillsDir := filepath.Join(tmp, "skills") if _, err := os.Stat(srcSkillsDir); err == nil { entries, _ := os.ReadDir(srcSkillsDir) for _, e := range entries { if e.IsDir() { updatedSkills = append(updatedSkills, e.Name()) } } CopyDir(srcSkillsDir, filepath.Join(skillsDir, teamName)) } // 读取新的 TEAM.md 元数据 teamMeta := readTeamMeta(tmp) // 更新 team.yaml,保留 installed_at team := &Team{ Name: teamMeta.Name, Description: teamMeta.Description, Author: teamMeta.Author, RepoURL: existing.RepoURL, Agents: updatedAgents, Skills: updatedSkills, InstalledAt: existing.InstalledAt, UpdatedAt: time.Now().Format("2006-01-02 15:04:05"), } team.Save(teamFile) return team, nil } // updateAgentDir 更新单个 agent 目录:覆盖代码文件,保留 memory/ func updateAgentDir(src, dst string) error { os.MkdirAll(dst, 0755) entries, err := os.ReadDir(src) if err != nil { return err } for _, e := range entries { srcPath := filepath.Join(src, e.Name()) dstPath := filepath.Join(dst, e.Name()) if e.IsDir() { // 跳过 memory 目录,保留用户的记忆数据 if e.Name() == "memory" { continue } if err := CopyDir(srcPath, dstPath); err != nil { return err } } else { data, err := os.ReadFile(srcPath) if err != nil { return err } if err := os.WriteFile(dstPath, data, 0644); err != nil { return err } } } return nil } func ExtractZIP(zipPath, dest string) error { r, err := zip.OpenReader(zipPath) if err != nil { return err } defer r.Close() for _, f := range r.File { fpath := filepath.Join(dest, f.Name) if f.FileInfo().IsDir() { os.MkdirAll(fpath, 0755) continue } if err := os.MkdirAll(filepath.Dir(fpath), 0755); err != nil { return err } outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) if err != nil { return err } rc, err := f.Open() if err != nil { outFile.Close() return err } buf := make([]byte, 4096) for { n, err := rc.Read(buf) if n > 0 { if _, writeErr := outFile.Write(buf[:n]); writeErr != nil { rc.Close() outFile.Close() return writeErr } } if err != nil { break } } rc.Close() outFile.Close() } return nil } func extractRepoName(url string) string { parts := strings.Split(url, "/") name := parts[len(parts)-1] name = strings.TrimSuffix(name, ".git") return name } func (t *Team) Save(path string) error { content := fmt.Sprintf(`name: %s description: %s author: %s repo_url: %s agents: %s skills: %s installed_at: %s updated_at: %s `, t.Name, t.Description, t.Author, t.RepoURL, arrayToYAML(t.Agents), arrayToYAML(t.Skills), t.InstalledAt, t.UpdatedAt) return os.WriteFile(path, []byte(content), 0644) } func arrayToYAML(arr []string) string { if len(arr) == 0 { return " []" } result := "" for _, s := range arr { result += fmt.Sprintf(" - %s\n", s) } return result } func CopyDir(src, dst string) error { if _, err := os.Stat(src); os.IsNotExist(err) { return nil // optional dir, skip } os.MkdirAll(dst, 0755) entries, err := os.ReadDir(src) if err != nil { return err } for _, e := range entries { srcPath := filepath.Join(src, e.Name()) dstPath := filepath.Join(dst, e.Name()) if e.IsDir() { if err := CopyDir(srcPath, dstPath); err != nil { return err } } else { data, err := os.ReadFile(srcPath) if err != nil { return err } os.MkdirAll(filepath.Dir(dstPath), 0755) if err := os.WriteFile(dstPath, data, 0644); err != nil { return err } } } return nil } func readTeamMeta(tmp string) Team { team := Team{} teamFile := filepath.Join(tmp, "TEAM.md") data, err := os.ReadFile(teamFile) if err != nil { return Team{Name: extractRepoName(tmp)} } content := string(data) if strings.HasPrefix(content, "---") { parts := strings.SplitN(content, "---", 3) if len(parts) >= 3 { yamlContent := parts[1] yaml.Unmarshal([]byte(yamlContent), &team) } } if team.Name == "" { team.Name = extractRepoName(tmp) } if team.Description == "" { team.Description = "团队描述" } return team }