chore: import Local-Cursor v1.1.8 source snapshot
Includes the current working directory changes. Historical commits and release binaries are excluded to limit Gitea storage. Source base: d7515973fbfd6dd2001fa156c483455b8081c7cb
This commit is contained in:
263
internal/skills/skills.go
Normal file
263
internal/skills/skills.go
Normal file
@@ -0,0 +1,263 @@
|
||||
// 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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user