From 327e4d74c0ab07cadc4f1c588dc998d030876539 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Tue, 7 Apr 2026 02:15:51 +0200 Subject: [PATCH] feat(skill): template rendering with Go text/template --- internal/skill/template.go | 43 +++++++++++++++++ internal/skill/template_test.go | 86 +++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 internal/skill/template.go create mode 100644 internal/skill/template_test.go diff --git a/internal/skill/template.go b/internal/skill/template.go new file mode 100644 index 0000000..af776d3 --- /dev/null +++ b/internal/skill/template.go @@ -0,0 +1,43 @@ +package skill + +import ( + "bytes" + "fmt" + "strings" + "text/template" +) + +// TemplateData holds the variables available in skill body templates. +type TemplateData struct { + Args string // raw user arguments after the skill name + Cwd string // current working directory + ProjectRoot string // detected project root +} + +// Render executes the skill body as a Go text/template with data. +// If the body contains no template directives and Args is non-empty, +// args are appended after the body with a blank line separator. +func (s *Skill) Render(data TemplateData) (string, error) { + t, err := template.New(s.Frontmatter.Name).Parse(s.Body) + if err != nil { + return "", fmt.Errorf("skill %q: template parse error: %w", s.Frontmatter.Name, err) + } + + var buf bytes.Buffer + if err := t.Execute(&buf, data); err != nil { + return "", fmt.Errorf("skill %q: template execute error: %w", s.Frontmatter.Name, err) + } + + rendered := buf.String() + + // If the body contained no template directives, the rendered output equals + // the original body. In that case, append args (if any) after a blank line. + if !strings.Contains(s.Body, "{{") && data.Args != "" { + if rendered == "" { + return data.Args, nil + } + return rendered + "\n" + data.Args, nil + } + + return rendered, nil +} diff --git a/internal/skill/template_test.go b/internal/skill/template_test.go new file mode 100644 index 0000000..b32a70c --- /dev/null +++ b/internal/skill/template_test.go @@ -0,0 +1,86 @@ +package skill + +import ( + "strings" + "testing" +) + +func skillWithBody(body string) *Skill { + return &Skill{ + Frontmatter: Frontmatter{Name: "test"}, + Body: body, + } +} + +func TestRender_ArgsSubstituted(t *testing.T) { + s := skillWithBody("Please do: {{.Args}}") + out, err := s.Render(TemplateData{Args: "fix the tests"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out != "Please do: fix the tests" { + t.Errorf("output = %q", out) + } +} + +func TestRender_AllVariables(t *testing.T) { + s := skillWithBody("Cwd={{.Cwd}} Root={{.ProjectRoot}} Args={{.Args}}") + out, err := s.Render(TemplateData{Args: "a", Cwd: "/tmp", ProjectRoot: "/proj"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out != "Cwd=/tmp Root=/proj Args=a" { + t.Errorf("output = %q", out) + } +} + +func TestRender_NoDirectives_ArgsAppended(t *testing.T) { + s := skillWithBody("Refactor all the things.\n") + out, err := s.Render(TemplateData{Args: "focus on error handling"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(out, "Refactor all the things.") { + t.Errorf("body missing from output: %q", out) + } + if !strings.Contains(out, "focus on error handling") { + t.Errorf("args missing from output: %q", out) + } + // args must follow body with blank line separator + if !strings.Contains(out, "Refactor all the things.\n\n\nfocus on error handling") && + !strings.Contains(out, "Refactor all the things.\n\nfocus on error handling") { + t.Errorf("unexpected separator in output: %q", out) + } +} + +func TestRender_NoDirectives_NoArgs(t *testing.T) { + body := "Just the body.\n" + s := skillWithBody(body) + out, err := s.Render(TemplateData{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out != body { + t.Errorf("output = %q, want %q", out, body) + } +} + +func TestRender_InvalidTemplate(t *testing.T) { + s := skillWithBody("{{.Unclosed") + _, err := s.Render(TemplateData{}) + if err == nil { + t.Error("expected error for invalid template syntax") + } +} + +func TestRender_EmptyBody_WithArgs(t *testing.T) { + s := skillWithBody("") + out, err := s.Render(TemplateData{Args: "do something"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Empty body + args → just the args + if out != "do something" { + t.Errorf("output = %q, want %q", out, "do something") + } +}