584 lines
16 KiB
Go
584 lines
16 KiB
Go
package skills
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"agentu/internal/safeio"
|
|
"agentu/pkg/config"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// DefaultDir is the default directory for global skills.
|
|
const DefaultDir = "~/.agentu/skills"
|
|
|
|
// SkillFileName is the required instruction file inside a Codex-style skill directory.
|
|
const SkillFileName = "SKILL.md"
|
|
|
|
// maxInstallDownloadBytes caps HTTP downloads at 1 MiB.
|
|
const maxInstallDownloadBytes = 1 << 20
|
|
|
|
// Skill is a single global Markdown skill.
|
|
type Skill struct {
|
|
Name string
|
|
Description string
|
|
Path string
|
|
Content string
|
|
}
|
|
|
|
// InstallResult describes a successful skill installation.
|
|
type InstallResult struct {
|
|
Skill Skill
|
|
TargetDir string
|
|
}
|
|
|
|
// validNameRe allows ASCII letters, digits, dots, underscores, and hyphens.
|
|
var validNameRe = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
|
|
|
// validateSkillName rejects empty, path-like, and otherwise unsafe names.
|
|
func validateSkillName(name string) error {
|
|
if name == "" || name == "." || name == ".." {
|
|
return fmt.Errorf("invalid skill name %q: use letters, numbers, dots, underscores, or hyphens", name)
|
|
}
|
|
if strings.ContainsAny(name, `/\`) {
|
|
return fmt.Errorf("invalid skill name %q: use letters, numbers, dots, underscores, or hyphens", name)
|
|
}
|
|
if strings.ContainsAny(name, " \t\n\r") {
|
|
return fmt.Errorf("invalid skill name %q: use letters, numbers, dots, underscores, or hyphens", name)
|
|
}
|
|
if !validNameRe.MatchString(name) {
|
|
return fmt.Errorf("invalid skill name %q: use letters, numbers, dots, underscores, or hyphens", name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// skillFrontmatter is the decoded YAML frontmatter.
|
|
type skillFrontmatter struct {
|
|
Name string `yaml:"name"`
|
|
Description string `yaml:"description"`
|
|
}
|
|
|
|
// parseSkillFile reads raw bytes and returns the skill body, frontmatter name,
|
|
// and frontmatter description. hasFrontmatter indicates whether the source
|
|
// bytes started with a --- delimiter pair.
|
|
func parseSkillFile(path string, data []byte) (body string, fmName string, fmDesc string, hasFrontmatter bool, err error) {
|
|
normalized := strings.ReplaceAll(string(data), "\r\n", "\n")
|
|
lines := strings.Split(normalized, "\n")
|
|
if len(lines) >= 2 && lines[0] == "---" {
|
|
for i := 1; i < len(lines); i++ {
|
|
if lines[i] == "---" {
|
|
yamlBlock := strings.Join(lines[1:i], "\n")
|
|
var fm skillFrontmatter
|
|
if err := yaml.Unmarshal([]byte(yamlBlock), &fm); err != nil {
|
|
return "", "", "", false, fmt.Errorf("parse skill %s: %w", path, err)
|
|
}
|
|
bodyText := strings.TrimSpace(strings.Join(lines[i+1:], "\n"))
|
|
return bodyText, fm.Name, fm.Description, true, nil
|
|
}
|
|
}
|
|
return "", "", "", false, fmt.Errorf("parse skill %s: missing closing ---", path)
|
|
}
|
|
return strings.TrimSpace(normalized), "", "", false, nil
|
|
}
|
|
|
|
// LoadGlobal loads global skills from DefaultDir.
|
|
func LoadGlobal() ([]Skill, error) {
|
|
return LoadDir(DefaultDir)
|
|
}
|
|
|
|
// LoadDir loads skills from a directory. Missing directories return nil, nil.
|
|
func LoadDir(dir string) ([]Skill, error) {
|
|
resolved, err := config.ResolvePath(dir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resolved, err = filepath.Abs(resolved)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
info, err := os.Stat(resolved)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("read skills directory: %w", err)
|
|
}
|
|
if !info.IsDir() {
|
|
return nil, fmt.Errorf("skills path is not a directory: %s", resolved)
|
|
}
|
|
|
|
entries, err := os.ReadDir(resolved)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read skills directory: %w", err)
|
|
}
|
|
|
|
var skills []Skill
|
|
for _, entry := range entries {
|
|
name := entry.Name()
|
|
if strings.HasPrefix(name, ".") {
|
|
continue
|
|
}
|
|
|
|
entryPath := filepath.Join(resolved, name)
|
|
|
|
// Shape 1: Codex-style directory with SKILL.md.
|
|
isDir, err := isDirectory(entryPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if isDir {
|
|
skillFile := filepath.Join(entryPath, SkillFileName)
|
|
fi, err := os.Stat(skillFile)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
continue
|
|
}
|
|
return nil, fmt.Errorf("read skill %s: %w", skillFile, err)
|
|
}
|
|
if !fi.Mode().IsRegular() {
|
|
continue
|
|
}
|
|
skill, err := loadSkillFile(name, skillFile, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if skill != nil {
|
|
skills = append(skills, *skill)
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Shape 2: Legacy flat .md file.
|
|
if !entry.Type().IsRegular() {
|
|
continue
|
|
}
|
|
if !strings.EqualFold(filepath.Ext(name), ".md") {
|
|
continue
|
|
}
|
|
skill, err := loadSkillFile(name[:len(name)-len(filepath.Ext(name))], entryPath, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if skill != nil {
|
|
skills = append(skills, *skill)
|
|
}
|
|
}
|
|
|
|
sort.Slice(skills, func(i, j int) bool {
|
|
if skills[i].Name != skills[j].Name {
|
|
return skills[i].Name < skills[j].Name
|
|
}
|
|
return skills[i].Path < skills[j].Path
|
|
})
|
|
|
|
return skills, nil
|
|
}
|
|
|
|
// isDirectory reports whether path is a directory. For symlinks it resolves
|
|
// the target and checks the resolved type.
|
|
func isDirectory(path string) (bool, error) {
|
|
fi, err := os.Lstat(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return false, nil
|
|
}
|
|
return false, fmt.Errorf("read skill %s: %w", path, err)
|
|
}
|
|
if fi.IsDir() {
|
|
return true, nil
|
|
}
|
|
if fi.Mode()&os.ModeSymlink != 0 {
|
|
resolved, err := filepath.EvalSymlinks(path)
|
|
if err != nil {
|
|
return false, nil
|
|
}
|
|
target, err := os.Stat(resolved)
|
|
if err != nil {
|
|
return false, nil
|
|
}
|
|
return target.IsDir(), nil
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// loadSkillFile reads one skill markdown file and returns a Skill or nil when
|
|
// the instruction body is empty. legacyBaseName is used as the default name
|
|
// when frontmatter does not supply one and isDir is false.
|
|
func loadSkillFile(legacyBaseName string, path string, isDirSkill bool) (*Skill, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read skill %s: %w", path, err)
|
|
}
|
|
body, fmName, fmDesc, _, err := parseSkillFile(path, data)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if body == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
name := fmName
|
|
if name == "" {
|
|
if isDirSkill {
|
|
name = filepath.Base(filepath.Dir(path))
|
|
} else {
|
|
name = legacyBaseName
|
|
}
|
|
}
|
|
if err := validateSkillName(name); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &Skill{
|
|
Name: name,
|
|
Description: strings.TrimSpace(fmDesc),
|
|
Path: path,
|
|
Content: body,
|
|
}, nil
|
|
}
|
|
|
|
// FormatForSystemPrompt formats skills for injection into the system prompt.
|
|
// Returns an empty string if items is empty.
|
|
func FormatForSystemPrompt(items []Skill) string {
|
|
if len(items) == 0 {
|
|
return ""
|
|
}
|
|
var b strings.Builder
|
|
b.WriteString("Global skills:\n")
|
|
b.WriteString("The following global skills are available for every conversation. Apply them when relevant.")
|
|
for _, skill := range items {
|
|
b.WriteString("\n\n### ")
|
|
b.WriteString(skill.Name)
|
|
b.WriteString("\nSource: ")
|
|
b.WriteString(skill.Path)
|
|
if skill.Description != "" {
|
|
b.WriteString("\nDescription: ")
|
|
b.WriteString(skill.Description)
|
|
}
|
|
b.WriteString("\n\n")
|
|
b.WriteString(skill.Content)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// FormatList formats skills for slash-command output.
|
|
func FormatList(items []Skill) string {
|
|
if len(items) == 0 {
|
|
return "No global skills installed."
|
|
}
|
|
var b strings.Builder
|
|
b.WriteString("Global skills:\n")
|
|
for _, skill := range items {
|
|
b.WriteString("- ")
|
|
b.WriteString(skill.Name)
|
|
if skill.Description != "" {
|
|
b.WriteString(" — ")
|
|
b.WriteString(skill.Description)
|
|
}
|
|
b.WriteString("\n Source: ")
|
|
b.WriteString(skill.Path)
|
|
b.WriteString("\n")
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// InstallGlobal installs a skill into DefaultDir.
|
|
func InstallGlobal(ctx context.Context, source string) (InstallResult, error) {
|
|
return InstallDir(ctx, DefaultDir, source)
|
|
}
|
|
|
|
// InstallDir installs a Codex-style skill directory under the target root.
|
|
func InstallDir(ctx context.Context, dir string, source string) (InstallResult, error) {
|
|
source = strings.TrimSpace(source)
|
|
if source == "" {
|
|
return InstallResult{}, fmt.Errorf("skill source is required")
|
|
}
|
|
|
|
resolvedRoot, err := config.ResolvePath(dir)
|
|
if err != nil {
|
|
return InstallResult{}, err
|
|
}
|
|
resolvedRoot, err = filepath.Abs(resolvedRoot)
|
|
if err != nil {
|
|
return InstallResult{}, err
|
|
}
|
|
|
|
// HTTP(S) source.
|
|
if strings.HasPrefix(source, "http://") || strings.HasPrefix(source, "https://") {
|
|
return installFromHTTP(ctx, resolvedRoot, source)
|
|
}
|
|
|
|
// Local source.
|
|
resolvedSource, err := config.ResolvePath(source)
|
|
if err != nil {
|
|
return InstallResult{}, err
|
|
}
|
|
resolvedSource, err = filepath.Abs(resolvedSource)
|
|
if err != nil {
|
|
return InstallResult{}, err
|
|
}
|
|
|
|
fi, err := os.Stat(resolvedSource)
|
|
if err != nil {
|
|
return InstallResult{}, fmt.Errorf("read skill source: %w", err)
|
|
}
|
|
if fi.IsDir() {
|
|
return installFromDirectory(ctx, resolvedRoot, resolvedSource)
|
|
}
|
|
if fi.Mode().IsRegular() && strings.EqualFold(filepath.Ext(resolvedSource), ".md") {
|
|
return installFromFile(ctx, resolvedRoot, resolvedSource)
|
|
}
|
|
return InstallResult{}, fmt.Errorf("skill source must be a directory, .md file, or http(s) URL")
|
|
}
|
|
|
|
func installFromHTTP(ctx context.Context, root string, url string) (InstallResult, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return InstallResult{}, fmt.Errorf("download skill: %w", err)
|
|
}
|
|
client := &http.Client{Timeout: 30 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return InstallResult{}, fmt.Errorf("download skill: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return InstallResult{}, fmt.Errorf("download skill: HTTP %d", resp.StatusCode)
|
|
}
|
|
limited := io.LimitReader(resp.Body, int64(maxInstallDownloadBytes)+1)
|
|
data, err := io.ReadAll(limited)
|
|
if err != nil {
|
|
return InstallResult{}, fmt.Errorf("download skill: %w", err)
|
|
}
|
|
if len(data) > maxInstallDownloadBytes {
|
|
return InstallResult{}, fmt.Errorf("skill file is too large: max %d bytes", maxInstallDownloadBytes)
|
|
}
|
|
return installMarkdownBytes(root, data, deriveNameFromURL(url))
|
|
}
|
|
|
|
func installFromDirectory(ctx context.Context, root string, sourceDir string) (InstallResult, error) {
|
|
skillFile := filepath.Join(sourceDir, SkillFileName)
|
|
fi, err := os.Stat(skillFile)
|
|
if err != nil {
|
|
return InstallResult{}, fmt.Errorf("read skill source: %w", err)
|
|
}
|
|
if !fi.Mode().IsRegular() {
|
|
return InstallResult{}, fmt.Errorf("skill source must contain a regular %s file", SkillFileName)
|
|
}
|
|
data, err := os.ReadFile(skillFile)
|
|
if err != nil {
|
|
return InstallResult{}, fmt.Errorf("read skill source: %w", err)
|
|
}
|
|
|
|
body, fmName, _, _, err := parseSkillFile(skillFile, data)
|
|
if err != nil {
|
|
return InstallResult{}, err
|
|
}
|
|
_ = body
|
|
name := fmName
|
|
if name == "" {
|
|
name = filepath.Base(sourceDir)
|
|
}
|
|
if err := validateSkillName(name); err != nil {
|
|
return InstallResult{}, err
|
|
}
|
|
|
|
targetDir := filepath.Join(root, name)
|
|
if err := createSkillTarget(root, targetDir); err != nil {
|
|
return InstallResult{}, err
|
|
}
|
|
|
|
// Copy tree.
|
|
if err := copyTree(ctx, sourceDir, targetDir); err != nil {
|
|
os.RemoveAll(targetDir)
|
|
return InstallResult{}, err
|
|
}
|
|
|
|
installedPath := filepath.Join(targetDir, SkillFileName)
|
|
installedData, _ := os.ReadFile(installedPath)
|
|
instBody, instName, instDesc, _, _ := parseSkillFile(installedPath, installedData)
|
|
if instName == "" {
|
|
instName = name
|
|
}
|
|
|
|
return InstallResult{
|
|
Skill: Skill{
|
|
Name: instName,
|
|
Description: strings.TrimSpace(instDesc),
|
|
Path: installedPath,
|
|
Content: instBody,
|
|
},
|
|
TargetDir: targetDir,
|
|
}, nil
|
|
}
|
|
|
|
func installFromFile(ctx context.Context, root string, sourcePath string) (InstallResult, error) {
|
|
data, err := os.ReadFile(sourcePath)
|
|
if err != nil {
|
|
return InstallResult{}, fmt.Errorf("read skill source: %w", err)
|
|
}
|
|
baseName := filepath.Base(sourcePath)
|
|
baseNameNoExt := baseName[:len(baseName)-len(filepath.Ext(baseName))]
|
|
// When the source is literally SKILL.md, use the parent directory name.
|
|
if strings.EqualFold(baseName, SkillFileName) {
|
|
baseNameNoExt = filepath.Base(filepath.Dir(sourcePath))
|
|
}
|
|
return installMarkdownBytes(root, data, baseNameNoExt)
|
|
}
|
|
|
|
// installMarkdownBytes parses markdown bytes and writes a Codex-style
|
|
// SKILL.md under <root>/<name>/. If the source had frontmatter, the original
|
|
// bytes are preserved. Otherwise a normalized header is prepended.
|
|
func installMarkdownBytes(root string, data []byte, fallbackName string) (InstallResult, error) {
|
|
normalized := strings.ReplaceAll(string(data), "\r\n", "\n")
|
|
lines := strings.Split(normalized, "\n")
|
|
|
|
hasFrontmatter := false
|
|
if len(lines) >= 2 && lines[0] == "---" {
|
|
for i := 1; i < len(lines); i++ {
|
|
if lines[i] == "---" {
|
|
hasFrontmatter = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
var body string
|
|
var fmName string
|
|
var fmDesc string
|
|
var err error
|
|
|
|
if hasFrontmatter {
|
|
body, fmName, fmDesc, _, err = parseSkillFile("<source>", data)
|
|
if err != nil {
|
|
return InstallResult{}, err
|
|
}
|
|
} else {
|
|
body = strings.TrimSpace(normalized)
|
|
}
|
|
name := fmName
|
|
if name == "" {
|
|
name = fallbackName
|
|
}
|
|
if err := validateSkillName(name); err != nil {
|
|
return InstallResult{}, err
|
|
}
|
|
if body == "" {
|
|
return InstallResult{}, fmt.Errorf("skill body is empty")
|
|
}
|
|
|
|
targetDir := filepath.Join(root, name)
|
|
if err := createSkillTarget(root, targetDir); err != nil {
|
|
return InstallResult{}, err
|
|
}
|
|
|
|
var writeData []byte
|
|
if hasFrontmatter {
|
|
writeData = data
|
|
} else {
|
|
var b strings.Builder
|
|
b.WriteString("---\n")
|
|
fmt.Fprintf(&b, "name: %s\n", name)
|
|
b.WriteString("description: \"\"\n")
|
|
b.WriteString("---\n\n")
|
|
b.WriteString(body)
|
|
b.WriteString("\n")
|
|
writeData = []byte(b.String())
|
|
}
|
|
|
|
targetFile := filepath.Join(targetDir, SkillFileName)
|
|
if err := safeio.AtomicWriteFile(targetFile, writeData, 0o644); err != nil {
|
|
os.RemoveAll(targetDir)
|
|
return InstallResult{}, err
|
|
}
|
|
|
|
return InstallResult{
|
|
Skill: Skill{
|
|
Name: name,
|
|
Description: strings.TrimSpace(fmDesc),
|
|
Path: targetFile,
|
|
Content: body,
|
|
},
|
|
TargetDir: targetDir,
|
|
}, nil
|
|
}
|
|
|
|
func createSkillTarget(root, targetDir string) error {
|
|
if err := os.MkdirAll(root, 0o755); err != nil {
|
|
return fmt.Errorf("create skills directory: %w", err)
|
|
}
|
|
if err := os.Mkdir(targetDir, 0o755); err != nil {
|
|
if os.IsExist(err) {
|
|
return fmt.Errorf("skill already exists: %s", targetDir)
|
|
}
|
|
return fmt.Errorf("create skill directory: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// deriveNameFromURL extracts a filename stem from a URL for use as a fallback name.
|
|
func deriveNameFromURL(rawURL string) string {
|
|
// Strip query and fragment.
|
|
u := rawURL
|
|
if idx := strings.IndexAny(u, "?#"); idx >= 0 {
|
|
u = u[:idx]
|
|
}
|
|
base := filepath.Base(u)
|
|
ext := filepath.Ext(base)
|
|
if ext != "" {
|
|
base = base[:len(base)-len(ext)]
|
|
}
|
|
if base == "" || strings.EqualFold(base, "SKILL") {
|
|
return "downloaded-skill"
|
|
}
|
|
return base
|
|
}
|
|
|
|
// copyTree copies srcDir to dstDir, rejecting symlinks inside the source tree.
|
|
func copyTree(ctx context.Context, srcDir string, dstDir string) error {
|
|
return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
rel, err := filepath.Rel(srcDir, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
target := filepath.Join(dstDir, rel)
|
|
|
|
// Reject any symlink inside the source tree.
|
|
lfi, lerr := os.Lstat(path)
|
|
if lerr != nil {
|
|
return lerr
|
|
}
|
|
if lfi.Mode()&os.ModeSymlink != 0 {
|
|
return fmt.Errorf("skill source contains symlink: %s", path)
|
|
}
|
|
|
|
if lfi.IsDir() {
|
|
return os.MkdirAll(target, 0o755)
|
|
}
|
|
if !lfi.Mode().IsRegular() {
|
|
return nil
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return safeio.AtomicWriteFile(target, data, 0o644)
|
|
})
|
|
}
|