forked from bots-garden/ori
| ✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme | 1 | // Package skills discovers the Claude Code skills available to the agent |
| 2 | // session and exposes them to the SPA's "/" selector. | |
| 3 | // | |
| 4 | // A skill is a directory holding a SKILL.md whose YAML frontmatter names and | |
| 5 | // describes it. Claude Code looks in two places: the project's | |
| 6 | // .claude/skills/ and the user's ~/.claude/skills/; a project skill shadows a | |
| 7 | // user skill of the same name. | |
| 8 | package skills | |
| 9 | ||
| 10 | import ( | |
| 11 | "bufio" | |
| 12 | "encoding/json" | |
| 13 | "net/http" | |
| 14 | "os" | |
| 15 | "path/filepath" | |
| 16 | "sort" | |
| 17 | "strings" | |
| 18 | ) | |
| 19 | ||
| 20 | // Source tells where a skill was found. | |
| 21 | type Source string | |
| 22 | ||
| 23 | const ( | |
| 24 | // SourceProject is <cwd>/.claude/skills. | |
| 25 | SourceProject Source = "project" | |
| 26 | // SourceUser is ~/.claude/skills. | |
| 27 | SourceUser Source = "user" | |
| 28 | ) | |
| 29 | ||
| 30 | // Skill is one discovered skill. | |
| 31 | type Skill struct { | |
| 32 | // Name is the frontmatter name, or the directory name when absent. | |
| 33 | Name string `json:"name"` | |
| 34 | // Description is the frontmatter description ("" when absent). | |
| 35 | Description string `json:"description"` | |
| 36 | // Path is the absolute path of the SKILL.md file. | |
| 37 | Path string `json:"path"` | |
| 38 | // Source is where the skill comes from. | |
| 39 | Source Source `json:"source"` | |
| 40 | } | |
| 41 | ||
| 42 | // Service answers GET /api/skills for one project directory and one home | |
| 43 | // directory. | |
| 44 | type Service struct { | |
| 45 | projectDir string | |
| 46 | homeDir string | |
| 47 | } | |
| 48 | ||
| 49 | // New creates a Service; cwd is the agent's working directory and home the | |
| 50 | // user's home directory (an empty home disables user skills). | |
| 51 | // | |
| 52 | // Example: | |
| 53 | // | |
| 54 | // svc := skills.New("/work/project", os.Getenv("HOME")) | |
| 55 | // for pattern, handler := range svc.Routes() { | |
| 56 | // mux.Handle(pattern, handler) | |
| 57 | // } | |
| 58 | func New(cwd, home string) *Service { | |
| 59 | return &Service{projectDir: cwd, homeDir: home} | |
| 60 | } | |
| 61 | ||
| 62 | // Routes returns the API endpoints, keyed by http.ServeMux pattern. | |
| 63 | func (s *Service) Routes() map[string]http.Handler { | |
| 64 | return map[string]http.Handler{ | |
| 65 | "GET /api/skills": http.HandlerFunc(s.listSkills), | |
| 66 | } | |
| 67 | } | |
| 68 | ||
| 69 | // Discover returns the skills of both locations, sorted by name, project | |
| 70 | // skills shadowing user skills of the same name. | |
| 71 | func (s *Service) Discover() []Skill { | |
| 72 | byName := map[string]Skill{} | |
| 73 | if s.homeDir != "" { | |
| 74 | for _, skill := range scanDirectory(filepath.Join(s.homeDir, ".claude", "skills"), SourceUser) { | |
| 75 | byName[skill.Name] = skill | |
| 76 | } | |
| 77 | } | |
| 78 | for _, skill := range scanDirectory(filepath.Join(s.projectDir, ".claude", "skills"), SourceProject) { | |
| 79 | byName[skill.Name] = skill | |
| 80 | } | |
| 81 | ||
| 82 | skills := make([]Skill, 0, len(byName)) | |
| 83 | for _, skill := range byName { | |
| 84 | skills = append(skills, skill) | |
| 85 | } | |
| 86 | sort.Slice(skills, func(i, j int) bool { return skills[i].Name < skills[j].Name }) | |
| 87 | return skills | |
| 88 | } | |
| 89 | ||
| 90 | // scanDirectory lists the <dir>/*/SKILL.md skills; a missing directory | |
| 91 | // yields nothing. | |
| 92 | func scanDirectory(dir string, source Source) []Skill { | |
| 93 | entries, err := os.ReadDir(dir) | |
| 94 | if err != nil { | |
| 95 | return nil | |
| 96 | } | |
| 97 | var skills []Skill | |
| 98 | for _, entry := range entries { | |
| 99 | if !entry.IsDir() { | |
| 100 | continue | |
| 101 | } | |
| 102 | path := filepath.Join(dir, entry.Name(), "SKILL.md") | |
| 103 | file, err := os.Open(path) | |
| 104 | if err != nil { | |
| 105 | continue | |
| 106 | } | |
| 107 | meta := parseFrontmatter(file) | |
| 108 | _ = file.Close() | |
| 109 | ||
| 110 | skill := Skill{Name: meta["name"], Description: meta["description"], Path: path, Source: source} | |
| 111 | if skill.Name == "" { | |
| 112 | skill.Name = entry.Name() | |
| 113 | } | |
| 114 | skills = append(skills, skill) | |
| 115 | } | |
| 116 | return skills | |
| 117 | } | |
| 118 | ||
| 119 | // parseFrontmatter reads the leading "---" YAML block of a SKILL.md and | |
| 120 | // returns its scalar key/values. It understands the subset skills use: | |
| 121 | // "key: value" lines, quoted values, and folded/literal block scalars | |
| 122 | // ("key: >" or "key: |" followed by indented lines). | |
| 123 | func parseFrontmatter(r *os.File) map[string]string { | |
| 124 | meta := map[string]string{} | |
| 125 | scanner := bufio.NewScanner(r) | |
| 126 | scanner.Buffer(make([]byte, 0, 64*1024), 1<<20) | |
| 127 | ||
| 128 | if !scanner.Scan() || strings.TrimSpace(scanner.Text()) != "---" { | |
| 129 | return meta | |
| 130 | } | |
| 131 | blockKey := "" | |
| 132 | var block []string | |
| 133 | flush := func() { | |
| 134 | if blockKey != "" { | |
| 135 | meta[blockKey] = strings.Join(block, " ") | |
| 136 | blockKey, block = "", nil | |
| 137 | } | |
| 138 | } | |
| 139 | for scanner.Scan() { | |
| 140 | line := scanner.Text() | |
| 141 | if strings.TrimSpace(line) == "---" { | |
| 142 | break | |
| 143 | } | |
| 144 | if blockKey != "" && (strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")) { | |
| 145 | block = append(block, strings.TrimSpace(line)) | |
| 146 | continue | |
| 147 | } | |
| 148 | flush() | |
| 149 | key, value, ok := strings.Cut(line, ":") | |
| 150 | if !ok || strings.TrimSpace(key) == "" || strings.HasPrefix(line, " ") { | |
| 151 | continue | |
| 152 | } | |
| 153 | value = strings.TrimSpace(value) | |
| 154 | if value == ">" || value == "|" || value == ">-" || value == "|-" { | |
| 155 | blockKey = strings.TrimSpace(key) | |
| 156 | continue | |
| 157 | } | |
| 158 | meta[strings.TrimSpace(key)] = unquote(value) | |
| 159 | } | |
| 160 | flush() | |
| 161 | return meta | |
| 162 | } | |
| 163 | ||
| 164 | // unquote strips one pair of matching single or double quotes. | |
| 165 | func unquote(value string) string { | |
| 166 | if len(value) >= 2 { | |
| 167 | first, last := value[0], value[len(value)-1] | |
| 168 | if (first == '"' && last == '"') || (first == '\'' && last == '\'') { | |
| 169 | return value[1 : len(value)-1] | |
| 170 | } | |
| 171 | } | |
| 172 | return value | |
| 173 | } | |
| 174 | ||
| 175 | func (s *Service) listSkills(w http.ResponseWriter, _ *http.Request) { | |
| 176 | skills := s.Discover() | |
| 177 | w.Header().Set("Content-Type", "application/json") | |
| 178 | w.WriteHeader(http.StatusOK) | |
| 179 | _ = json.NewEncoder(w).Encode(map[string]any{"skills": skills}) | |
| 180 | } |