brute 5.1.0 → 6.0.0

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.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/lib/brute/compaction/middleware/sliding_window.rb +1 -1
  3. data/lib/brute/compaction/middleware/strategy.rb +0 -2
  4. data/lib/brute/compaction/summarize.rb +30 -21
  5. data/lib/brute/completion/lang_chain.rb +34 -31
  6. data/lib/brute/completion/llmrb.rb +31 -29
  7. data/lib/brute/completion/open_router.rb +43 -40
  8. data/lib/brute/completion/ruby_llm.rb +28 -27
  9. data/lib/brute/contrib/otel.rb +81 -49
  10. data/lib/brute/eval/case.rb +7 -2
  11. data/lib/brute/eval/suite.rb +10 -5
  12. data/lib/brute/eval/transcript.rb +3 -3
  13. data/lib/brute/eval/world.rb +2 -2
  14. data/lib/brute/eval.rb +1 -1
  15. data/lib/brute/events/handler.rb +3 -1
  16. data/lib/brute/events/prefixed_terminal_output.rb +3 -1
  17. data/lib/brute/events/terminal_output_handler.rb +1 -1
  18. data/lib/brute/hooks.rb +88 -112
  19. data/lib/brute/message_transport/anthropic.rb +13 -3
  20. data/lib/brute/message_transport/llm.rb +8 -4
  21. data/lib/brute/message_transport/open_router.rb +30 -16
  22. data/lib/brute/message_transport/openai.rb +14 -7
  23. data/lib/brute/message_transport/ruby_llm.rb +21 -17
  24. data/lib/brute/message_transport/ruby_open_ai.rb +59 -57
  25. data/lib/brute/messages.rb +15 -8
  26. data/lib/brute/middleware/000_base.rb +5 -5
  27. data/lib/brute/middleware/002_session_log.rb +12 -4
  28. data/lib/brute/middleware/008_checkpoint.rb +26 -18
  29. data/lib/brute/middleware/010_max_iterations.rb +1 -1
  30. data/lib/brute/middleware/020_system_prompt.rb +1 -1
  31. data/lib/brute/middleware/040_default_compaction_pipeline.rb +49 -45
  32. data/lib/brute/middleware/070_default_tool_pipeline.rb +44 -53
  33. data/lib/brute/prompt_template.rb +19 -15
  34. data/lib/brute/prompts/base.rb +19 -10
  35. data/lib/brute/prompts/environment.rb +3 -1
  36. data/lib/brute/prompts/instructions.rb +9 -7
  37. data/lib/brute/prompts/skills.rb +5 -3
  38. data/lib/brute/rack/adapter.rb +40 -23
  39. data/lib/brute/skill.rb +136 -89
  40. data/lib/brute/system_prompt.rb +9 -9
  41. data/lib/brute/token_counter.rb +3 -1
  42. data/lib/brute/tool.rb +10 -6
  43. data/lib/brute/tools/adapter.rb +53 -43
  44. data/lib/brute/tools/fs/snapshot_store.rb +5 -1
  45. data/lib/brute/tools/fs_patch.rb +16 -8
  46. data/lib/brute/tools/fs_read.rb +107 -80
  47. data/lib/brute/tools/fs_remove.rb +6 -2
  48. data/lib/brute/tools/fs_search.rb +14 -4
  49. data/lib/brute/tools/fs_undo.rb +6 -2
  50. data/lib/brute/tools/fs_write.rb +5 -1
  51. data/lib/brute/tools/net_fetch.rb +6 -2
  52. data/lib/brute/tools/question.rb +42 -39
  53. data/lib/brute/tools/shell.rb +20 -5
  54. data/lib/brute/tools/skill_load.rb +44 -39
  55. data/lib/brute/tools/todo_write.rb +19 -15
  56. data/lib/brute/truncation.rb +68 -43
  57. data/lib/brute/turn/agent_pipeline.rb +23 -13
  58. data/lib/brute/turn/compaction_pipeline.rb +4 -3
  59. data/lib/brute/turn/pipeline.rb +87 -96
  60. data/lib/brute/turn/tool_pipeline.rb +4 -3
  61. data/lib/brute/usage_detection/llmrb.rb +18 -14
  62. data/lib/brute/usage_detection/open_router.rb +20 -17
  63. data/lib/brute/usage_detection/ruby_llm.rb +18 -14
  64. data/lib/brute/usage_detection/usage.rb +10 -1
  65. data/lib/brute/utils/diff.rb +18 -10
  66. data/lib/brute/version.rb +1 -1
  67. data/lib/brute.rb +10 -11
  68. data/lib/brute_cli/providers/shell.rb +32 -29
  69. data/lib/brute_cli/providers/shell_response.rb +20 -18
  70. metadata +30 -5
  71. data/lib/brute/middleware/040_compaction_check.rb +0 -73
  72. data/lib/brute/middleware/070_tool_pipeline.rb +0 -61
  73. data/lib/brute/middleware/event_handler.rb +0 -27
