Class: Brute::Skill

Inherits:
Object
  • Object
show all
Defined in:
lib/brute/skill.rb

Overview

A single skill: metadata plus the address of its SKILL.md on disk.

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...

The object is a value object — it carries the parsed frontmatter, the body, and the file location, nothing else. Modeled on prime-agent's BaseSkill (packages/coding-agent/src/core/skills.ts).

Discovery is class-level and caller-side: Skill.all scans (in order)

  1. /.brute/skills/**/SKILL.md (project-local, :project)
  2. ~/.config/brute/skills/**/SKILL.md (global, :user)
  3. explicit paths: (dirs or .md files, :path)

First found wins on name collisions (with a stderr warning naming winner and loser), and the same file reached twice via symlinks is skipped.

Parsing and validation mirror the Agent Skills specification (https://agentskills.io/specification). A skill whose frontmatter violates a rule is skipped with a stderr warning naming the rule — never raised.

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 disable-model-invocation].freeze
MAX_NAME_LENGTH =
64
MAX_DESCRIPTION_LENGTH =
1024
MAX_COMPATIBILITY_LENGTH =
500

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(name:, description:, file_path:, content: nil, source: :path, license: nil, compatibility: nil, metadata: nil, allowed_tools: nil, disable_model_invocation: false) ⇒ Skill

Returns a new instance of Skill.



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

def initialize(name:, description:, file_path:, content: nil, source: :path,
               license: nil, compatibility: nil, metadata: nil,
               allowed_tools: nil, disable_model_invocation: false)
  @name = name
  @description = description
  @file_path = file_path
  @base_dir = File.dirname(file_path)
  @content = content
  @source = source
  @license = license
  @compatibility = compatibility
  @metadata = 
  @allowed_tools = allowed_tools
  @disable_model_invocation = disable_model_invocation
end

Instance Attribute Details

#allowed_toolsObject (readonly)

Returns the value of attribute allowed_tools.



37
38
39
# File 'lib/brute/skill.rb', line 37

def allowed_tools
  @allowed_tools
end

#base_dirObject (readonly)

Returns the value of attribute base_dir.



37
38
39
# File 'lib/brute/skill.rb', line 37

def base_dir
  @base_dir
end

#compatibilityObject (readonly)

Returns the value of attribute compatibility.



37
38
39
# File 'lib/brute/skill.rb', line 37

def compatibility
  @compatibility
end

#contentObject (readonly)

Returns the value of attribute content.



37
38
39
# File 'lib/brute/skill.rb', line 37

def content
  @content
end

#descriptionObject (readonly)

Returns the value of attribute description.



37
38
39
# File 'lib/brute/skill.rb', line 37

def description
  @description
end

#file_pathObject (readonly)

Returns the value of attribute file_path.



37
38
39
# File 'lib/brute/skill.rb', line 37

def file_path
  @file_path
end

#licenseObject (readonly)

Returns the value of attribute license.



37
38
39
# File 'lib/brute/skill.rb', line 37

def license
  @license
end

#metadataObject (readonly)

Returns the value of attribute metadata.



37
38
39
# File 'lib/brute/skill.rb', line 37

def 
  @metadata
end

#nameObject (readonly)

Returns the value of attribute name.



37
38
39
# File 'lib/brute/skill.rb', line 37

def name
  @name
end

#sourceObject (readonly)

Returns the value of attribute source.



37
38
39
# File 'lib/brute/skill.rb', line 37

def source
  @source
end

Class Method Details

.all(cwd: Dir.pwd, paths: []) ⇒ Object

Scan all skill directories and return an array of Skills, sorted by name.

Precedence is first-found-wins: project-local overrides global overrides explicit paths. Name collisions warn to stderr naming winner and loser; the same file reached via different symlinks is loaded only once.



119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
# File 'lib/brute/skill.rb', line 119

def self.all(cwd: Dir.pwd, paths: [])
  skills = {}
  seen_files = {}

  add = lambda do |path, source|
    skill = load(path, source: source)
    return unless skill

    real = realpath(path)
    return if seen_files[real]

    if (winner = skills[skill.name])
      warn "Skill name collision: '#{skill.name}' at #{path} ignored; " \
           "already loaded from #{winner.file_path}"
      return
    end

    seen_files[real] = true
    skills[skill.name] = skill
  end

  project = File.join(cwd, ".brute", "skills")
  glob(project) { |path| add.call(path, :project) }

  global = File.join(Dir.home, ".config", "brute", "skills")
  glob(global) { |path| add.call(path, :user) }

  paths.each do |raw|
    path = File.expand_path(raw.to_s.sub(/\A~(?=\/|\z)/, Dir.home))
    if File.directory?(path)
      glob(path) { |p| add.call(p, :path) }
    elsif File.file?(path) && path.end_with?(".md")
      add.call(path, :path)
    else
      warn "Skill path #{path} is not a directory or markdown file (ignored)"
    end
  end

  skills.values.sort_by(&:name)
end

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

Get a single skill by name through the same scan as .all.



161
162
163
# File 'lib/brute/skill.rb', line 161

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

.load(path, source: :path) ⇒ Object

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



74
75
76
77
78
79
80
81
82
83
84
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
# File 'lib/brute/skill.rb', line 74

def self.load(path, source: :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

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

Instance Method Details

#disable_model_invocation?Boolean

Hidden from the prompt listing (explicit invocation only), but still handed to the agent as an object.

Returns:

  • (Boolean)


67
# File 'lib/brute/skill.rb', line 67

def disable_model_invocation? = @disable_model_invocation

#locationObject

Back-compat alias (Tools::SkillLoad era).



70
# File 'lib/brute/skill.rb', line 70

def location = file_path