mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-14 03:30:26 +00:00
add skills & prompts system with auto-discovery and prompt composition (Plan 08)
This commit is contained in:
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user