data/lib/brute/skill.rb CHANGED
@@ -34,8 +34,16 @@ module Brute
34
34
  # (https://agentskills.io/specification). A skill whose frontmatter violates
35
35
  # a rule is skipped with a stderr warning naming the rule — never raised.
36
36
  class Skill
37
- attr_reader :name, :description, :file_path, :base_dir, :content,
38
- :source, :license, :compatibility, :metadata, :allowed_tools
37
+ attr_reader :name,
38
+ :description,
39
+ :file_path,
40
+ :base_dir,
41
+ :content,
42
+ :source,
43
+ :license,
44
+ :compatibility,
45
+ :metadata,
46
+ :allowed_tools
39
47
 
40
48
  FILENAME = "SKILL.md"
41
49
 
@@ -74,38 +82,42 @@ module Brute
74
82
  def self.load(path, source: :path)
75
83
  raw = File.read(path)
76
84
  frontmatter, content = parse_frontmatter(path, raw)
77
- return nil unless frontmatter
78
-
79
- dir_name = File.basename(File.dirname(path))
80
- # Spec requires `name`; brute keeps the convenience of defaulting to the
81
- # directory name when omitted (which trivially satisfies the dir-match rule).
82
- frontmatter = { "name" => dir_name }.merge(frontmatter) unless frontmatter.key?("name")
83
-
84
- # Unknown fields are a soft violation: warn and drop them rather than
85
- # reject the skill. The reference validator hard-fails here, but a runtime
86
- # loader must tolerate vendor/forward extensions (e.g. `tags`), which real
87
- # published skills carry, instead of silently dropping the whole skill.
88
- extra = frontmatter.keys - ALLOWED_FIELDS
89
- warn "Skill #{path} has unexpected frontmatter fields (ignored): #{extra.sort.join(', ')}" unless extra.empty?
90
-
91
- errors = validate(frontmatter, dir_name)
92
- unless errors.empty?
93
- warn "Skipping invalid skill #{path}: #{errors.join('; ')}"
94
- return nil
95
- end
85
+ if frontmatter
86
+ dir_name = File.basename(File.dirname(path))
87
+ # Spec requires `name`; brute keeps the convenience of defaulting to the
88
+ # directory name when omitted (which trivially satisfies the dir-match rule).
89
+ unless frontmatter.key?("name")
90
+ frontmatter = { "name" => dir_name }.merge(frontmatter)
91
+ end
96
92
 
