114 lines
4.0 KiB
Go
114 lines
4.0 KiB
Go
// Package skillkit implements, for enkly's local agent, the same
|
|
// progressive-disclosure "skill" pattern Claude Code uses for its
|
|
// own document-editing skills: short, versioned markdown files that
|
|
// document how to use a tool family well — conventions, best
|
|
// practices, and honestly-stated limitations — kept out of the tool
|
|
// schema itself (which the model sees on every single request) and
|
|
// instead loaded on demand, once, the first time the model is about
|
|
// to actually use that tool family.
|
|
//
|
|
// Today that's the three office document skills (docx, xlsx, pptx),
|
|
// exposed to the model through the read_office_skill tool (see
|
|
// toolexec/office_skill.go and toolcatalog/catalog.go). The skill
|
|
// files themselves live in skills/*.md as plain, human-editable
|
|
// markdown — updating a skill's guidance is a markdown edit, not a Go
|
|
// code change, the same way updating a Claude Code skill is.
|
|
package skillkit
|
|
|
|
import (
|
|
"embed"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
//go:embed skills/*.md
|
|
var skillFS embed.FS
|
|
|
|
// Skill is one loaded SKILL.md-equivalent file.
|
|
type Skill struct {
|
|
// Name is the catalog key the model passes to read_office_skill —
|
|
// the file's basename without ".md" (e.g. "docx").
|
|
Name string
|
|
// Description is the one-line summary (the "> ..." blockquote
|
|
// line right after the file's title) shown in the
|
|
// read_office_skill tool description, so the model can pick the
|
|
// right skill without having read any of them yet — mirroring how
|
|
// Claude Code lists available skills by their one-line
|
|
// descriptions before reading a full SKILL.md.
|
|
Description string
|
|
// Body is the file's full content, returned verbatim to the model
|
|
// when it reads this skill.
|
|
Body string
|
|
}
|
|
|
|
var registry = mustLoad()
|
|
|
|
func mustLoad() map[string]Skill {
|
|
entries, err := skillFS.ReadDir("skills")
|
|
if err != nil {
|
|
// Embedded at build time; a failure here means the package
|
|
// itself is broken, not a runtime condition callers can
|
|
// recover from.
|
|
panic(fmt.Sprintf("skillkit: embedded skills directory unreadable: %v", err))
|
|
}
|
|
reg := make(map[string]Skill, len(entries))
|
|
for _, entry := range entries {
|
|
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
|
|
continue
|
|
}
|
|
raw, err := skillFS.ReadFile("skills/" + entry.Name())
|
|
if err != nil {
|
|
panic(fmt.Sprintf("skillkit: reading embedded skills/%s: %v", entry.Name(), err))
|
|
}
|
|
name := strings.TrimSuffix(entry.Name(), ".md")
|
|
reg[name] = Skill{Name: name, Description: parseDescription(string(raw)), Body: string(raw)}
|
|
}
|
|
return reg
|
|
}
|
|
|
|
// parseDescription pulls the one-line "> ..." blockquote that follows
|
|
// each skill file's title — see skills/*.md for the convention.
|
|
// Falls back to the empty string if a file doesn't follow it, rather
|
|
// than failing the whole package load over one malformed doc comment.
|
|
func parseDescription(body string) string {
|
|
for _, line := range strings.Split(body, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if strings.HasPrefix(line, "> ") {
|
|
return strings.TrimPrefix(line, "> ")
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// List returns every available skill (name + description, no body),
|
|
// sorted by name, for building the read_office_skill tool's
|
|
// description and for any other "what skills exist" listing.
|
|
func List() []Skill {
|
|
out := make([]Skill, 0, len(registry))
|
|
for _, s := range registry {
|
|
out = append(out, Skill{Name: s.Name, Description: s.Description})
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
|
return out
|
|
}
|
|
|
|
// Read returns one skill's full content by name, or ok=false if no
|
|
// such skill exists.
|
|
func Read(name string) (Skill, bool) {
|
|
s, ok := registry[strings.ToLower(strings.TrimSpace(name))]
|
|
return s, ok
|
|
}
|
|
|
|
// Names returns every valid skill name, sorted — used to build the
|
|
// read_office_skill tool's JSON-schema "enum" so the model can only
|
|
// ever request a skill that actually exists.
|
|
func Names() []string {
|
|
names := make([]string, 0, len(registry))
|
|
for name := range registry {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
return names
|
|
}
|