Includes the current working directory changes. Historical commits and release binaries are excluded to limit Gitea storage. Source base: d7515973fbfd6dd2001fa156c483455b8081c7cb
264 lines
8.2 KiB
Go
264 lines
8.2 KiB
Go
// Package skills 管理内置 Skill 资源及其向目标项目的安装/移除。
|
||
// 状态以文件系统为准:项目 .cursor/skills/<name>/ 存在即已安装。
|
||
// skill 目录内的 commands/ 子目录是斜杠命令模板,安装时分发到项目 .cursor/commands/。
|
||
package skills
|
||
|
||
import (
|
||
"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)
|
||
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 := 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)
|
||
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) {
|
||
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
|
||
}
|
||
return os.WriteFile(target, data, 0o644)
|
||
})
|
||
}
|