97
- new(
98
- name: frontmatter["name"].to_s.strip,
99
- description: frontmatter["description"].to_s.strip,
100
- file_path: path,
101
- content: content.to_s.strip,
102
- source: source,
103
- license: frontmatter["license"]&.to_s,
104
- compatibility: frontmatter["compatibility"]&.to_s,
105
- metadata: frontmatter["metadata"],
106
- allowed_tools: parse_allowed_tools(frontmatter["allowed-tools"]),
107
- disable_model_invocation: frontmatter["disable-model-invocation"] == true,
108
- )
93
+ # Unknown fields are a soft violation: warn and drop them rather than
94
+ # reject the skill. The reference validator hard-fails here, but a runtime
95
+ # loader must tolerate vendor/forward extensions (e.g. `tags`), which real
96
+ # published skills carry, instead of silently dropping the whole skill.
97
+ extra = frontmatter.keys - ALLOWED_FIELDS
98
+ unless extra.empty?
99
+ warn "Skill #{path} has unexpected frontmatter fields (ignored): #{extra.sort.join(', ')}"
100
+ end
101
+
102
+ errors = validate(frontmatter, dir_name)
103
+ if errors.empty?
104
+ new(
105
+ name: frontmatter["name"].to_s.strip,
106
+ description: frontmatter["description"].to_s.strip,
107
+ file_path: path,
108
+ content: content.to_s.strip,
109
+ source: source,
110
+ license: frontmatter["license"]&.to_s,
111
+ compatibility: frontmatter["compatibility"]&.to_s,
112
+ metadata: frontmatter["metadata"],
113
+ allowed_tools: parse_allowed_tools(frontmatter["allowed-tools"]),
114
+ disable_model_invocation: frontmatter["disable-model-invocation"] == true,
115
+ )
116
+ else
117
+ warn "Skipping invalid skill #{path}: #{errors.join('; ')}"
118
+ nil
119
+ end
120
+ end
109
121
  rescue => e
110
122
  warn "Failed to load skill #{path}: #{e.message}"
111
123
  nil
@@ -122,25 +134,28 @@ module Brute
122
134
 
123
135
  add = lambda do |path, source|
124
136
  skill = load(path, source: source)
125
- return unless skill
126
-
127
- real = realpath(path)
128
- return if seen_files[real]
129
-
130
- if (winner = skills[skill.name])
131
- warn "Skill name collision: '#{skill.name}' at #{path} ignored; " \
132
- "already loaded from #{winner.file_path}"
133
- return
137
+ real = skill && realpath(path)
138
+
139
+ if skill && !seen_files[real]
140
+ if (winner = skills[skill.name])
141
+ warn "Skill name collision: '#{skill.name}' at #{path} ignored; " \
142
+ "already loaded from #{winner.file_path}"
143
+ else
144
+ seen_files[real] = true
145
+ skills[skill.name] = skill
146
+ end
134
147
  end
135
-
136
- seen_files[real] = true
137
- skills[skill.name] = skill
138
148
  end
139
149
 
140
150
  project = File.join(cwd, ".brute", "skills")
141
151
  glob(project) { |path| add.call(path, :project) }
142
152
 
143
- global = File.join(Dir.home, ".config", "brute", "skills")
153
+ global = File.join(
154
+ Dir.home,
155
+ ".config",
156
+ "brute",
157
+ "skills",
158
+ )
144
159
  glob(global) { |path| add.call(path, :user) }
145
160
 
146
161
  paths.each do |raw|
@@ -163,9 +178,11 @@ module Brute
163
178
  end
164
179
 
165
180
  def self.glob(dir, &block)
166
- return unless File.directory?(dir)
167
-
168
- Dir.glob(File.join(dir, "**", FILENAME)).sort.each(&block)
181
+ if File.directory?(dir)
182
+ Dir.glob(File.join(dir, "**", FILENAME)).sort.each(&block)
183
+ else
184
+ nil
185
+ end
169
186
  end
170
187
 
171
188
  def self.realpath(path)
@@ -177,70 +194,100 @@ module Brute
177
194
  # Validate frontmatter against the spec. Returns an array of error strings
178
195
  # (empty means valid), each naming the violated rule.
179
196
  def self.validate(frontmatter, dir_name)
