Skip to content

Commit a5d1d51

Browse files
Support full skill folder structure (br#26) (#28)
Add folder awareness to skill show, list, and validate commands. Skills can now bundle helper scripts and assets alongside SKILL.md, with validation warning about missing referenced files. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e4305e2 commit a5d1d51

12 files changed

Lines changed: 424 additions & 15 deletions

File tree

docs/concepts/registry.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ Project-scoped skills live in `.skern/skills/` within your project directory. Th
1515
SKILL.md
1616
deploy/
1717
SKILL.md
18+
scripts/
19+
deploy.sh
20+
config/
21+
targets.json
1822
```
1923

2024
Initialize the project registry with:
@@ -32,6 +36,8 @@ User-scoped skills live in `~/.skern/skills/` and are available across all proje
3236
skills/
3337
global-lint/
3438
SKILL.md
39+
scripts/
40+
lint-rules.js
3541
```
3642

3743
### Scope Selection

docs/concepts/skill-format.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,24 @@ The markdown body contains the skill's instructions. This is what the agent read
4747

4848
Skills track author metadata and an optional `modified-by` history. `skern skill show` displays the full provenance chain when present, including editor name, type (human/agent), platform, and date.
4949

50+
## Folder Structure
51+
52+
Skills can include additional files alongside `SKILL.md` — helper scripts, templates, configuration files, and other assets. When a skill is installed to a platform, the entire directory is copied.
53+
54+
```
55+
my-skill/
56+
├── SKILL.md
57+
├── scripts/
58+
│ ├── convert.py
59+
│ └── setup.sh
60+
└── assets/
61+
└── template.json
62+
```
63+
64+
The `scripts/` directory is language-agnostic — skills can include Python, shell, JavaScript, or any other scripts. The agent decides which language is appropriate for the skill.
65+
66+
Use `skern skill show <name>` to see which files are bundled with a skill, and `skern skill validate <name>` to check that files referenced in the skill body actually exist.
67+
5068
## Creating Skills
5169

5270
Use `skern skill create` to scaffold a new skill:

docs/reference/validation.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ If `allowed-tools` is specified in the frontmatter, no entries may be empty stri
3030
- **Author type** — must be `human` or `agent`
3131
- **Version** — should follow [semantic versioning](https://semver.org) (e.g., `1.0.0`)
3232

33+
### Folder Integrity
34+
35+
When a skill body references files (via backtick-enclosed paths like `` `scripts/run.py` `` or markdown links like `[script](scripts/run.py)`), validation checks that those files exist in the skill directory. Missing references produce **warnings**, not errors — the skill remains valid since references may be aspirational or provided at runtime.
36+
3337
## Exit Codes
3438

3539
| Code | Meaning |

internal/cli/skill_helpers.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,13 +86,17 @@ func formatSkillTable(skills []output.SkillResult) string {
8686
}
8787

8888
var b strings.Builder
89-
fmt.Fprintf(&b, "%-30s %-10s %-40s\n", "NAME", "SCOPE", "DESCRIPTION")
89+
fmt.Fprintf(&b, "%-30s %-10s %-7s %-40s\n", "NAME", "SCOPE", "FILES", "DESCRIPTION")
9090
for _, s := range skills {
9191
desc := s.Description
9292
if len(desc) > 40 {
9393
desc = desc[:37] + "..."
9494
}
95-
fmt.Fprintf(&b, "%-30s %-10s %-40s\n", s.Name, s.Scope, desc)
95+
fileCount := "-"
96+
if len(s.Files) > 0 {
97+
fileCount = fmt.Sprintf("%d", len(s.Files))
98+
}
99+
fmt.Fprintf(&b, "%-30s %-10s %-7s %-40s\n", s.Name, s.Scope, fileCount, desc)
96100
}
97101
return b.String()
98102
}
@@ -117,6 +121,12 @@ func formatSkillShow(s output.SkillResult) string {
117121
if len(s.AllowedTools) > 0 {
118122
fmt.Fprintf(&b, "Tools: %s\n", strings.Join(s.AllowedTools, ", "))
119123
}
124+
if len(s.Files) > 0 {
125+
b.WriteString("Files:\n")
126+
for _, f := range s.Files {
127+
fmt.Fprintf(&b, " - %s\n", f)
128+
}
129+
}
120130
if len(s.ModifiedBy) > 0 {
121131
b.WriteString("Modified-by:\n")
122132
for _, m := range s.ModifiedBy {

internal/cli/skill_list.go

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package cli
33
import (
44
"github.com/devrimcavusoglu/skern/internal/output"
55
"github.com/devrimcavusoglu/skern/internal/overlap"
6+
"github.com/devrimcavusoglu/skern/internal/registry"
67
"github.com/devrimcavusoglu/skern/internal/skill"
78
"github.com/spf13/cobra"
89
)
@@ -22,32 +23,35 @@ func newSkillListCmd() *cobra.Command {
2223

2324
var skillResults []output.SkillResult
2425

26+
var discovered []registry.DiscoveredSkill
27+
2528
if scope == "all" {
26-
discovered, err := reg.ListAll()
29+
discovered, err = reg.ListAll()
2730
if err != nil {
2831
return err
2932
}
30-
for _, d := range discovered {
31-
skillResults = append(skillResults, toDiscoveredSkillResult(d))
32-
}
3333
} else {
3434
scopeVal, err := parseScope(scope)
3535
if err != nil {
3636
return err
3737
}
38-
skills, err := reg.List(scopeVal)
38+
all, err := reg.ListAll()
3939
if err != nil {
4040
return err
4141
}
42-
dir := ""
43-
if scopeVal == skill.ScopeUser {
44-
dir = "user"
45-
} else {
46-
dir = "project"
42+
for _, d := range all {
43+
if d.Scope == scopeVal {
44+
discovered = append(discovered, d)
45+
}
4746
}
48-
for _, s := range skills {
49-
skillResults = append(skillResults, toSkillResult(&s, dir, ""))
47+
}
48+
49+
for _, d := range discovered {
50+
r := toDiscoveredSkillResult(d)
51+
if files, err := skill.ListFiles(d.Path); err == nil && len(files) > 0 {
52+
r.Files = files
5053
}
54+
skillResults = append(skillResults, r)
5155
}
5256

5357
// Pairwise dedup detection

internal/cli/skill_show.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package cli
22

33
import (
4+
"github.com/devrimcavusoglu/skern/internal/skill"
45
"github.com/spf13/cobra"
56
)
67

@@ -25,6 +26,9 @@ func newSkillShowCmd() *cobra.Command {
2526
}
2627

2728
result := toSkillResult(s, string(foundScope), path)
29+
if files, err := skill.ListFiles(path); err == nil && len(files) > 0 {
30+
result.Files = files
31+
}
2832
text := formatSkillShow(result)
2933
printer.PrintResult(result, text)
3034
return nil

internal/cli/skill_test.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,91 @@ func TestSkillList_NoDedupHints(t *testing.T) {
473473

474474
// --- author provenance (modified-by) ---
475475

476+
// --- skill show with files ---
477+
478+
func TestSkillShow_WithFiles(t *testing.T) {
479+
setupTestRegistry(t)
480+
481+
_, err := runCmd(t, "skill", "create", "file-skill", "--description", "A skill with files")
482+
require.NoError(t, err)
483+
484+
// Get the skill path
485+
showOut, err := runCmd(t, "skill", "show", "file-skill", "--json")
486+
require.NoError(t, err)
487+
488+
var initial output.SkillResult
489+
require.NoError(t, json.Unmarshal([]byte(showOut), &initial))
490+
491+
// Add extra files to the skill directory
492+
scriptsDir := filepath.Join(initial.Path, "scripts")
493+
require.NoError(t, os.MkdirAll(scriptsDir, 0o755))
494+
require.NoError(t, os.WriteFile(filepath.Join(scriptsDir, "convert.py"), []byte("# python"), 0o644))
495+
require.NoError(t, os.WriteFile(filepath.Join(initial.Path, "config.json"), []byte("{}"), 0o644))
496+
497+
// Show again — should include files
498+
out, err := runCmd(t, "skill", "show", "file-skill", "--json")
499+
require.NoError(t, err)
500+
501+
var result output.SkillResult
502+
require.NoError(t, json.Unmarshal([]byte(out), &result))
503+
assert.Len(t, result.Files, 2)
504+
assert.Contains(t, result.Files, "config.json")
505+
assert.Contains(t, result.Files, filepath.Join("scripts", "convert.py"))
506+
}
507+
508+
// --- skill validate folder warning ---
509+
510+
func TestSkillValidate_FolderWarning(t *testing.T) {
511+
setupTestRegistry(t)
512+
513+
_, err := runCmd(t, "skill", "create", "ref-skill", "--description", "A skill with refs", "--author", "alice")
514+
require.NoError(t, err)
515+
516+
// Get the skill path
517+
showOut, err := runCmd(t, "skill", "show", "ref-skill", "--json")
518+
require.NoError(t, err)
519+
520+
var initial output.SkillResult
521+
require.NoError(t, json.Unmarshal([]byte(showOut), &initial))
522+
523+
// Overwrite SKILL.md with a body referencing a missing file
524+
skillMdPath := filepath.Join(initial.Path, "SKILL.md")
525+
content := `---
526+
name: ref-skill
527+
description: A skill with refs
528+
metadata:
529+
author:
530+
name: alice
531+
type: human
532+
version: "0.1.0"
533+
---
534+
## Instructions
535+
536+
Run ` + "`scripts/run.py`" + ` to process data.
537+
`
538+
require.NoError(t, os.WriteFile(skillMdPath, []byte(content), 0o644))
539+
540+
// Validate — should warn about missing file
541+
out, err := runCmd(t, "skill", "validate", "ref-skill", "--json")
542+
require.NoError(t, err)
543+
544+
var result output.SkillValidateResult
545+
require.NoError(t, json.Unmarshal([]byte(out), &result))
546+
assert.True(t, result.Valid, "should still be valid (warnings only)")
547+
assert.Equal(t, 1, result.Warns)
548+
549+
// Find the folder warning
550+
found := false
551+
for _, issue := range result.Issues {
552+
if issue.Field == "folder" {
553+
found = true
554+
assert.Equal(t, "warning", issue.Severity)
555+
assert.Contains(t, issue.Message, "scripts/run.py")
556+
}
557+
}
558+
assert.True(t, found, "should have a folder warning")
559+
}
560+
476561
func TestSkillShow_ModifiedBy(t *testing.T) {
477562
setupTestRegistry(t)
478563

internal/cli/skill_validate.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,13 @@ func newSkillValidateCmd() *cobra.Command {
2424
return err
2525
}
2626

27-
s, _, _, err := resolveSkill(reg, name, scope)
27+
s, skillDir, _, err := resolveSkill(reg, name, scope)
2828
if err != nil {
2929
return err
3030
}
3131

3232
issues := skill.Validate(s)
33+
issues = append(issues, skill.ValidateFolder(s, skillDir)...)
3334
result := toValidateResult(name, issues)
3435
text := formatValidateResult(result)
3536
printer.PrintResult(result, text)

internal/output/output.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ type SkillResult struct {
143143
Scope string `json:"scope,omitempty"`
144144
Path string `json:"path,omitempty"`
145145
AllowedTools []string `json:"allowed_tools,omitempty"`
146+
Files []string `json:"files,omitempty"`
146147
ModifiedBy []ModifiedByResult `json:"modified_by,omitempty"`
147148
}
148149

internal/skill/folder.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package skill
2+
3+
import (
4+
"io/fs"
5+
"path/filepath"
6+
"regexp"
7+
"strings"
8+
)
9+
10+
// ListFiles walks the skill directory and returns relative paths of all files
11+
// except SKILL.md. Returns an empty slice for directories containing only SKILL.md.
12+
func ListFiles(skillDir string) ([]string, error) {
13+
var files []string
14+
15+
err := filepath.WalkDir(skillDir, func(path string, d fs.DirEntry, err error) error {
16+
if err != nil {
17+
return err
18+
}
19+
if d.IsDir() {
20+
return nil
21+
}
22+
23+
rel, err := filepath.Rel(skillDir, path)
24+
if err != nil {
25+
return err
26+
}
27+
28+
if rel != "SKILL.md" {
29+
files = append(files, rel)
30+
}
31+
return nil
32+
})
33+
if err != nil {
34+
return nil, err
35+
}
36+
37+
return files, nil
38+
}
39+
40+
var (
41+
// Matches backtick-enclosed paths that contain a slash (to avoid false positives like `v1.0.0`).
42+
backtickPathRe = regexp.MustCompile("`([^`]+/[^`]+)`")
43+
// Matches markdown link targets, excluding URLs (http) and anchors (#).
44+
mdLinkRe = regexp.MustCompile(`\]\(([^)]+)\)`)
45+
)
46+
47+
// ExtractFileReferences extracts path-like references from a markdown body.
48+
// It looks for backtick-enclosed paths containing '/' and markdown link targets
49+
// that are not URLs or anchors.
50+
func ExtractFileReferences(body string) []string {
51+
seen := make(map[string]bool)
52+
var refs []string
53+
54+
for _, m := range backtickPathRe.FindAllStringSubmatch(body, -1) {
55+
p := m[1]
56+
if !seen[p] {
57+
seen[p] = true
58+
refs = append(refs, p)
59+
}
60+
}
61+
62+
for _, m := range mdLinkRe.FindAllStringSubmatch(body, -1) {
63+
p := m[1]
64+
if strings.HasPrefix(p, "http") || strings.HasPrefix(p, "#") {
65+
continue
66+
}
67+
if !seen[p] {
68+
seen[p] = true
69+
refs = append(refs, p)
70+
}
71+
}
72+
73+
return refs
74+
}

0 commit comments

Comments
 (0)