feat: complete v0.3.1 agent workflow
This commit is contained in:
@@ -0,0 +1,583 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user