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