// Package skills 管理内置 Skill 资源及其向目标项目的安装/移除。 // 状态以文件系统为准:项目 .cursor/skills// 存在即已安装。 // skill 目录内的 commands/ 子目录是斜杠命令模板,安装时分发到项目 .cursor/commands/。 package skills import ( "bytes" "embed" "fmt" "io/fs" "os" "path" "path/filepath" "sort" "strings" ) //go:embed builtin var builtinFS embed.FS const ( // builtinRoot 是 embed FS 中内置资源的根目录名。 builtinRoot = "builtin" // commandsDirName 是 skill 目录内存放命令模板的子目录名。 commandsDirName = "commands" ) // retiredSkillFiles 记录各内置 skill 更新时需要清理的退役文件。 var retiredSkillFiles = map[string][]string{ "fd-engineering": { "references/native-tools.md", "references/mode-switch.md", }, } // CommandInfo 描述 skill 附带的一个斜杠命令。 type CommandInfo struct { Name string `json:"name"` Description string `json:"description"` } // Skill 描述一个内置 Skill。 type Skill struct { Name string `json:"name"` Description string `json:"description"` Commands []CommandInfo `json:"commands"` } // skillsDir 返回项目内的 skill 安装目录。 func skillsDir(projectPath string) string { return filepath.Join(projectPath, ".cursor", "skills") } // skillTargetDir 返回某 skill 在项目内的安装目录。 func skillTargetDir(projectPath string, name string) string { return filepath.Join(skillsDir(projectPath), name) } // projectCommandsDir 返回项目内的斜杠命令目录。 func projectCommandsDir(projectPath string) string { return filepath.Join(projectPath, ".cursor", "commands") } // List 返回所有内置 Skill,按名称排序。 func List() []Skill { dirEntries, err := fs.ReadDir(builtinFS, builtinRoot) if err != nil { return []Skill{} } result := make([]Skill, 0, len(dirEntries)) for _, dirEntry := range dirEntries { if !dirEntry.IsDir() { continue } result = append(result, Skill{ Name: dirEntry.Name(), Description: readFrontmatterDescription(path.Join(builtinRoot, dirEntry.Name(), "SKILL.md")), Commands: listCommands(dirEntry.Name()), }) } return result } // listCommands 列出内置 skill 附带的斜杠命令及其 description。 func listCommands(name string) []CommandInfo { entries, err := fs.ReadDir(builtinFS, path.Join(builtinRoot, name, commandsDirName)) if err != nil { return []CommandInfo{} } result := make([]CommandInfo, 0, len(entries)) for _, entry := range entries { if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") { continue } result = append(result, CommandInfo{ Name: strings.TrimSuffix(entry.Name(), ".md"), Description: readFrontmatterDescription(path.Join(builtinRoot, name, commandsDirName, entry.Name())), }) } sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name }) return result } // commandFileNames 返回内置 skill 附带的命令文件名列表。 func commandFileNames(name string) []string { entries, err := fs.ReadDir(builtinFS, path.Join(builtinRoot, name, commandsDirName)) if err != nil { return nil } names := make([]string, 0, len(entries)) for _, entry := range entries { if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".md") { names = append(names, entry.Name()) } } return names } // Exists 校验内置 skill 是否存在。 func Exists(name string) bool { name = sanitizeSkillName(name) if name == "" { return false } info, err := fs.Stat(builtinFS, path.Join(builtinRoot, name, "SKILL.md")) return err == nil && !info.IsDir() } // Install 将内置 skill 同步到项目 .cursor/skills//, // 并将其 commands/ 模板分发到项目 .cursor/commands/。 // 目标已存在时同步为最新内置内容,并清理已退役的内置文件。 func Install(projectPath string, name string) error { name = sanitizeSkillName(name) projectPath = strings.TrimSpace(projectPath) operationMu.Lock() defer operationMu.Unlock() if !Exists(name) { return fmt.Errorf("未知的内置 skill") } if err := checkManagedPaths(projectPath, name); err != nil { return err } name = sanitizeSkillName(name) if name == "" || !Exists(name) { return fmt.Errorf("内置 skill 不存在: %s", name) } projectPath = strings.TrimSpace(projectPath) if info, err := os.Stat(projectPath); err != nil || !info.IsDir() { return fmt.Errorf("项目目录不存在: %s", projectPath) } target := skillTargetDir(projectPath, name) // commands/ 模板单独分发到 .cursor/commands/,不进入 skill 目录。 if err := copyEmbeddedDir(path.Join(builtinRoot, name), target, commandsDirName); err != nil { return fmt.Errorf("复制 skill 文件失败: %w", err) } for _, relativePath := range retiredSkillFiles[name] { if err := os.Remove(filepath.Join(target, filepath.FromSlash(relativePath))); err != nil && !os.IsNotExist(err) { return fmt.Errorf("清理退役 skill 文件失败: %w", err) } } if err := installCommands(projectPath, name); err != nil { return err } return nil } // installCommands 将 skill 的命令模板复制到项目 .cursor/commands/。 func installCommands(projectPath string, name string) error { fileNames := commandFileNames(name) if len(fileNames) == 0 { return nil } commandsDir := projectCommandsDir(projectPath) if err := os.MkdirAll(commandsDir, 0o755); err != nil { return fmt.Errorf("创建命令目录失败: %w", err) } for _, fileName := range fileNames { data, err := builtinFS.ReadFile(path.Join(builtinRoot, name, commandsDirName, fileName)) if err != nil { return fmt.Errorf("读取命令模板失败: %w", err) } if err := backupBeforeReplace(filepath.Join(commandsDir, fileName), data); err != nil { return err } if err := os.WriteFile(filepath.Join(commandsDir, fileName), data, 0o644); err != nil { return fmt.Errorf("写入命令文件失败: %w", err) } } return nil } // Uninstall 移除项目内的 skill 目录与随附的命令文件。不存在时幂等成功。 func Uninstall(projectPath string, name string) error { name = sanitizeSkillName(name) projectPath = strings.TrimSpace(projectPath) operationMu.Lock() defer operationMu.Unlock() if !Exists(name) { return fmt.Errorf("未知的内置 skill") } if err := checkManagedPaths(projectPath, name); err != nil { return err } if err := ensureNoCustomSkillFiles(projectPath, name); err != nil { return err } name = sanitizeSkillName(name) if name == "" { return fmt.Errorf("skill 名称不能为空") } projectPath = strings.TrimSpace(projectPath) target := skillTargetDir(projectPath, name) // 防御:只允许删除 .cursor/skills/ 一级目录。 if filepath.Base(target) != name || !strings.Contains(filepath.ToSlash(target), "/.cursor/skills/") { return fmt.Errorf("非法的 skill 路径") } if err := os.RemoveAll(target); err != nil { return fmt.Errorf("移除 skill 失败: %w", err) } // 只删除内置清单中的命令文件,不碰用户自建命令。 commandsDir := projectCommandsDir(projectPath) for _, fileName := range commandFileNames(name) { expected, _ := builtinFS.ReadFile(path.Join(builtinRoot, name, commandsDirName, fileName)) current, readErr := os.ReadFile(filepath.Join(commandsDir, fileName)) if readErr == nil && !bytes.Equal(current, expected) { continue } if readErr != nil && !os.IsNotExist(readErr) { return readErr } if err := os.Remove(filepath.Join(commandsDir, fileName)); err != nil && !os.IsNotExist(err) { return fmt.Errorf("移除命令文件失败: %w", err) } } return nil } // IsInstalled 检测项目内是否已安装该 skill。 func IsInstalled(projectPath string, name string) bool { name = sanitizeSkillName(name) if name == "" { return false } _, err := os.Stat(filepath.Join(skillTargetDir(projectPath, name), "SKILL.md")) return err == nil } // readFrontmatterDescription 解析 markdown frontmatter 中的 description。 func readFrontmatterDescription(embedPath string) string { raw, err := builtinFS.ReadFile(embedPath) if err != nil { return "" } for _, line := range strings.Split(string(raw), "\n") { line = strings.TrimSpace(line) if strings.HasPrefix(line, "description:") { return strings.TrimSpace(strings.TrimPrefix(line, "description:")) } } return "" } // sanitizeSkillName 防止路径穿越:只允许字母数字、连字符、下划线、点。 func sanitizeSkillName(name string) string { name = strings.TrimSpace(name) if name == "" || strings.ContainsAny(name, "/\\") || strings.Contains(name, "..") { return "" } return name } // copyEmbeddedDir 递归复制 embed FS 子目录到磁盘,skipDir 指定要跳过的子目录名。 func copyEmbeddedDir(embedDir string, targetDir string, skipDir string) error { return fs.WalkDir(builtinFS, embedDir, func(p string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() && skipDir != "" && d.Name() == skipDir && p != embedDir { return fs.SkipDir } rel, err := filepath.Rel(embedDir, p) if err != nil { return err } target := filepath.Join(targetDir, rel) if d.IsDir() { return os.MkdirAll(target, 0o755) } data, err := builtinFS.ReadFile(p) if err != nil { return err } if err := backupBeforeReplace(target, data); err != nil { return err } return os.WriteFile(target, data, 0o644) }) }