brute 2.0.6 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/brute/events/handler.rb +3 -1
- data/lib/brute/events/prefixed_terminal_output.rb +3 -1
- data/lib/brute/events/terminal_output_handler.rb +3 -1
- data/lib/brute/message_transport/anthropic.rb +143 -0
- data/lib/brute/message_transport/llm.rb +94 -0
- data/lib/brute/message_transport/openai.rb +113 -0
- data/lib/brute/message_transport/ruby_llm.rb +78 -0
- data/lib/brute/message_transport.rb +133 -0
- data/lib/brute/messages.rb +120 -0
- data/lib/brute/middleware/001_otel_span.rb +4 -2
- data/lib/brute/middleware/002_session_log.rb +100 -0
- data/lib/brute/middleware/004_summarize.rb +21 -19
- data/lib/brute/middleware/006_loop.rb +158 -0
- data/lib/brute/middleware/010_max_iterations.rb +9 -7
- data/lib/brute/middleware/015_otel_token_usage.rb +3 -1
- data/lib/brute/middleware/020_system_prompt.rb +6 -4
- data/lib/brute/middleware/040_compaction_check.rb +5 -3
- data/lib/brute/middleware/060_questions.rb +3 -1
- data/lib/brute/middleware/{070_tool_call.rb → 070_tool_pipeline.rb} +60 -46
- data/lib/brute/middleware/073_otel_tool_call.rb +3 -1
- data/lib/brute/middleware/075_otel_tool_results.rb +3 -1
- data/lib/brute/middleware/event_handler.rb +3 -1
- data/lib/brute/middleware/user_queue.rb +3 -1
- data/lib/brute/prompts/autonomy.rb +3 -0
- data/lib/brute/prompts/base.rb +3 -0
- data/lib/brute/prompts/build_switch.rb +3 -1
- data/lib/brute/prompts/code_references.rb +3 -0
- data/lib/brute/prompts/code_style.rb +3 -0
- data/lib/brute/prompts/conventions.rb +3 -0
- data/lib/brute/prompts/doing_tasks.rb +3 -0
- data/lib/brute/prompts/editing_approach.rb +3 -0
- data/lib/brute/prompts/editing_constraints.rb +3 -0
- data/lib/brute/prompts/environment.rb +3 -1
- data/lib/brute/prompts/frontend_tasks.rb +3 -0
- data/lib/brute/prompts/git_safety.rb +3 -0
- data/lib/brute/prompts/identity.rb +3 -1
- data/lib/brute/prompts/instructions.rb +3 -1
- data/lib/brute/prompts/max_steps.rb +3 -1
- data/lib/brute/prompts/objectivity.rb +3 -0
- data/lib/brute/prompts/plan_reminder.rb +3 -1
- data/lib/brute/prompts/proactiveness.rb +3 -0
- data/lib/brute/prompts/security_and_safety.rb +3 -0
- data/lib/brute/prompts/skills.rb +6 -2
- data/lib/brute/prompts/task_management.rb +3 -0
- data/lib/brute/prompts/tone_and_style.rb +3 -0
- data/lib/brute/prompts/tool_usage.rb +3 -0
- data/lib/brute/prompts.rb +5 -0
- data/lib/brute/rack/adapter.rb +237 -0
- data/lib/brute/skill.rb +192 -15
- data/lib/brute/system_prompt.rb +3 -1
- data/lib/brute/tool.rb +92 -75
- data/lib/brute/tools/adapter.rb +258 -0
- data/lib/brute/tools/fs/file_mutation_queue.rb +107 -0
- data/lib/brute/tools/fs/snapshot_store.rb +41 -0
- data/lib/brute/tools/fs_patch.rb +6 -4
- data/lib/brute/tools/fs_read.rb +5 -3
- data/lib/brute/tools/fs_remove.rb +3 -3
- data/lib/brute/tools/fs_search.rb +4 -2
- data/lib/brute/tools/fs_undo.rb +3 -3
- data/lib/brute/tools/fs_write.rb +6 -4
- data/lib/brute/tools/net_fetch.rb +1 -1
- data/lib/brute/tools/question.rb +1 -1
- data/lib/brute/tools/shell.rb +4 -2
- data/lib/brute/tools/skill_load.rb +156 -0
- data/lib/brute/tools/sub_agent.rb +101 -0
- data/lib/brute/tools/todo_list/store.rb +36 -0
- data/lib/brute/tools/todo_read.rb +2 -2
- data/lib/brute/tools/todo_write.rb +2 -2
- data/lib/brute/tools.rb +2 -1
- data/lib/brute/truncation.rb +3 -1
- data/lib/brute/turn/agent_pipeline.rb +169 -0
- data/lib/brute/turn/pipeline.rb +95 -0
- data/lib/brute/turn/tool_pipeline.rb +96 -0
- data/lib/brute/utils/diff.rb +3 -1
- data/lib/brute/version.rb +1 -1
- data/lib/brute.rb +45 -71
- data/lib/{brute → brute_cli}/providers/shell.rb +4 -1
- data/lib/{brute → brute_cli}/providers/shell_response.rb +9 -15
- metadata +52 -43
- data/lib/brute/agent.rb +0 -82
- data/lib/brute/middleware/003_tool_result_loop.rb +0 -103
- data/lib/brute/middleware/100_llm_call.rb +0 -63
- data/lib/brute/pipeline.rb +0 -97
- data/lib/brute/queue/file_mutation_queue.rb +0 -102
- data/lib/brute/session.rb +0 -51
- data/lib/brute/store/snapshot_store.rb +0 -36
- data/lib/brute/store/todo_store.rb +0 -30
- data/lib/brute/sub_agent.rb +0 -106
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "brute"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Brute
|
|
8
|
+
# The mirror image of protocol-rack. Where `Protocol::Rack::Adapter` wraps a
|
|
9
|
+
# *Rack app* so an HTTP server can drive it (env in, `[status, headers, body]`
|
|
10
|
+
# out), `Brute::Rack::Adapter` wraps a *Brute agent* so a Rack server can
|
|
11
|
+
# drive it. It is itself a Rack app — `call(env) -> [status, headers, body]` —
|
|
12
|
+
# so any AgentPipeline drops straight into a config.ru and serves over HTTP
|
|
13
|
+
# behind Falcon/Puma/etc:
|
|
14
|
+
#
|
|
15
|
+
# # config.ru
|
|
16
|
+
# agent = Brute::Turn::AgentPipeline.parse_file("examples/agents/brute.ru")
|
|
17
|
+
# run Brute::Rack::Adapter.for(agent)
|
|
18
|
+
#
|
|
19
|
+
# $ curl -d 'What files are here?' localhost:9292
|
|
20
|
+
# $ curl -H 'content-type: application/json' -d '{"prompt":"hi"}' localhost:9292
|
|
21
|
+
# $ curl 'localhost:9292/?prompt=hi'
|
|
22
|
+
#
|
|
23
|
+
# The whole job is two pure transforms — the two directions the request named:
|
|
24
|
+
#
|
|
25
|
+
# env -> prompt string (#prompt_from) the request half
|
|
26
|
+
# output -> [status, headers, body] (#response_for) the response half
|
|
27
|
+
#
|
|
28
|
+
# `call` just wires them together around one `agent.start`.
|
|
29
|
+
module Rack
|
|
30
|
+
class Adapter
|
|
31
|
+
# Body/JSON keys we accept a prompt under, in priority order. `prompt`
|
|
32
|
+
# is canonical; `message`/`input` are common aliases.
|
|
33
|
+
PROMPT_KEYS = %w[prompt message input].freeze
|
|
34
|
+
|
|
35
|
+
# Convenience factory so the config.ru reads `run Adapter.for(agent)`.
|
|
36
|
+
def self.for(agent) = new(agent)
|
|
37
|
+
|
|
38
|
+
# @parameter agent [#start] Anything with `start(prompt) -> env` — an
|
|
39
|
+
# AgentPipeline, a SubAgent, or any turn-shaped callable.
|
|
40
|
+
def initialize(agent)
|
|
41
|
+
raise ArgumentError, "agent must respond to #start" unless agent.respond_to?(:start)
|
|
42
|
+
|
|
43
|
+
@agent = agent
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Rack entry point. Extract the prompt, run one agent turn, render the
|
|
47
|
+
# assistant's reply back as an HTTP response. A missing prompt is a 400
|
|
48
|
+
# (client's fault); anything the turn raises is a 500.
|
|
49
|
+
def call(env)
|
|
50
|
+
prompt = prompt_from(env)
|
|
51
|
+
return response_for(env, 400, "No prompt provided.") if prompt.nil? || prompt.empty?
|
|
52
|
+
|
|
53
|
+
turn = @agent.start(prompt)
|
|
54
|
+
response_for(env, 200, output_of(turn))
|
|
55
|
+
rescue => error
|
|
56
|
+
response_for(env, 500, error.message)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# env -> prompt string. Four ways in, most explicit first:
|
|
60
|
+
#
|
|
61
|
+
# 1. a `?prompt=` query param (never touches the body),
|
|
62
|
+
# 2. a JSON body — a known key of an object, or a bare JSON string,
|
|
63
|
+
# 3. a form-encoded `prompt=` field,
|
|
64
|
+
# 4. otherwise the raw request body IS the prompt.
|
|
65
|
+
#
|
|
66
|
+
# Returns nil when nothing usable is present.
|
|
67
|
+
def prompt_from(env)
|
|
68
|
+
request = ::Rack::Request.new(env)
|
|
69
|
+
|
|
70
|
+
if (query = request.GET["prompt"]) && !query.empty?
|
|
71
|
+
return query
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
body = read_body(request)
|
|
75
|
+
return nil if body.nil? || body.strip.empty?
|
|
76
|
+
|
|
77
|
+
if json?(request.media_type)
|
|
78
|
+
case data = parse_json(body)
|
|
79
|
+
when ::Hash then return PROMPT_KEYS.filter_map { |key| data[key] }.first&.to_s || body
|
|
80
|
+
when ::String then return data
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
if request.form_data? && (field = ::Rack::Utils.parse_nested_query(body)["prompt"])
|
|
85
|
+
return field
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
body
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# output -> [status, headers, body]. Content-negotiated: JSON in (or an
|
|
92
|
+
# `Accept: application/json`) gets `{"response": "..."}` back; everything
|
|
93
|
+
# else gets `text/plain`. Errors ride the same path so a 500 body is
|
|
94
|
+
# shaped like a 200 body.
|
|
95
|
+
def response_for(env, status, output)
|
|
96
|
+
text = output.to_s
|
|
97
|
+
|
|
98
|
+
if wants_json?(env)
|
|
99
|
+
key = status == 200 ? :response : :error
|
|
100
|
+
[status, {"content-type" => "application/json"}, [::JSON.generate(key => text)]]
|
|
101
|
+
else
|
|
102
|
+
[status, {"content-type" => "text/plain; charset=utf-8"}, [text]]
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
# The agent's answer is the last message it appended to the log.
|
|
109
|
+
def output_of(turn)
|
|
110
|
+
messages = turn.is_a?(::Hash) ? turn[:messages] : turn
|
|
111
|
+
messages&.last&.content.to_s
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def read_body(request)
|
|
115
|
+
input = request.body
|
|
116
|
+
input&.read
|
|
117
|
+
rescue
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def json?(media_type)
|
|
122
|
+
media_type.to_s.include?("json")
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def parse_json(body)
|
|
126
|
+
::JSON.parse(body)
|
|
127
|
+
rescue ::JSON::ParserError
|
|
128
|
+
nil
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Prefer JSON when the client posted JSON or explicitly accepts it.
|
|
132
|
+
def wants_json?(env)
|
|
133
|
+
json?(env["CONTENT_TYPE"]) || json?(env["HTTP_ACCEPT"])
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
__END__
|
|
140
|
+
|
|
141
|
+
describe "brute/rack/adapter" do
|
|
142
|
+
require "stringio"
|
|
143
|
+
# Specs are eval'd standalone by the scampi runner, so make sure the pipeline
|
|
144
|
+
# is loaded here; otherwise `Brute.agent` may be undefined.
|
|
145
|
+
require "brute/turn/agent_pipeline"
|
|
146
|
+
|
|
147
|
+
# A tiny agent that echoes the prompt straight back through a real turn, and a
|
|
148
|
+
# (stateless, reusable) adapter wrapping it.
|
|
149
|
+
echo_agent = Brute.agent.run(->(env) { env[:messages].assistant("echo: #{env[:messages].last.content}") })
|
|
150
|
+
adapter = Brute::Rack::Adapter.new(echo_agent)
|
|
151
|
+
|
|
152
|
+
# Build a minimal Rack env by hand (no server needed).
|
|
153
|
+
rack_env = lambda do |method: "POST", path: "/", query: "", body: "", type: nil, accept: nil|
|
|
154
|
+
env = {
|
|
155
|
+
"REQUEST_METHOD" => method,
|
|
156
|
+
"PATH_INFO" => path,
|
|
157
|
+
"QUERY_STRING" => query,
|
|
158
|
+
"rack.input" => StringIO.new(body),
|
|
159
|
+
}
|
|
160
|
+
env["CONTENT_TYPE"] = type if type
|
|
161
|
+
env["HTTP_ACCEPT"] = accept if accept
|
|
162
|
+
env
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
it "is a Rack app: env in, [status, headers, body] out" do
|
|
166
|
+
status, headers, body = adapter.call(rack_env.call(body: "hello"))
|
|
167
|
+
status.should == 200
|
|
168
|
+
headers["content-type"].should == "text/plain; charset=utf-8"
|
|
169
|
+
body.first.should == "echo: hello"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
it "reads a raw text body as the prompt" do
|
|
173
|
+
adapter.prompt_from(rack_env.call(body: "what changed?")).should == "what changed?"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
it "reads a JSON body under a known key" do
|
|
177
|
+
env = rack_env.call(body: '{"prompt":"from json"}', type: "application/json")
|
|
178
|
+
adapter.prompt_from(env).should == "from json"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
it "accepts message/input JSON aliases" do
|
|
182
|
+
env = rack_env.call(body: '{"message":"aliased"}', type: "application/json")
|
|
183
|
+
adapter.prompt_from(env).should == "aliased"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
it "reads a bare JSON string body" do
|
|
187
|
+
env = rack_env.call(body: '"just a string"', type: "application/json")
|
|
188
|
+
adapter.prompt_from(env).should == "just a string"
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
it "reads a form-encoded prompt field" do
|
|
192
|
+
env = rack_env.call(body: "prompt=formed&x=1", type: "application/x-www-form-urlencoded")
|
|
193
|
+
adapter.prompt_from(env).should == "formed"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
it "reads a ?prompt= query param without consuming the body" do
|
|
197
|
+
env = rack_env.call(method: "GET", query: "prompt=queried", body: "")
|
|
198
|
+
adapter.prompt_from(env).should == "queried"
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
it "returns nil for an empty request" do
|
|
202
|
+
adapter.prompt_from(rack_env.call(body: " ")).should.be.nil
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
it "answers a missing prompt with 400" do
|
|
206
|
+
status, _headers, body = adapter.call(rack_env.call(body: ""))
|
|
207
|
+
status.should == 400
|
|
208
|
+
body.first.should == "No prompt provided."
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
it "content-negotiates JSON responses" do
|
|
212
|
+
env = rack_env.call(body: '{"prompt":"hi"}', type: "application/json")
|
|
213
|
+
status, headers, body = adapter.call(env)
|
|
214
|
+
status.should == 200
|
|
215
|
+
headers["content-type"].should == "application/json"
|
|
216
|
+
JSON.parse(body.first).should == { "response" => "echo: hi" }
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
it "honors an Accept: application/json header" do
|
|
220
|
+
env = rack_env.call(body: "hi", accept: "application/json")
|
|
221
|
+
_status, headers, _body = adapter.call(env)
|
|
222
|
+
headers["content-type"].should == "application/json"
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
it "renders a raised turn as a 500 (JSON error key when negotiated)" do
|
|
226
|
+
boom = Brute.agent.run(->(_env) { raise "kaboom" })
|
|
227
|
+
env = rack_env.call(body: '{"prompt":"hi"}', type: "application/json")
|
|
228
|
+
status, _headers, body = Brute::Rack::Adapter.new(boom).call(env)
|
|
229
|
+
status.should == 500
|
|
230
|
+
JSON.parse(body.first).should == { "error" => "kaboom" }
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
it ".for is a factory and #start is required" do
|
|
234
|
+
Brute::Rack::Adapter.for(echo_agent).should.be.kind_of?(Brute::Rack::Adapter)
|
|
235
|
+
lambda { Brute::Rack::Adapter.new(Object.new) }.should.raise(ArgumentError)
|
|
236
|
+
end
|
|
237
|
+
end
|
data/lib/brute/skill.rb
CHANGED
|
@@ -2,10 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
require "yaml"
|
|
4
4
|
|
|
5
|
+
require "bundler/setup"
|
|
6
|
+
require "brute"
|
|
7
|
+
|
|
5
8
|
module Brute
|
|
6
|
-
# Discovers and loads SKILL.md files from standard directories.
|
|
9
|
+
# Discovers, validates, and loads SKILL.md files from standard directories.
|
|
7
10
|
#
|
|
8
|
-
# A skill is a markdown file with YAML
|
|
11
|
+
# A skill is a directory containing a SKILL.md markdown file with YAML
|
|
12
|
+
# frontmatter:
|
|
9
13
|
#
|
|
10
14
|
# ---
|
|
11
15
|
# name: debugging
|
|
@@ -18,14 +22,28 @@ module Brute
|
|
|
18
22
|
# 1. .brute/skills/**/SKILL.md (project-local)
|
|
19
23
|
# 2. ~/.config/brute/skills/**/SKILL.md (global)
|
|
20
24
|
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
25
|
+
# Parsing and validation mirror the Agent Skills specification
|
|
26
|
+
# (https://agentskills.io/specification) and its reference validator
|
|
27
|
+
# (https://github.com/agentskills/agentskills/tree/main/skills-ref). A skill
|
|
28
|
+
# whose frontmatter violates a rule is skipped with a stderr warning naming
|
|
29
|
+
# the violated rule, never raised.
|
|
23
30
|
#
|
|
24
31
|
module Skill
|
|
25
|
-
Info = Struct.new(
|
|
32
|
+
Info = Struct.new(
|
|
33
|
+
:name, :description, :location, :content,
|
|
34
|
+
:license, :compatibility, :metadata, :allowed_tools,
|
|
35
|
+
keyword_init: true,
|
|
36
|
+
)
|
|
26
37
|
|
|
27
38
|
FILENAME = "SKILL.md"
|
|
28
39
|
|
|
40
|
+
# Frontmatter keys permitted by the spec. Anything else is a violation.
|
|
41
|
+
ALLOWED_FIELDS = %w[name description license allowed-tools metadata compatibility].freeze
|
|
42
|
+
|
|
43
|
+
MAX_NAME_LENGTH = 64
|
|
44
|
+
MAX_DESCRIPTION_LENGTH = 1024
|
|
45
|
+
MAX_COMPATIBILITY_LENGTH = 500
|
|
46
|
+
|
|
29
47
|
# Scan all skill directories and return an array of Info structs.
|
|
30
48
|
def self.all(cwd: Dir.pwd)
|
|
31
49
|
skills = {}
|
|
@@ -62,28 +80,94 @@ module Brute
|
|
|
62
80
|
lines.join("\n")
|
|
63
81
|
end
|
|
64
82
|
|
|
65
|
-
# Parse a SKILL.md file into an Info struct.
|
|
66
|
-
# Returns nil if the file is invalid
|
|
83
|
+
# Parse and validate a SKILL.md file into an Info struct.
|
|
84
|
+
# Returns nil (with a stderr warning) if the file is invalid.
|
|
67
85
|
def self.load(path)
|
|
68
86
|
raw = File.read(path)
|
|
69
|
-
frontmatter, content = parse_frontmatter(raw)
|
|
87
|
+
frontmatter, content = parse_frontmatter(path, raw)
|
|
70
88
|
return nil unless frontmatter
|
|
71
89
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
90
|
+
dir_name = File.basename(File.dirname(path))
|
|
91
|
+
# Spec requires `name`; brute keeps the convenience of defaulting to the
|
|
92
|
+
# directory name when omitted (which trivially satisfies the dir-match rule).
|
|
93
|
+
frontmatter = { "name" => dir_name }.merge(frontmatter) unless frontmatter.key?("name")
|
|
94
|
+
|
|
95
|
+
# Unknown fields are a soft violation: warn and drop them rather than
|
|
96
|
+
# reject the skill. The reference validator hard-fails here, but a runtime
|
|
97
|
+
# loader must tolerate vendor/forward extensions (e.g. `tags`), which real
|
|
98
|
+
# published skills carry, instead of silently dropping the whole skill.
|
|
99
|
+
extra = frontmatter.keys - ALLOWED_FIELDS
|
|
100
|
+
warn "Skill #{path} has unexpected frontmatter fields (ignored): #{extra.sort.join(', ')}" unless extra.empty?
|
|
101
|
+
|
|
102
|
+
errors = validate(frontmatter, dir_name)
|
|
103
|
+
unless errors.empty?
|
|
104
|
+
warn "Skipping invalid skill #{path}: #{errors.join('; ')}"
|
|
105
|
+
return nil
|
|
106
|
+
end
|
|
75
107
|
|
|
76
108
|
Info.new(
|
|
77
|
-
name: name.to_s.strip,
|
|
78
|
-
description: description.to_s.strip,
|
|
109
|
+
name: frontmatter["name"].to_s.strip,
|
|
110
|
+
description: frontmatter["description"].to_s.strip,
|
|
79
111
|
location: path,
|
|
80
112
|
content: content.to_s.strip,
|
|
113
|
+
license: frontmatter["license"]&.to_s,
|
|
114
|
+
compatibility: frontmatter["compatibility"]&.to_s,
|
|
115
|
+
metadata: frontmatter["metadata"],
|
|
116
|
+
allowed_tools: parse_allowed_tools(frontmatter["allowed-tools"]),
|
|
81
117
|
)
|
|
82
118
|
rescue => e
|
|
83
119
|
warn "Failed to load skill #{path}: #{e.message}"
|
|
84
120
|
nil
|
|
85
121
|
end
|
|
86
122
|
|
|
123
|
+
# Validate frontmatter against the spec. Returns an array of error strings
|
|
124
|
+
# (empty means valid), each naming the violated rule.
|
|
125
|
+
def self.validate(frontmatter, dir_name)
|
|
126
|
+
errors = []
|
|
127
|
+
|
|
128
|
+
errors.concat(validate_name(frontmatter["name"], dir_name))
|
|
129
|
+
errors.concat(validate_description(frontmatter["description"]))
|
|
130
|
+
|
|
131
|
+
if frontmatter.key?("compatibility")
|
|
132
|
+
compatibility = frontmatter["compatibility"]
|
|
133
|
+
if !compatibility.is_a?(String)
|
|
134
|
+
errors << "'compatibility' must be a string"
|
|
135
|
+
elsif compatibility.length > MAX_COMPATIBILITY_LENGTH
|
|
136
|
+
errors << "'compatibility' exceeds #{MAX_COMPATIBILITY_LENGTH} characters"
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
errors
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def self.validate_name(name, dir_name)
|
|
144
|
+
return ["missing required field 'name'"] unless name.is_a?(String) && !name.strip.empty?
|
|
145
|
+
|
|
146
|
+
name = name.strip.unicode_normalize(:nfkc)
|
|
147
|
+
errors = []
|
|
148
|
+
errors << "'name' exceeds #{MAX_NAME_LENGTH} characters" if name.length > MAX_NAME_LENGTH
|
|
149
|
+
errors << "'name' must be lowercase" if name != name.downcase
|
|
150
|
+
errors << "'name' cannot start or end with a hyphen" if name.start_with?("-") || name.end_with?("-")
|
|
151
|
+
errors << "'name' cannot contain consecutive hyphens" if name.include?("--")
|
|
152
|
+
errors << "'name' may only contain letters, digits, and hyphens" unless name.match?(/\A[\p{Alnum}\-]+\z/)
|
|
153
|
+
errors << "directory name '#{dir_name}' must match skill name '#{name}'" if dir_name.unicode_normalize(:nfkc) != name
|
|
154
|
+
errors
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def self.validate_description(description)
|
|
158
|
+
return ["missing required field 'description'"] unless description.is_a?(String) && !description.strip.empty?
|
|
159
|
+
return ["'description' exceeds #{MAX_DESCRIPTION_LENGTH} characters"] if description.length > MAX_DESCRIPTION_LENGTH
|
|
160
|
+
|
|
161
|
+
[]
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# `allowed-tools` is an experimental, space-separated list of tool names.
|
|
165
|
+
def self.parse_allowed_tools(value)
|
|
166
|
+
return nil if value.nil?
|
|
167
|
+
|
|
168
|
+
value.to_s.split(/\s+/).reject(&:empty?)
|
|
169
|
+
end
|
|
170
|
+
|
|
87
171
|
# Directories to scan for skills, in priority order.
|
|
88
172
|
def self.scan_dirs(cwd)
|
|
89
173
|
dirs = []
|
|
@@ -101,7 +185,7 @@ module Brute
|
|
|
101
185
|
|
|
102
186
|
# Split YAML frontmatter from markdown body.
|
|
103
187
|
# Returns [hash, string] or [nil, nil].
|
|
104
|
-
def self.parse_frontmatter(raw)
|
|
188
|
+
def self.parse_frontmatter(path, raw)
|
|
105
189
|
return [nil, nil] unless raw.start_with?("---")
|
|
106
190
|
|
|
107
191
|
parts = raw.split(/^---\s*$/, 3)
|
|
@@ -111,8 +195,101 @@ module Brute
|
|
|
111
195
|
return [nil, nil] unless frontmatter.is_a?(Hash)
|
|
112
196
|
|
|
113
197
|
[frontmatter, parts[2]]
|
|
198
|
+
rescue => e
|
|
199
|
+
warn "Failed to parse frontmatter in #{path}: #{e.message}"
|
|
200
|
+
[nil, nil]
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
private_class_method :scan_dirs, :parse_frontmatter,
|
|
204
|
+
:validate, :validate_name, :validate_description, :parse_allowed_tools
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
__END__
|
|
209
|
+
|
|
210
|
+
describe "brute/skill" do
|
|
211
|
+
require "tmpdir"
|
|
212
|
+
require "fileutils"
|
|
213
|
+
|
|
214
|
+
def make_skill_dir(root, dir_name, frontmatter, body: "Body.")
|
|
215
|
+
dir = File.join(root, ".brute", "skills", dir_name)
|
|
216
|
+
FileUtils.mkdir_p(dir)
|
|
217
|
+
path = File.join(dir, "SKILL.md")
|
|
218
|
+
File.write(path, "---\n#{frontmatter}\n---\n\n#{body}\n")
|
|
219
|
+
path
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
it "loads a valid skill" do
|
|
223
|
+
Dir.mktmpdir do |root|
|
|
224
|
+
path = make_skill_dir(root, "debugging", "name: debugging\ndescription: Debug things")
|
|
225
|
+
info = Brute::Skill.load(path)
|
|
226
|
+
info.name.should == "debugging"
|
|
227
|
+
info.description.should == "Debug things"
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
it "defaults the name to the directory when omitted" do
|
|
232
|
+
Dir.mktmpdir do |root|
|
|
233
|
+
path = make_skill_dir(root, "deploy", "description: Deploy things")
|
|
234
|
+
Brute::Skill.load(path).name.should == "deploy"
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
it "skips a skill whose name does not match its directory" do
|
|
239
|
+
Dir.mktmpdir do |root|
|
|
240
|
+
path = make_skill_dir(root, "deploy", "name: shipit\ndescription: Ship it")
|
|
241
|
+
Brute::Skill.load(path).should.be.nil
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
it "skips a skill with an uppercase name" do
|
|
246
|
+
Dir.mktmpdir do |root|
|
|
247
|
+
path = make_skill_dir(root, "Deploy", "name: Deploy\ndescription: Deploy")
|
|
248
|
+
Brute::Skill.load(path).should.be.nil
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
it "skips a skill with consecutive hyphens in the name" do
|
|
253
|
+
Dir.mktmpdir do |root|
|
|
254
|
+
path = make_skill_dir(root, "a--b", "name: a--b\ndescription: nope")
|
|
255
|
+
Brute::Skill.load(path).should.be.nil
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
it "skips a skill without a description" do
|
|
260
|
+
Dir.mktmpdir do |root|
|
|
261
|
+
path = make_skill_dir(root, "nodesc", "name: nodesc")
|
|
262
|
+
Brute::Skill.load(path).should.be.nil
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
it "loads a skill with an unexpected frontmatter field, dropping the extra" do
|
|
267
|
+
Dir.mktmpdir do |root|
|
|
268
|
+
path = make_skill_dir(root, "extra", "name: extra\ndescription: x\ntags: [a, b]")
|
|
269
|
+
info = Brute::Skill.load(path)
|
|
270
|
+
info.name.should == "extra"
|
|
271
|
+
info.respond_to?(:tags).should.be.false
|
|
114
272
|
end
|
|
273
|
+
end
|
|
115
274
|
|
|
116
|
-
|
|
275
|
+
it "parses optional spec fields" do
|
|
276
|
+
Dir.mktmpdir do |root|
|
|
277
|
+
path = make_skill_dir(
|
|
278
|
+
root, "full",
|
|
279
|
+
"name: full\ndescription: x\nlicense: MIT\ncompatibility: claude\nallowed-tools: read shell\nmetadata:\n team: core",
|
|
280
|
+
)
|
|
281
|
+
info = Brute::Skill.load(path)
|
|
282
|
+
info.license.should == "MIT"
|
|
283
|
+
info.compatibility.should == "claude"
|
|
284
|
+
info.allowed_tools.should == %w[read shell]
|
|
285
|
+
info.metadata.should == { "team" => "core" }
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
it "skips a skill whose description exceeds the length bound" do
|
|
290
|
+
Dir.mktmpdir do |root|
|
|
291
|
+
path = make_skill_dir(root, "long", "name: long\ndescription: #{"x" * 1025}")
|
|
292
|
+
Brute::Skill.load(path).should.be.nil
|
|
293
|
+
end
|
|
117
294
|
end
|
|
118
295
|
end
|