package system import ( "archive/zip" "bytes" "context" "encoding/json" "errors" "fmt" "io" "io/fs" "net/http" "os" "path/filepath" "sort" "strings" "git.echol.cn/loser/st/server/global" "git.echol.cn/loser/st/server/model/system" "git.echol.cn/loser/st/server/model/system/request" "gopkg.in/yaml.v3" ) const ( skillFileName = "SKILL.md" globalConstraintFileName = "README.md" ) var skillToolOrder = []string{"copilot", "claude", "cursor", "trae", "codex"} var skillToolDirs = map[string]string{ "copilot": ".aone_copilot", "claude": ".claude", "trae": ".trae", "codex": ".codex", "cursor": ".cursor", } var skillToolLabels = map[string]string{ "copilot": "Copilot", "claude": "Claude", "trae": "Trae", "codex": "Codex", "cursor": "Cursor", } const defaultSkillMarkdown = "## 技能用途\n请在这里描述技能的目标、适用场景与限制条件。\n\n## 输入\n- 请补充输入格式与示例。\n\n## 输出\n- 请补充输出格式与示例。\n\n## 关键步骤\n1. 第一步\n2. 第二步\n\n## 示例\n在此补充一到两个典型示例。\n" const defaultResourceMarkdown = "# 资源说明\n请在这里补充资源内容。\n" const defaultReferenceMarkdown = "# 参考资料\n请在这里补充参考资料内容。\n" const defaultTemplateMarkdown = "# 模板\n请在这里补充模板内容。\n" const defaultGlobalConstraintMarkdown = "# 全局约束\n请在这里补充该工具的统一约束与使用规范。\n" type SkillsService struct{} func (s *SkillsService) Tools(_ context.Context) ([]system.SkillTool, error) { tools := make([]system.SkillTool, 0, len(skillToolOrder)) for _, key := range skillToolOrder { if _, err := s.toolSkillsDir(key); err != nil { return nil, err } tools = append(tools, system.SkillTool{Key: key, Label: skillToolLabels[key]}) } return tools, nil } func (s *SkillsService) List(_ context.Context, tool string) ([]string, error) { skillsDir, err := s.toolSkillsDir(tool) if err != nil { return nil, err } entries, err := os.ReadDir(skillsDir) if err != nil { return nil, err } var skills []string for _, entry := range entries { if entry.IsDir() { skills = append(skills, entry.Name()) } } sort.Strings(skills) return skills, nil } func (s *SkillsService) Detail(_ context.Context, tool, skill string) (system.SkillDetail, error) { var detail system.SkillDetail if !isSafeName(skill) { return detail, errors.New("技能名称不合法") } detail.Tool = tool detail.Skill = skill skillDir, err := s.skillDir(tool, skill) if err != nil { return detail, err } skillFilePath := filepath.Join(skillDir, skillFileName) content, err := os.ReadFile(skillFilePath) if err != nil { if !os.IsNotExist(err) { return detail, err } detail.Meta = system.SkillMeta{Name: skill} detail.Markdown = defaultSkillMarkdown } else { meta, body, parseErr := parseSkillContent(string(content)) if parseErr != nil { meta = system.SkillMeta{Name: skill} body = string(content) } if meta.Name == "" { meta.Name = skill } detail.Meta = meta detail.Markdown = body } detail.Scripts = listFiles(filepath.Join(skillDir, "scripts")) detail.Resources = listFiles(filepath.Join(skillDir, "resources")) detail.References = listFiles(filepath.Join(skillDir, "references")) detail.Templates = listFiles(filepath.Join(skillDir, "templates")) return detail, nil } func (s *SkillsService) Save(_ context.Context, req request.SkillSaveRequest) error { if !isSafeName(req.Skill) { return errors.New("技能名称不合法") } skillDir, err := s.ensureSkillDir(req.Tool, req.Skill) if err != nil { return err } if req.Meta.Name == "" { req.Meta.Name = req.Skill } content, err := buildSkillContent(req.Meta, req.Markdown) if err != nil { return err } if err := os.WriteFile(filepath.Join(skillDir, skillFileName), []byte(content), 0644); err != nil { return err } if len(req.SyncTools) > 0 { for _, tool := range req.SyncTools { if tool == req.Tool { continue } targetDir, err := s.ensureSkillDir(tool, req.Skill) if err != nil { return err } if err := copySkillDir(skillDir, targetDir); err != nil { return err } } } return nil } func (s *SkillsService) Delete(_ context.Context, req request.SkillDeleteRequest) error { if strings.TrimSpace(req.Tool) == "" { return errors.New("工具类型不能为空") } if !isSafeName(req.Skill) { return errors.New("技能名称不合法") } skillDir, err := s.skillDir(req.Tool, req.Skill) if err != nil { return err } info, err := os.Stat(skillDir) if err != nil { if os.IsNotExist(err) { return errors.New("技能不存在") } return err } if !info.IsDir() { return errors.New("技能目录异常") } return os.RemoveAll(skillDir) } func (s *SkillsService) Package(_ context.Context, req request.SkillPackageRequest) (string, []byte, error) { if strings.TrimSpace(req.Tool) == "" { return "", nil, errors.New("工具类型不能为空") } if !isSafeName(req.Skill) { return "", nil, errors.New("技能名称不合法") } skillDir, err := s.skillDir(req.Tool, req.Skill) if err != nil { return "", nil, err } info, err := os.Stat(skillDir) if err != nil { if os.IsNotExist(err) { return "", nil, errors.New("技能不存在") } return "", nil, err } if !info.IsDir() { return "", nil, errors.New("技能目录异常") } buf := bytes.NewBuffer(nil) zw := zip.NewWriter(buf) walkErr := filepath.WalkDir(skillDir, func(path string, d fs.DirEntry, walkErr error) error { if walkErr != nil { return walkErr } rel, err := filepath.Rel(skillDir, path) if err != nil { return err } if rel == "." { return nil } zipName := filepath.ToSlash(rel) if d.IsDir() { _, err = zw.Create(strings.TrimSuffix(zipName, "/") + "/") return err } fileInfo, err := d.Info() if err != nil { return err } header, err := zip.FileInfoHeader(fileInfo) if err != nil { return err } header.Name = zipName header.Method = zip.Deflate writer, err := zw.CreateHeader(header) if err != nil { return err } content, err := os.ReadFile(path) if err != nil { return err } _, err = writer.Write(content) return err }) if walkErr != nil { _ = zw.Close() return "", nil, walkErr } if err = zw.Close(); err != nil { return "", nil, err } return req.Skill + ".zip", buf.Bytes(), nil } func (s *SkillsService) CreateScript(_ context.Context, req request.SkillScriptCreateRequest) (string, string, error) { if !isSafeName(req.Skill) { return "", "", errors.New("技能名称不合法") } fileName, lang, err := buildScriptFileName(req.FileName, req.ScriptType) if err != nil { return "", "", err } if lang == "" { return "", "", errors.New("脚本类型不支持") } skillDir, err := s.ensureSkillDir(req.Tool, req.Skill) if err != nil { return "", "", err } filePath := filepath.Join(skillDir, "scripts", fileName) if _, err := os.Stat(filePath); err == nil { return "", "", errors.New("脚本已存在") } if err := os.MkdirAll(filepath.Dir(filePath), os.ModePerm); err != nil { return "", "", err } content := scriptTemplate(lang) if err := os.WriteFile(filePath, []byte(content), 0644); err != nil { return "", "", err } return fileName, content, nil } func (s *SkillsService) GetScript(_ context.Context, req request.SkillFileRequest) (string, error) { return s.readSkillFile(req.Tool, req.Skill, "scripts", req.FileName) } func (s *SkillsService) SaveScript(_ context.Context, req request.SkillFileSaveRequest) error { return s.writeSkillFile(req.Tool, req.Skill, "scripts", req.FileName, req.Content) } func (s *SkillsService) CreateResource(_ context.Context, req request.SkillResourceCreateRequest) (string, string, error) { return s.createMarkdownFile(req.Tool, req.Skill, "resources", req.FileName, defaultResourceMarkdown, "资源") } func (s *SkillsService) GetResource(_ context.Context, req request.SkillFileRequest) (string, error) { return s.readSkillFile(req.Tool, req.Skill, "resources", req.FileName) } func (s *SkillsService) SaveResource(_ context.Context, req request.SkillFileSaveRequest) error { return s.writeSkillFile(req.Tool, req.Skill, "resources", req.FileName, req.Content) } func (s *SkillsService) CreateReference(_ context.Context, req request.SkillReferenceCreateRequest) (string, string, error) { return s.createMarkdownFile(req.Tool, req.Skill, "references", req.FileName, defaultReferenceMarkdown, "参考") } func (s *SkillsService) GetReference(_ context.Context, req request.SkillFileRequest) (string, error) { return s.readSkillFile(req.Tool, req.Skill, "references", req.FileName) } func (s *SkillsService) SaveReference(_ context.Context, req request.SkillFileSaveRequest) error { return s.writeSkillFile(req.Tool, req.Skill, "references", req.FileName, req.Content) } func (s *SkillsService) CreateTemplate(_ context.Context, req request.SkillTemplateCreateRequest) (string, string, error) { return s.createMarkdownFile(req.Tool, req.Skill, "templates", req.FileName, defaultTemplateMarkdown, "模板") } func (s *SkillsService) GetTemplate(_ context.Context, req request.SkillFileRequest) (string, error) { return s.readSkillFile(req.Tool, req.Skill, "templates", req.FileName) } func (s *SkillsService) SaveTemplate(_ context.Context, req request.SkillFileSaveRequest) error { return s.writeSkillFile(req.Tool, req.Skill, "templates", req.FileName, req.Content) } func (s *SkillsService) GetGlobalConstraint(_ context.Context, tool string) (string, bool, error) { skillsDir, err := s.toolSkillsDir(tool) if err != nil { return "", false, err } filePath := filepath.Join(skillsDir, globalConstraintFileName) content, err := os.ReadFile(filePath) if err != nil { if os.IsNotExist(err) { return defaultGlobalConstraintMarkdown, false, nil } return "", false, err } return string(content), true, nil } func (s *SkillsService) SaveGlobalConstraint(_ context.Context, req request.SkillGlobalConstraintSaveRequest) error { if strings.TrimSpace(req.Tool) == "" { return errors.New("工具类型不能为空") } writeConstraint := func(tool, content string) error { skillsDir, err := s.toolSkillsDir(tool) if err != nil { return err } filePath := filepath.Join(skillsDir, globalConstraintFileName) if err := os.MkdirAll(filepath.Dir(filePath), os.ModePerm); err != nil { return err } return os.WriteFile(filePath, []byte(content), 0644) } if err := writeConstraint(req.Tool, req.Content); err != nil { return err } if len(req.SyncTools) == 0 { return nil } for _, tool := range req.SyncTools { if tool == "" || tool == req.Tool { continue } if err := writeConstraint(tool, req.Content); err != nil { return err } } return nil } func (s *SkillsService) DownloadOnlineSkill(_ context.Context, req request.DownloadOnlineSkillReq) error { skillsDir, err := s.toolSkillsDir(req.Tool) if err != nil { return err } body, err := json.Marshal(map[string]interface{}{ "plugin_id": req.ID, "version": req.Version, }) if err != nil { return fmt.Errorf("构建下载请求失败: %w", err) } downloadReq, err := http.NewRequest(http.MethodPost, "https://plugin.gin-vue-admin.com/api/shopPlugin/downloadSkill", bytes.NewReader(body)) if err != nil { return fmt.Errorf("构建下载请求失败: %w", err) } downloadReq.Header.Set("Content-Type", "application/json") downloadResp, err := http.DefaultClient.Do(downloadReq) if err != nil { return fmt.Errorf("下载技能失败: %w", err) } defer downloadResp.Body.Close() if downloadResp.StatusCode != http.StatusOK { return fmt.Errorf("下载技能失败, HTTP状态码: %d", downloadResp.StatusCode) } metaBody, err := io.ReadAll(downloadResp.Body) if err != nil { return fmt.Errorf("读取下载结果失败: %w", err) } var meta struct { Data struct { URL string `json:"url"` } `json:"data"` } if err = json.Unmarshal(metaBody, &meta); err != nil { return fmt.Errorf("解析下载结果失败: %w", err) } realDownloadURL := strings.TrimSpace(meta.Data.URL) if realDownloadURL == "" { return errors.New("下载结果缺少 url") } zipResp, err := http.Get(realDownloadURL) if err != nil { return fmt.Errorf("下载压缩包失败: %w", err) } defer zipResp.Body.Close() if zipResp.StatusCode != http.StatusOK { return fmt.Errorf("下载压缩包失败, HTTP状态码: %d", zipResp.StatusCode) } tmpFile, err := os.CreateTemp("", "gva-skill-*.zip") if err != nil { return fmt.Errorf("创建临时文件失败: %w", err) } tmpPath := tmpFile.Name() defer os.Remove(tmpPath) if _, err = io.Copy(tmpFile, zipResp.Body); err != nil { tmpFile.Close() return fmt.Errorf("保存技能包失败: %w", err) } tmpFile.Close() if err = extractZipToDir(tmpPath, skillsDir); err != nil { return fmt.Errorf("解压技能包失败: %w", err) } return nil } func extractZipToDir(zipPath, destDir string) error { r, err := zip.OpenReader(zipPath) if err != nil { return err } defer r.Close() for _, f := range r.File { name := filepath.FromSlash(f.Name) if strings.Contains(name, "..") { continue } target := filepath.Join(destDir, name) if !strings.HasPrefix(filepath.Clean(target), filepath.Clean(destDir)) { continue } if f.FileInfo().IsDir() { if err := os.MkdirAll(target, os.ModePerm); err != nil { return err } continue } if err := os.MkdirAll(filepath.Dir(target), os.ModePerm); err != nil { return err } rc, err := f.Open() if err != nil { return err } out, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) if err != nil { rc.Close() return err } _, err = io.Copy(out, rc) rc.Close() out.Close() if err != nil { return err } } return nil } func (s *SkillsService) toolSkillsDir(tool string) (string, error) { toolDir, ok := skillToolDirs[tool] if !ok { return "", errors.New("工具类型不支持") } root := strings.TrimSpace(global.GVA_CONFIG.AutoCode.Root) if root == "" { root = "." } skillsDir := filepath.Join(root, toolDir, "skills") if err := os.MkdirAll(skillsDir, os.ModePerm); err != nil { return "", err } return skillsDir, nil } func (s *SkillsService) skillDir(tool, skill string) (string, error) { skillsDir, err := s.toolSkillsDir(tool) if err != nil { return "", err } return filepath.Join(skillsDir, skill), nil } func (s *SkillsService) ensureSkillDir(tool, skill string) (string, error) { if !isSafeName(skill) { return "", errors.New("技能名称不合法") } skillDir, err := s.skillDir(tool, skill) if err != nil { return "", err } if err := os.MkdirAll(skillDir, os.ModePerm); err != nil { return "", err } return skillDir, nil } func (s *SkillsService) createMarkdownFile(tool, skill, subDir, fileName, defaultContent, label string) (string, string, error) { if !isSafeName(skill) { return "", "", errors.New("技能名称不合法") } cleanName, err := buildResourceFileName(fileName) if err != nil { return "", "", err } skillDir, err := s.ensureSkillDir(tool, skill) if err != nil { return "", "", err } filePath := filepath.Join(skillDir, subDir, cleanName) if _, err := os.Stat(filePath); err == nil { if label == "" { label = "文件" } return "", "", fmt.Errorf("%s已存在", label) } if err := os.MkdirAll(filepath.Dir(filePath), os.ModePerm); err != nil { return "", "", err } content := defaultContent if err := os.WriteFile(filePath, []byte(content), 0644); err != nil { return "", "", err } return cleanName, content, nil } func (s *SkillsService) readSkillFile(tool, skill, subDir, fileName string) (string, error) { if !isSafeName(skill) { return "", errors.New("技能名称不合法") } if !isSafeFileName(fileName) { return "", errors.New("文件名不合法") } skillDir, err := s.skillDir(tool, skill) if err != nil { return "", err } filePath := filepath.Join(skillDir, subDir, fileName) content, err := os.ReadFile(filePath) if err != nil { return "", err } return string(content), nil } func (s *SkillsService) writeSkillFile(tool, skill, subDir, fileName, content string) error { if !isSafeName(skill) { return errors.New("技能名称不合法") } if !isSafeFileName(fileName) { return errors.New("文件名不合法") } skillDir, err := s.ensureSkillDir(tool, skill) if err != nil { return err } filePath := filepath.Join(skillDir, subDir, fileName) if err := os.MkdirAll(filepath.Dir(filePath), os.ModePerm); err != nil { return err } return os.WriteFile(filePath, []byte(content), 0644) } func parseSkillContent(content string) (system.SkillMeta, string, error) { clean := strings.TrimPrefix(content, "\ufeff") lines := strings.Split(clean, "\n") if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" { return system.SkillMeta{}, clean, nil } end := -1 for i := 1; i < len(lines); i++ { if strings.TrimSpace(lines[i]) == "---" { end = i break } } if end == -1 { return system.SkillMeta{}, clean, nil } yamlText := strings.Join(lines[1:end], "\n") body := strings.Join(lines[end+1:], "\n") var meta system.SkillMeta if err := yaml.Unmarshal([]byte(yamlText), &meta); err != nil { return system.SkillMeta{}, body, err } return meta, body, nil } func buildSkillContent(meta system.SkillMeta, markdown string) (string, error) { if meta.Name == "" { return "", errors.New("name不能为空") } data, err := yaml.Marshal(meta) if err != nil { return "", err } yamlText := strings.TrimRight(string(data), "\n") body := strings.TrimLeft(markdown, "\n") if body != "" { body = body + "\n" } return fmt.Sprintf("---\n%s\n---\n%s", yamlText, body), nil } func listFiles(dir string) []string { entries, err := os.ReadDir(dir) if err != nil { return []string{} } files := make([]string, 0, len(entries)) for _, entry := range entries { if entry.Type().IsRegular() { files = append(files, entry.Name()) } } sort.Strings(files) return files } func isSafeName(name string) bool { if strings.TrimSpace(name) == "" { return false } if strings.Contains(name, "..") { return false } if strings.ContainsAny(name, "/\\") { return false } return name == filepath.Base(name) } func isSafeFileName(name string) bool { if strings.TrimSpace(name) == "" { return false } if strings.Contains(name, "..") { return false } if strings.ContainsAny(name, "/\\") { return false } return name == filepath.Base(name) } func buildScriptFileName(fileName, scriptType string) (string, string, error) { clean := strings.TrimSpace(fileName) if clean == "" { return "", "", errors.New("文件名不能为空") } if !isSafeFileName(clean) { return "", "", errors.New("文件名不合法") } base := strings.TrimSuffix(clean, filepath.Ext(clean)) if base == "" { return "", "", errors.New("文件名不合法") } switch strings.ToLower(scriptType) { case "py", "python": return base + ".py", "python", nil case "js", "javascript", "script": return base + ".js", "javascript", nil case "sh", "shell", "bash": return base + ".sh", "sh", nil default: return "", "", errors.New("脚本类型不支持") } } func buildResourceFileName(fileName string) (string, error) { clean := strings.TrimSpace(fileName) if clean == "" { return "", errors.New("文件名不能为空") } if !isSafeFileName(clean) { return "", errors.New("文件名不合法") } base := strings.TrimSuffix(clean, filepath.Ext(clean)) if base == "" { return "", errors.New("文件名不合法") } return base + ".md", nil } func scriptTemplate(lang string) string { switch lang { case "python": return "# -*- coding: utf-8 -*-\n# TODO: 在这里实现脚本逻辑\n" case "javascript": return "// TODO: 在这里实现脚本逻辑\n" case "sh": return "#!/usr/bin/env bash\nset -euo pipefail\n\n# TODO: 在这里实现脚本逻辑\n" default: return "" } } func copySkillDir(src, dst string) error { return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } rel, err := filepath.Rel(src, path) if err != nil { return err } if rel == "." { return nil } target := filepath.Join(dst, rel) if d.IsDir() { return os.MkdirAll(target, os.ModePerm) } if !d.Type().IsRegular() { return nil } data, err := os.ReadFile(path) if err != nil { return err } if err := os.MkdirAll(filepath.Dir(target), os.ModePerm); err != nil { return err } return os.WriteFile(target, data, 0644) }) }