// Package skills discovers the Claude Code skills available to the agent // session and exposes them to the SPA's "/" selector. // // A skill is a directory holding a SKILL.md whose YAML frontmatter names and // describes it. Claude Code looks in two places: the project's // .claude/skills/ and the user's ~/.claude/skills/; a project skill shadows a // user skill of the same name. package skills import ( "bufio" "encoding/json" "net/http" "os" "path/filepath" "sort" "strings" ) // Source tells where a skill was found. type Source string const ( // SourceProject is /.claude/skills. SourceProject Source = "project" // SourceUser is ~/.claude/skills. SourceUser Source = "user" ) // Skill is one discovered skill. type Skill struct { // Name is the frontmatter name, or the directory name when absent. Name string `json:"name"` // Description is the frontmatter description ("" when absent). Description string `json:"description"` // Path is the absolute path of the SKILL.md file. Path string `json:"path"` // Source is where the skill comes from. Source Source `json:"source"` } // Service answers GET /api/skills for one project directory and one home // directory. type Service struct { projectDir string homeDir string } // New creates a Service; cwd is the agent's working directory and home the // user's home directory (an empty home disables user skills). // // Example: // // svc := skills.New("/work/project", os.Getenv("HOME")) // for pattern, handler := range svc.Routes() { // mux.Handle(pattern, handler) // } func New(cwd, home string) *Service { return &Service{projectDir: cwd, homeDir: home} } // Routes returns the API endpoints, keyed by http.ServeMux pattern. func (s *Service) Routes() map[string]http.Handler { return map[string]http.Handler{ "GET /api/skills": http.HandlerFunc(s.listSkills), } } // Discover returns the skills of both locations, sorted by name, project // skills shadowing user skills of the same name. func (s *Service) Discover() []Skill { byName := map[string]Skill{} if s.homeDir != "" { for _, skill := range scanDirectory(filepath.Join(s.homeDir, ".claude", "skills"), SourceUser) { byName[skill.Name] = skill } } for _, skill := range scanDirectory(filepath.Join(s.projectDir, ".claude", "skills"), SourceProject) { byName[skill.Name] = skill } skills := make([]Skill, 0, len(byName)) for _, skill := range byName { skills = append(skills, skill) } sort.Slice(skills, func(i, j int) bool { return skills[i].Name < skills[j].Name }) return skills } // scanDirectory lists the /*/SKILL.md skills; a missing directory // yields nothing. func scanDirectory(dir string, source Source) []Skill { entries, err := os.ReadDir(dir) if err != nil { return nil } var skills []Skill for _, entry := range entries { if !entry.IsDir() { continue } path := filepath.Join(dir, entry.Name(), "SKILL.md") file, err := os.Open(path) if err != nil { continue } meta := parseFrontmatter(file) _ = file.Close() skill := Skill{Name: meta["name"], Description: meta["description"], Path: path, Source: source} if skill.Name == "" { skill.Name = entry.Name() } skills = append(skills, skill) } return skills } // parseFrontmatter reads the leading "---" YAML block of a SKILL.md and // returns its scalar key/values. It understands the subset skills use: // "key: value" lines, quoted values, and folded/literal block scalars // ("key: >" or "key: |" followed by indented lines). func parseFrontmatter(r *os.File) map[string]string { meta := map[string]string{} scanner := bufio.NewScanner(r) scanner.Buffer(make([]byte, 0, 64*1024), 1<<20) if !scanner.Scan() || strings.TrimSpace(scanner.Text()) != "---" { return meta } blockKey := "" var block []string flush := func() { if blockKey != "" { meta[blockKey] = strings.Join(block, " ") blockKey, block = "", nil } } for scanner.Scan() { line := scanner.Text() if strings.TrimSpace(line) == "---" { break } if blockKey != "" && (strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")) { block = append(block, strings.TrimSpace(line)) continue } flush() key, value, ok := strings.Cut(line, ":") if !ok || strings.TrimSpace(key) == "" || strings.HasPrefix(line, " ") { continue } value = strings.TrimSpace(value) if value == ">" || value == "|" || value == ">-" || value == "|-" { blockKey = strings.TrimSpace(key) continue } meta[strings.TrimSpace(key)] = unquote(value) } flush() return meta } // unquote strips one pair of matching single or double quotes. func unquote(value string) string { if len(value) >= 2 { first, last := value[0], value[len(value)-1] if (first == '"' && last == '"') || (first == '\'' && last == '\'') { return value[1 : len(value)-1] } } return value } func (s *Service) listSkills(w http.ResponseWriter, _ *http.Request) { skills := s.Discover() w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(map[string]any{"skills": skills}) }