Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,8 @@ The `--compare` flag is useful for understanding how different models perceive y

All commands accept `-o text` (default), `-o compact`, `-o json`, or `-o markdown` for output format. Use `--emit-annotations` with any format to emit GitHub Actions workflow annotations alongside normal output.

All commands that take a path also accept `--max-depth=N` to control how many directory levels below the path are searched for skills (see [Multi-skill directories](#multi-skill-directories)).

### Compact output

The default text output prints every passing check, token counts, and the
Expand Down Expand Up @@ -658,12 +660,20 @@ For in-progress skills where you want warnings to be non-blocking, remove `--str

### Multi-skill directories

If the given path does not contain a `SKILL.md` but has subdirectories that do, the validator automatically detects and validates each skill. This is useful when skills are organized as sibling directories (e.g. `skills/algorithmic-art/`, `skills/brand-guidelines/`). Symlinks are followed during detection.
If the given path does not contain a `SKILL.md`, the validator searches its subdirectories for skills and validates each one. This covers skills organized as sibling directories (e.g. `skills/algorithmic-art/`, `skills/brand-guidelines/`) and skills grouped under intermediate directories (e.g. `skills/<product>/<skill>/`), so one invocation can cover a whole catalog:

```
skill-validator check skills/
```

Only the given path is searched. The [agentskills.io implementation guide](https://agentskills.io/client-implementation/adding-skills-support.md) describes how an agent discovers skills at session start, by scanning fixed locations such as `~/.agents/skills/` and `<project>/.agents/skills/`. A validator has no reason to look anywhere but where it is pointed, so skill-validator never scans the home directory or any other conventional location. What it does take from the guide is its scanning rules:

- Subdirectories are searched up to 6 levels deep by default. Use `--max-depth=N` to change this; `--max-depth=1` searches only the immediate subdirectories, and `--max-depth=0` treats the given path as a single skill and nothing else.
- A directory that contains a `SKILL.md` is a skill, and nothing below it is searched, so a `SKILL.md` inside a skill's examples or references does not count as a second skill.
- Hidden directories (names starting with `.`, which covers `.git/`) and dependency directories (`node_modules/`, `__pycache__/`) are skipped. `.gitignore` is not consulted: honoring it would mean locating the repository root and evaluating nested ignore rules, and the directory budget already bounds the cost of scanning build output.
- Symlinks are followed, so a skills directory assembled from links to skill folders kept elsewhere works. Each real directory is examined once, which makes symlink cycles harmless and keeps a skill from being reported twice under two paths.
- At most 2000 directories are listed. A skill is found as soon as its parent directory is listed, and the search is breadth-first, so shallow skills are found before a large subtree can use up that budget. If the limit is reached, a warning on stderr says that skills below that point were not found.

Each skill is validated independently. The text output separates skills with a line and appends an overall summary. The JSON output wraps individual skill reports in a `skills` array:

```json
Expand All @@ -678,7 +688,7 @@ Each skill is validated independently. The text output separates skills with a l
}
```

If no `SKILL.md` is found at the root or in any immediate subdirectory, the validator exits with code 3 (CLI error).
If no `SKILL.md` is found at the root or within the search depth, the validator exits with code 3 (CLI error).

## Examples

Expand Down
39 changes: 39 additions & 0 deletions cmd/exitcode_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -312,3 +312,42 @@ func TestExcludeTokenPathsOutputFormats(t *testing.T) {
})
}
}

// Issue #77: skills under intermediate directories are found by default,
// and --max-depth bounds the search.
func TestMaxDepth(t *testing.T) {
bin := buildBinary(t)
root := t.TempDir()
skillDir := filepath.Join(root, "product", "skill")
if err := os.MkdirAll(skillDir, 0o755); err != nil {
t.Fatal(err)
}
skill := "---\nname: skill\ndescription: Nested two levels down.\n---\n# Skill\n\nDo the thing.\n"
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(skill), 0o644); err != nil {
t.Fatal(err)
}

tests := []struct {
name string
args []string
wantCode int
want string
}{
{"default finds depth 2", []string{"validate", "structure", "-o", "json", root}, 0, `"skill_dir"`},
{"max-depth 1 does not", []string{"validate", "structure", "--max-depth=1", root}, 3, "up to 1 levels deep"},
{"max-depth 0 on the skill", []string{"validate", "structure", "--max-depth=0", skillDir}, 0, ""},
{"negative is a usage error", []string{"check", "--max-depth=-1", root}, 3, "invalid --max-depth"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := exec.Command(bin, tt.args...)
out, _ := cmd.CombinedOutput()
if got := cmd.ProcessState.ExitCode(); got != tt.wantCode {
t.Errorf("exit code = %d, want %d\noutput: %s", got, tt.wantCode, out)
}
if tt.want != "" && !strings.Contains(string(out), tt.want) {
t.Errorf("expected output to contain %q, got:\n%s", tt.want, out)
}
})
}
}
17 changes: 13 additions & 4 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const version = "v1.6.2"
var (
outputFormat string
emitAnnotations bool
maxDepth int
)

var rootCmd = &cobra.Command{
Expand All @@ -33,6 +34,7 @@ func init() {
rootCmd.Version = version
rootCmd.PersistentFlags().StringVarP(&outputFormat, "output", "o", "text", "output format: text, compact, json, or markdown")
rootCmd.PersistentFlags().BoolVar(&emitAnnotations, "emit-annotations", false, "emit GitHub Actions workflow command annotations (::error/::warning) alongside normal output")
rootCmd.PersistentFlags().IntVar(&maxDepth, "max-depth", skillcheck.DefaultMaxDepth, "how many directory levels below <path> to search for SKILL.md files (0 searches <path> only)")
}

// Execute runs the root command.
Expand Down Expand Up @@ -78,10 +80,17 @@ func detectAndResolve(args []string) (string, types.SkillMode, []string, error)
return "", 0, nil, err
}

mode, dirs := skillcheck.DetectSkills(absDir)
if mode == types.NoSkill {
return "", 0, nil, fmt.Errorf("no skills found in %s (expected SKILL.md or subdirectories containing SKILL.md)", args[0])
if maxDepth < 0 {
return "", 0, nil, fmt.Errorf("invalid --max-depth: %d is negative", maxDepth)
}

return absDir, mode, dirs, nil
d := skillcheck.DiscoverSkills(absDir, maxDepth)
if d.Truncated {
fmt.Fprintf(os.Stderr, "Warning: skill discovery stopped after listing %d directories under %s; skills below that point were not found\n", skillcheck.MaxDiscoveryDirs, args[0])
}
if d.Mode == types.NoSkill {
return "", 0, nil, fmt.Errorf("no skills found in %s (expected SKILL.md in the directory or in a subdirectory up to %d levels deep)", args[0], maxDepth)
}

return absDir, d.Mode, d.Dirs, nil
}
184 changes: 163 additions & 21 deletions skillcheck/validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,42 +15,184 @@ import (
"github.com/agent-ecosystem/skill-validator/util"
)

// Discovery bounds. The agentskills.io client implementation guide
// recommends that scanners "set reasonable bounds (e.g., max depth of 4-6
// levels, max 2000 directories) to prevent runaway scanning in large
// directory trees".
const (
// DefaultMaxDepth is how many directory levels below the given path
// DetectSkills searches for SKILL.md files. Depth 1 is the immediate
// subdirectories.
DefaultMaxDepth = 6

// MaxDiscoveryDirs is the most directories a discovery scan lists
// before it stops. A scan that stops early reports Truncated.
MaxDiscoveryDirs = 2000
)

// skipDirs are directory names that never hold skills and can be large
// enough to use up the discovery budget. Hidden directories (".git",
// ".venv", ...) are skipped separately by name prefix.
var skipDirs = map[string]bool{
"node_modules": true,
"__pycache__": true,
}

// Discovery is the result of DiscoverSkills.
type Discovery struct {
Mode types.SkillMode
// Dirs holds the skill directories in sorted order. For SingleSkill it
// is the given path; for NoSkill it is nil.
Dirs []string
// Depths maps each entry of Dirs to how many directory levels below
// the given path it was found at: 0 for a single skill, 1 for an
// immediate subdirectory. Agents differ in how deep they look, so a
// skill found at depth 2 may be invisible to some of them.
Depths map[string]int
// Truncated reports that the scan stopped at MaxDiscoveryDirs before it
// had examined the whole tree, so skills below that point were missed.
Truncated bool
}

// DetectSkills determines whether dir is a single skill, a multi-skill
// parent, or contains no skills. It follows symlinks when checking
// subdirectories.
// parent, or contains no skills, searching DefaultMaxDepth levels deep.
// See DiscoverSkills.
func DetectSkills(dir string) (types.SkillMode, []string) {
// If the directory itself contains SKILL.md, it's a single skill.
if _, err := os.Stat(filepath.Join(dir, "SKILL.md")); err == nil {
return types.SingleSkill, []string{dir}
d := DiscoverSkills(dir, DefaultMaxDepth)
return d.Mode, d.Dirs
}

// DiscoverSkills determines whether root is a single skill, a multi-skill
// parent, or contains no skills.
//
// If root itself contains SKILL.md it is a single skill and nothing below
// it is examined. Otherwise the subdirectories are searched breadth-first,
// up to maxDepth levels below root (1 means the immediate subdirectories
// only), and every directory holding a SKILL.md is a skill. A skill's own
// subtree is not searched further, so a SKILL.md nested inside a skill's
// references or examples does not count as a second skill. Hidden
// directories and dependency directories such as node_modules are
// skipped. Only the given path is scanned: the user's home directory and
// the .agents/skills conventions are an agent's concern, not a
// validator's.
//
// Symlinks are followed, so a skills directory assembled from links to
// skill folders elsewhere works. Each real directory is examined once,
// which makes symlink cycles and aliases harmless and keeps a skill from
// being reported under two paths. The scan lists at most MaxDiscoveryDirs
// directories and reports Truncated if it had to stop. A skill is found
// as soon as its parent directory is listed, and the search is
// breadth-first, so shallow skills are found before a large or linked
// subtree can exhaust that budget.
func DiscoverSkills(root string, maxDepth int) Discovery {
return discoverSkills(root, maxDepth, MaxDiscoveryDirs)
}

func discoverSkills(root string, maxDepth, maxDirs int) Discovery {
if hasSkillFile(root) {
return Discovery{Mode: types.SingleSkill, Dirs: []string{root}, Depths: map[string]int{root: 0}}
}
if maxDepth < 1 {
return Discovery{Mode: types.NoSkill}
}

// Scan immediate subdirectories for SKILL.md.
entries, err := os.ReadDir(dir)
// A queued directory is one that will be listed. Its children are
// classified as they are listed: a child with SKILL.md is a skill and
// is not queued; any other child is queued if it is shallow enough to
// be listed in turn. So a skill is found as soon as its parent is
// listed, and the budget is spent on listings, not on skills.
type dirItem struct {
path string // path as reached from root, reported to the caller
real string // absolute, symlink-resolved path, used to visit each directory once
depth int
}

// Identity keys must be absolute: a relative root would otherwise give
// relative keys to ordinary children while a link to an absolute target
// resolves to an absolute key, and the same directory would be visited
// twice. Caller-facing paths are left as given.
visited := make(map[string]bool)
absRoot, err := filepath.Abs(root)
if err != nil {
return Discovery{Mode: types.NoSkill}
}
realRoot, err := filepath.EvalSymlinks(absRoot)
if err != nil {
return types.NoSkill, nil
return Discovery{Mode: types.NoSkill}
}
visited[realRoot] = true

queue := []dirItem{{path: root, real: realRoot, depth: 0}}
var skillDirs []string
for _, entry := range entries {
name := entry.Name()
if strings.HasPrefix(name, ".") {
continue
depths := make(map[string]int)
listed := 0
truncated := false

for len(queue) > 0 {
cur := queue[0]
queue = queue[1:]

if listed >= maxDirs {
truncated = true
break
}
subdir := filepath.Join(dir, name)
// Use os.Stat (not entry.IsDir()) to follow symlinks.
info, err := os.Stat(subdir)
if err != nil || !info.IsDir() {
listed++

entries, err := os.ReadDir(cur.path)
if err != nil {
continue
}
if _, err := os.Stat(filepath.Join(subdir, "SKILL.md")); err == nil {
skillDirs = append(skillDirs, subdir)
for _, entry := range entries {
name := entry.Name()
if strings.HasPrefix(name, ".") || skipDirs[name] {
continue
}
sub := filepath.Join(cur.path, name)
var real string
switch {
case entry.IsDir():
real = filepath.Join(cur.real, name)
case entry.Type()&os.ModeSymlink != 0:
// Follow the link, but only into a directory that exists.
// Resolving through cur.real keeps the key absolute.
resolved, err := filepath.EvalSymlinks(filepath.Join(cur.real, name))
if err != nil {
continue
}
if info, err := os.Stat(resolved); err != nil || !info.IsDir() {
continue
}
real = resolved
default:
continue
}
if visited[real] {
continue
}
visited[real] = true

if hasSkillFile(sub) {
skillDirs = append(skillDirs, sub)
depths[sub] = cur.depth + 1
continue
}
if cur.depth+1 < maxDepth {
queue = append(queue, dirItem{path: sub, real: real, depth: cur.depth + 1})
}
}
}

if len(skillDirs) > 0 {
return types.MultiSkill, skillDirs
if len(skillDirs) == 0 {
return Discovery{Mode: types.NoSkill, Truncated: truncated}
}
return types.NoSkill, nil
sort.Strings(skillDirs)
return Discovery{Mode: types.MultiSkill, Dirs: skillDirs, Depths: depths, Truncated: truncated}
}

// hasSkillFile reports whether dir contains a SKILL.md (following symlinks).
func hasSkillFile(dir string) bool {
_, err := os.Stat(filepath.Join(dir, "SKILL.md"))
return err == nil
}

// ReadSkillRaw reads the raw SKILL.md content from a directory without parsing
Expand Down
Loading
Loading