nub/internal/skill/skill.go
Tom a97013d876 initial commit
- v 0.1.0 siehe CHANGELOG.md
2026-07-25 11:08:01 +02:00

114 lines
3.1 KiB
Go

// Package skill implementiert Skill-Discovery mit Progressive Disclosure
// (5.5): Beim Start werden alle Skills gefunden, aber nur Name+Description
// in den Prompt geschrieben. Der Body kommt erst über das read_skill-Tool.
package skill
import (
"fmt"
"os"
"path/filepath"
"strings"
)
type Skill struct {
Name string
Description string
Dir string // Verzeichnis des Skills (für weitere Dateien, die das Modell per read findet)
Path string // absoluter Pfad zu SKILL.md
Body string // Inhalt nach dem Frontmatter
}
// Discover sammelt Skills aus mehreren Verzeichnissen (4.7: skills.paths).
// Jedes Unterverzeichnis mit einer SKILL.md ist ein Skill. Spätere Pfade
// gewinnen bei Namenskollisionen (projekt-lokal überschreibt global) —
// dasselbe Präzedenzprinzip wie bei der Config (4.7).
func Discover(paths []string) ([]Skill, error) {
byName := make(map[string]Skill)
var order []string
for _, root := range paths {
root = expandHome(root)
entries, err := os.ReadDir(root)
if err != nil {
continue // Pfad fehlt -> überspringen, kein Fehler
}
for _, e := range entries {
if !e.IsDir() {
continue
}
skillPath := filepath.Join(root, e.Name(), "SKILL.md")
data, err := os.ReadFile(skillPath)
if err != nil {
continue
}
meta, body, err := parseFrontmatter(string(data))
if err != nil {
return nil, fmt.Errorf("skill %s: %w", skillPath, err)
}
name := meta["name"]
if name == "" {
name = e.Name()
}
if _, seen := byName[name]; !seen {
order = append(order, name)
}
byName[name] = Skill{
Name: name,
Description: meta["description"],
Dir: filepath.Join(root, e.Name()),
Path: skillPath,
Body: body,
}
}
}
out := make([]Skill, 0, len(order))
for _, name := range order {
out = append(out, byName[name])
}
return out, nil
}
// parseFrontmatter liest den Bereich zwischen den ersten beiden `---`-Zeilen
// als flache key: value-Paare. Kein echter YAML-Parser — für das dokumentierte
// Format (zwei flache String-Felder) reicht das, und es spart eine
// Dependency, die nicht in der Allowlist steht (2.3).
func parseFrontmatter(content string) (map[string]string, string, error) {
lines := strings.Split(content, "\n")
if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" {
return nil, "", fmt.Errorf("missing frontmatter (expected leading ---)")
}
meta := make(map[string]string)
i := 1
for ; i < len(lines); i++ {
line := lines[i]
if strings.TrimSpace(line) == "---" {
break
}
key, val, ok := strings.Cut(line, ":")
if !ok {
continue
}
key = strings.TrimSpace(key)
val = strings.Trim(strings.TrimSpace(val), `"'`)
meta[key] = val
}
if i >= len(lines) {
return nil, "", fmt.Errorf("unterminated frontmatter (missing closing ---)")
}
body := strings.TrimLeft(strings.Join(lines[i+1:], "\n"), "\n")
return meta, body, nil
}
func expandHome(path string) string {
if !strings.HasPrefix(path, "~") {
return path
}
home, err := os.UserHomeDir()
if err != nil {
return path
}
return filepath.Join(home, strings.TrimPrefix(path, "~"))
}