Files
picoclaw/pkg/skills/loader.go
ian c6c82b3c44 feat(skills): add validation for skill info and test cases (#231)
Add validation logic for SkillInfo to ensure name and description meet requirements
Include test cases covering various validation scenarios
Add testify dependency for testing assertions
2026-02-16 02:12:50 +08:00

330 lines
8.7 KiB
Go

package skills
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"regexp"
"strings"
)
var namePattern = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`)
const (
MaxNameLength = 64
MaxDescriptionLength = 1024
)
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"`
}
func (info SkillInfo) validate() error {
var errs error
if info.Name == "" {
errs = errors.Join(errs, errors.New("name is required"))
} else {
if len(info.Name) > MaxNameLength {
errs = errors.Join(errs, fmt.Errorf("name exceeds %d characters", MaxNameLength))
}
if !namePattern.MatchString(info.Name) {
errs = errors.Join(errs, errors.New("name must be alphanumeric with hyphens"))
}
}
if info.Description == "" {
errs = errors.Join(errs, errors.New("description is required"))
} else if len(info.Description) > MaxDescriptionLength {
errs = errors.Join(errs, fmt.Errorf("description exceeds %d character", MaxDescriptionLength))
}
return errs
}
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
info.Name = metadata.Name
}
if err := info.validate(); err != nil {
slog.Warn("invalid skill from workspace", "name", info.Name, "error", err)
continue
}
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
info.Name = metadata.Name
}
if err := info.validate(); err != nil {
slog.Warn("invalid skill from global", "name", info.Name, "error", err)
continue
}
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
info.Name = metadata.Name
}
if err := info.validate(); err != nil {
slog.Warn("invalid skill from builtin", "name", info.Name, "error", err)
continue
}
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, "<skills>")
for _, s := range allSkills {
escapedName := escapeXML(s.Name)
escapedDesc := escapeXML(s.Description)
escapedPath := escapeXML(s.Path)
lines = append(lines, fmt.Sprintf(" <skill>"))
lines = append(lines, fmt.Sprintf(" <name>%s</name>", escapedName))
lines = append(lines, fmt.Sprintf(" <description>%s</description>", escapedDesc))
lines = append(lines, fmt.Sprintf(" <location>%s</location>", escapedPath))
lines = append(lines, fmt.Sprintf(" <source>%s</source>", s.Source))
lines = append(lines, " </skill>")
}
lines = append(lines, "</skills>")
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, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
return s
}