nandi/oripublic Fork 0
b6cd929ab1425a4ef6073ea9c189917cca02cf3c
Commits
Clone
git clone https://git.rickub.com/nandi/ori.git
git clone ssh://git@rickub.com/nandi/ori.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

forked from bots-garden/ori

skills.go · 180 lines · 5.0 KBGo Blame HistoryRaw
✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme 76d62ac k33g yesterday1// 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.
8package skills
9
10import (
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.
21type Source string
22
23const (
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.
31type 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.
44type 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// }
58func 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.
63func (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.
71func (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.
92func 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).
123func 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.
165func 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
175func (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}