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 //. 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("", 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) }) }