nandi/oripublic Fork 0
00224b167266e7d496672adfe1e60fcffeab7e63
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
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
// 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 <cwd>/.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 <dir>/*/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})
}