180
- errors = []
181
-
182
- errors.concat(validate_name(frontmatter["name"], dir_name))
183
- errors.concat(validate_description(frontmatter["description"]))
184
-
185
- if frontmatter.key?("compatibility")
186
- compatibility = frontmatter["compatibility"]
187
- if !compatibility.is_a?(String)
188
- errors << "'compatibility' must be a string"
189
- elsif compatibility.length > MAX_COMPATIBILITY_LENGTH
190
- errors << "'compatibility' exceeds #{MAX_COMPATIBILITY_LENGTH} characters"
197
+ [].tap do |errors|
198
+ errors.concat(validate_name(frontmatter["name"], dir_name))
199
+ errors.concat(validate_description(frontmatter["description"]))
200
+
201
+ if frontmatter.key?("compatibility")
202
+ compatibility = frontmatter["compatibility"]
203
+ if !compatibility.is_a?(String)
204
+ errors << "'compatibility' must be a string"
205
+ elsif compatibility.length > MAX_COMPATIBILITY_LENGTH
206
+ errors << "'compatibility' exceeds #{MAX_COMPATIBILITY_LENGTH} characters"
207
+ end
191
208
  end
192
209
  end
193
-
194
- errors
195
210
  end
196
211
 
197
212
  def self.validate_name(name, dir_name)
198
- return ["missing required field 'name'"] unless name.is_a?(String) && !name.strip.empty?
199
-
200
- name = name.strip.unicode_normalize(:nfkc)
201
- errors = []
202
- errors << "'name' exceeds #{MAX_NAME_LENGTH} characters" if name.length > MAX_NAME_LENGTH
203
- errors << "'name' must be lowercase" if name != name.downcase
204
- errors << "'name' cannot start or end with a hyphen" if name.start_with?("-") || name.end_with?("-")
205
- errors << "'name' cannot contain consecutive hyphens" if name.include?("--")
206
- errors << "'name' may only contain letters, digits, and hyphens" unless name.match?(/\A[\p{Alnum}\-]+\z/)
207
- errors << "directory name '#{dir_name}' must match skill name '#{name}'" if dir_name.unicode_normalize(:nfkc) != name
208
- errors
213
+ if name.is_a?(String) && !name.strip.empty?
214
+ name = name.strip.unicode_normalize(:nfkc)
215
+ errors = []
216
+ if name.length > MAX_NAME_LENGTH
217
+ errors << "'name' exceeds #{MAX_NAME_LENGTH} characters"
218
+ end
219
+ if name != name.downcase
220
+ errors << "'name' must be lowercase"
221
+ end
222
+ if name.start_with?("-") || name.end_with?("-")
223
+ errors << "'name' cannot start or end with a hyphen"
224
+ end
225
+ if name.include?("--")
226
+ errors << "'name' cannot contain consecutive hyphens"
227
+ end
228
+ unless name.match?(/\A[\p{Alnum}\-]+\z/)
229
+ errors << "'name' may only contain letters, digits, and hyphens"
230
+ end
231
+ if dir_name.unicode_normalize(:nfkc) != name
232
+ errors << "directory name '#{dir_name}' must match skill name '#{name}'"
233
+ end
234
+ errors
235
+ else
236
+ ["missing required field 'name'"]
237
+ end
209
238
  end
210
239
 
211
240
  def self.validate_description(description)
212
- return ["missing required field 'description'"] unless description.is_a?(String) && !description.strip.empty?
213
- return ["'description' exceeds #{MAX_DESCRIPTION_LENGTH} characters"] if description.length > MAX_DESCRIPTION_LENGTH
214
-
215
- []
241
+ if description.is_a?(String) && !description.strip.empty?
242
+ if description.length > MAX_DESCRIPTION_LENGTH
243
+ ["'description' exceeds #{MAX_DESCRIPTION_LENGTH} characters"]
244
+ else
245
+ []
246
+ end
247
+ else
248
+ ["missing required field 'description'"]
249
+ end
216
250
  end
217
251
 
218
252
  # `allowed-tools` is an experimental, space-separated list of tool names.
219
253
  def self.parse_allowed_tools(value)
