207 lines
7.1 KiB
Go
207 lines
7.1 KiB
Go
|
|
// Package skill handles the markdown representation of skills: parsing,
|
||
|
|
// loading, writing, and the structure shared with the agent runtime.
|
||
|
|
//
|
||
|
|
// A skill file is markdown with a YAML frontmatter block delimited by "---".
|
||
|
|
// The frontmatter carries machine-readable metadata (tools, permissions,
|
||
|
|
// endpoint mappings); the document body is the natural-language system
|
||
|
|
// prompt fed to the LLM. This dual form lets operators read and edit a skill
|
||
|
|
// like a normal doc while the runtime parses a strict contract from it.
|
||
|
|
package skill
|
||
|
|
|
||
|
|
import (
|
||
|
|
"fmt"
|
||
|
|
"os"
|
||
|
|
"path/filepath"
|
||
|
|
"strings"
|
||
|
|
|
||
|
|
"gopkg.in/yaml.v3"
|
||
|
|
)
|
||
|
|
|
||
|
|
// Skill is the in-memory representation of a skill markdown file.
|
||
|
|
type Skill struct {
|
||
|
|
Name string `yaml:"name" json:"name"`
|
||
|
|
Description string `yaml:"description" json:"description"`
|
||
|
|
Version int `yaml:"version" json:"version"`
|
||
|
|
AppSlug string `yaml:"app_slug" json:"app_slug"`
|
||
|
|
ModelConfig string `yaml:"model_config" json:"model_config,omitempty"`
|
||
|
|
Permissions Permissions `yaml:"permissions" json:"permissions"`
|
||
|
|
Tools []Tool `yaml:"tools" json:"tools"`
|
||
|
|
// SystemPrompt is the markdown body (everything after frontmatter). It
|
||
|
|
// is NOT stored in frontmatter; Write places it after the delimiter.
|
||
|
|
SystemPrompt string `yaml:"-" json:"system_prompt"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// Permissions governs what the agent may do with this skill's tools.
|
||
|
|
type Permissions struct {
|
||
|
|
// DefaultMode is the blanket policy: "readonly" (default) restricts
|
||
|
|
// tools to safe methods; "confirm" requires end-user confirmation for
|
||
|
|
// any tool not explicitly allowed.
|
||
|
|
DefaultMode string `yaml:"default_mode" json:"default_mode"`
|
||
|
|
// RequireConfirmation lists tool names that always need end-user
|
||
|
|
// confirmation before execution, regardless of HTTP method.
|
||
|
|
RequireConfirmation []string `yaml:"require_confirmation" json:"require_confirmation"`
|
||
|
|
// AllowWrite lists tool names explicitly permitted to use mutating HTTP
|
||
|
|
// methods without per-call confirmation. Empty by default.
|
||
|
|
AllowWrite []string `yaml:"allow_write" json:"allow_write"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// Tool is one callable capability exposed to the LLM. Parameters is a JSON
|
||
|
|
// Schema describing the arguments; Endpoint maps the tool to a concrete HTTP
|
||
|
|
// call against the target site.
|
||
|
|
type Tool struct {
|
||
|
|
Name string `yaml:"name" json:"name"`
|
||
|
|
Description string `yaml:"description" json:"description"`
|
||
|
|
Parameters jsonObject `yaml:"parameters" json:"parameters"` // JSON Schema object
|
||
|
|
Endpoint Endpoint `yaml:"endpoint" json:"endpoint"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// Endpoint maps a tool call to a concrete HTTP request. Path/Query/Header
|
||
|
|
// values may use {{param}} placeholders resolved from the tool arguments at
|
||
|
|
// runtime. Body is a JSON template (also {{param}}-substituted) for writes.
|
||
|
|
type Endpoint struct {
|
||
|
|
Method string `yaml:"method" json:"method"`
|
||
|
|
URL string `yaml:"url" json:"url"`
|
||
|
|
Path map[string]string `yaml:"path" json:"path,omitempty"`
|
||
|
|
Query map[string]string `yaml:"query" json:"query,omitempty"`
|
||
|
|
Header map[string]string `yaml:"header" json:"header,omitempty"`
|
||
|
|
Body string `yaml:"body" json:"body,omitempty"` // JSON template
|
||
|
|
}
|
||
|
|
|
||
|
|
// MakeParameters wraps a JSON Schema byte slice into the Parameters type used
|
||
|
|
// by Tool. Exported so callers in other packages (and tests) can build a Tool
|
||
|
|
// without referencing the unexported jsonObject type directly. Returns the
|
||
|
|
// same underlying bytes; the conversion is type-level.
|
||
|
|
func MakeParameters(jsonSchema []byte) jsonObject {
|
||
|
|
return jsonObject(jsonSchema)
|
||
|
|
}
|
||
|
|
|
||
|
|
const frontMatterDelim = "---"
|
||
|
|
|
||
|
|
// Load reads and parses a skill markdown file from path.
|
||
|
|
func Load(path string) (*Skill, error) {
|
||
|
|
raw, err := os.ReadFile(path)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("read skill %s: %w", path, err)
|
||
|
|
}
|
||
|
|
return Parse(raw)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Parse decodes a skill markdown document from raw bytes.
|
||
|
|
func Parse(raw []byte) (*Skill, error) {
|
||
|
|
s := &Skill{}
|
||
|
|
// Defaults: skills are read-only and require confirmation for anything
|
||
|
|
// mutating unless the operator explicitly opts in.
|
||
|
|
s.Permissions.DefaultMode = "readonly"
|
||
|
|
|
||
|
|
body, err := splitFrontMatter(raw)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if body.front != nil {
|
||
|
|
if err := yaml.Unmarshal(body.front, s); err != nil {
|
||
|
|
return nil, fmt.Errorf("parse frontmatter yaml: %w", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if s.Permissions.DefaultMode == "" {
|
||
|
|
s.Permissions.DefaultMode = "readonly"
|
||
|
|
}
|
||
|
|
if s.Version == 0 {
|
||
|
|
s.Version = 1
|
||
|
|
}
|
||
|
|
s.SystemPrompt = strings.TrimSpace(body.body)
|
||
|
|
return s, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
type parts struct {
|
||
|
|
front []byte
|
||
|
|
body string
|
||
|
|
}
|
||
|
|
|
||
|
|
// splitFrontMatter separates a leading "---\n...\n---\n" block from the
|
||
|
|
// remainder of the document. If no frontmatter is present the whole input is
|
||
|
|
// returned as the body.
|
||
|
|
func splitFrontMatter(raw []byte) (*parts, error) {
|
||
|
|
text := string(raw)
|
||
|
|
// Trim a leading UTF-8 BOM if present so editors that add one don't
|
||
|
|
// break frontmatter detection.
|
||
|
|
text = strings.TrimPrefix(text, "\uFEFF")
|
||
|
|
text = strings.TrimPrefix(text, "\n")
|
||
|
|
|
||
|
|
if !strings.HasPrefix(text, frontMatterDelim+"\n") && text != frontMatterDelim {
|
||
|
|
return &parts{body: strings.TrimSpace(text)}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// Skip the opening delimiter line.
|
||
|
|
rest := text[len(frontMatterDelim):]
|
||
|
|
rest = strings.TrimPrefix(rest, "\n")
|
||
|
|
|
||
|
|
// Find the closing delimiter on its own line.
|
||
|
|
endIdx := indexClosingDelim(rest)
|
||
|
|
if endIdx < 0 {
|
||
|
|
return nil, fmt.Errorf("frontmatter not terminated by a closing %q line", frontMatterDelim)
|
||
|
|
}
|
||
|
|
front := []byte(rest[:endIdx])
|
||
|
|
// Move past the closing delimiter and its trailing newline.
|
||
|
|
tail := rest[endIdx+len(frontMatterDelim):]
|
||
|
|
tail = strings.TrimPrefix(tail, "\r")
|
||
|
|
tail = strings.TrimPrefix(tail, "\n")
|
||
|
|
return &parts{front: front, body: strings.TrimSpace(tail)}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// indexClosingDelim returns the byte offset of a line equal to "---" within
|
||
|
|
// rest, or -1 if none. A line is matched when it starts with "---" followed
|
||
|
|
// by end-of-line.
|
||
|
|
func indexClosingDelim(rest string) int {
|
||
|
|
lines := strings.SplitN(rest, "\n", -1)
|
||
|
|
off := 0
|
||
|
|
for _, ln := range lines {
|
||
|
|
if strings.TrimRight(ln, "\r") == frontMatterDelim {
|
||
|
|
return off
|
||
|
|
}
|
||
|
|
// +1 for the '\n' that Split removed.
|
||
|
|
off += len(ln) + 1
|
||
|
|
}
|
||
|
|
return -1
|
||
|
|
}
|
||
|
|
|
||
|
|
// Write serialises a skill to a markdown file at path, creating parent dirs.
|
||
|
|
func Write(path string, s *Skill) error {
|
||
|
|
raw, err := Marshal(s)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||
|
|
return fmt.Errorf("mkdir for skill: %w", err)
|
||
|
|
}
|
||
|
|
if err := os.WriteFile(path, raw, 0o644); err != nil {
|
||
|
|
return fmt.Errorf("write skill %s: %w", path, err)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// Marshal renders a skill as markdown: frontmatter block + system prompt body.
|
||
|
|
func Marshal(s *Skill) ([]byte, error) {
|
||
|
|
if s == nil {
|
||
|
|
return nil, fmt.Errorf("nil skill")
|
||
|
|
}
|
||
|
|
if s.Permissions.DefaultMode == "" {
|
||
|
|
s.Permissions.DefaultMode = "readonly"
|
||
|
|
}
|
||
|
|
if s.Version == 0 {
|
||
|
|
s.Version = 1
|
||
|
|
}
|
||
|
|
front, err := yaml.Marshal(s)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("marshal frontmatter: %w", err)
|
||
|
|
}
|
||
|
|
var b strings.Builder
|
||
|
|
b.WriteString(frontMatterDelim)
|
||
|
|
b.WriteString("\n")
|
||
|
|
b.Write(front)
|
||
|
|
b.WriteString(frontMatterDelim)
|
||
|
|
b.WriteString("\n\n")
|
||
|
|
b.WriteString(strings.TrimSpace(s.SystemPrompt))
|
||
|
|
b.WriteString("\n")
|
||
|
|
return []byte(b.String()), nil
|
||
|
|
}
|