Files
enkly-ai/pkg/skillkit/skillkit.go
enkly 79ce8dc4c0
Some checks failed
Go CI Pipeline / build_and_test (push) Failing after 22m0s
Init Project
2026-10-30 18:06:55 +01:00

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
}