From 106694e36a42a5e03504e34d215bc18979a1781d Mon Sep 17 00:00:00 2001 From: vikingowl Date: Tue, 7 Apr 2026 02:05:49 +0200 Subject: [PATCH] feat(skill): core Skill type and YAML frontmatter parser --- go.mod | 1 + go.sum | 3 + internal/skill/skill.go | 80 +++++++++++++++++++++ internal/skill/skill_test.go | 132 +++++++++++++++++++++++++++++++++++ 4 files changed, 216 insertions(+) create mode 100644 internal/skill/skill.go create mode 100644 internal/skill/skill_test.go diff --git a/go.mod b/go.mod index 144806f..6acf5ed 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/pkoukk/tiktoken-go v0.1.8 golang.org/x/text v0.35.0 google.golang.org/genai v1.52.1 + gopkg.in/yaml.v3 v3.0.1 mvdan.cc/sh/v3 v3.13.0 ) diff --git a/go.sum b/go.sum index 776ceb0..56a6112 100644 --- a/go.sum +++ b/go.sum @@ -170,6 +170,9 @@ google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/skill/skill.go b/internal/skill/skill.go new file mode 100644 index 0000000..ac99f73 --- /dev/null +++ b/internal/skill/skill.go @@ -0,0 +1,80 @@ +package skill + +import ( + "bytes" + "fmt" + "regexp" + + "gopkg.in/yaml.v3" +) + +// namePattern validates skill names: lowercase letters, digits, hyphens, underscores. +// Must start with a letter. No spaces, slashes, or uppercase. +var namePattern = regexp.MustCompile(`^[a-z][a-z0-9_-]*$`) + +// Frontmatter holds the YAML metadata from a skill file's front matter block. +// allowedTools and paths are parsed but not enforced until M8.3. +type Frontmatter struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + WhenToUse string `yaml:"whenToUse"` + AllowedTools []string `yaml:"allowedTools"` // TODO(M8.3): enforce tool restrictions + Paths []string `yaml:"paths"` // TODO(M8.3): enforce path restrictions +} + +// Skill is a parsed skill definition ready for invocation. +type Skill struct { + Frontmatter Frontmatter + Body string // markdown template body, may contain {{.Args}} etc. + Source string // "bundled", "user", "project" + FilePath string // original file path; empty for bundled skills +} + +// Parse reads a markdown file with YAML front matter delimited by `---` lines. +// The file must start with `---\n`. Only the first two `---` delimiters are +// treated as front matter boundaries; subsequent `---` in the body are untouched. +func Parse(data []byte, source string) (*Skill, error) { + // File must start with the opening delimiter. + if !bytes.HasPrefix(data, []byte("---\n")) { + return nil, fmt.Errorf("skill: missing YAML front matter (file must start with ---)") + } + + // Find the closing `---` delimiter. We look for `\n---\n` (or `\n---` at EOF) + // after the opening `---\n`, so we skip the first 4 bytes. + rest := data[4:] + sep := []byte("\n---\n") + idx := bytes.Index(rest, sep) + var yamlBytes, bodyBytes []byte + if idx >= 0 { + yamlBytes = rest[:idx] + bodyBytes = rest[idx+len(sep):] + } else { + // Try `\n---` at end of file (no trailing newline after closing delimiter). + sep2 := []byte("\n---") + idx2 := bytes.LastIndex(rest, sep2) + if idx2 >= 0 && idx2 == len(rest)-len(sep2) { + yamlBytes = rest[:idx2] + bodyBytes = nil + } else { + return nil, fmt.Errorf("skill: YAML front matter not closed (missing closing ---)") + } + } + + var fm Frontmatter + if err := yaml.Unmarshal(yamlBytes, &fm); err != nil { + return nil, fmt.Errorf("skill: invalid YAML front matter: %w", err) + } + + if fm.Name == "" { + return nil, fmt.Errorf("skill: front matter missing required field: name") + } + if !namePattern.MatchString(fm.Name) { + return nil, fmt.Errorf("skill: invalid name %q (must match ^[a-z][a-z0-9_-]*$)", fm.Name) + } + + return &Skill{ + Frontmatter: fm, + Body: string(bodyBytes), + Source: source, + }, nil +} diff --git a/internal/skill/skill_test.go b/internal/skill/skill_test.go new file mode 100644 index 0000000..df6053d --- /dev/null +++ b/internal/skill/skill_test.go @@ -0,0 +1,132 @@ +package skill + +import ( + "testing" +) + +func TestParse_FullFrontmatter(t *testing.T) { + data := []byte(`--- +name: my-skill +description: Does something useful +whenToUse: When you need to do something +allowedTools: + - bash + - fs.read +paths: + - ./internal +--- +This is the skill body. +`) + s, err := Parse(data, "test") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if s.Frontmatter.Name != "my-skill" { + t.Errorf("name = %q, want %q", s.Frontmatter.Name, "my-skill") + } + if s.Frontmatter.Description != "Does something useful" { + t.Errorf("description = %q", s.Frontmatter.Description) + } + if len(s.Frontmatter.AllowedTools) != 2 { + t.Errorf("allowedTools len = %d, want 2", len(s.Frontmatter.AllowedTools)) + } + if len(s.Frontmatter.Paths) != 1 { + t.Errorf("paths len = %d, want 1", len(s.Frontmatter.Paths)) + } + if s.Source != "test" { + t.Errorf("source = %q, want %q", s.Source, "test") + } + want := "This is the skill body.\n" + if s.Body != want { + t.Errorf("body = %q, want %q", s.Body, want) + } +} + +func TestParse_MinimalFrontmatter(t *testing.T) { + data := []byte("---\nname: simple\n---\ndo the thing\n") + s, err := Parse(data, "bundled") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if s.Frontmatter.Name != "simple" { + t.Errorf("name = %q", s.Frontmatter.Name) + } + if s.Body != "do the thing\n" { + t.Errorf("body = %q", s.Body) + } +} + +func TestParse_EmptyBody(t *testing.T) { + data := []byte("---\nname: empty-body\n---\n") + s, err := Parse(data, "user") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if s.Body != "" { + t.Errorf("body = %q, want empty", s.Body) + } +} + +func TestParse_MissingName(t *testing.T) { + data := []byte("---\ndescription: no name here\n---\nbody\n") + _, err := Parse(data, "test") + if err == nil { + t.Error("expected error for missing name") + } +} + +func TestParse_NoFrontmatter(t *testing.T) { + data := []byte("Just a plain markdown file with no frontmatter.\n") + _, err := Parse(data, "test") + if err == nil { + t.Error("expected error for missing frontmatter") + } +} + +func TestParse_InvalidYAML(t *testing.T) { + data := []byte("---\nname: [unclosed bracket\n---\nbody\n") + _, err := Parse(data, "test") + if err == nil { + t.Error("expected error for invalid YAML") + } +} + +func TestParse_InvalidName_Uppercase(t *testing.T) { + data := []byte("---\nname: MySkill\n---\nbody\n") + _, err := Parse(data, "test") + if err == nil { + t.Error("expected error for uppercase name") + } +} + +func TestParse_InvalidName_Spaces(t *testing.T) { + data := []byte("---\nname: my skill\n---\nbody\n") + _, err := Parse(data, "test") + if err == nil { + t.Error("expected error for name with spaces") + } +} + +func TestParse_InvalidName_Slash(t *testing.T) { + data := []byte("---\nname: my/skill\n---\nbody\n") + _, err := Parse(data, "test") + if err == nil { + t.Error("expected error for name with slash") + } +} + +func TestParse_DashesInMarkdownBody(t *testing.T) { + // --- appearing inside the body must NOT be treated as a second delimiter + data := []byte("---\nname: with-dashes\n---\nBody text.\n\n---\n\nMore text after a horizontal rule.\n") + s, err := Parse(data, "test") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if s.Frontmatter.Name != "with-dashes" { + t.Errorf("name = %q", s.Frontmatter.Name) + } + // Body should include the horizontal rule and text after it + if s.Body == "" { + t.Error("body should not be empty") + } +}