Files
agent-lib/internal/discovery/discovery.go
T
m3ta-chiron 875a3c8aae feat(discovery): frontmatter guard for flat agents/commands docs
A flat .md under agents/ or commands/ whose frontmatter contains none of
the definition keys (name, description, mode, agent, model,
argument-hint, temperature, permission) is documentation, not an item:
discovery marks it Skipped with a recorded warning — it never enters a
selection, inventory, availability listing or collision index, but the
warning lands in the lockfile and add/update/select reports like other
discovery warnings. Skills keep their folder+SKILL.md marker, MCP keeps
the .yaml marker. README gains the canonical layout table and the
migration note for folder-style agent repos.

Closes beads: agent-lib-2n7
2026-08-23 15:17:46 +02:00

223 lines
6.1 KiB
Go

package discovery
import (
"fmt"
"sort"
"strings"
"github.com/m3tam3re/agent-lib/internal/lockfile"
)
// Tree abstracts a readable content tree: a git commit tree or a filesystem
// root. Paths are slash-separated and relative to the tree root.
type Tree interface {
Walk(fn func(rel string, isDir bool) error) error
ReadFile(rel string) ([]byte, error)
}
// Config directs Scan; zero values select the standard layout.
type Config struct {
Root string
SkillsDir string
CommandsDir string
AgentsDir string
McpDir string
}
func FromLockfileDiscovery(d lockfile.Discovery) Config {
r := d.Resolve()
return Config{Root: r.Root, SkillsDir: r.SkillsDir, CommandsDir: r.CommandsDir, AgentsDir: r.AgentsDir, McpDir: r.McpDir}
}
// Item is one discovered deployable artifact.
type Item struct {
Type string
UpstreamID string
RelPath string
Frontmatter *Frontmatter
Warnings []string
// Skipped marks a file discovery recognized but deliberately excluded —
// e.g. a documentation .md under agents/ or commands/ without definition
// frontmatter. Skipped items carry warnings but must never enter a
// selection, inventory or availability listing.
Skipped bool
}
// Scan discovers items of all four content types under cfg. Discovery is a
// read-only scan; it never evaluates content beyond reading files.
func Scan(tree Tree, cfg Config) ([]Item, error) {
dirs := lockfile.Discovery{
Root: cfg.Root, SkillsDir: cfg.SkillsDir, CommandsDir: cfg.CommandsDir,
AgentsDir: cfg.AgentsDir, McpDir: cfg.McpDir,
}.Resolve()
skillRoot := join(dirs.Root, dirs.SkillsDir)
commandRoot := join(dirs.Root, dirs.CommandsDir)
agentRoot := join(dirs.Root, dirs.AgentsDir)
mcpRoot := join(dirs.Root, dirs.McpDir)
dirEntries := map[string]map[string]bool{}
fileEntries := map[string]map[string]bool{}
for _, d := range []string{skillRoot, commandRoot, agentRoot, mcpRoot} {
dirEntries[d] = map[string]bool{}
fileEntries[d] = map[string]bool{}
}
var skillDescendants []string
err := tree.Walk(func(rel string, isDir bool) error {
parent, base := splitPath(rel)
if isDir {
if set, ok := dirEntries[parent]; ok {
set[base] = true
}
if skillRoot != "" && (parent == skillRoot || strings.HasPrefix(parent, skillRoot+"/")) {
skillDescendants = append(skillDescendants, rel)
}
return nil
}
if set, ok := fileEntries[parent]; ok {
set[base] = true
}
return nil
})
if err != nil {
return nil, fmt.Errorf("walking source tree: %w", err)
}
var items []Item
for _, it := range scanSkills(tree, skillRoot, skillDescendants) {
items = append(items, it)
}
items = append(items, scanFlatFiles(tree, fileEntries, commandRoot, lockfile.TypeCommand)...)
items = append(items, scanFlatFiles(tree, fileEntries, agentRoot, lockfile.TypeAgent)...)
items = append(items, scanMcpFiles(tree, fileEntries, mcpRoot)...)
sort.Slice(items, func(i, j int) bool {
if items[i].Type != items[j].Type {
return typeRank(items[i].Type) < typeRank(items[j].Type)
}
return items[i].UpstreamID < items[j].UpstreamID
})
return items, nil
}
// scanSkills finds skill folders at any depth under skillRoot (flat layouts
// and category-nested layouts alike). The upstream id is the folder's base
// name; a directory that is itself a skill wins over anything nested inside
// it, and same-basename folders collide at the vendor layer.
func scanSkills(tree Tree, skillRoot string, descendants []string) []Item {
if skillRoot == "" {
return nil
}
sorted := append([]string(nil), descendants...)
sort.Strings(sorted)
withSkillMd := map[string]bool{}
for _, d := range sorted {
if _, err := tree.ReadFile(join(d, "SKILL.md")); err == nil {
withSkillMd[d] = true
}
}
var items []Item
for _, d := range sorted {
if !withSkillMd[d] {
continue
}
nested := false
for other := range withSkillMd {
if other != d && strings.HasPrefix(d, other+"/") {
nested = true
break
}
}
if nested {
continue
}
data, err := tree.ReadFile(join(d, "SKILL.md"))
if err != nil {
continue
}
name := d[strings.LastIndex(d, "/")+1:]
it := Item{Type: lockfile.TypeSkill, UpstreamID: name, RelPath: d}
fm, warnings := ParseFrontmatter(data)
it.Frontmatter = &fm
for _, w := range warnings {
it.Warnings = append(it.Warnings, fmt.Sprintf("skills/%s: %s", name, w))
}
items = append(items, it)
}
return items
}
func scanFlatFiles(tree Tree, fileEntries map[string]map[string]bool, root, typ string) []Item {
var items []Item
for base := range fileEntries[root] {
if !strings.HasSuffix(base, ".md") {
continue
}
id := strings.TrimSuffix(base, ".md")
rel := join(root, base)
data, err := tree.ReadFile(rel)
if err != nil {
continue
}
it := Item{Type: typ, UpstreamID: id, RelPath: rel}
if !HasDefinitionFrontmatter(data) {
it.Skipped = true
it.Warnings = append(it.Warnings, fmt.Sprintf("%s: no %s frontmatter, skipped", rel, strings.TrimSuffix(typ, "s")))
items = append(items, it)
continue
}
fm, warnings := ParseFrontmatter(data)
it.Frontmatter = &fm
for _, w := range warnings {
it.Warnings = append(it.Warnings, fmt.Sprintf("%s/%s: %s", typ, id, w))
}
items = append(items, it)
}
return items
}
func scanMcpFiles(tree Tree, fileEntries map[string]map[string]bool, root string) []Item {
var items []Item
for base := range fileEntries[root] {
var id string
switch {
case strings.HasSuffix(base, ".yaml"):
id = strings.TrimSuffix(base, ".yaml")
case strings.HasSuffix(base, ".yml"):
id = strings.TrimSuffix(base, ".yml")
default:
continue
}
items = append(items, Item{Type: lockfile.TypeMcp, UpstreamID: id, RelPath: join(root, base)})
}
return items
}
func typeRank(t string) int {
for i, tt := range lockfile.Types {
if tt == t {
return i
}
}
return len(lockfile.Types)
}
func join(parts ...string) string {
var nonEmpty []string
for _, p := range parts {
if p != "" {
nonEmpty = append(nonEmpty, p)
}
}
return strings.Join(nonEmpty, "/")
}
func splitPath(rel string) (parent, base string) {
i := strings.LastIndex(rel, "/")
if i < 0 {
return "", rel
}
return rel[:i], rel[i+1:]
}