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
330 lines
8.7 KiB
Go
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, "&", "&")
|
|
s = strings.ReplaceAll(s, "<", "<")
|
|
s = strings.ReplaceAll(s, ">", ">")
|
|
return s
|
|
}
|