feat: recursive skill discovery + tree-url type-root folding
- skills are found at any depth under the skills dir, so category-nested layouts (skills/<category>/<id>/SKILL.md) vendor correctly; the folder basename is the upstream id and the outermost SKILL.md wins over anything nested inside a skill folder - web-tree urls pointing directly at a type directory (.../tree/main/skills) now map that directory as the type root instead of nesting it under the default layout - verified against the real github.com/mattpocock/skills repository (36 skills across categories; include-selection by basename)
This commit is contained in:
@@ -57,6 +57,7 @@ func Scan(tree Tree, cfg Config) ([]Item, error) {
|
||||
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)
|
||||
@@ -64,6 +65,9 @@ func Scan(tree Tree, cfg Config) ([]Item, error) {
|
||||
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 {
|
||||
@@ -76,18 +80,7 @@ func Scan(tree Tree, cfg Config) ([]Item, error) {
|
||||
}
|
||||
|
||||
var items []Item
|
||||
for name := range dirEntries[skillRoot] {
|
||||
skillMd := join(skillRoot, name, "SKILL.md")
|
||||
data, err := tree.ReadFile(skillMd)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
it := Item{Type: lockfile.TypeSkill, UpstreamID: name, RelPath: join(skillRoot, name)}
|
||||
fm, warnings := ParseFrontmatter(data)
|
||||
it.Frontmatter = &fm
|
||||
for _, w := range warnings {
|
||||
it.Warnings = append(it.Warnings, fmt.Sprintf("skills/%s: %s", name, w))
|
||||
}
|
||||
for _, it := range scanSkills(tree, skillRoot, skillDescendants) {
|
||||
items = append(items, it)
|
||||
}
|
||||
items = append(items, scanFlatFiles(tree, fileEntries, commandRoot, lockfile.TypeCommand)...)
|
||||
@@ -103,6 +96,53 @@ func Scan(tree Tree, cfg Config) ([]Item, error) {
|
||||
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] {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeTree(t *testing.T, root string, files map[string]string) {
|
||||
t.Helper()
|
||||
for rel, content := range files {
|
||||
p := filepath.Join(root, filepath.FromSlash(rel))
|
||||
os.MkdirAll(filepath.Dir(p), 0o755)
|
||||
os.WriteFile(p, []byte(content), 0o644)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanNestedSkillCategories(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeTree(t, dir, map[string]string{
|
||||
"skills/flat-skill/SKILL.md": "---\nname: Flat\n---\n",
|
||||
"skills/engineering/tdd/SKILL.md": "---\nname: TDD\n---\n",
|
||||
"skills/productivity/grill-me/SKILL.md": "---\nname: Grill Me\n---\n",
|
||||
"skills/engineering/tdd/reference.md": "extra artifact",
|
||||
"skills/deprecated/old/README.md": "no skill here",
|
||||
"commands/deploy.md": "---\nname: Deploy\n---\n",
|
||||
})
|
||||
|
||||
items, err := Scan(&FsTree{Root: dir}, Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := map[string]Item{}
|
||||
for _, it := range items {
|
||||
got[it.UpstreamID] = it
|
||||
}
|
||||
for _, want := range []string{"flat-skill", "tdd", "grill-me"} {
|
||||
it, ok := got[want]
|
||||
if !ok {
|
||||
t.Fatalf("skill %q not discovered (items: %+v)", want, items)
|
||||
}
|
||||
if it.Type != "skills" {
|
||||
t.Errorf("%q type = %q", want, it.Type)
|
||||
}
|
||||
}
|
||||
if _, ok := got["old"]; ok {
|
||||
t.Error("folder without SKILL.md must not be a skill")
|
||||
}
|
||||
tdd := got["tdd"]
|
||||
if tdd.RelPath != "skills/engineering/tdd" {
|
||||
t.Errorf("tdd RelPath = %q", tdd.RelPath)
|
||||
}
|
||||
if got["deploy"].Type != "commands" {
|
||||
t.Error("flat command types must still be discovered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanOuterSkillWinsOverNested(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeTree(t, dir, map[string]string{
|
||||
"skills/bundle/SKILL.md": "---\nname: Bundle\n---\n",
|
||||
"skills/bundle/inner/SKILL.md": "---\nname: Inner\n---\n",
|
||||
"skills/bundle/inner/deep/SKILL.md": "---\nname: Deep\n---\n",
|
||||
})
|
||||
|
||||
items, err := Scan(&FsTree{Root: dir}, Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) != 1 || items[0].UpstreamID != "bundle" {
|
||||
t.Fatalf("outermost skill must win, got %+v", items)
|
||||
}
|
||||
}
|
||||
Vendored
+31
-1
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -51,6 +52,7 @@ func Add(workDir string, opts AddOptions, stdout io.Writer) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg := discoveryFromTreeRoot(root)
|
||||
|
||||
repo, err := gitsource.Clone(normalized, opts.Ref)
|
||||
if err != nil {
|
||||
@@ -61,7 +63,6 @@ func Add(workDir string, opts AddOptions, stdout io.Writer) error {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg := lockfile.Discovery{Root: root}
|
||||
items, err := discovery.Scan(tree, discovery.FromLockfileDiscovery(cfg))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -117,6 +118,35 @@ func Add(workDir string, opts AddOptions, stdout io.Writer) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// discoveryFromTreeRoot maps a pasted tree sub-path onto discovery config.
|
||||
// A sub-path ending in a standard type directory (e.g. ".../tree/main/skills")
|
||||
// selects that directory as the type root; anything else becomes the
|
||||
// discovery root under which the standard layout is expected.
|
||||
func discoveryFromTreeRoot(root string) lockfile.Discovery {
|
||||
if root == "" {
|
||||
return lockfile.Discovery{}
|
||||
}
|
||||
parent, base := path.Split(strings.TrimSuffix(root, "/"))
|
||||
parent = strings.TrimSuffix(parent, "/")
|
||||
d := lockfile.Discovery{Root: parent}
|
||||
switch base {
|
||||
case lockfile.TypeSkill:
|
||||
d.SkillsDir = base
|
||||
case lockfile.TypeCommand:
|
||||
d.CommandsDir = base
|
||||
case lockfile.TypeAgent:
|
||||
d.AgentsDir = base
|
||||
case lockfile.TypeMcp:
|
||||
d.McpDir = base
|
||||
default:
|
||||
return lockfile.Discovery{Root: root}
|
||||
}
|
||||
if d == (lockfile.Discovery{}) {
|
||||
return lockfile.Discovery{}
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func checkIncludeEntries(items []discovery.Item, include []string, name string) error {
|
||||
available := map[string]bool{}
|
||||
for _, it := range items {
|
||||
|
||||
Reference in New Issue
Block a user