2026-08-22 21:52:30 +02:00
|
|
|
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
|
2026-08-23 15:17:46 +02:00
|
|
|
// 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
|
2026-08-22 21:52:30 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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{}
|
|
|
|
|
}
|
2026-08-23 14:50:56 +02:00
|
|
|
var skillDescendants []string
|
2026-08-22 21:52:30 +02:00
|
|
|
|
|
|
|
|
err := tree.Walk(func(rel string, isDir bool) error {
|
|
|
|
|
parent, base := splitPath(rel)
|
|
|
|
|
if isDir {
|
|
|
|
|
if set, ok := dirEntries[parent]; ok {
|
|
|
|
|
set[base] = true
|
|
|
|
|
}
|
2026-08-23 14:50:56 +02:00
|
|
|
if skillRoot != "" && (parent == skillRoot || strings.HasPrefix(parent, skillRoot+"/")) {
|
|
|
|
|
skillDescendants = append(skillDescendants, rel)
|
|
|
|
|
}
|
2026-08-22 21:52:30 +02:00
|
|
|
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
|
2026-08-23 14:50:56 +02:00
|
|
|
for _, it := range scanSkills(tree, skillRoot, skillDescendants) {
|
2026-08-22 21:52:30 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-23 14:50:56 +02:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-22 21:52:30 +02:00
|
|
|
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}
|
2026-08-23 15:17:46 +02:00
|
|
|
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
|
|
|
|
|
}
|
2026-08-22 21:52:30 +02:00
|
|
|
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:]
|
|
|
|
|
}
|