From 6c069907dd1edeca95e170015612698ddddacfa4 Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Fri, 27 Feb 2026 12:48:19 +0300 Subject: [PATCH] add skills & prompts system with auto-discovery and prompt composition (Plan 08) --- internal/skills/prompt_builder.go | 73 ++++++ internal/skills/prompt_builder_test.go | 113 ++++++++ internal/skills/skills.go | 235 +++++++++++++++++ internal/skills/skills_test.go | 344 +++++++++++++++++++++++++ internal/skills/templates.go | 88 +++++++ internal/skills/templates_test.go | 127 +++++++++ pkg/kit/kit.go | 86 +++++++ pkg/kit/skills.go | 69 +++++ 8 files changed, 1135 insertions(+) create mode 100644 internal/skills/prompt_builder.go create mode 100644 internal/skills/prompt_builder_test.go create mode 100644 internal/skills/skills.go create mode 100644 internal/skills/skills_test.go create mode 100644 internal/skills/templates.go create mode 100644 internal/skills/templates_test.go create mode 100644 pkg/kit/skills.go diff --git a/internal/skills/prompt_builder.go b/internal/skills/prompt_builder.go new file mode 100644 index 00000000..988f05b2 --- /dev/null +++ b/internal/skills/prompt_builder.go @@ -0,0 +1,73 @@ +package skills + +import ( + "bytes" + "fmt" +) + +// section is a named block of text that will be included in the final +// system prompt. +type section struct { + name string + content string +} + +// PromptBuilder composes a system prompt from a base prompt, skills, and +// arbitrary named sections. +type PromptBuilder struct { + basePrompt string + sections []section +} + +// NewPromptBuilder creates a PromptBuilder with the given base system prompt. +// The base prompt is always emitted first. +func NewPromptBuilder(basePrompt string) *PromptBuilder { + return &PromptBuilder{basePrompt: basePrompt} +} + +// WithSkills appends a formatted skills section. If skills is empty, no +// section is added. Returns the builder for chaining. +func (pb *PromptBuilder) WithSkills(skills []*Skill) *PromptBuilder { + formatted := FormatForPrompt(skills) + if formatted != "" { + pb.sections = append(pb.sections, section{ + name: "Skills", + content: formatted, + }) + } + return pb +} + +// WithSection appends a named section. Duplicate names are allowed (both +// will appear). Returns the builder for chaining. +func (pb *PromptBuilder) WithSection(name, content string) *PromptBuilder { + if content != "" { + pb.sections = append(pb.sections, section{ + name: name, + content: content, + }) + } + return pb +} + +// Build assembles the final system prompt. The base prompt comes first, +// followed by each section separated by double newlines. +func (pb *PromptBuilder) Build() string { + var buf bytes.Buffer + + if pb.basePrompt != "" { + buf.WriteString(pb.basePrompt) + } + + for _, s := range pb.sections { + if buf.Len() > 0 { + buf.WriteString("\n\n") + } + if s.name != "" { + buf.WriteString(fmt.Sprintf("# %s\n\n", s.name)) + } + buf.WriteString(s.content) + } + + return buf.String() +} diff --git a/internal/skills/prompt_builder_test.go b/internal/skills/prompt_builder_test.go new file mode 100644 index 00000000..e3372294 --- /dev/null +++ b/internal/skills/prompt_builder_test.go @@ -0,0 +1,113 @@ +package skills + +import ( + "strings" + "testing" +) + +func TestPromptBuilder_BaseOnly(t *testing.T) { + result := NewPromptBuilder("You are a helpful assistant.").Build() + if result != "You are a helpful assistant." { + t.Errorf("Build = %q, want base prompt only", result) + } +} + +func TestPromptBuilder_EmptyBase(t *testing.T) { + result := NewPromptBuilder("").Build() + if result != "" { + t.Errorf("Build = %q, want empty string", result) + } +} + +func TestPromptBuilder_WithSection(t *testing.T) { + result := NewPromptBuilder("Base prompt."). + WithSection("Context", "Some context here."). + Build() + if !strings.Contains(result, "Base prompt.") { + t.Error("missing base prompt") + } + if !strings.Contains(result, "# Context") { + t.Error("missing section header") + } + if !strings.Contains(result, "Some context here.") { + t.Error("missing section content") + } +} + +func TestPromptBuilder_WithSkills(t *testing.T) { + skills := []*Skill{ + {Name: "coding", Description: "Write code", Content: "Use TDD."}, + } + result := NewPromptBuilder("You are an agent."). + WithSkills(skills). + Build() + if !strings.Contains(result, "You are an agent.") { + t.Error("missing base prompt") + } + if !strings.Contains(result, "## coding") { + t.Error("missing skill header") + } + if !strings.Contains(result, "Use TDD.") { + t.Error("missing skill content") + } +} + +func TestPromptBuilder_WithSkills_Empty(t *testing.T) { + result := NewPromptBuilder("Base."). + WithSkills(nil). + Build() + // No skills section should be added. + if strings.Contains(result, "Skills") { + t.Error("should not add skills section when skills are empty") + } + if result != "Base." { + t.Errorf("Build = %q, want just base", result) + } +} + +func TestPromptBuilder_MultipleSections(t *testing.T) { + result := NewPromptBuilder("Base."). + WithSection("Rules", "Follow rules."). + WithSection("Examples", "Example 1."). + Build() + if !strings.Contains(result, "# Rules") { + t.Error("missing Rules section") + } + if !strings.Contains(result, "# Examples") { + t.Error("missing Examples section") + } +} + +func TestPromptBuilder_EmptySectionSkipped(t *testing.T) { + result := NewPromptBuilder("Base."). + WithSection("Empty", ""). + WithSection("Present", "Content."). + Build() + if strings.Contains(result, "# Empty") { + t.Error("empty section should be skipped") + } + if !strings.Contains(result, "# Present") { + t.Error("non-empty section should be present") + } +} + +func TestPromptBuilder_Chaining(t *testing.T) { + // Verify that methods return the builder for chaining. + pb := NewPromptBuilder("Base.") + result := pb.WithSection("A", "a").WithSection("B", "b").Build() + if !strings.Contains(result, "# A") || !strings.Contains(result, "# B") { + t.Error("chaining should work") + } +} + +func TestPromptBuilder_EmptyBaseWithSection(t *testing.T) { + result := NewPromptBuilder(""). + WithSection("Only", "Just a section."). + Build() + if !strings.Contains(result, "# Only") { + t.Error("section should be present even with empty base") + } + if !strings.Contains(result, "Just a section.") { + t.Error("section content should be present") + } +} diff --git a/internal/skills/skills.go b/internal/skills/skills.go new file mode 100644 index 00000000..6669b988 --- /dev/null +++ b/internal/skills/skills.go @@ -0,0 +1,235 @@ +// Package skills provides skill loading, parsing, and system prompt composition. +// +// Skills are markdown instruction files with optional YAML frontmatter that +// provide domain-specific context, instructions, and workflows to the agent. +// They follow a hierarchical discovery pattern similar to extensions: +// +// ~/.config/kit/skills/ global skills directory +// .kit/skills/ project-local skills directory +// +// Skills can be single .md/.txt files or subdirectories containing a SKILL.md file. +package skills + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +// Skill represents a markdown-based instruction file that provides +// domain-specific context and workflows to the agent. +type Skill struct { + // Name is the human-readable identifier for this skill. + Name string `yaml:"name" json:"name"` + // Description summarises what this skill provides. + Description string `yaml:"description" json:"description"` + // Content is the full markdown body (after frontmatter). + Content string `yaml:"-" json:"content"` + // Path is the absolute filesystem path the skill was loaded from. + Path string `yaml:"-" json:"path"` + // Tags are optional labels for categorisation. + Tags []string `yaml:"tags,omitempty" json:"tags,omitempty"` + // When controls automatic inclusion: "always", "on-demand", or a + // file-glob like "file:*.go". Empty defaults to "on-demand". + When string `yaml:"when,omitempty" json:"when,omitempty"` +} + +// frontmatterSep is the YAML frontmatter delimiter. +const frontmatterSep = "---" + +// LoadSkill reads a single skill file (markdown with optional YAML +// frontmatter). If no frontmatter is present the skill name is derived +// from the filename. +func LoadSkill(path string) (*Skill, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading skill %s: %w", path, err) + } + + abs, err := filepath.Abs(path) + if err != nil { + abs = path + } + + skill := &Skill{Path: abs} + + content := string(data) + + // Try to parse YAML frontmatter (--- ... ---). + if strings.HasPrefix(strings.TrimSpace(content), frontmatterSep) { + trimmed := strings.TrimSpace(content) + // Find the closing separator (skip the opening one). + rest := trimmed[len(frontmatterSep):] + frontmatter, body, found := strings.Cut(rest, "\n"+frontmatterSep) + if found { + // Strip an optional trailing newline right after the closing ---. + body = strings.TrimPrefix(body, "\n") + + if err := yaml.Unmarshal([]byte(frontmatter), skill); err != nil { + return nil, fmt.Errorf("parsing frontmatter in %s: %w", path, err) + } + skill.Content = strings.TrimSpace(body) + } else { + // Opening --- but no closing --- — treat entire file as content. + skill.Content = strings.TrimSpace(content) + } + } else { + skill.Content = strings.TrimSpace(content) + } + + // Fallback: derive name from filename if frontmatter didn't set one. + if skill.Name == "" { + base := filepath.Base(path) + ext := filepath.Ext(base) + skill.Name = strings.TrimSuffix(base, ext) + // Convert SKILL → directory name for SKILL.md files. + if strings.EqualFold(skill.Name, "SKILL") || strings.EqualFold(skill.Name, "skill") { + skill.Name = filepath.Base(filepath.Dir(path)) + } + } + + return skill, nil +} + +// LoadSkillsFromDir loads all skills from a single directory. It looks for: +// - *.md and *.txt files directly in dir +// - SKILL.md (case-insensitive) in immediate subdirectories +// +// Files that fail to parse are skipped with a warning logged via the +// returned error list. +func LoadSkillsFromDir(dir string) ([]*Skill, error) { + info, err := os.Stat(dir) + if err != nil || !info.IsDir() { + return nil, nil // directory doesn't exist — not an error + } + + entries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("reading skills directory %s: %w", dir, err) + } + + var skills []*Skill + var errs []string + + for _, entry := range entries { + full := filepath.Join(dir, entry.Name()) + + if !entry.IsDir() { + ext := strings.ToLower(filepath.Ext(entry.Name())) + if ext == ".md" || ext == ".txt" { + s, err := LoadSkill(full) + if err != nil { + errs = append(errs, err.Error()) + continue + } + skills = append(skills, s) + } + continue + } + + // Subdirectory: look for SKILL.md (case-insensitive). + subEntries, err := os.ReadDir(full) + if err != nil { + continue + } + for _, se := range subEntries { + if !se.IsDir() && strings.EqualFold(se.Name(), "SKILL.md") { + s, err := LoadSkill(filepath.Join(full, se.Name())) + if err != nil { + errs = append(errs, err.Error()) + continue + } + skills = append(skills, s) + break // only one SKILL.md per subdirectory + } + } + } + + if len(errs) > 0 { + return skills, fmt.Errorf("some skills failed to load: %s", strings.Join(errs, "; ")) + } + return skills, nil +} + +// LoadSkills auto-discovers skills from standard directories: +// 1. Global: $XDG_CONFIG_HOME/kit/skills/ (default ~/.config/kit/skills/) +// 2. Project-local: /.kit/skills/ +// +// Skills from project-local directories take precedence (appended last). +// cwd is the working directory for project-local discovery; if empty the +// current working directory is used. +func LoadSkills(cwd string) ([]*Skill, error) { + if cwd == "" { + cwd, _ = os.Getwd() + } + + seen := make(map[string]bool) + var all []*Skill + + addUnique := func(skills []*Skill) { + for _, s := range skills { + if !seen[s.Path] { + seen[s.Path] = true + all = append(all, s) + } + } + } + + // Global skills. + globalDir := globalSkillsDir() + if globalDir != "" { + global, _ := LoadSkillsFromDir(globalDir) + addUnique(global) + } + + // Project-local skills. + localDir := filepath.Join(cwd, ".kit", "skills") + local, _ := LoadSkillsFromDir(localDir) + addUnique(local) + + return all, nil +} + +// FormatForPrompt formats skills for inclusion in a system prompt. +// Each skill is rendered as a named section with its content. +func FormatForPrompt(skills []*Skill) string { + if len(skills) == 0 { + return "" + } + + var buf bytes.Buffer + buf.WriteString("# Available Skills\n\n") + + for i, s := range skills { + if i > 0 { + buf.WriteString("\n") + } + buf.WriteString(fmt.Sprintf("## %s\n", s.Name)) + if s.Description != "" { + buf.WriteString(fmt.Sprintf("%s\n", s.Description)) + } + buf.WriteString("\n") + buf.WriteString(s.Content) + buf.WriteString("\n") + } + + return buf.String() +} + +// globalSkillsDir returns the global skills directory, respecting +// $XDG_CONFIG_HOME. Defaults to ~/.config/kit/skills. +func globalSkillsDir() string { + base := os.Getenv("XDG_CONFIG_HOME") + if base == "" { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + base = filepath.Join(home, ".config") + } + return filepath.Join(base, "kit", "skills") +} diff --git a/internal/skills/skills_test.go b/internal/skills/skills_test.go new file mode 100644 index 00000000..55a28670 --- /dev/null +++ b/internal/skills/skills_test.go @@ -0,0 +1,344 @@ +package skills + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// --------------------------------------------------------------------------- +// LoadSkill +// --------------------------------------------------------------------------- + +func TestLoadSkill_WithFrontmatter(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.md") + content := `--- +name: my-skill +description: A test skill +tags: + - testing + - example +when: always +--- +# Hello + +This is the body.` + + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + s, err := LoadSkill(path) + if err != nil { + t.Fatal(err) + } + if s.Name != "my-skill" { + t.Errorf("Name = %q, want %q", s.Name, "my-skill") + } + if s.Description != "A test skill" { + t.Errorf("Description = %q, want %q", s.Description, "A test skill") + } + if len(s.Tags) != 2 || s.Tags[0] != "testing" || s.Tags[1] != "example" { + t.Errorf("Tags = %v, want [testing example]", s.Tags) + } + if s.When != "always" { + t.Errorf("When = %q, want %q", s.When, "always") + } + if !strings.Contains(s.Content, "# Hello") { + t.Errorf("Content should contain '# Hello', got %q", s.Content) + } + if !strings.Contains(s.Content, "This is the body.") { + t.Errorf("Content should contain body text, got %q", s.Content) + } + if s.Path == "" { + t.Error("Path should be set") + } +} + +func TestLoadSkill_WithoutFrontmatter(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "my-tool.md") + content := "# My Tool\n\nSome instructions." + + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + s, err := LoadSkill(path) + if err != nil { + t.Fatal(err) + } + if s.Name != "my-tool" { + t.Errorf("Name = %q, want %q (derived from filename)", s.Name, "my-tool") + } + if s.Content != "# My Tool\n\nSome instructions." { + t.Errorf("Content = %q, unexpected", s.Content) + } +} + +func TestLoadSkill_SKILLmd_DerivesNameFromDir(t *testing.T) { + dir := t.TempDir() + skillDir := filepath.Join(dir, "awesome-plugin") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatal(err) + } + path := filepath.Join(skillDir, "SKILL.md") + if err := os.WriteFile(path, []byte("Plugin instructions."), 0o644); err != nil { + t.Fatal(err) + } + + s, err := LoadSkill(path) + if err != nil { + t.Fatal(err) + } + if s.Name != "awesome-plugin" { + t.Errorf("Name = %q, want %q (derived from parent dir)", s.Name, "awesome-plugin") + } +} + +func TestLoadSkill_FrontmatterNameOverridesFilename(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "generic.md") + content := "---\nname: specific-name\n---\nBody." + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + s, err := LoadSkill(path) + if err != nil { + t.Fatal(err) + } + if s.Name != "specific-name" { + t.Errorf("Name = %q, want %q", s.Name, "specific-name") + } +} + +func TestLoadSkill_InvalidFrontmatter(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "bad.md") + content := "---\n: invalid yaml {{{\n---\nBody." + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + _, err := LoadSkill(path) + if err == nil { + t.Error("expected error for invalid frontmatter") + } +} + +func TestLoadSkill_OpeningSepNoClosing(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "partial.md") + content := "---\nsome text without closing separator" + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + s, err := LoadSkill(path) + if err != nil { + t.Fatal(err) + } + // Entire file becomes content. + if !strings.Contains(s.Content, "some text") { + t.Errorf("Content = %q, expected to contain the text", s.Content) + } +} + +func TestLoadSkill_NonexistentFile(t *testing.T) { + _, err := LoadSkill("/nonexistent/path.md") + if err == nil { + t.Error("expected error for nonexistent file") + } +} + +// --------------------------------------------------------------------------- +// LoadSkillsFromDir +// --------------------------------------------------------------------------- + +func TestLoadSkillsFromDir_Mixed(t *testing.T) { + dir := t.TempDir() + + // Direct .md file. + if err := os.WriteFile(filepath.Join(dir, "a.md"), []byte("Skill A"), 0o644); err != nil { + t.Fatal(err) + } + // Direct .txt file. + if err := os.WriteFile(filepath.Join(dir, "b.txt"), []byte("Skill B"), 0o644); err != nil { + t.Fatal(err) + } + // Non-skill file — should be ignored. + if err := os.WriteFile(filepath.Join(dir, "c.go"), []byte("not a skill"), 0o644); err != nil { + t.Fatal(err) + } + // Subdirectory with SKILL.md. + subDir := filepath.Join(dir, "sub-skill") + if err := os.MkdirAll(subDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(subDir, "SKILL.md"), []byte("Skill Sub"), 0o644); err != nil { + t.Fatal(err) + } + // Subdirectory without SKILL.md — should be ignored. + emptyDir := filepath.Join(dir, "empty-dir") + if err := os.MkdirAll(emptyDir, 0o755); err != nil { + t.Fatal(err) + } + + skills, err := LoadSkillsFromDir(dir) + if err != nil { + t.Fatal(err) + } + if len(skills) != 3 { + t.Fatalf("expected 3 skills, got %d", len(skills)) + } + + names := make(map[string]bool) + for _, s := range skills { + names[s.Name] = true + } + for _, want := range []string{"a", "b", "sub-skill"} { + if !names[want] { + t.Errorf("missing skill %q", want) + } + } +} + +func TestLoadSkillsFromDir_NonexistentDir(t *testing.T) { + skills, err := LoadSkillsFromDir("/nonexistent/dir") + if err != nil { + t.Fatal("should not error for missing directory") + } + if len(skills) != 0 { + t.Errorf("expected 0 skills, got %d", len(skills)) + } +} + +func TestLoadSkillsFromDir_CaseInsensitiveSKILLmd(t *testing.T) { + dir := t.TempDir() + subDir := filepath.Join(dir, "my-skill") + if err := os.MkdirAll(subDir, 0o755); err != nil { + t.Fatal(err) + } + // Lowercase skill.md should also be found. + if err := os.WriteFile(filepath.Join(subDir, "skill.md"), []byte("lowercase skill"), 0o644); err != nil { + t.Fatal(err) + } + + skills, err := LoadSkillsFromDir(dir) + if err != nil { + t.Fatal(err) + } + if len(skills) != 1 { + t.Fatalf("expected 1 skill, got %d", len(skills)) + } + if skills[0].Name != "my-skill" { + t.Errorf("Name = %q, want %q", skills[0].Name, "my-skill") + } +} + +// --------------------------------------------------------------------------- +// LoadSkills (auto-discovery) +// --------------------------------------------------------------------------- + +func TestLoadSkills_ProjectLocal(t *testing.T) { + dir := t.TempDir() + skillsDir := filepath.Join(dir, ".kit", "skills") + if err := os.MkdirAll(skillsDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(skillsDir, "local.md"), []byte("Local skill"), 0o644); err != nil { + t.Fatal(err) + } + + skills, err := LoadSkills(dir) + if err != nil { + t.Fatal(err) + } + if len(skills) != 1 { + t.Fatalf("expected 1 skill, got %d", len(skills)) + } + if skills[0].Name != "local" { + t.Errorf("Name = %q, want %q", skills[0].Name, "local") + } +} + +func TestLoadSkills_Deduplication(t *testing.T) { + dir := t.TempDir() + + // Set XDG_CONFIG_HOME to our temp dir so global and local overlap. + t.Setenv("XDG_CONFIG_HOME", dir) + + globalDir := filepath.Join(dir, "kit", "skills") + localDir := filepath.Join(dir, ".kit", "skills") + + if err := os.MkdirAll(globalDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(localDir, 0o755); err != nil { + t.Fatal(err) + } + + // Same content in both directories but different paths — both should load. + if err := os.WriteFile(filepath.Join(globalDir, "shared.md"), []byte("Global version"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(localDir, "shared.md"), []byte("Local version"), 0o644); err != nil { + t.Fatal(err) + } + + skills, err := LoadSkills(dir) + if err != nil { + t.Fatal(err) + } + // Different absolute paths = both loaded. + if len(skills) != 2 { + t.Fatalf("expected 2 skills (different paths), got %d", len(skills)) + } +} + +// --------------------------------------------------------------------------- +// FormatForPrompt +// --------------------------------------------------------------------------- + +func TestFormatForPrompt_Empty(t *testing.T) { + result := FormatForPrompt(nil) + if result != "" { + t.Errorf("expected empty string, got %q", result) + } +} + +func TestFormatForPrompt_SingleSkill(t *testing.T) { + skills := []*Skill{ + {Name: "test-skill", Description: "A test", Content: "Do things."}, + } + result := FormatForPrompt(skills) + if !strings.Contains(result, "## test-skill") { + t.Errorf("result should contain skill name header") + } + if !strings.Contains(result, "A test") { + t.Errorf("result should contain description") + } + if !strings.Contains(result, "Do things.") { + t.Errorf("result should contain content") + } +} + +func TestFormatForPrompt_MultipleSkills(t *testing.T) { + skills := []*Skill{ + {Name: "skill-a", Content: "A content"}, + {Name: "skill-b", Description: "B desc", Content: "B content"}, + } + result := FormatForPrompt(skills) + if !strings.Contains(result, "## skill-a") { + t.Error("missing skill-a header") + } + if !strings.Contains(result, "## skill-b") { + t.Error("missing skill-b header") + } + if !strings.Contains(result, "# Available Skills") { + t.Error("missing top-level header") + } +} diff --git a/internal/skills/templates.go b/internal/skills/templates.go new file mode 100644 index 00000000..7902d662 --- /dev/null +++ b/internal/skills/templates.go @@ -0,0 +1,88 @@ +package skills + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" +) + +// PromptTemplate is a named text template with {{variable}} placeholders. +type PromptTemplate struct { + // Name is the human-readable identifier for this template. + Name string + // Content is the raw template text with {{variable}} placeholders. + Content string + // Variables lists the placeholder names discovered in Content. + Variables []string +} + +// variableRe matches {{variable_name}} placeholders. +var variableRe = regexp.MustCompile(`\{\{(\w+)\}\}`) + +// NewPromptTemplate creates a PromptTemplate, automatically extracting +// variable names from {{...}} placeholders in content. +func NewPromptTemplate(name, content string) *PromptTemplate { + vars := extractVariables(content) + return &PromptTemplate{ + Name: name, + Content: content, + Variables: vars, + } +} + +// LoadPromptTemplate reads a template from a file. The template name is +// derived from the filename (without extension). +func LoadPromptTemplate(path string) (*PromptTemplate, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading template %s: %w", path, err) + } + + base := filepath.Base(path) + ext := filepath.Ext(base) + name := strings.TrimSuffix(base, ext) + + return NewPromptTemplate(name, string(data)), nil +} + +// Expand replaces all {{variable}} placeholders with values from the +// provided map. Missing variables are left as-is (no error). +func (t *PromptTemplate) Expand(values map[string]string) string { + result := t.Content + for k, v := range values { + result = strings.ReplaceAll(result, "{{"+k+"}}", v) + } + return result +} + +// ExpandStrict replaces all {{variable}} placeholders and returns an error +// if any variable in the template has no corresponding value. +func (t *PromptTemplate) ExpandStrict(values map[string]string) (string, error) { + var missing []string + for _, v := range t.Variables { + if _, ok := values[v]; !ok { + missing = append(missing, v) + } + } + if len(missing) > 0 { + return "", fmt.Errorf("missing template variables: %s", strings.Join(missing, ", ")) + } + return t.Expand(values), nil +} + +// extractVariables returns unique variable names from {{...}} placeholders. +func extractVariables(content string) []string { + matches := variableRe.FindAllStringSubmatch(content, -1) + seen := make(map[string]bool) + var vars []string + for _, m := range matches { + name := m[1] + if !seen[name] { + seen[name] = true + vars = append(vars, name) + } + } + return vars +} diff --git a/internal/skills/templates_test.go b/internal/skills/templates_test.go new file mode 100644 index 00000000..454a444a --- /dev/null +++ b/internal/skills/templates_test.go @@ -0,0 +1,127 @@ +package skills + +import ( + "os" + "path/filepath" + "testing" +) + +// --------------------------------------------------------------------------- +// NewPromptTemplate +// --------------------------------------------------------------------------- + +func TestNewPromptTemplate_ExtractsVariables(t *testing.T) { + tpl := NewPromptTemplate("test", "Hello {{name}}, you are {{role}}.") + if len(tpl.Variables) != 2 { + t.Fatalf("expected 2 variables, got %d", len(tpl.Variables)) + } + if tpl.Variables[0] != "name" || tpl.Variables[1] != "role" { + t.Errorf("Variables = %v, want [name role]", tpl.Variables) + } +} + +func TestNewPromptTemplate_DeduplicatesVariables(t *testing.T) { + tpl := NewPromptTemplate("test", "{{x}} and {{x}} and {{y}}") + if len(tpl.Variables) != 2 { + t.Fatalf("expected 2 unique variables, got %d", len(tpl.Variables)) + } +} + +func TestNewPromptTemplate_NoVariables(t *testing.T) { + tpl := NewPromptTemplate("plain", "No variables here.") + if len(tpl.Variables) != 0 { + t.Errorf("expected 0 variables, got %d", len(tpl.Variables)) + } +} + +// --------------------------------------------------------------------------- +// Expand +// --------------------------------------------------------------------------- + +func TestExpand_AllVariablesProvided(t *testing.T) { + tpl := NewPromptTemplate("test", "Hello {{name}}, welcome to {{place}}.") + result := tpl.Expand(map[string]string{ + "name": "Alice", + "place": "Wonderland", + }) + want := "Hello Alice, welcome to Wonderland." + if result != want { + t.Errorf("Expand = %q, want %q", result, want) + } +} + +func TestExpand_MissingVariable_LeftAsIs(t *testing.T) { + tpl := NewPromptTemplate("test", "Hello {{name}}, your {{role}}.") + result := tpl.Expand(map[string]string{ + "name": "Bob", + }) + want := "Hello Bob, your {{role}}." + if result != want { + t.Errorf("Expand = %q, want %q", result, want) + } +} + +func TestExpand_EmptyValues(t *testing.T) { + tpl := NewPromptTemplate("test", "Value: {{val}}") + result := tpl.Expand(map[string]string{}) + if result != "Value: {{val}}" { + t.Errorf("Expand = %q, want unchanged", result) + } +} + +// --------------------------------------------------------------------------- +// ExpandStrict +// --------------------------------------------------------------------------- + +func TestExpandStrict_AllProvided(t *testing.T) { + tpl := NewPromptTemplate("test", "{{greeting}} {{target}}") + result, err := tpl.ExpandStrict(map[string]string{ + "greeting": "Hi", + "target": "World", + }) + if err != nil { + t.Fatal(err) + } + if result != "Hi World" { + t.Errorf("ExpandStrict = %q, want %q", result, "Hi World") + } +} + +func TestExpandStrict_MissingVariable_Error(t *testing.T) { + tpl := NewPromptTemplate("test", "{{a}} {{b}} {{c}}") + _, err := tpl.ExpandStrict(map[string]string{"a": "1"}) + if err == nil { + t.Error("expected error for missing variables") + } +} + +// --------------------------------------------------------------------------- +// LoadPromptTemplate +// --------------------------------------------------------------------------- + +func TestLoadPromptTemplate(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "greeting.txt") + content := "Hello {{name}}, you work on {{project}}." + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + tpl, err := LoadPromptTemplate(path) + if err != nil { + t.Fatal(err) + } + if tpl.Name != "greeting" { + t.Errorf("Name = %q, want %q", tpl.Name, "greeting") + } + if len(tpl.Variables) != 2 { + t.Errorf("expected 2 variables, got %d", len(tpl.Variables)) + } +} + +func TestLoadPromptTemplate_NonexistentFile(t *testing.T) { + _, err := LoadPromptTemplate("/nonexistent/file.txt") + if err == nil { + t.Error("expected error for nonexistent file") + } +} diff --git a/pkg/kit/kit.go b/pkg/kit/kit.go index f224d6dc..6322053d 100644 --- a/pkg/kit/kit.go +++ b/pkg/kit/kit.go @@ -4,12 +4,14 @@ import ( "context" "fmt" "os" + "path/filepath" "charm.land/fantasy" "github.com/mark3labs/kit/internal/agent" "github.com/mark3labs/kit/internal/config" "github.com/mark3labs/kit/internal/session" + "github.com/mark3labs/kit/internal/skills" "github.com/spf13/viper" ) @@ -24,6 +26,7 @@ type Kit struct { events *eventBus autoCompact bool compactionOpts *CompactionOptions + skills []*skills.Skill } // Subscribe registers an EventListener that will be called for every lifecycle @@ -51,6 +54,10 @@ type Options struct { Continue bool // Continue the most recent session for SessionDir NoSession bool // Ephemeral mode — in-memory session, no persistence + // Skills + Skills []string // Explicit skill files/dirs to load (empty = auto-discover) + SkillsDir string // Override default project-local skills directory + // Compaction AutoCompact bool // Auto-compact when near context limit CompactionOptions *CompactionOptions // Config for auto-compaction (nil = defaults) @@ -120,6 +127,21 @@ func New(ctx context.Context, opts *Options) (*Kit, error) { } viper.Set("stream", opts.Streaming) + // Load skills — either from explicit paths or via auto-discovery. + loadedSkills, err := loadSkills(opts) + if err != nil { + return nil, fmt.Errorf("failed to load skills: %w", err) + } + + // Compose the system prompt with skills if any were loaded. + if len(loadedSkills) > 0 { + basePrompt := viper.GetString("system-prompt") + composed := skills.NewPromptBuilder(basePrompt). + WithSkills(loadedSkills). + Build() + viper.Set("system-prompt", composed) + } + // Load MCP configuration. mcpConfig, err := config.LoadAndValidateConfig() if err != nil { @@ -150,9 +172,73 @@ func New(ctx context.Context, opts *Options) (*Kit, error) { events: newEventBus(), autoCompact: opts.AutoCompact, compactionOpts: opts.CompactionOptions, + skills: loadedSkills, }, nil } +// GetSkills returns the skills loaded during initialisation. +func (m *Kit) GetSkills() []*Skill { + return m.skills +} + +// --------------------------------------------------------------------------- +// Skills loading +// --------------------------------------------------------------------------- + +// loadSkills loads skills based on Options. If explicit paths are provided +// they are loaded directly; otherwise auto-discovery runs. +func loadSkills(opts *Options) ([]*skills.Skill, error) { + if len(opts.Skills) > 0 { + return loadExplicitSkills(opts.Skills) + } + + // Auto-discover from standard directories. + cwd := opts.SkillsDir + if cwd == "" { + cwd = opts.SessionDir + } + return skills.LoadSkills(cwd) +} + +// loadExplicitSkills loads skills from a list of explicit paths. Each path +// can be a file or a directory. +func loadExplicitSkills(paths []string) ([]*skills.Skill, error) { + seen := make(map[string]bool) + var all []*skills.Skill + + for _, p := range paths { + info, err := os.Stat(p) + if err != nil { + return nil, fmt.Errorf("skill path %s: %w", p, err) + } + + if info.IsDir() { + dirSkills, err := skills.LoadSkillsFromDir(p) + if err != nil { + return nil, err + } + for _, s := range dirSkills { + if !seen[s.Path] { + seen[s.Path] = true + all = append(all, s) + } + } + } else { + abs, _ := filepath.Abs(p) + if !seen[abs] { + seen[abs] = true + s, err := skills.LoadSkill(p) + if err != nil { + return nil, err + } + all = append(all, s) + } + } + } + + return all, nil +} + // --------------------------------------------------------------------------- // Shared generation helpers // --------------------------------------------------------------------------- diff --git a/pkg/kit/skills.go b/pkg/kit/skills.go new file mode 100644 index 00000000..d2e2b814 --- /dev/null +++ b/pkg/kit/skills.go @@ -0,0 +1,69 @@ +package kit + +import "github.com/mark3labs/kit/internal/skills" + +// ==== Skills Types ==== + +// Skill represents a markdown-based instruction file with optional YAML +// frontmatter that provides domain-specific context and workflows. +type Skill = skills.Skill + +// PromptTemplate is a named text template with {{variable}} placeholders. +type PromptTemplate = skills.PromptTemplate + +// PromptBuilder composes a system prompt from a base prompt, skills, and +// arbitrary named sections. +type PromptBuilder = skills.PromptBuilder + +// ==== Skills Functions ==== + +// LoadSkill reads a single skill file (markdown with optional YAML frontmatter). +// If no frontmatter is present the skill name is derived from the filename. +func LoadSkill(path string) (*Skill, error) { + return skills.LoadSkill(path) +} + +// LoadSkillsFromDir loads all skills from a single directory. It finds *.md +// and *.txt files directly in the directory, and SKILL.md files in immediate +// subdirectories. +func LoadSkillsFromDir(dir string) ([]*Skill, error) { + return skills.LoadSkillsFromDir(dir) +} + +// LoadSkills auto-discovers skills from standard directories: +// - Global: $XDG_CONFIG_HOME/kit/skills/ (default ~/.config/kit/skills/) +// - Project-local: /.kit/skills/ +// +// cwd is the working directory for project-local discovery; if empty the +// current working directory is used. +func LoadSkills(cwd string) ([]*Skill, error) { + return skills.LoadSkills(cwd) +} + +// FormatSkillsForPrompt formats skills for inclusion in a system prompt. +// Each skill is rendered as a named section with its content. +func FormatSkillsForPrompt(s []*Skill) string { + return skills.FormatForPrompt(s) +} + +// ==== Prompt Template Functions ==== + +// NewPromptTemplate creates a PromptTemplate, automatically extracting +// variable names from {{...}} placeholders in content. +func NewPromptTemplate(name, content string) *PromptTemplate { + return skills.NewPromptTemplate(name, content) +} + +// LoadPromptTemplate reads a template from a file. The template name is +// derived from the filename (without extension). +func LoadPromptTemplate(path string) (*PromptTemplate, error) { + return skills.LoadPromptTemplate(path) +} + +// ==== Prompt Builder Functions ==== + +// NewPromptBuilder creates a PromptBuilder with the given base system prompt. +// The base prompt is always emitted first. +func NewPromptBuilder(basePrompt string) *PromptBuilder { + return skills.NewPromptBuilder(basePrompt) +}