220
- return nil if value.nil?
221
-
222
- value.to_s.split(/\s+/).reject(&:empty?)
254
+ if value.nil?
255
+ nil
256
+ else
257
+ value.to_s.split(/\s+/).reject(&:empty?)
258
+ end
223
259
  end
224
260
 
225
261
  # Split YAML frontmatter from markdown body.
226
262
  # Returns [hash, string] or [nil, nil].
227
263
  def self.parse_frontmatter(path, raw)
228
- return [nil, nil] unless raw.start_with?("---")
229
-
230
- parts = raw.split(/^---\s*$/, 3)
231
- return [nil, nil] if parts.length < 3
264
+ parts = nil
265
+ if raw.start_with?("---")
266
+ parts = raw.split(/^---\s*$/, 3)
267
+ end
232
268
 
233
- frontmatter = YAML.safe_load(parts[1])
234
- return [nil, nil] unless frontmatter.is_a?(Hash)
269
+ frontmatter = nil
270
+ if parts && parts.length >= 3
271
+ frontmatter = YAML.safe_load(parts[1])
272
+ end
235
273
 
236
- [frontmatter, parts[2]]
274
+ if frontmatter.is_a?(Hash)
275
+ [frontmatter, parts[2]]
276
+ else
277
+ [nil, nil]
278
+ end
237
279
  rescue => e
238
280
  warn "Failed to parse frontmatter in #{path}: #{e.message}"
239
281
  [nil, nil]
240
282
  end
241
283
 
242
- private_class_method :glob, :realpath, :validate, :validate_name,
243
- :validate_description, :parse_allowed_tools, :parse_frontmatter
284
+ private_class_method :glob,
285
+ :realpath,
286
+ :validate,
287
+ :validate_name,
288
+ :validate_description,
289
+ :parse_allowed_tools,
290
+ :parse_frontmatter
244
291
  end
245
292
  end
246
293
 
@@ -34,11 +34,11 @@ module Brute
34
34
  # upstream model family from the model name so we use the most
35
35
  # appropriate prompt stack (e.g., anthropic stack for claude-*).
36
36
  provider = ctx[:provider_name].to_s
37
- stack_key = if provider.start_with?("opencode")
38
- infer_stack_from_model(ctx[:model_name].to_s)
39
- else
40
- provider
41
- end
37
+ if provider.start_with?("opencode")
38
+ stack_key = infer_stack_from_model(ctx[:model_name].to_s)
39
+ else
40
+ stack_key = provider
41
+ end
42
42
  STACKS.fetch(stack_key, STACKS["default"]).each do |mod|
43
43
  prompt << mod.call(ctx)
44
44
  end
@@ -78,7 +78,7 @@ module Brute
78
78
  ],
79
79
 
80
80
  # GPT-4 / o1 / o3 — pragmatic engineer persona, editing focus, autonomy
