diff --git a/README.md b/README.md index 2aa6c9e..61231bb 100644 --- a/README.md +++ b/README.md @@ -247,6 +247,10 @@ Alongside the toolchain, brief picks up the conventional documents that describe Matching is case-insensitive and checks the repo root first, then `docs/`, `.github/`, and `.gitlab/`. Paths in the output are repo-relative, so a funding file found under `.github/` is reported as `.github/FUNDING.yml` rather than just the basename. In JSON the groups appear as nested objects under `resources.legal`, `resources.community`, `resources.security`, and `resources.metadata`. +## Agent skills + +Separately from resources, brief reports agent skills the project provides. These are packaged instructions an AI coding agent can load on demand, not guidance on how to work on this codebase. Detection currently covers Anthropic's `SKILL.md` convention: a `SKILL.md` file with YAML frontmatter under `skills//` or `.claude/skills//`. Each skill is listed with its name and description from the frontmatter (falling back to the directory name) and the path to its `SKILL.md`. In JSON they appear under `skills` with a `format` field set to `claude` so other skill formats can be added later without changing the shape. + ## What it detects diff --git a/brief.go b/brief.go index a7e4715..b09a125 100644 --- a/brief.go +++ b/brief.go @@ -151,6 +151,15 @@ func (r *ResourceInfo) Empty() bool { len(r.Security) == 0 && len(r.Metadata) == 0 } +// Skill is an agent skill the project provides: packaged instructions an AI +// coding agent can load on demand. Path is relative to the repository root. +type Skill struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Path string `json:"path"` + Format string `json:"format"` +} + // GitInfo describes the git repository state. type GitInfo struct { Branch string `json:"branch,omitempty"` @@ -297,6 +306,7 @@ type Report struct { Layout *LayoutInfo `json:"layout,omitempty"` Platforms *PlatformInfo `json:"platforms,omitempty"` Resources *ResourceInfo `json:"resources,omitempty"` + Skills []Skill `json:"skills,omitempty"` Git *GitInfo `json:"git,omitempty"` Lines *LineCount `json:"lines,omitempty"` Dependencies []DepInfo `json:"dependencies,omitempty"` diff --git a/detect/detect.go b/detect/detect.go index 9f56639..5bcd51f 100644 --- a/detect/detect.go +++ b/detect/detect.go @@ -2,6 +2,7 @@ package detect import ( + "bytes" "encoding/json" "errors" "fmt" @@ -213,6 +214,7 @@ func (e *Engine) Run() (*brief.Report, error) { report.Style = e.detectStyle() report.Layout = e.detectLayout(report.Languages) report.Platforms = e.detectPlatforms() + report.Skills = e.detectSkills() // Run slow detections concurrently. var wg sync.WaitGroup @@ -1236,6 +1238,65 @@ func (e *Engine) findResource(r kb.ResourceInfo) (abs, rel string) { return "", "" } +var skillFrontmatterDelim = []byte("---") + +type skillFrontmatter struct { + Name string `yaml:"name"` + Description string `yaml:"description"` +} + +// detectSkills looks for agent skill definitions the project provides. +func (e *Engine) detectSkills() []brief.Skill { + var skills []brief.Skill + for _, glob := range []string{"skills/*/SKILL.md", ".claude/skills/*/SKILL.md"} { + matches, err := filepath.Glob(filepath.Join(e.Root, filepath.FromSlash(glob))) + if err != nil { + continue + } + sort.Strings(matches) + for _, abs := range matches { + rel, err := filepath.Rel(e.Root, abs) + if err != nil { + continue + } + rel = filepath.ToSlash(rel) + skills = append(skills, e.parseSkill(rel)) + } + } + return skills +} + +// parseSkill reads a SKILL.md file and extracts name/description from its +// YAML frontmatter. Falls back to the parent directory name if frontmatter is +// missing or unparseable. +func (e *Engine) parseSkill(rel string) brief.Skill { + skill := brief.Skill{ + Name: path.Base(path.Dir(rel)), + Path: rel, + Format: "claude", + } + data, err := e.safeReadFile(rel) + if err != nil { + return skill + } + if !bytes.HasPrefix(data, skillFrontmatterDelim) { + return skill + } + rest := bytes.TrimLeft(data[len(skillFrontmatterDelim):], "\r\n") + end := bytes.Index(rest, []byte("\n---")) + if end == -1 { + return skill + } + var fm skillFrontmatter + if yaml.Unmarshal(rest[:end], &fm) == nil { + if fm.Name != "" { + skill.Name = fm.Name + } + skill.Description = fm.Description + } + return skill +} + // dirFiles returns the regular file names in dir (relative to e.Root), // caching results per directory. func (e *Engine) dirFiles(dir string) []string { diff --git a/detect/detect_test.go b/detect/detect_test.go index c35621b..11e1802 100644 --- a/detect/detect_test.go +++ b/detect/detect_test.go @@ -228,6 +228,93 @@ func TestResourceRootBeatsSubdir(t *testing.T) { } } +func writeFile(t *testing.T, dir, p, content string) { + t.Helper() + full := filepath.Join(dir, filepath.FromSlash(p)) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestDetectSkills(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "skills/pdf/SKILL.md", `--- +name: pdf +description: Read and fill PDF forms +--- + +Body here. +`) + writeFile(t, dir, ".claude/skills/excel/SKILL.md", `--- +name: excel-tools +description: Generate spreadsheets +--- +`) + writeFile(t, dir, "skills/empty/SKILL.md", "no frontmatter\n") + + engine := New(loadKB(t), dir) + r, err := engine.Run() + if err != nil { + t.Fatalf("Run: %v", err) + } + if len(r.Skills) != 3 { + t.Fatalf("expected 3 skills, got %d: %+v", len(r.Skills), r.Skills) + } + + byPath := map[string]brief.Skill{} + for _, s := range r.Skills { + byPath[s.Path] = s + } + + pdf := byPath["skills/pdf/SKILL.md"] + if pdf.Name != "pdf" || pdf.Description != "Read and fill PDF forms" || pdf.Format != "claude" { + t.Errorf("pdf skill = %+v", pdf) + } + excel := byPath[".claude/skills/excel/SKILL.md"] + if excel.Name != "excel-tools" || excel.Description != "Generate spreadsheets" { + t.Errorf("excel skill = %+v", excel) + } + empty := byPath["skills/empty/SKILL.md"] + if empty.Name != "empty" || empty.Description != "" { + t.Errorf("empty skill should fall back to dir name, got %+v", empty) + } +} + +func TestDetectSkillsMalformedFrontmatter(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "skills/broken/SKILL.md", `--- +name: [unclosed +--- +`) + engine := New(loadKB(t), dir) + r, err := engine.Run() + if err != nil { + t.Fatalf("Run: %v", err) + } + if len(r.Skills) != 1 { + t.Fatalf("expected 1 skill, got %d", len(r.Skills)) + } + if r.Skills[0].Name != "broken" { + t.Errorf("expected fallback to dir name, got %q", r.Skills[0].Name) + } +} + +func TestDetectSkillsNone(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "README.md", "x") + engine := New(loadKB(t), dir) + r, err := engine.Run() + if err != nil { + t.Fatalf("Run: %v", err) + } + if r.Skills != nil { + t.Errorf("expected nil skills, got %+v", r.Skills) + } +} + func TestRubyPlatforms(t *testing.T) { r := rubyReport(t) if r.Platforms == nil { diff --git a/detect/filter.go b/detect/filter.go index d3d37c7..f8ebf17 100644 --- a/detect/filter.go +++ b/detect/filter.go @@ -1,6 +1,7 @@ package detect import ( + "path" "path/filepath" "slices" "strings" @@ -69,6 +70,7 @@ func FilterByChangedFiles(r *brief.Report, knowledgeBase *kb.KnowledgeBase, chan filtered.Style = fc.filterStyle(r.Style) filtered.Resources = fc.filterResources(r.Resources, changedFiles) filtered.Platforms = fc.filterPlatforms(r.Platforms, changedFiles) + filtered.Skills = fc.filterSkills(r.Skills, changedFiles) if fc.manifestChanged { filtered.Dependencies = r.Dependencies @@ -152,6 +154,20 @@ func (fc *filterContext) filterStyle(style *brief.StyleInfo) *brief.StyleInfo { return nil } +func (fc *filterContext) filterSkills(skills []brief.Skill, changedFiles []string) []brief.Skill { + var out []brief.Skill + for _, s := range skills { + dir := path.Dir(s.Path) + "/" + for _, f := range changedFiles { + if strings.HasPrefix(filepath.ToSlash(f), dir) { + out = append(out, s) + break + } + } + } + return out +} + func (fc *filterContext) filterResources(res *brief.ResourceInfo, changedFiles []string) *brief.ResourceInfo { if res == nil { return nil diff --git a/detect/filter_test.go b/detect/filter_test.go index 93c230f..8454b89 100644 --- a/detect/filter_test.go +++ b/detect/filter_test.go @@ -42,6 +42,28 @@ func TestFilterResources(t *testing.T) { } } +func TestFilterSkills(t *testing.T) { + skills := []brief.Skill{ + {Name: "pdf", Path: "skills/pdf/SKILL.md", Format: "claude"}, + {Name: "excel", Path: ".claude/skills/excel/SKILL.md", Format: "claude"}, + } + fc := &filterContext{} + + out := fc.filterSkills(skills, []string{"skills/pdf/helper.py", "main.go"}) + if len(out) != 1 || out[0].Name != "pdf" { + t.Errorf("expected only pdf skill, got %+v", out) + } + + out = fc.filterSkills(skills, []string{".claude/skills/excel/SKILL.md"}) + if len(out) != 1 || out[0].Name != "excel" { + t.Errorf("expected only excel skill, got %+v", out) + } + + if got := fc.filterSkills(skills, []string{"main.go"}); got != nil { + t.Errorf("expected nil when no skills changed, got %+v", got) + } +} + func TestFilterByChangedFiles_Languages(t *testing.T) { knowledgeBase := loadKB(t) diff --git a/report/markdown.go b/report/markdown.go index 24914fd..0cd41c2 100644 --- a/report/markdown.go +++ b/report/markdown.go @@ -27,6 +27,7 @@ func Markdown(w io.Writer, r *brief.Report, verbose bool) { mdLayout(w, r.Layout) mdPlatforms(w, r.Platforms) mdResources(w, r.Resources) + mdSkills(w, r.Skills) mdGit(w, r.Git) mdLines(w, r.Lines) mdEnrichment(w, r.Enrichment) @@ -282,6 +283,21 @@ func mdResourceGroup(w io.Writer, label string, group map[string]string) { } } +func mdSkills(w io.Writer, skills []brief.Skill) { + if len(skills) == 0 { + return + } + _, _ = fmt.Fprintln(w) + _, _ = fmt.Fprintln(w, "**Skills:**") + for _, s := range skills { + line := s.Name + if s.Description != "" { + line += " — " + s.Description + } + _, _ = fmt.Fprintf(w, "- %s `%s`\n", sanitize(line), sanitize(s.Path)) + } +} + func mdGit(w io.Writer, git *brief.GitInfo) { if git == nil { return diff --git a/report/markdown_test.go b/report/markdown_test.go index fb9a2a9..a8c71d9 100644 --- a/report/markdown_test.go +++ b/report/markdown_test.go @@ -134,6 +134,27 @@ func TestMarkdownStyle(t *testing.T) { } } +func TestMarkdownSkills(t *testing.T) { + r := &brief.Report{ + Version: "dev", + Path: "/tmp/test", + Skills: []brief.Skill{ + {Name: "pdf", Description: "Read PDF forms", Path: "skills/pdf/SKILL.md", Format: "claude"}, + }, + } + + var buf bytes.Buffer + Markdown(&buf, r, false) + out := buf.String() + + if !strings.Contains(out, "**Skills:**") { + t.Errorf("missing skills header\ngot:\n%s", out) + } + if !strings.Contains(out, "- pdf — Read PDF forms `skills/pdf/SKILL.md`") { + t.Errorf("missing skill line\ngot:\n%s", out) + } +} + func TestMarkdownResources(t *testing.T) { r := &brief.Report{ Version: "dev", diff --git a/report/report.go b/report/report.go index a9eae7e..8d6e04d 100644 --- a/report/report.go +++ b/report/report.go @@ -85,6 +85,7 @@ func Human(w io.Writer, r *brief.Report, verbose bool) { printLayout(w, r.Layout) printPlatforms(w, r.Platforms) printResources(w, r.Resources) + printSkills(w, r.Skills) printGit(w, r.Git) printLines(w, r.Lines) printEnrichment(w, r.Enrichment) @@ -307,6 +308,20 @@ func printResourceGroup(w io.Writer, label string, group map[string]string) { } } +func printSkills(w io.Writer, skills []brief.Skill) { + if len(skills) == 0 { + return + } + _, _ = fmt.Fprintln(w) + for _, s := range skills { + line := s.Name + if s.Description != "" { + line += " — " + s.Description + } + _, _ = fmt.Fprintf(w, "Skills: %s [%s]\n", sanitize(line), sanitize(s.Path)) + } +} + func printGit(w io.Writer, git *brief.GitInfo) { if git == nil { return diff --git a/report/report_test.go b/report/report_test.go index 473ced9..ff114ef 100644 --- a/report/report_test.go +++ b/report/report_test.go @@ -227,6 +227,28 @@ func TestHumanSanitizesCIMatrix(t *testing.T) { } } +func TestHumanSkills(t *testing.T) { + r := &brief.Report{ + Version: "dev", + Path: "/tmp/test", + Skills: []brief.Skill{ + {Name: "pdf", Description: "Read PDF forms", Path: "skills/pdf/SKILL.md", Format: "claude"}, + {Name: "excel", Path: ".claude/skills/excel/SKILL.md", Format: "claude"}, + }, + } + + var buf bytes.Buffer + Human(&buf, r, false) + out := buf.String() + + if !strings.Contains(out, "Skills: pdf — Read PDF forms [skills/pdf/SKILL.md]") { + t.Errorf("missing pdf skill line\ngot:\n%s", out) + } + if !strings.Contains(out, "Skills: excel [.claude/skills/excel/SKILL.md]") { + t.Errorf("missing excel skill line\ngot:\n%s", out) + } +} + func TestHumanSanitizesResources(t *testing.T) { r := &brief.Report{ Version: "dev",