2026-11-10 01:24:28 +08:00
|
|
|
|
// Package skills 管理内置 Skill 资源及其向目标项目的安装/移除。
|
|
|
|
|
|
// 状态以文件系统为准:项目 .cursor/skills/<name>/ 存在即已安装。
|
|
|
|
|
|
// skill 目录内的 commands/ 子目录是斜杠命令模板,安装时分发到项目 .cursor/commands/。
|
|
|
|
|
|
package skills
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
2026-11-10 09:59:34 +08:00
|
|
|
|
"bytes"
|
2026-11-10 01:24:28 +08:00
|
|
|
|
"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 {
|
2026-11-10 09:59:34 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-11-10 01:24:28 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
2026-11-10 09:59:34 +08:00
|
|
|
|
if err := backupBeforeReplace(filepath.Join(commandsDir, fileName), data); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
2026-11-10 01:24:28 +08:00
|
|
|
|
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 {
|
2026-11-10 09:59:34 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-11-10 01:24:28 +08:00
|
|
|
|
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) {
|
2026-11-10 09:59:34 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-11-10 01:24:28 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-11-10 09:59:34 +08:00
|
|
|
|
if err := backupBeforeReplace(target, data); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
2026-11-10 01:24:28 +08:00
|
|
|
|
return os.WriteFile(target, data, 0o644)
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|