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:] }