Skip to content

Commit 8b3f2ae

Browse files
authored
Merge pull request #41 from agent-ecosystem/feat-allow-dirs
Feat: Add support for explicitly allowing specified directories
2 parents cc5d9d7 + ee042e7 commit 8b3f2ae

16 files changed

Lines changed: 535 additions & 33 deletions

File tree

README.md

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ Spec compliance is table stakes. `skill-validator` goes further: it checks that
3333
- [Examples](#examples)
3434
- [What it checks & why](#what-it-checks)
3535
- [Structure validation](#structure-validation-validate-structure)
36+
- [Flat skill layouts](#flat-skill-layouts)
37+
- [Allowing non-standard directories](#allowing-non-standard-directories)
3638
- [Link validation](#link-validation-validate-links)
3739
- [Content analysis](#content-analysis-analyze-content)
3840
- [Contamination analysis](#contamination-analysis-analyze-contamination)
@@ -185,9 +187,18 @@ skill-validator validate structure --skip-orphans <path>
185187
skill-validator validate structure --strict <path>
186188
skill-validator validate structure --allow-extra-frontmatter <path>
187189
skill-validator validate structure --allow-flat-layouts <path>
190+
skill-validator validate structure --allow-dirs=evals,testing <path>
188191
```
189192
190-
Checks spec compliance: directory structure, frontmatter fields, token limits, skill ratio, code fence integrity, internal link validity, and orphan file detection. Use `--skip-orphans` to suppress warnings about unreferenced files in `scripts/`, `references/`, and `assets/`. Use `--strict` to treat warnings as errors (exit 1 instead of 2). Use `--allow-extra-frontmatter` to suppress warnings for frontmatter fields not defined in the spec (e.g. `user-invokable`). Standard frontmatter fields are still fully validated. Use `--allow-flat-layouts` to allow supplemental files alongside SKILL.md at the skill root without warnings (see [Flat skill layouts](#flat-skill-layouts)).
193+
Checks spec compliance: directory structure, frontmatter fields, token limits, skill ratio, code fence integrity, internal link validity, and orphan file detection.
194+
195+
| Flag | Effect |
196+
|---|---|
197+
| `--strict` | Treat warnings as errors (exit 1 instead of 2) |
198+
| `--skip-orphans` | Suppress warnings about unreferenced files in `scripts/`, `references/`, and `assets/` |
199+
| `--allow-extra-frontmatter` | Suppress warnings for non-spec frontmatter fields (e.g. `user-invokable`). Standard fields are still fully validated |
200+
| `--allow-flat-layouts` | Allow files at the skill root without warnings (see [Flat skill layouts](#flat-skill-layouts)) |
201+
| `--allow-dirs=evals,testing` | Accept specific non-standard directories without warnings (see [Allowing non-standard directories](#allowing-non-standard-directories)) |
191202
192203
```
193204
Validating skill: my-skill/
@@ -284,9 +295,21 @@ skill-validator check --skip-orphans <path>
284295
skill-validator check --strict <path>
285296
skill-validator check --allow-extra-frontmatter <path>
286297
skill-validator check --allow-flat-layouts <path>
298+
skill-validator check --allow-dirs=evals,testing <path>
287299
```
288300
289-
Runs all checks (structure + links + content + contamination). Use `--only` or `--skip` to select specific check groups. The flags are mutually exclusive. Use `--per-file` to see per-file reference analysis alongside the aggregate. Use `--skip-orphans` to suppress orphan file warnings in the structure check. Use `--strict` to treat warnings as errors (exit 1 instead of 2). Use `--allow-extra-frontmatter` to suppress warnings for non-spec frontmatter fields. Use `--allow-flat-layouts` to allow supplemental files at the skill root without warnings (see [Flat skill layouts](#flat-skill-layouts)).
301+
Runs all checks (structure + links + content + contamination).
302+
303+
| Flag | Effect |
304+
|---|---|
305+
| `--only` | Comma-separated list of check groups to run (mutually exclusive with `--skip`) |
306+
| `--skip` | Comma-separated list of check groups to skip (mutually exclusive with `--only`) |
307+
| `--per-file` | Show per-file reference analysis alongside the aggregate |
308+
| `--strict` | Treat warnings as errors (exit 1 instead of 2) |
309+
| `--skip-orphans` | Suppress orphan file warnings in the structure check |
310+
| `--allow-extra-frontmatter` | Suppress warnings for non-spec frontmatter fields |
311+
| `--allow-flat-layouts` | Allow files at the skill root without warnings (see [Flat skill layouts](#flat-skill-layouts)) |
312+
| `--allow-dirs=evals,testing` | Accept specific non-standard directories without warnings (see [Allowing non-standard directories](#allowing-non-standard-directories)) |
290313
291314
Valid check groups: `structure`, `links`, `content`, `contamination`.
292315
@@ -709,6 +732,27 @@ skill-validator check --allow-flat-layouts my-skill/
709732
> [!NOTE]
710733
> The standard directory structure remains the recommended approach for maximum portability across agent platforms. Use `--allow-flat-layouts` when a flat layout better fits your workflow, with the understanding that some platforms may not discover files outside the recognized directories.
711734
735+
**Allowing non-standard directories**
736+
737+
The spec defines three recognized directories (`scripts/`, `references/`, `assets/`). Any other directory at the skill root produces a warning. This relates to cross-platform skill file loading considerations described in [agent-ecosystem/agent-skill-implementation](https://github.com/agent-ecosystem/agent-skill-implementation).
738+
739+
Some development workflows use additional directories that may produce unexpected behavior across agent platforms. For example, the [evaluating-skills guide](https://agentskills.io/skill-creation/evaluating-skills) recommends an `evals/` directory for evaluation test cases, and teams may keep integration test fixtures in a `testing/` directory. If you are not distributing cross-platform skills and want to suppress warnings for specific directories that you know your preferred agent platform supports, use the `--allow-dirs` flag to suppress warnings for specific directories by name:
740+
741+
```
742+
skill-validator validate structure --allow-dirs=evals my-skill/
743+
skill-validator check --allow-dirs=evals,testing my-skill/
744+
```
745+
746+
The flag accepts a comma-separated list or can be repeated (`--allow-dirs=evals --allow-dirs=testing`). Allowed directories differ from recognized directories in two ways:
747+
748+
1. **Exempt from deep-nesting checks**: The validator can't know the expected internal structure of arbitrary directories, so subdirectories like `evals/files/` won't trigger nesting warnings.
749+
2. **Skipped for orphan detection**: Since the validator doesn't know how these directories are used, it skips orphan file checks for them and emits an informational note instead.
750+
751+
Directories not in the allow list still produce the standard warning with file counts and suggestions. If an allowed directory name matches a recognized directory (e.g., `--allow-dirs=scripts`), it's silently accepted with no change in behavior.
752+
753+
> [!NOTE]
754+
> Allowing a directory suppresses validator warnings but does not change how agent platforms handle the directory. Files in non-standard directories may not be discovered during skill activation, or may load into agent context unexpectedly. If you're distributing skills across platforms, consider whether those files belong in `references/` or `assets/` instead.
755+
712756
### Link validation (`validate links`)
713757

714758
- Checks external (HTTP/HTTPS) links only -- internal (relative) links are validated by `validate structure`

cmd/check.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ var (
2020
strictCheck bool
2121
checkAllowExtraFrontmatter bool
2222
checkAllowFlatLayouts bool
23+
checkAllowDirs []string
2324
)
2425

2526
var checkCmd = &cobra.Command{
@@ -41,6 +42,8 @@ func init() {
4142
"suppress warnings for non-spec frontmatter fields")
4243
checkCmd.Flags().BoolVar(&checkAllowFlatLayouts, "allow-flat-layouts", false,
4344
"allow files at the skill root without warnings and treat them as standard content for token counting")
45+
checkCmd.Flags().StringSliceVar(&checkAllowDirs, "allow-dirs", nil,
46+
"comma-separated list of directory names to accept without warnings (e.g. --allow-dirs=evals,testing)")
4447
rootCmd.AddCommand(checkCmd)
4548
}
4649

@@ -72,6 +75,7 @@ func runCheck(cmd *cobra.Command, args []string) error {
7275
SkipOrphans: checkSkipOrphans,
7376
AllowExtraFrontmatter: checkAllowExtraFrontmatter,
7477
AllowFlatLayouts: checkAllowFlatLayouts,
78+
AllowDirs: checkAllowDirs,
7579
},
7680
}
7781
eopts := exitOpts{strict: strictCheck}

cmd/cmd_test.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -551,6 +551,121 @@ func TestValidateCommand_FlatSkill_OrphanDetection(t *testing.T) {
551551
}
552552
}
553553

554+
func TestValidateCommand_AllowedDirsSkill_WithoutFlag(t *testing.T) {
555+
dir := fixtureDir(t, "allowed-dirs-skill")
556+
557+
r := structure.Validate(dir, structure.Options{})
558+
// Without --allow-dirs, evals/ and testing/ should produce warnings
559+
hasEvalsWarning := false
560+
hasTestingWarning := false
561+
for _, res := range r.Results {
562+
if res.Level == types.Warning && strings.Contains(res.Message, "unknown directory: evals/") {
563+
hasEvalsWarning = true
564+
}
565+
if res.Level == types.Warning && strings.Contains(res.Message, "unknown directory: testing/") {
566+
hasTestingWarning = true
567+
}
568+
}
569+
if !hasEvalsWarning {
570+
t.Error("expected warning for evals/ without --allow-dirs")
571+
}
572+
if !hasTestingWarning {
573+
t.Error("expected warning for testing/ without --allow-dirs")
574+
}
575+
}
576+
577+
func TestValidateCommand_AllowedDirsSkill_WithFlag(t *testing.T) {
578+
dir := fixtureDir(t, "allowed-dirs-skill")
579+
580+
r := structure.Validate(dir, structure.Options{AllowDirs: []string{"evals", "testing"}})
581+
582+
// Should pass with no errors
583+
if r.Errors != 0 {
584+
t.Errorf("expected 0 errors, got %d", r.Errors)
585+
for _, res := range r.Results {
586+
if res.Level == types.Error {
587+
t.Logf(" error: %s: %s", res.Category, res.Message)
588+
}
589+
}
590+
}
591+
592+
// No warnings about evals/ or testing/
593+
for _, res := range r.Results {
594+
if res.Level == types.Warning &&
595+
(strings.Contains(res.Message, "evals/") || strings.Contains(res.Message, "testing/")) {
596+
t.Errorf("unexpected warning with --allow-dirs: %s", res.Message)
597+
}
598+
}
599+
600+
// No deep nesting warning for evals/files/
601+
for _, res := range r.Results {
602+
if res.Level == types.Warning && strings.Contains(res.Message, "deep nesting") {
603+
t.Errorf("unexpected deep nesting warning for allowed dir: %s", res.Message)
604+
}
605+
}
606+
607+
// Should have info notes for orphan detection skipping
608+
hasEvalsInfo := false
609+
hasTestingInfo := false
610+
for _, res := range r.Results {
611+
if res.Level == types.Info && strings.Contains(res.Message, "evals/ skipped for orphan detection") {
612+
hasEvalsInfo = true
613+
}
614+
if res.Level == types.Info && strings.Contains(res.Message, "testing/ skipped for orphan detection") {
615+
hasTestingInfo = true
616+
}
617+
}
618+
if !hasEvalsInfo {
619+
t.Error("expected info note about evals/ being skipped for orphan detection")
620+
}
621+
if !hasTestingInfo {
622+
t.Error("expected info note about testing/ being skipped for orphan detection")
623+
}
624+
625+
// Recognized dirs should still have orphan pass results
626+
hasReferencesPass := false
627+
hasScriptsPass := false
628+
for _, res := range r.Results {
629+
if res.Level == types.Pass && strings.Contains(res.Message, "all files in references/ are referenced") {
630+
hasReferencesPass = true
631+
}
632+
if res.Level == types.Pass && strings.Contains(res.Message, "all files in scripts/ are referenced") {
633+
hasScriptsPass = true
634+
}
635+
}
636+
if !hasReferencesPass {
637+
t.Error("expected pass for references/ orphan check")
638+
}
639+
if !hasScriptsPass {
640+
t.Error("expected pass for scripts/ orphan check")
641+
}
642+
}
643+
644+
func TestValidateCommand_AllowedDirsSkill_PartialAllow(t *testing.T) {
645+
dir := fixtureDir(t, "allowed-dirs-skill")
646+
647+
// Only allow evals, not testing
648+
r := structure.Validate(dir, structure.Options{AllowDirs: []string{"evals"}})
649+
650+
// evals/ should not warn
651+
for _, res := range r.Results {
652+
if res.Level == types.Warning && strings.Contains(res.Message, "unknown directory: evals/") {
653+
t.Error("unexpected warning for evals/ with --allow-dirs=evals")
654+
}
655+
}
656+
657+
// testing/ should still warn
658+
hasTestingWarning := false
659+
for _, res := range r.Results {
660+
if res.Level == types.Warning && strings.Contains(res.Message, "unknown directory: testing/") {
661+
hasTestingWarning = true
662+
}
663+
}
664+
if !hasTestingWarning {
665+
t.Error("expected warning for testing/ when not in --allow-dirs")
666+
}
667+
}
668+
554669
func TestDetectAndResolve_NoSkill(t *testing.T) {
555670
dir := t.TempDir()
556671
_, _, _, err := detectAndResolve([]string{dir})

cmd/validate_structure.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ var (
1212
strictStructure bool
1313
structAllowExtraFrontmatter bool
1414
structAllowFlatLayouts bool
15+
structAllowDirs []string
1516
)
1617

1718
var validateStructureCmd = &cobra.Command{
@@ -30,6 +31,8 @@ func init() {
3031
"suppress warnings for non-spec frontmatter fields")
3132
validateStructureCmd.Flags().BoolVar(&structAllowFlatLayouts, "allow-flat-layouts", false,
3233
"allow files at the skill root without warnings and treat them as standard content for token counting")
34+
validateStructureCmd.Flags().StringSliceVar(&structAllowDirs, "allow-dirs", nil,
35+
"comma-separated list of directory names to accept without warnings (e.g. --allow-dirs=evals,testing)")
3336
validateCmd.AddCommand(validateStructureCmd)
3437
}
3538

@@ -43,6 +46,7 @@ func runValidateStructure(cmd *cobra.Command, args []string) error {
4346
SkipOrphans: skipOrphans,
4447
AllowExtraFrontmatter: structAllowExtraFrontmatter,
4548
AllowFlatLayouts: structAllowFlatLayouts,
49+
AllowDirs: structAllowDirs,
4650
}
4751
eopts := exitOpts{strict: strictStructure}
4852

structure/checks.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,18 @@ var knownExtraneousFiles = map[string]string{
4242
// CheckStructure validates the directory layout of a skill package. It checks
4343
// for the required SKILL.md file, flags unrecognized directories and extraneous
4444
// root files, and warns about deep nesting in recognized directories.
45+
// Directories listed in opts.AllowDirs are accepted without warning and are
46+
// exempt from deep-nesting checks.
4547
func CheckStructure(dir string, opts Options) []types.Result {
4648
ctx := types.ResultContext{Category: "Structure"}
4749
var results []types.Result
4850

51+
// Build a set of user-allowed directories.
52+
allowedDirs := make(map[string]bool, len(opts.AllowDirs))
53+
for _, d := range opts.AllowDirs {
54+
allowedDirs[d] = true
55+
}
56+
4957
// Check SKILL.md exists
5058
skillPath := filepath.Join(dir, "SKILL.md")
5159
if _, err := os.Stat(skillPath); os.IsNotExist(err) {
@@ -72,7 +80,7 @@ func CheckStructure(dir string, opts Options) []types.Result {
7280
}
7381
continue
7482
}
75-
if !recognizedDirs[name] {
83+
if !recognizedDirs[name] && !allowedDirs[name] {
7684
msg := fmt.Sprintf("unknown directory: %s/", name)
7785
if subEntries, err := os.ReadDir(filepath.Join(dir, name)); err == nil {
7886
fileCount := 0
@@ -93,7 +101,9 @@ func CheckStructure(dir string, opts Options) []types.Result {
93101
}
94102
}
95103

96-
// Check for deep nesting in recognized directories
104+
// Check for deep nesting in recognized directories only.
105+
// Allowed directories are exempt because the validator cannot know
106+
// their expected internal structure.
97107
for dirName := range recognizedDirs {
98108
subdir := filepath.Join(dir, dirName)
99109
if _, err := os.Stat(subdir); os.IsNotExist(err) {

structure/checks_test.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,4 +205,96 @@ func TestCheckStructure(t *testing.T) {
205205
results := CheckStructure(dir, Options{AllowFlatLayouts: true})
206206
requireResultContaining(t, results, types.Warning, "unknown directory: extras/")
207207
})
208+
209+
t.Run("allow-dirs suppresses warning for allowed directory", func(t *testing.T) {
210+
dir := t.TempDir()
211+
writeFile(t, dir, "SKILL.md", "content")
212+
writeFile(t, dir, "evals/evals.json", `{"tests": []}`)
213+
results := CheckStructure(dir, Options{AllowDirs: []string{"evals"}})
214+
requireResult(t, results, types.Pass, "SKILL.md found")
215+
requireNoLevel(t, results, types.Warning)
216+
})
217+
218+
t.Run("allow-dirs with multiple directories", func(t *testing.T) {
219+
dir := t.TempDir()
220+
writeFile(t, dir, "SKILL.md", "content")
221+
writeFile(t, dir, "evals/evals.json", `{"tests": []}`)
222+
writeFile(t, dir, "testing/test1.md", "test content")
223+
results := CheckStructure(dir, Options{AllowDirs: []string{"evals", "testing"}})
224+
requireResult(t, results, types.Pass, "SKILL.md found")
225+
requireNoLevel(t, results, types.Warning)
226+
})
227+
228+
t.Run("allow-dirs partial allows still warn for non-allowed dirs", func(t *testing.T) {
229+
dir := t.TempDir()
230+
writeFile(t, dir, "SKILL.md", "content")
231+
writeFile(t, dir, "evals/evals.json", `{"tests": []}`)
232+
writeFile(t, dir, "extras/file.md", "content")
233+
results := CheckStructure(dir, Options{AllowDirs: []string{"evals"}})
234+
requireNoResultContaining(t, results, types.Warning, "evals/")
235+
requireResultContaining(t, results, types.Warning, "unknown directory: extras/")
236+
})
237+
238+
t.Run("allow-dirs silently accepts already-recognized directory", func(t *testing.T) {
239+
dir := t.TempDir()
240+
writeFile(t, dir, "SKILL.md", "content")
241+
writeFile(t, dir, "scripts/setup.sh", "#!/bin/bash")
242+
results := CheckStructure(dir, Options{AllowDirs: []string{"scripts"}})
243+
requireResult(t, results, types.Pass, "SKILL.md found")
244+
requireNoLevel(t, results, types.Warning)
245+
})
246+
247+
t.Run("allow-dirs exempt from deep nesting checks", func(t *testing.T) {
248+
dir := t.TempDir()
249+
writeFile(t, dir, "SKILL.md", "content")
250+
writeFile(t, dir, "evals/files/test1.txt", "test input")
251+
results := CheckStructure(dir, Options{AllowDirs: []string{"evals"}})
252+
requireResult(t, results, types.Pass, "SKILL.md found")
253+
requireNoResultContaining(t, results, types.Warning, "deep nesting")
254+
})
255+
256+
t.Run("allow-dirs does not exempt recognized dirs from deep nesting", func(t *testing.T) {
257+
dir := t.TempDir()
258+
writeFile(t, dir, "SKILL.md", "content")
259+
writeFile(t, dir, "evals/files/test1.txt", "test input")
260+
if err := os.MkdirAll(filepath.Join(dir, "references", "subdir"), 0o755); err != nil {
261+
t.Fatal(err)
262+
}
263+
results := CheckStructure(dir, Options{AllowDirs: []string{"evals"}})
264+
requireNoResultContaining(t, results, types.Warning, "evals/")
265+
requireResult(t, results, types.Warning, "deep nesting detected: references/subdir/")
266+
})
267+
268+
t.Run("allow-dirs with allow-flat-layouts", func(t *testing.T) {
269+
dir := t.TempDir()
270+
writeFile(t, dir, "SKILL.md", "content")
271+
writeFile(t, dir, "README.md", "readme")
272+
writeFile(t, dir, "notes.txt", "notes")
273+
writeFile(t, dir, "evals/evals.json", `{"tests": []}`)
274+
results := CheckStructure(dir, Options{AllowFlatLayouts: true, AllowDirs: []string{"evals"}})
275+
requireResult(t, results, types.Pass, "SKILL.md found")
276+
requireNoLevel(t, results, types.Warning)
277+
})
278+
279+
t.Run("allow-dirs with allow-flat-layouts still warns on non-allowed dirs", func(t *testing.T) {
280+
dir := t.TempDir()
281+
writeFile(t, dir, "SKILL.md", "content")
282+
writeFile(t, dir, "README.md", "readme")
283+
writeFile(t, dir, "extras/file.md", "content")
284+
writeFile(t, dir, "evals/evals.json", `{"tests": []}`)
285+
results := CheckStructure(dir, Options{AllowFlatLayouts: true, AllowDirs: []string{"evals"}})
286+
requireNoLevel(t, results, types.Error)
287+
requireNoResultContaining(t, results, types.Warning, "README.md")
288+
requireNoResultContaining(t, results, types.Warning, "evals/")
289+
requireResultContaining(t, results, types.Warning, "unknown directory: extras/")
290+
})
291+
292+
t.Run("allow-dirs hint still shown for non-allowed unknown dirs", func(t *testing.T) {
293+
dir := t.TempDir()
294+
writeFile(t, dir, "SKILL.md", "content")
295+
writeFile(t, dir, "evals/evals.json", `{"tests": []}`)
296+
writeFile(t, dir, "extras/file.md", "content")
297+
results := CheckStructure(dir, Options{AllowDirs: []string{"evals"}})
298+
requireResultContaining(t, results, types.Warning, "should this be references/ or assets/?")
299+
})
208300
}

0 commit comments

Comments
 (0)