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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>/` or `.claude/skills/<name>/`. 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.

<!-- brief:tools:start (generated by: brief list -readme tools) -->
## What it detects

Expand Down
10 changes: 10 additions & 0 deletions brief.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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"`
Expand Down
61 changes: 61 additions & 0 deletions detect/detect.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
package detect

import (
"bytes"
"encoding/json"
"errors"
"fmt"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
87 changes: 87 additions & 0 deletions detect/detect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
16 changes: 16 additions & 0 deletions detect/filter.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package detect

import (
"path"
"path/filepath"
"slices"
"strings"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions detect/filter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
16 changes: 16 additions & 0 deletions report/markdown.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions report/markdown_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 15 additions & 0 deletions report/report.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions report/report_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading