add skills & prompts system with auto-discovery and prompt composition (Plan 08)

This commit is contained in:
Ed Zynda
2026-02-27 12:48:19 +03:00
parent 744642f2ee
commit 6c069907dd
8 changed files with 1135 additions and 0 deletions
+73
View File
@@ -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()
}
+113
View File
@@ -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")
}
}
+235
View File
@@ -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: <cwd>/.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")
}
+344
View File
@@ -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")
}
}
+88
View File
@@ -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
}
+127
View File
@@ -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")
}
}
+86
View File
@@ -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
// ---------------------------------------------------------------------------
+69
View File
@@ -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: <cwd>/.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)
}