mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-04-21 18:26:38 +02:00
Delete skillpackage directory
This commit is contained in:
@@ -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]) + "…"
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user