Module: Brute::Skill

Defined in:
lib/brute/skill.rb

Overview

Discovers, validates, and loads SKILL.md files from standard directories.

A skill is a directory containing a SKILL.md markdown file with YAML frontmatter:

---
name: debugging
description: Systematic debugging workflow for isolating and fixing bugs
---

When debugging, follow these steps...

Skills are scanned from (in order):

1. .brute/skills/**/SKILL.md   (project-local)
2. ~/.config/brute/skills/**/SKILL.md (global)

Parsing and validation mirror the Agent Skills specification (https://agentskills.io/specification) and its reference validator (https://github.com/agentskills/agentskills/tree/main/skills-ref). A skill whose frontmatter violates a rule is skipped with a stderr warning naming the violated rule, never raised.

Defined Under Namespace

Classes: Info

Constant Summary collapse

FILENAME =
"SKILL.md"
ALLOWED_FIELDS =

Frontmatter keys permitted by the spec. Anything else is a violation.

%w[name description license allowed-tools metadata compatibility].freeze
MAX_NAME_LENGTH =
64
MAX_DESCRIPTION_LENGTH =
1024
MAX_COMPATIBILITY_LENGTH =
500

Class Method Summary collapse

Class Method Details

.all(cwd: Dir.pwd) ⇒ Object

Scan all skill directories and return an array of Info structs.



48
49
50
51
52
53
54
55
56
57
58
59
60
61
# File 'lib/brute/skill.rb', line 48

def self.all(cwd: Dir.pwd)
  skills = {}

  scan_dirs(cwd).each do |dir|
    Dir.glob(File.join(dir, "**", FILENAME)).sort.each do |path|
      info = load(path)
      next unless info
      # First found wins (project-local overrides global)
      skills[info.name] ||= info
    end
  end

  skills.values.sort_by(&:name)
end

.fmt(skills) ⇒ Object

Format skills as XML for the system prompt.



69
70
71
72
73
74
75
76
77
78
79
80
81
# File 'lib/brute/skill.rb', line 69

def self.fmt(skills)
  return nil if skills.empty?

  lines = ["<available_skills>"]
  skills.each do |skill|
    lines << "  <skill>"
    lines << "    <name>#{skill.name}</name>"
    lines << "    <description>#{skill.description}</description>"
    lines << "  </skill>"
  end
  lines << "</available_skills>"
  lines.join("\n")
end

.get(name, cwd: Dir.pwd) ⇒ Object

Get a single skill by name.



64
65
66
# File 'lib/brute/skill.rb', line 64

def self.get(name, cwd: Dir.pwd)
  all(cwd: cwd).detect { |s| s.name == name }
end

.load(path) ⇒ Object

Parse and validate a SKILL.md file into an Info struct. Returns nil (with a stderr warning) if the file is invalid.



85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
# File 'lib/brute/skill.rb', line 85

def self.load(path)
  raw = File.read(path)
  frontmatter, content = parse_frontmatter(path, raw)
  return nil unless frontmatter

  dir_name = File.basename(File.dirname(path))
  # Spec requires `name`; brute keeps the convenience of defaulting to the
  # directory name when omitted (which trivially satisfies the dir-match rule).
  frontmatter = { "name" => dir_name }.merge(frontmatter) unless frontmatter.key?("name")

  # Unknown fields are a soft violation: warn and drop them rather than
  # reject the skill. The reference validator hard-fails here, but a runtime
  # loader must tolerate vendor/forward extensions (e.g. `tags`), which real
  # published skills carry, instead of silently dropping the whole skill.
  extra = frontmatter.keys - ALLOWED_FIELDS
  warn "Skill #{path} has unexpected frontmatter fields (ignored): #{extra.sort.join(', ')}" unless extra.empty?

  errors = validate(frontmatter, dir_name)
  unless errors.empty?
    warn "Skipping invalid skill #{path}: #{errors.join('; ')}"
    return nil
  end

  Info.new(
    name: frontmatter["name"].to_s.strip,
    description: frontmatter["description"].to_s.strip,
    location: path,
    content: content.to_s.strip,
    license: frontmatter["license"]&.to_s,
    compatibility: frontmatter["compatibility"]&.to_s,
    metadata: frontmatter["metadata"],
    allowed_tools: parse_allowed_tools(frontmatter["allowed-tools"]),
  )
rescue => e
  warn "Failed to load skill #{path}: #{e.message}"
  nil
end