Files

302 lines
9.3 KiB
Go
Raw Permalink Normal View History

// Package skills 管理内置 Skill 资源及其向目标项目的安装/移除。
// 状态以文件系统为准:项目 .cursor/skills/<name>/ 存在即已安装。
// 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/<name>/,
// 并将其 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/<name> 一级目录。
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)
})
}