package skills import ( "encoding/json" "fmt" "os" "path/filepath" "regexp" "strings" ) type SkillMetadata struct { Name string `json:"name"` Description string `json:"description"` } type SkillInfo struct { Name string `json:"name"` Path string `json:"path"` Source string `json:"source"` Description string `json:"description"` } type SkillsLoader struct { workspace string workspaceSkills string // workspace skills (项目级别) globalSkills string // 全局 skills (~/.picoclaw/skills) builtinSkills string // 内置 skills } func NewSkillsLoader(workspace string, globalSkills string, builtinSkills string) *SkillsLoader { return &SkillsLoader{ workspace: workspace, workspaceSkills: filepath.Join(workspace, "skills"), globalSkills: globalSkills, // ~/.picoclaw/skills builtinSkills: builtinSkills, } } func (sl *SkillsLoader) ListSkills() []SkillInfo { skills := make([]SkillInfo, 0) if sl.workspaceSkills != "" { if dirs, err := os.ReadDir(sl.workspaceSkills); err == nil { for _, dir := range dirs { if dir.IsDir() { skillFile := filepath.Join(sl.workspaceSkills, dir.Name(), "SKILL.md") if _, err := os.Stat(skillFile); err == nil { info := SkillInfo{ Name: dir.Name(), Path: skillFile, Source: "workspace", } metadata := sl.getSkillMetadata(skillFile) if metadata != nil { info.Description = metadata.Description } skills = append(skills, info) } } } } } // 全局 skills (~/.picoclaw/skills) - 被 workspace skills 覆盖 if sl.globalSkills != "" { if dirs, err := os.ReadDir(sl.globalSkills); err == nil { for _, dir := range dirs { if dir.IsDir() { skillFile := filepath.Join(sl.globalSkills, dir.Name(), "SKILL.md") if _, err := os.Stat(skillFile); err == nil { // 检查是否已被 workspace skills 覆盖 exists := false for _, s := range skills { if s.Name == dir.Name() && s.Source == "workspace" { exists = true break } } if exists { continue } info := SkillInfo{ Name: dir.Name(), Path: skillFile, Source: "global", } metadata := sl.getSkillMetadata(skillFile) if metadata != nil { info.Description = metadata.Description } skills = append(skills, info) } } } } } if sl.builtinSkills != "" { if dirs, err := os.ReadDir(sl.builtinSkills); err == nil { for _, dir := range dirs { if dir.IsDir() { skillFile := filepath.Join(sl.builtinSkills, dir.Name(), "SKILL.md") if _, err := os.Stat(skillFile); err == nil { // 检查是否已被 workspace 或 global skills 覆盖 exists := false for _, s := range skills { if s.Name == dir.Name() && (s.Source == "workspace" || s.Source == "global") { exists = true break } } if exists { continue } info := SkillInfo{ Name: dir.Name(), Path: skillFile, Source: "builtin", } metadata := sl.getSkillMetadata(skillFile) if metadata != nil { info.Description = metadata.Description } skills = append(skills, info) } } } } } return skills } func (sl *SkillsLoader) LoadSkill(name string) (string, bool) { // 1. 优先从 workspace skills 加载(项目级别) if sl.workspaceSkills != "" { skillFile := filepath.Join(sl.workspaceSkills, name, "SKILL.md") if content, err := os.ReadFile(skillFile); err == nil { return sl.stripFrontmatter(string(content)), true } } // 2. 其次从全局 skills 加载 (~/.picoclaw/skills) if sl.globalSkills != "" { skillFile := filepath.Join(sl.globalSkills, name, "SKILL.md") if content, err := os.ReadFile(skillFile); err == nil { return sl.stripFrontmatter(string(content)), true } } // 3. 最后从内置 skills 加载 if sl.builtinSkills != "" { skillFile := filepath.Join(sl.builtinSkills, name, "SKILL.md") if content, err := os.ReadFile(skillFile); err == nil { return sl.stripFrontmatter(string(content)), true } } return "", false } func (sl *SkillsLoader) LoadSkillsForContext(skillNames []string) string { if len(skillNames) == 0 { return "" } var parts []string for _, name := range skillNames { content, ok := sl.LoadSkill(name) if ok { parts = append(parts, fmt.Sprintf("### Skill: %s\n\n%s", name, content)) } } return strings.Join(parts, "\n\n---\n\n") } func (sl *SkillsLoader) BuildSkillsSummary() string { allSkills := sl.ListSkills() if len(allSkills) == 0 { return "" } var lines []string lines = append(lines, "") for _, s := range allSkills { escapedName := escapeXML(s.Name) escapedDesc := escapeXML(s.Description) escapedPath := escapeXML(s.Path) lines = append(lines, fmt.Sprintf(" ")) lines = append(lines, fmt.Sprintf(" %s", escapedName)) lines = append(lines, fmt.Sprintf(" %s", escapedDesc)) lines = append(lines, fmt.Sprintf(" %s", escapedPath)) lines = append(lines, fmt.Sprintf(" %s", s.Source)) lines = append(lines, " ") } lines = append(lines, "") return strings.Join(lines, "\n") } func (sl *SkillsLoader) getSkillMetadata(skillPath string) *SkillMetadata { content, err := os.ReadFile(skillPath) if err != nil { return nil } frontmatter := sl.extractFrontmatter(string(content)) if frontmatter == "" { return &SkillMetadata{ Name: filepath.Base(filepath.Dir(skillPath)), } } // Try JSON first (for backward compatibility) var jsonMeta struct { Name string `json:"name"` Description string `json:"description"` } if err := json.Unmarshal([]byte(frontmatter), &jsonMeta); err == nil { return &SkillMetadata{ Name: jsonMeta.Name, Description: jsonMeta.Description, } } // Fall back to simple YAML parsing yamlMeta := sl.parseSimpleYAML(frontmatter) return &SkillMetadata{ Name: yamlMeta["name"], Description: yamlMeta["description"], } } // parseSimpleYAML parses simple key: value YAML format // Example: name: github\n description: "..." func (sl *SkillsLoader) parseSimpleYAML(content string) map[string]string { result := make(map[string]string) for _, line := range strings.Split(content, "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { continue } parts := strings.SplitN(line, ":", 2) if len(parts) == 2 { key := strings.TrimSpace(parts[0]) value := strings.TrimSpace(parts[1]) // Remove quotes if present value = strings.Trim(value, "\"'") result[key] = value } } return result } func (sl *SkillsLoader) extractFrontmatter(content string) string { // (?s) enables DOTALL mode so . matches newlines // Match first ---, capture everything until next --- on its own line re := regexp.MustCompile(`(?s)^---\n(.*)\n---`) match := re.FindStringSubmatch(content) if len(match) > 1 { return match[1] } return "" } func (sl *SkillsLoader) stripFrontmatter(content string) string { re := regexp.MustCompile(`^---\n.*?\n---\n`) return re.ReplaceAllString(content, "") } func escapeXML(s string) string { s = strings.ReplaceAll(s, "&", "&") s = strings.ReplaceAll(s, "<", "<") s = strings.ReplaceAll(s, ">", ">") return s }