// 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 }