81
- "openai" => [
81
+ "openai" => [
82
82
  Prompts::Identity,
83
83
  Prompts::EditingApproach,
84
84
  Prompts::Autonomy,
@@ -94,7 +94,7 @@ module Brute
94
94
  ],
95
95
 
96
96
  # Gemini — formal/structured, explicit workflows, security focus
97
- "google" => [
97
+ "google" => [
98
98
  Prompts::Identity,
99
99
  Prompts::Conventions,
100
100
  Prompts::DoingTasks,
@@ -109,7 +109,7 @@ module Brute
109
109
  ],
110
110
 
111
111
  # Ollama — lean stack for local models with smaller context windows
112
- "ollama" => [
112
+ "ollama" => [
113
113
  Prompts::Identity,
114
114
  Prompts::ToneAndStyle,
115
115
  Prompts::Conventions,
@@ -121,7 +121,7 @@ module Brute
121
121
  ],
122
122
 
123
123
  # Fallback — conservative, concise, fewer than 4 lines
124
- "default" => [
124
+ "default" => [
125
125
  Prompts::Identity,
126
126
  Prompts::ToneAndStyle,
127
127
  Prompts::Proactiveness,
@@ -37,7 +37,9 @@ module Brute
37
37
  # the schemas, which are already inside it. Anything else double counts.
38
38
  def self.estimate(env, counter: nil, tools: nil)
39
39
  counter ||= env[:token_counter] || default
40
- tools = env[:tools] if tools.nil?
40
+ if tools.nil?
41
+ tools = env[:tools]
42
+ end
41
43
  messages = env[:messages] || []
42
44
  reported = env.dig(:metadata, :last_llm_usage)&.total.to_i
43
45
  answered = messages.rindex { |message| message.role == :assistant }
data/lib/brute/tool.rb CHANGED
@@ -30,9 +30,11 @@ module Brute
30
30
  class Tool
31
31
  class << self
32
32
  def description(text = nil)
33
- return @description unless text
34
-
35
- @description = text
33
+ if text
34
+ @description = text
35
+ else
36
+ @description
37
+ end
36
38
  end
37
39
 
38
40
  # Declare one parameter: param :key, type:, desc:, required:
@@ -46,9 +48,11 @@ module Brute
46
48
 
47
49
  # Raw JSON-schema override for complex argument shapes.
48
50
  def params(schema = nil)
49
- return @params_schema unless schema
50
-
51
- @params_schema = schema.deep_symbolize_keys
51
+ if schema
52
+ @params_schema = schema.deep_symbolize_keys
53
+ else
54
+ @params_schema
55
+ end
52
56
  end
53
57
  end
54
58
 
@@ -39,29 +39,32 @@ module Brute
39
39
 
40
40
  # Wrap a single tool of any supported shape. Idempotent.
41
41
  def self.wrap(tool)
42
- return tool if tool.is_a?(Adapter)
43
-
44
- tool = tool.new if tool.is_a?(Class)
45
-
46
- case tool
47
- when Hash then from_hash(tool)
48
- when ::Brute::Tool then from_brute_tool(tool)
49
- when Brute::Tools::SubAgent then new(
50
- name: tool.name,
51
- description: tool.description,
52
- params: tool.params,
53
- handler: ->(**args) { tool.execute(args) },
54
- original: tool,
55
- )
56
- when Brute::Turn::ToolPipeline then new(
57
- name: tool.name,
58
- description: tool.description,
59
- params: tool.params,
60
- handler: ->(**args) { tool.call(**args) },
61
- original: tool,
62
- )
42
+ if tool.is_a?(Adapter)
43
+ tool
63
44
  else
64
- from_duck_type(tool)
45
+ if tool.is_a?(Class)
46
+ tool = tool.new
47
+ end
48
+ case tool
49
+ when Hash then from_hash(tool)
50
+ when ::Brute::Tool then from_brute_tool(tool)
51
+ when Brute::Tools::SubAgent then new(
52
+ name: tool.name,
53
+ description: tool.description,
54
+ params: tool.params,
55
+ handler: ->(**args) { tool.execute(args) },
56
+ original: tool,
57
+ )
58
+ when Brute::Turn::ToolPipeline then new(
59
+ name: tool.name,
60
+ description: tool.description,
61
+ params: tool.params,
62
+ handler: ->(**args) { tool.call(**args) },
63
+ original: tool,
64
+ )
65
+ else
66
+ from_duck_type(tool)
67
+ end
65
68
  end
66
69
  end
67
70
 
@@ -78,7 +81,9 @@ module Brute
78
81
  def self.from_hash(definition)
79
82
  definition = definition.transform_keys(&:to_sym)
80
83
  handler = definition.fetch(:execute) { definition[:handler] }
81
- raise ArgumentError, "inline tool needs an :execute proc" unless handler.respond_to?(:call)
84
+ unless handler.respond_to?(:call)
85
+ raise ArgumentError, "inline tool needs an :execute proc"
86
+ end
82
87
 
83
88
  new(
84
89
  name: definition.fetch(:name).to_s,
@@ -108,7 +113,11 @@ module Brute
108
113
  raise ArgumentError, "don't know how to adapt #{tool.inspect} into a tool"
109
114
  end
110
115
 
111
- entry = tool.respond_to?(:execute) ? tool.method(:execute) : tool.method(:call)
116
+ if tool.respond_to?(:execute)
117
+ entry = tool.method(:execute)
118
+ else
119
+ entry = tool.method(:call)
120
+ end
112
121
  new(
113
122
  name: tool.name.to_s,
114
123
  description: tool.respond_to?(:description) ? tool.description : "",
@@ -141,27 +150,28 @@ module Brute
141
150
  # Library-neutral tool definition (JSON-Schema-ish). The inline `run`
142
151
  # proc reshapes this into whatever its LLM library expects.
143
152
  def to_h
144
- return { name: @name, description: @description, parameters: @schema.deep_symbolize_keys } if @schema
145
-
146
- properties = @params.transform_values do |opts|
153
+ if @schema
154
+ { name: @name, description: @description, parameters: @schema.deep_symbolize_keys }
155
+ else
156
+ properties = @params.transform_values do |opts|
157
+ {
158
+ type: opts[:type] || "string",
159
+ description: opts[:desc] || opts[:description],
160
+ items: opts[:items],
161
+ enum: opts[:enum],
162
+ }.compact
163
+ end
164
+ required = @params.select { |_k, opts| opts[:required] }.keys
147
165
  {
148
- type: opts[:type] || "string",
149
- description: opts[:desc] || opts[:description],
150
- items: opts[:items],
151
- enum: opts[:enum],
152
- }.compact
166
+ name: @name,
167
+ description: @description,
168
+ parameters: {
169
+ type: "object",
170
+ properties: properties,
171
+ required: required.map(&:to_s),
172
+ },
173
+ }
153
174
  end
154
- required = @params.select { |_k, opts| opts[:required] }.keys
155
-
156
- {
157
- name: @name,
158
- description: @description,
159
- parameters: {
160
- type: "object",
161
- properties: properties,
162
- required: required.map(&:to_s),
163
- },
164
- }
165
175
  end
166
176
  end
167
177
  end
@@ -19,7 +19,11 @@ module Brute
19
19
  # If the file doesn't exist yet, records +:did_not_exist+.
20
20
  def save(path)
21
21
  key = File.expand_path(path)
22
- content = File.exist?(key) ? File.read(key) : :did_not_exist
22
+ if File.exist?(key)
23
+ content = File.read(key)
24
+ else
25
+ content = :did_not_exist
26
+ end
23
27
  @mutex.synchronize { @snapshots[key].push(content) }
24
28
  end
25
29
 
@@ -20,22 +20,30 @@ module Brute
20
20
  def execute(file_path:, old_string:, new_string:, replace_all: false)
21
21
  path = File.expand_path(file_path)
22
22
  Brute::Tools::FS::FileMutationQueue.serialize(path) do
23
- raise "File not found: #{path}" unless File.exist?(path)
23
+ unless File.exist?(path)
24
+ raise "File not found: #{path}"
25
+ end
24
26
 
25
27
  original = File.read(path)
26
- raise "old_string not found in #{path}" unless original.include?(old_string)
28
+ unless original.include?(old_string)
29
+ raise "old_string not found in #{path}"
30
+ end
27
31
 
28
32
  Brute::Tools::FS::SnapshotStore.save(path)
29
33
 
30
- updated = if replace_all
31
- original.gsub(old_string, new_string)
32
- else
33
- original.sub(old_string, new_string)
34
- end
34
+ if replace_all
35
+ updated = original.gsub(old_string, new_string)
36
+ else
37
+ updated = original.sub(old_string, new_string)
38
+ end
35
39
 
36
40
  File.write(path, updated)
37
41
  diff = Brute::Diff.unified(original, updated)
38
- count = replace_all ? original.scan(old_string).size : 1
42
+ if replace_all
43
+ count = original.scan(old_string).size
44
+ else
45
+ count = 1
46
+ end
39
47
  { success: true, file_path: path, replacements: count, diff: diff }
40
48
  end
41
49
  end