catalog.go

  1package skills
  2
  3import (
  4	"errors"
  5	"fmt"
  6	"os"
  7	"path/filepath"
  8	"strings"
  9)
 10
 11// SourceType describes where a visible skill comes from.
 12type SourceType string
 13
 14const (
 15	SourceSystem  SourceType = "system"
 16	SourceUser    SourceType = "user"
 17	SourceProject SourceType = "project"
 18)
 19
 20// CatalogEntry describes an effective visible skill for frontend display.
 21type CatalogEntry struct {
 22	ID          string     `json:"id"`
 23	Name        string     `json:"name"`
 24	Description string     `json:"description"`
 25	Label       string     `json:"label"`
 26	Source      SourceType `json:"source"`
 27}
 28
 29// SkillReadResult holds metadata about a skill returned alongside its
 30// content.
 31type SkillReadResult struct {
 32	Name        string     `json:"name"`
 33	Description string     `json:"description"`
 34	Source      SourceType `json:"source"`
 35	Builtin     bool       `json:"builtin"`
 36}
 37
 38// ErrSkillNotFound is returned when a skill ID is not part of the
 39// effective visible skill set.
 40var ErrSkillNotFound = errors.New("skill not found")
 41
 42// Catalog builds a slice of CatalogEntry values from pre-discovered
 43// skills. The skillPaths and workingDir parameters are used only for
 44// labelling (system / user / project); pass nil/empty when labels are
 45// not needed.
 46func Catalog(active []*Skill, skillPaths []string, workingDir string) []CatalogEntry {
 47	entries := make([]CatalogEntry, 0, len(active))
 48	for _, skill := range active {
 49		label, source := skillLabel(skillPaths, workingDir, skill)
 50		entries = append(entries, CatalogEntry{
 51			ID:          skill.SkillFilePath,
 52			Name:        skill.Name,
 53			Description: skill.Description,
 54			Label:       label,
 55			Source:      source,
 56		})
 57	}
 58	return entries
 59}
 60
 61// FindEffective returns the named skill from the given active skill
 62// set.
 63func FindEffective(active []*Skill, skillID string) (*Skill, error) {
 64	for _, skill := range active {
 65		if skill.SkillFilePath == skillID {
 66			return skill, nil
 67		}
 68	}
 69	return nil, fmt.Errorf("%w: %s", ErrSkillNotFound, skillID)
 70}
 71
 72// ReadContent reads the contents of a visible skill by ID and returns
 73// the raw bytes along with metadata about the skill.
 74func ReadContent(active []*Skill, skillPaths []string, workingDir string, skillID string) ([]byte, SkillReadResult, error) {
 75	skill, err := FindEffective(active, skillID)
 76	if err != nil {
 77		return nil, SkillReadResult{}, err
 78	}
 79
 80	_, source := skillLabel(skillPaths, workingDir, skill)
 81	result := SkillReadResult{
 82		Name:        skill.Name,
 83		Description: skill.Description,
 84		Source:      source,
 85		Builtin:     skill.Builtin,
 86	}
 87
 88	if skill.Builtin {
 89		embeddedPath := "builtin/" + strings.TrimPrefix(skill.SkillFilePath, BuiltinPrefix)
 90		content, err := BuiltinFS().ReadFile(embeddedPath)
 91		if err != nil {
 92			return nil, SkillReadResult{}, fmt.Errorf("read builtin skill %q: %w", skillID, err)
 93		}
 94		return content, result, nil
 95	}
 96
 97	content, err := os.ReadFile(skill.SkillFilePath)
 98	if err != nil {
 99		return nil, SkillReadResult{}, fmt.Errorf("read skill %q: %w", skillID, err)
100	}
101	return content, result, nil
102}
103
104func skillLabel(skillPaths []string, workingDir string, skill *Skill) (string, SourceType) {
105	if skill.Builtin {
106		return string(SourceSystem) + ":" + skill.Name, SourceSystem
107	}
108
109	cleanFile := filepath.Clean(skill.SkillFilePath)
110	for _, base := range skillPaths {
111		cleanBase := filepath.Clean(base)
112		rel, err := filepath.Rel(cleanBase, cleanFile)
113		if err != nil || escapesParent(rel) {
114			continue
115		}
116
117		source := SourceUser
118		prefix := string(SourceUser) + ":"
119		if isProjectSkillPath(cleanBase, workingDir) {
120			source = SourceProject
121			prefix = string(SourceProject) + ":"
122		}
123		return prefix + filepath.Base(filepath.Dir(cleanFile)), source
124	}
125
126	return string(SourceUser) + ":" + filepath.Base(filepath.Dir(cleanFile)), SourceUser
127}
128
129func escapesParent(rel string) bool {
130	return rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator))
131}
132
133func isProjectSkillPath(basePath, workingDir string) bool {
134	if workingDir == "" {
135		return false
136	}
137	absBase, err := filepath.Abs(basePath)
138	if err != nil {
139		return false
140	}
141	absWD, err := filepath.Abs(workingDir)
142	if err != nil {
143		return false
144	}
145	cleanBase := filepath.Clean(absBase)
146	cleanWD := filepath.Clean(absWD)
147	rel, err := filepath.Rel(cleanWD, cleanBase)
148	if err != nil {
149		return false
150	}
151	return !escapesParent(rel)
152}