diff --git a/skillpackage/content.go b/skillpackage/content.go deleted file mode 100644 index 851a5238..00000000 --- a/skillpackage/content.go +++ /dev/null @@ -1,165 +0,0 @@ -package skillpackage - -import ( - "fmt" - "regexp" - "strings" -) - -var reH2 = regexp.MustCompile(`(?m)^##\s+(.+)$`) - -const summaryContentRunes = 6000 - -type markdownSection struct { - Heading string - Title string - Content string -} - -func splitMarkdownSections(body string) []markdownSection { - body = strings.TrimSpace(body) - if body == "" { - return nil - } - idxs := reH2.FindAllStringIndex(body, -1) - titles := reH2.FindAllStringSubmatch(body, -1) - if len(idxs) == 0 { - return []markdownSection{{ - Heading: "", - Title: "_body", - Content: body, - }} - } - var out []markdownSection - for i := range idxs { - title := strings.TrimSpace(titles[i][1]) - start := idxs[i][0] - end := len(body) - if i+1 < len(idxs) { - end = idxs[i+1][0] - } - chunk := strings.TrimSpace(body[start:end]) - out = append(out, markdownSection{ - Heading: "## " + title, - Title: title, - Content: chunk, - }) - } - return out -} - -func deriveSections(body string) []SkillSection { - md := splitMarkdownSections(body) - out := make([]SkillSection, 0, len(md)) - for _, ms := range md { - if ms.Title == "_body" { - continue - } - out = append(out, SkillSection{ - ID: slugifySectionID(ms.Title), - Title: ms.Title, - Heading: ms.Heading, - Level: 2, - }) - } - return out -} - -func slugifySectionID(title string) string { - title = strings.TrimSpace(strings.ToLower(title)) - if title == "" { - return "section" - } - var b strings.Builder - for _, r := range title { - switch { - case r >= 'a' && r <= 'z', r >= '0' && r <= '9': - b.WriteRune(r) - case r == ' ', r == '-', r == '_': - b.WriteRune('-') - } - } - s := strings.Trim(b.String(), "-") - if s == "" { - return "section" - } - return s -} - -func findSectionContent(sections []markdownSection, sec string) string { - sec = strings.TrimSpace(sec) - if sec == "" { - return "" - } - want := strings.ToLower(sec) - for _, s := range sections { - if strings.EqualFold(slugifySectionID(s.Title), want) || strings.EqualFold(s.Title, sec) { - return s.Content - } - if strings.EqualFold(strings.ReplaceAll(s.Title, " ", "-"), want) { - return s.Content - } - } - return "" -} - -func buildSummaryMarkdown(name, description string, tags []string, scripts []SkillScriptInfo, sections []SkillSection, body string) string { - var b strings.Builder - if description != "" { - b.WriteString(description) - b.WriteString("\n\n") - } - if len(tags) > 0 { - b.WriteString("**Tags**: ") - b.WriteString(strings.Join(tags, ", ")) - b.WriteString("\n\n") - } - if len(scripts) > 0 { - b.WriteString("### Bundled scripts\n\n") - for _, sc := range scripts { - line := "- `" + sc.RelPath + "`" - if sc.Description != "" { - line += " — " + sc.Description - } - b.WriteString(line) - b.WriteString("\n") - } - b.WriteString("\n") - } - if len(sections) > 0 { - b.WriteString("### Sections\n\n") - for _, sec := range sections { - line := "- **" + sec.ID + "**" - if sec.Title != "" && sec.Title != sec.ID { - line += ": " + sec.Title - } - b.WriteString(line) - b.WriteString("\n") - } - b.WriteString("\n") - } - mdSecs := splitMarkdownSections(body) - preview := body - if len(mdSecs) > 0 && mdSecs[0].Title != "_body" { - preview = mdSecs[0].Content - } - b.WriteString("### Preview (SKILL.md)\n\n") - b.WriteString(truncateRunes(strings.TrimSpace(preview), summaryContentRunes)) - b.WriteString("\n\n---\n\n_(Summary for admin UI. Agents use Eino `skill` tool for full SKILL.md progressive loading.)_") - if name != "" { - b.WriteString(fmt.Sprintf("\n\n_Skill name: %s_", name)) - } - return b.String() -} - -func truncateRunes(s string, max int) string { - if max <= 0 || s == "" { - return s - } - r := []rune(s) - if len(r) <= max { - return s - } - return string(r[:max]) + "…" -} - diff --git a/skillpackage/frontmatter.go b/skillpackage/frontmatter.go deleted file mode 100644 index 620f698d..00000000 --- a/skillpackage/frontmatter.go +++ /dev/null @@ -1,114 +0,0 @@ -package skillpackage - -import ( - "fmt" - "strings" - - "gopkg.in/yaml.v3" -) - -// ExtractSkillMDFrontMatterYAML returns the YAML source inside the first --- ... --- block and the markdown body. -func ExtractSkillMDFrontMatterYAML(raw []byte) (fmYAML string, body string, err error) { - text := strings.TrimPrefix(string(raw), "\ufeff") - if strings.TrimSpace(text) == "" { - return "", "", fmt.Errorf("SKILL.md is empty") - } - lines := strings.Split(text, "\n") - if len(lines) < 2 || strings.TrimSpace(lines[0]) != "---" { - return "", "", fmt.Errorf("SKILL.md must start with YAML front matter (---) per Agent Skills standard") - } - var fmLines []string - i := 1 - for i < len(lines) { - if strings.TrimSpace(lines[i]) == "---" { - break - } - fmLines = append(fmLines, lines[i]) - i++ - } - if i >= len(lines) { - return "", "", fmt.Errorf("SKILL.md: front matter must end with a line containing only ---") - } - body = strings.Join(lines[i+1:], "\n") - body = strings.TrimSpace(body) - fmYAML = strings.Join(fmLines, "\n") - return fmYAML, body, nil -} - -// ParseSkillMD parses SKILL.md YAML head + body. -func ParseSkillMD(raw []byte) (*SkillManifest, string, error) { - fmYAML, body, err := ExtractSkillMDFrontMatterYAML(raw) - if err != nil { - return nil, "", err - } - var m SkillManifest - if err := yaml.Unmarshal([]byte(fmYAML), &m); err != nil { - return nil, "", fmt.Errorf("SKILL.md front matter: %w", err) - } - return &m, body, nil -} - -type skillFrontMatterExport struct { - Name string `yaml:"name"` - Description string `yaml:"description"` - License string `yaml:"license,omitempty"` - Compatibility string `yaml:"compatibility,omitempty"` - Metadata map[string]any `yaml:"metadata,omitempty"` - AllowedTools string `yaml:"allowed-tools,omitempty"` -} - -// BuildSkillMD serializes SKILL.md per agentskills.io. -func BuildSkillMD(m *SkillManifest, body string) ([]byte, error) { - if m == nil { - return nil, fmt.Errorf("nil manifest") - } - fm := skillFrontMatterExport{ - Name: strings.TrimSpace(m.Name), - Description: strings.TrimSpace(m.Description), - License: strings.TrimSpace(m.License), - Compatibility: strings.TrimSpace(m.Compatibility), - AllowedTools: strings.TrimSpace(m.AllowedTools), - } - if len(m.Metadata) > 0 { - fm.Metadata = m.Metadata - } - head, err := yaml.Marshal(&fm) - if err != nil { - return nil, err - } - s := strings.TrimSpace(string(head)) - out := "---\n" + s + "\n---\n\n" + strings.TrimSpace(body) + "\n" - return []byte(out), nil -} - -func manifestTags(m *SkillManifest) []string { - if m == nil || m.Metadata == nil { - return nil - } - var out []string - if raw, ok := m.Metadata["tags"]; ok { - switch v := raw.(type) { - case []any: - for _, x := range v { - if s, ok := x.(string); ok && s != "" { - out = append(out, s) - } - } - case []string: - out = append(out, v...) - } - } - return out -} - -func versionFromMetadata(m *SkillManifest) string { - if m == nil || m.Metadata == nil { - return "" - } - if v, ok := m.Metadata["version"]; ok { - if s, ok := v.(string); ok { - return strings.TrimSpace(s) - } - } - return "" -} diff --git a/skillpackage/io.go b/skillpackage/io.go deleted file mode 100644 index f89f4506..00000000 --- a/skillpackage/io.go +++ /dev/null @@ -1,200 +0,0 @@ -package skillpackage - -import ( - "fmt" - "io/fs" - "os" - "path/filepath" - "strings" -) - -const ( - maxPackageFiles = 4000 - maxPackageDepth = 24 - maxScriptsDepth = 24 - defaultMaxRead = 10 << 20 -) - -// SafeRelPath resolves rel inside root (no ..). -func SafeRelPath(root, rel string) (string, error) { - rel = strings.TrimSpace(rel) - rel = filepath.ToSlash(rel) - rel = strings.TrimPrefix(rel, "/") - if rel == "" || rel == "." { - return "", fmt.Errorf("empty resource path") - } - if strings.Contains(rel, "..") { - return "", fmt.Errorf("invalid path %q", rel) - } - abs := filepath.Join(root, filepath.FromSlash(rel)) - cleanRoot := filepath.Clean(root) - cleanAbs := filepath.Clean(abs) - relOut, err := filepath.Rel(cleanRoot, cleanAbs) - if err != nil || relOut == ".." || strings.HasPrefix(relOut, ".."+string(filepath.Separator)) { - return "", fmt.Errorf("path escapes skill directory: %q", rel) - } - return cleanAbs, nil -} - -// ListPackageFiles lists files under a skill directory. -func ListPackageFiles(skillsRoot, skillID string) ([]PackageFileInfo, error) { - root := SkillDir(skillsRoot, skillID) - if _, err := ResolveSKILLPath(root); err != nil { - return nil, fmt.Errorf("skill %q: %w", skillID, err) - } - var out []PackageFileInfo - err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - rel, e := filepath.Rel(root, path) - if e != nil { - return e - } - if rel == "." { - return nil - } - depth := strings.Count(rel, string(os.PathSeparator)) - if depth > maxPackageDepth { - if d.IsDir() { - return filepath.SkipDir - } - return nil - } - if strings.HasPrefix(d.Name(), ".") { - if d.IsDir() { - return filepath.SkipDir - } - return nil - } - if len(out) >= maxPackageFiles { - return fmt.Errorf("skill package exceeds %d files", maxPackageFiles) - } - fi, err := d.Info() - if err != nil { - return err - } - out = append(out, PackageFileInfo{ - Path: filepath.ToSlash(rel), - Size: fi.Size(), - IsDir: d.IsDir(), - }) - return nil - }) - return out, err -} - -// ReadPackageFile reads a file relative to the skill package. -func ReadPackageFile(skillsRoot, skillID, relPath string, maxBytes int64) ([]byte, error) { - if maxBytes <= 0 { - maxBytes = defaultMaxRead - } - root := SkillDir(skillsRoot, skillID) - abs, err := SafeRelPath(root, relPath) - if err != nil { - return nil, err - } - fi, err := os.Stat(abs) - if err != nil { - return nil, err - } - if fi.IsDir() { - return nil, fmt.Errorf("path is a directory") - } - if fi.Size() > maxBytes { - return readFileHead(abs, maxBytes) - } - return os.ReadFile(abs) -} - -// WritePackageFile writes a file inside the skill package. -func WritePackageFile(skillsRoot, skillID, relPath string, content []byte) error { - root := SkillDir(skillsRoot, skillID) - if _, err := ResolveSKILLPath(root); err != nil { - return fmt.Errorf("skill %q: %w", skillID, err) - } - abs, err := SafeRelPath(root, relPath) - if err != nil { - return err - } - if err := os.MkdirAll(filepath.Dir(abs), 0755); err != nil { - return err - } - return os.WriteFile(abs, content, 0644) -} - -func readFileHead(path string, max int64) ([]byte, error) { - f, err := os.Open(path) - if err != nil { - return nil, err - } - defer f.Close() - buf := make([]byte, max) - n, err := f.Read(buf) - if err != nil && n == 0 { - return nil, err - } - return buf[:n], nil -} - -func listScripts(skillsRoot, skillID string) ([]SkillScriptInfo, error) { - root := filepath.Join(SkillDir(skillsRoot, skillID), "scripts") - st, err := os.Stat(root) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, err - } - if !st.IsDir() { - return nil, nil - } - var out []SkillScriptInfo - err = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { - if err != nil { - return err - } - rel, e := filepath.Rel(root, path) - if e != nil { - return e - } - if rel == "." { - return nil - } - if d.IsDir() { - if strings.HasPrefix(d.Name(), ".") { - return filepath.SkipDir - } - if strings.Count(rel, string(os.PathSeparator)) >= maxScriptsDepth { - return filepath.SkipDir - } - return nil - } - if strings.HasPrefix(d.Name(), ".") { - return nil - } - relSkill := filepath.Join("scripts", rel) - full := filepath.Join(root, rel) - fi, err := os.Stat(full) - if err != nil || fi.IsDir() { - return nil - } - out = append(out, SkillScriptInfo{ - Name: filepath.Base(rel), - RelPath: filepath.ToSlash(relSkill), - Size: fi.Size(), - }) - return nil - }) - return out, err -} - -func countNonDirFiles(files []PackageFileInfo) int { - n := 0 - for _, f := range files { - if !f.IsDir && f.Path != "SKILL.md" { - n++ - } - } - return n -} diff --git a/skillpackage/layout.go b/skillpackage/layout.go deleted file mode 100644 index 0da7395a..00000000 --- a/skillpackage/layout.go +++ /dev/null @@ -1,66 +0,0 @@ -package skillpackage - -import ( - "fmt" - "os" - "path/filepath" - "strings" -) - -// SkillDir returns the absolute path to a skill package directory. -func SkillDir(skillsRoot, skillID string) string { - return filepath.Join(skillsRoot, skillID) -} - -// ResolveSKILLPath returns SKILL.md path or error if missing. -func ResolveSKILLPath(skillPath string) (string, error) { - md := filepath.Join(skillPath, "SKILL.md") - if st, err := os.Stat(md); err != nil || st.IsDir() { - return "", fmt.Errorf("missing SKILL.md in %q (Agent Skills standard)", filepath.Base(skillPath)) - } - return md, nil -} - -// SkillsRootFromConfig resolves cfg.SkillsDir relative to the config file directory. -func SkillsRootFromConfig(skillsDir string, configPath string) string { - if skillsDir == "" { - skillsDir = "skills" - } - configDir := filepath.Dir(configPath) - if !filepath.IsAbs(skillsDir) { - skillsDir = filepath.Join(configDir, skillsDir) - } - return skillsDir -} - -// DirLister satisfies handler.SkillsManager for role UI (lists package directory names). -type DirLister struct { - SkillsRoot string -} - -// ListSkills implements the role handler dependency. -func (d DirLister) ListSkills() ([]string, error) { - return ListSkillDirNames(d.SkillsRoot) -} - -// ListSkillDirNames returns subdirectory names under skillsRoot that contain SKILL.md. -func ListSkillDirNames(skillsRoot string) ([]string, error) { - if _, err := os.Stat(skillsRoot); os.IsNotExist(err) { - return nil, nil - } - entries, err := os.ReadDir(skillsRoot) - if err != nil { - return nil, fmt.Errorf("read skills directory: %w", err) - } - var names []string - for _, entry := range entries { - if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") { - continue - } - skillPath := filepath.Join(skillsRoot, entry.Name()) - if _, err := ResolveSKILLPath(skillPath); err == nil { - names = append(names, entry.Name()) - } - } - return names, nil -} diff --git a/skillpackage/service.go b/skillpackage/service.go deleted file mode 100644 index 52dbe90a..00000000 --- a/skillpackage/service.go +++ /dev/null @@ -1,155 +0,0 @@ -package skillpackage - -import ( - "fmt" - "os" - "sort" - "strings" -) - -// ListSkillSummaries scans skillsRoot and returns index rows for the admin API. -func ListSkillSummaries(skillsRoot string) ([]SkillSummary, error) { - names, err := ListSkillDirNames(skillsRoot) - if err != nil { - return nil, err - } - sort.Strings(names) - out := make([]SkillSummary, 0, len(names)) - for _, dirName := range names { - su, err := loadSummary(skillsRoot, dirName) - if err != nil { - continue - } - out = append(out, su) - } - return out, nil -} - -func loadSummary(skillsRoot, dirName string) (SkillSummary, error) { - skillPath := SkillDir(skillsRoot, dirName) - mdPath, err := ResolveSKILLPath(skillPath) - if err != nil { - return SkillSummary{}, err - } - raw, err := os.ReadFile(mdPath) - if err != nil { - return SkillSummary{}, err - } - man, _, err := ParseSkillMD(raw) - if err != nil { - return SkillSummary{}, err - } - if err := ValidateAgentSkillManifestInPackage(man, dirName); err != nil { - return SkillSummary{}, err - } - fi, err := os.Stat(mdPath) - if err != nil { - return SkillSummary{}, err - } - pfiles, err := ListPackageFiles(skillsRoot, dirName) - if err != nil { - return SkillSummary{}, err - } - nFiles := 0 - for _, p := range pfiles { - if !p.IsDir { - nFiles++ - } - } - scripts, err := listScripts(skillsRoot, dirName) - if err != nil { - return SkillSummary{}, err - } - ver := versionFromMetadata(man) - return SkillSummary{ - ID: dirName, - DirName: dirName, - Name: man.Name, - Description: man.Description, - Version: ver, - Path: skillPath, - Tags: manifestTags(man), - ScriptCount: len(scripts), - FileCount: nFiles, - FileSize: fi.Size(), - ModTime: fi.ModTime().Format("2006-01-02 15:04:05"), - Progressive: true, - }, nil -} - -// LoadOptions mirrors legacy API query params for the web admin. -type LoadOptions struct { - Depth string // summary | full - Section string -} - -// LoadSkill returns manifest + body + package listing for admin. -func LoadSkill(skillsRoot, skillID string, opt LoadOptions) (*SkillView, error) { - skillPath := SkillDir(skillsRoot, skillID) - mdPath, err := ResolveSKILLPath(skillPath) - if err != nil { - return nil, err - } - raw, err := os.ReadFile(mdPath) - if err != nil { - return nil, err - } - man, body, err := ParseSkillMD(raw) - if err != nil { - return nil, err - } - if err := ValidateAgentSkillManifestInPackage(man, skillID); err != nil { - return nil, err - } - pfiles, err := ListPackageFiles(skillsRoot, skillID) - if err != nil { - return nil, err - } - scripts, err := listScripts(skillsRoot, skillID) - if err != nil { - return nil, err - } - sort.Slice(scripts, func(i, j int) bool { return scripts[i].RelPath < scripts[j].RelPath }) - sections := deriveSections(body) - ver := versionFromMetadata(man) - v := &SkillView{ - DirName: skillID, - Name: man.Name, - Description: man.Description, - Content: body, - Path: skillPath, - Version: ver, - Tags: manifestTags(man), - Scripts: scripts, - Sections: sections, - PackageFiles: pfiles, - } - depth := strings.ToLower(strings.TrimSpace(opt.Depth)) - if depth == "" { - depth = "full" - } - sec := strings.TrimSpace(opt.Section) - if sec != "" { - mds := splitMarkdownSections(body) - chunk := findSectionContent(mds, sec) - if chunk == "" { - v.Content = fmt.Sprintf("_(section %q not found in SKILL.md for skill %s)_", sec, skillID) - } else { - v.Content = chunk - } - return v, nil - } - if depth == "summary" { - v.Content = buildSummaryMarkdown(man.Name, man.Description, v.Tags, scripts, sections, body) - } - return v, nil -} - -// ReadScriptText returns file content as string (for HTTP resource_path). -func ReadScriptText(skillsRoot, skillID, relPath string, maxBytes int64) (string, error) { - b, err := ReadPackageFile(skillsRoot, skillID, relPath, maxBytes) - if err != nil { - return "", err - } - return string(b), nil -} diff --git a/skillpackage/types.go b/skillpackage/types.go deleted file mode 100644 index bf313425..00000000 --- a/skillpackage/types.go +++ /dev/null @@ -1,67 +0,0 @@ -// Package skillpackage provides filesystem-backed Agent Skills layout (SKILL.md + package files) -// for HTTP admin APIs. Runtime discovery and progressive loading for agents use Eino ADK skill middleware. -package skillpackage - -// SkillManifest is parsed from SKILL.md front matter (https://agentskills.io/specification.md). -type SkillManifest struct { - Name string `yaml:"name"` - Description string `yaml:"description"` - License string `yaml:"license,omitempty"` - Compatibility string `yaml:"compatibility,omitempty"` - Metadata map[string]any `yaml:"metadata,omitempty"` - AllowedTools string `yaml:"allowed-tools,omitempty"` -} - -// SkillSummary is API metadata for one skill directory. -type SkillSummary struct { - ID string `json:"id"` - DirName string `json:"dir_name"` - Name string `json:"name"` - Description string `json:"description"` - Version string `json:"version"` - Path string `json:"path"` - Tags []string `json:"tags"` - Triggers []string `json:"triggers,omitempty"` - ScriptCount int `json:"script_count"` - FileCount int `json:"file_count"` - FileSize int64 `json:"file_size"` - ModTime string `json:"mod_time"` - Progressive bool `json:"progressive"` -} - -// SkillScriptInfo describes a file under scripts/. -type SkillScriptInfo struct { - Name string `json:"name"` - RelPath string `json:"rel_path"` - Description string `json:"description,omitempty"` - Size int64 `json:"size"` -} - -// SkillSection is derived from ## headings in SKILL.md. -type SkillSection struct { - ID string `json:"id"` - Title string `json:"title"` - Heading string `json:"heading"` - Level int `json:"level"` -} - -// PackageFileInfo describes one file inside a package. -type PackageFileInfo struct { - Path string `json:"path"` - Size int64 `json:"size"` - IsDir bool `json:"is_dir,omitempty"` -} - -// SkillView is a loaded package for admin / API. -type SkillView struct { - DirName string `json:"dir_name"` - Name string `json:"name"` - Description string `json:"description"` - Content string `json:"content"` - Path string `json:"path"` - Version string `json:"version"` - Tags []string `json:"tags"` - Scripts []SkillScriptInfo `json:"scripts,omitempty"` - Sections []SkillSection `json:"sections,omitempty"` - PackageFiles []PackageFileInfo `json:"package_files,omitempty"` -} diff --git a/skillpackage/validate.go b/skillpackage/validate.go deleted file mode 100644 index 79d8255c..00000000 --- a/skillpackage/validate.go +++ /dev/null @@ -1,102 +0,0 @@ -package skillpackage - -import ( - "fmt" - "strings" - "unicode/utf8" - - "gopkg.in/yaml.v3" -) - -var agentSkillsSpecFrontMatterKeys = map[string]struct{}{ - "name": {}, "description": {}, "license": {}, "compatibility": {}, - "metadata": {}, "allowed-tools": {}, -} - -// ValidateAgentSkillManifest enforces Agent Skills rules for name and description. -func ValidateAgentSkillManifest(m *SkillManifest) error { - if m == nil { - return fmt.Errorf("skill manifest is nil") - } - if strings.TrimSpace(m.Name) == "" { - return fmt.Errorf("SKILL.md front matter: name is required") - } - if strings.TrimSpace(m.Description) == "" { - return fmt.Errorf("SKILL.md front matter: description is required") - } - if utf8.RuneCountInString(m.Name) > 64 { - return fmt.Errorf("name exceeds 64 characters (Agent Skills limit)") - } - if utf8.RuneCountInString(m.Description) > 1024 { - return fmt.Errorf("description exceeds 1024 characters (Agent Skills limit)") - } - if m.Name != strings.ToLower(m.Name) { - return fmt.Errorf("name must be lowercase (Agent Skills)") - } - for _, r := range m.Name { - if !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-') { - return fmt.Errorf("name must contain only lowercase letters, numbers, hyphens (Agent Skills)") - } - } - if strings.HasPrefix(m.Name, "-") || strings.HasSuffix(m.Name, "-") { - return fmt.Errorf("name must not start or end with a hyphen (Agent Skills spec)") - } - if strings.Contains(m.Name, "--") { - return fmt.Errorf("name must not contain consecutive hyphens (Agent Skills spec)") - } - lname := strings.ToLower(m.Name) - if strings.Contains(lname, "anthropic") || strings.Contains(lname, "claude") { - return fmt.Errorf("name must not contain reserved words anthropic or claude") - } - return nil -} - -// ValidateAgentSkillManifestInPackage checks manifest and that name matches package directory. -func ValidateAgentSkillManifestInPackage(m *SkillManifest, packageDirName string) error { - if err := ValidateAgentSkillManifest(m); err != nil { - return err - } - if strings.TrimSpace(packageDirName) == "" { - return nil - } - if m.Name != packageDirName { - return fmt.Errorf("SKILL.md name %q must match directory name %q (Agent Skills spec)", m.Name, packageDirName) - } - return nil -} - -// ValidateOfficialFrontMatterTopLevelKeys rejects keys not in the open spec. -func ValidateOfficialFrontMatterTopLevelKeys(fmYAML string) error { - var top map[string]interface{} - if err := yaml.Unmarshal([]byte(fmYAML), &top); err != nil { - return fmt.Errorf("SKILL.md front matter: %w", err) - } - for k := range top { - if _, ok := agentSkillsSpecFrontMatterKeys[k]; !ok { - return fmt.Errorf("SKILL.md front matter: unsupported key %q (allowed: name, description, license, compatibility, metadata, allowed-tools — see https://agentskills.io/specification.md)", k) - } - } - return nil -} - -// ValidateSkillMDPackage validates SKILL.md bytes for writes. -func ValidateSkillMDPackage(raw []byte, packageDirName string) error { - fmYAML, body, err := ExtractSkillMDFrontMatterYAML(raw) - if err != nil { - return err - } - if err := ValidateOfficialFrontMatterTopLevelKeys(fmYAML); err != nil { - return err - } - if strings.TrimSpace(body) == "" { - return fmt.Errorf("SKILL.md: markdown body after front matter must not be empty") - } - var fm SkillManifest - if err := yaml.Unmarshal([]byte(fmYAML), &fm); err != nil { - return fmt.Errorf("SKILL.md front matter: %w", err) - } - if c := strings.TrimSpace(fm.Compatibility); c != "" && utf8.RuneCountInString(c) > 500 { - return fmt.Errorf("compatibility exceeds 500 characters (Agent Skills spec)") - } - return ValidateAgentSkillManifestInPackage(&fm, packageDirName) -}