brute 0.1.8 → 0.2.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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/lib/brute/agent_stream.rb +4 -1
  3. data/lib/brute/middleware/message_tracking.rb +15 -1
  4. data/lib/brute/orchestrator.rb +20 -6
  5. data/lib/brute/prompts/autonomy.rb +21 -0
  6. data/lib/brute/prompts/base.rb +23 -0
  7. data/lib/brute/prompts/build_switch.rb +19 -0
  8. data/lib/brute/prompts/code_references.rb +21 -0
  9. data/lib/brute/prompts/code_style.rb +16 -0
  10. data/lib/brute/prompts/conventions.rb +20 -0
  11. data/lib/brute/prompts/doing_tasks.rb +11 -0
  12. data/lib/brute/prompts/editing_approach.rb +20 -0
  13. data/lib/brute/prompts/editing_constraints.rb +24 -0
  14. data/lib/brute/prompts/environment.rb +25 -0
  15. data/lib/brute/prompts/frontend_tasks.rb +21 -0
  16. data/lib/brute/prompts/git_safety.rb +19 -0
  17. data/lib/brute/prompts/identity.rb +11 -0
  18. data/lib/brute/prompts/instructions.rb +18 -0
  19. data/lib/brute/prompts/max_steps.rb +30 -0
  20. data/lib/brute/prompts/objectivity.rb +16 -0
  21. data/lib/brute/prompts/plan_reminder.rb +40 -0
  22. data/lib/brute/prompts/proactiveness.rb +19 -0
  23. data/lib/brute/prompts/security_and_safety.rb +17 -0
  24. data/lib/brute/prompts/skills.rb +22 -0
  25. data/lib/brute/prompts/task_management.rb +59 -0
  26. data/lib/brute/prompts/text/agents/compaction.txt +15 -0
  27. data/lib/brute/prompts/text/agents/explore.txt +17 -0
  28. data/lib/brute/prompts/text/agents/summary.txt +11 -0
  29. data/lib/brute/prompts/text/agents/title.txt +40 -0
  30. data/lib/brute/prompts/text/doing_tasks/anthropic.txt +11 -0
  31. data/lib/brute/prompts/text/doing_tasks/default.txt +6 -0
  32. data/lib/brute/prompts/text/doing_tasks/google.txt +9 -0
  33. data/lib/brute/prompts/text/identity/anthropic.txt +5 -0
  34. data/lib/brute/prompts/text/identity/default.txt +3 -0
  35. data/lib/brute/prompts/text/identity/google.txt +1 -0
  36. data/lib/brute/prompts/text/identity/openai.txt +3 -0
  37. data/lib/brute/prompts/text/tone_and_style/anthropic.txt +5 -0
  38. data/lib/brute/prompts/text/tone_and_style/default.txt +9 -0
  39. data/lib/brute/prompts/text/tone_and_style/google.txt +6 -0
  40. data/lib/brute/prompts/text/tone_and_style/openai.txt +17 -0
  41. data/lib/brute/prompts/text/tool_usage/anthropic.txt +16 -0
  42. data/lib/brute/prompts/text/tool_usage/default.txt +4 -0
  43. data/lib/brute/prompts/text/tool_usage/google.txt +4 -0
  44. data/lib/brute/prompts/tone_and_style.rb +11 -0
  45. data/lib/brute/prompts/tool_usage.rb +11 -0
  46. data/lib/brute/providers/models_dev.rb +111 -0
  47. data/lib/brute/providers/opencode_go.rb +38 -0
  48. data/lib/brute/providers/opencode_zen.rb +82 -0
  49. data/lib/brute/providers/shell.rb +108 -0
  50. data/lib/brute/providers/shell_response.rb +100 -0
  51. data/lib/brute/skill.rb +118 -0
  52. data/lib/brute/system_prompt.rb +141 -63
  53. data/lib/brute/tools/delegate.rb +25 -1
  54. data/lib/brute/tools/question.rb +59 -0
  55. data/lib/brute/version.rb +1 -1
  56. data/lib/brute.rb +83 -3
  57. metadata +49 -1
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Brute
6
+ # Discovers and loads SKILL.md files from standard directories.
7
+ #
8
+ # A skill is a markdown file with YAML frontmatter:
9
+ #
10
+ # ---
11
+ # name: debugging
12
+ # description: Systematic debugging workflow for isolating and fixing bugs
13
+ # ---
14
+ #
15
+ # When debugging, follow these steps...
16
+ #
17
+ # Skills are scanned from (in order):
18
+ # 1. .brute/skills/**/SKILL.md (project-local)
19
+ # 2. ~/.config/brute/skills/**/SKILL.md (global)
20
+ #
21
+ # The directory name containing SKILL.md becomes the skill name if frontmatter
22
+ # doesn't specify one.
23
+ #
24
+ module Skill
25
+ Info = Struct.new(:name, :description, :location, :content, keyword_init: true)
26
+
27
+ FILENAME = "SKILL.md"
28
+
29
+ # Scan all skill directories and return an array of Info structs.
30
+ def self.all(cwd: Dir.pwd)
31
+ skills = {}
32
+
33
+ scan_dirs(cwd).each do |dir|
34
+ Dir.glob(File.join(dir, "**", FILENAME)).sort.each do |path|
35
+ info = load(path)
36
+ next unless info
37
+ # First found wins (project-local overrides global)
38
+ skills[info.name] ||= info
39
+ end
40
+ end
41
+
42
+ skills.values.sort_by(&:name)
43
+ end
44
+
45
+ # Get a single skill by name.
46
+ def self.get(name, cwd: Dir.pwd)
47
+ all(cwd: cwd).detect { |s| s.name == name }
48
+ end
49
+
50
+ # Format skills as XML for the system prompt.
51
+ def self.fmt(skills)
52
+ return nil if skills.empty?
53
+
54
+ lines = ["<available_skills>"]
55
+ skills.each do |skill|
56
+ lines << " <skill>"
57
+ lines << " <name>#{skill.name}</name>"
58
+ lines << " <description>#{skill.description}</description>"
59
+ lines << " </skill>"
60
+ end
61
+ lines << "</available_skills>"
62
+ lines.join("\n")
63
+ end
64
+
65
+ # Parse a SKILL.md file into an Info struct.
66
+ # Returns nil if the file is invalid or missing required fields.
67
+ def self.load(path)
68
+ raw = File.read(path)
69
+ frontmatter, content = parse_frontmatter(raw)
70
+ return nil unless frontmatter
71
+
72
+ name = frontmatter["name"] || File.basename(File.dirname(path))
73
+ description = frontmatter["description"]
74
+ return nil unless description && !description.strip.empty?
75
+
76
+ Info.new(
77
+ name: name.to_s.strip,
78
+ description: description.to_s.strip,
79
+ location: path,
80
+ content: content.to_s.strip,
81
+ )
82
+ rescue => e
83
+ warn "Failed to load skill #{path}: #{e.message}"
84
+ nil
85
+ end
86
+
87
+ # Directories to scan for skills, in priority order.
88
+ def self.scan_dirs(cwd)
89
+ dirs = []
90
+
91
+ # Project-local
92
+ project = File.join(cwd, ".brute", "skills")
93
+ dirs << project if File.directory?(project)
94
+
95
+ # Global
96
+ global = File.join(Dir.home, ".config", "brute", "skills")
97
+ dirs << global if File.directory?(global)
98
+
99
+ dirs
100
+ end
101
+
102
+ # Split YAML frontmatter from markdown body.
103
+ # Returns [hash, string] or [nil, nil].
104
+ def self.parse_frontmatter(raw)
105
+ return [nil, nil] unless raw.start_with?("---")
106
+
107
+ parts = raw.split(/^---\s*$/, 3)
108
+ return [nil, nil] if parts.length < 3
109
+
110
+ frontmatter = YAML.safe_load(parts[1])
111
+ return [nil, nil] unless frontmatter.is_a?(Hash)
112
+
113
+ [frontmatter, parts[2]]
114
+ end
115
+
116
+ private_class_method :scan_dirs, :parse_frontmatter
117
+ end
118
+ end
@@ -1,88 +1,166 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Brute
4
- # Builds the system prompt dynamically based on available tools, environment,
5
- # custom rules, and working directory context.
4
+ # Deferred system prompt builder.
5
+ #
6
+ # The block passed to +build+ is stored — not executed — until +prepare+
7
+ # is called with a runtime context hash (provider_name, model_name, cwd, etc).
8
+ #
9
+ # sp = Brute::SystemPrompt.build do |prompt, ctx|
10
+ # prompt << Brute::Prompts::Identity.call(ctx)
11
+ # prompt << Brute::Prompts::ToneAndStyle.call(ctx)
12
+ # prompt << Brute::Prompts::Environment.call(ctx)
13
+ # end
14
+ #
15
+ # result = sp.prepare(provider_name: "anthropic", model_name: "claude-sonnet-4-20250514", cwd: Dir.pwd)
16
+ # result.to_s # single joined string
17
+ # result.sections # array of strings (one per p.system call)
6
18
  #
7
- # Modeled after forgecode's SystemPrompt which composes a static agent
8
- # personality block with dynamic environment/tool information.
9
19
  class SystemPrompt
10
- def initialize(cwd: Dir.pwd, tools: [], custom_rules: nil)
11
- @cwd = cwd
12
- @tools = tools
13
- @custom_rules = custom_rules
20
+ # Build a deferred system prompt. The block is stored and called later by +prepare+.
21
+ def self.build(&block)
22
+ new(block)
14
23
  end
15
24
 
16
- def build
17
- sections = []
18
- sections << identity_section
19
- sections << tools_section
20
- sections << guidelines_section
21
- sections << environment_section
22
- sections << custom_rules_section if @custom_rules
23
- sections.compact.join("\n\n")
24
- end
25
+ # Return the default system prompt. Selects the right provider stack at
26
+ # prepare-time, then appends conditional sections based on runtime state.
27
+ def self.default
28
+ build do |prompt, ctx|
29
+ # Provider-specific base stack.
30
+ # For gateway providers (opencode_zen, opencode_go), infer the
31
+ # upstream model family from the model name so we use the most
32
+ # appropriate prompt stack (e.g., anthropic stack for claude-*).
33
+ provider = ctx[:provider_name].to_s
34
+ stack_key = if provider.start_with?("opencode")
35
+ infer_stack_from_model(ctx[:model_name].to_s)
36
+ else
37
+ provider
38
+ end
39
+ STACKS.fetch(stack_key, STACKS["default"]).each do |mod|
40
+ prompt << mod.call(ctx)
41
+ end
25
42
 
26
- private
43
+ # Conditional: agent-specific reminders
44
+ if ctx[:agent] == "plan"
45
+ prompt << Prompts::PlanReminder.call(ctx)
46
+ end
27
47
 
28
- def identity_section
29
- <<~SECTION
30
- # Identity
48
+ if ctx[:agent_switched] == "build"
49
+ prompt << Prompts::BuildSwitch.call(ctx)
50
+ end
31
51
 
32
- You are Brute, an expert software engineering agent. You help users with coding tasks
33
- by reading, writing, and editing files, running shell commands, and searching codebases.
34
- You are methodical, precise, and always verify your work.
35
- SECTION
52
+ if ctx[:max_steps_reached]
53
+ prompt << Prompts::MaxSteps.call(ctx)
54
+ end
55
+ end
36
56
  end
37
57
 
38
- def tools_section
39
- tool_list = LLM::Function.registry.filter_map { |fn|
40
- "- **#{fn.name}**: #{fn.description.to_s.split(". ").first}."
41
- }.join("\n")
58
+ # Pre-configured prompt stacks per provider.
59
+ # Each maps a provider name to an ordered list of prompt modules.
60
+ STACKS = {
61
+ # Claude — full-featured with task management and detailed tool policy
62
+ "anthropic" => [
63
+ Prompts::Identity,
64
+ Prompts::ToneAndStyle,
65
+ Prompts::Objectivity,
66
+ Prompts::TaskManagement,
67
+ Prompts::DoingTasks,
68
+ Prompts::ToolUsage,
69
+ Prompts::Conventions,
70
+ Prompts::GitSafety,
71
+ Prompts::CodeReferences,
72
+ Prompts::Environment,
73
+ Prompts::Skills,
74
+ Prompts::Instructions,
75
+ ],
42
76
 
43
- <<~SECTION
44
- # Available Tools
77
+ # GPT-4 / o1 / o3 — pragmatic engineer persona, editing focus, autonomy
78
+ "openai" => [
79
+ Prompts::Identity,
80
+ Prompts::EditingApproach,
81
+ Prompts::Autonomy,
82
+ Prompts::EditingConstraints,
83
+ Prompts::FrontendTasks,
84
+ Prompts::ToneAndStyle,
85
+ Prompts::Conventions,
86
+ Prompts::GitSafety,
87
+ Prompts::CodeReferences,
88
+ Prompts::Environment,
89
+ Prompts::Skills,
90
+ Prompts::Instructions,
91
+ ],
45
92
 
46
- #{tool_list}
47
- SECTION
48
- end
93
+ # Gemini — formal/structured, explicit workflows, security focus
94
+ "google" => [
95
+ Prompts::Identity,
96
+ Prompts::Conventions,
97
+ Prompts::DoingTasks,
98
+ Prompts::ToneAndStyle,
99
+ Prompts::SecurityAndSafety,
100
+ Prompts::ToolUsage,
101
+ Prompts::GitSafety,
102
+ Prompts::CodeReferences,
103
+ Prompts::Environment,
104
+ Prompts::Skills,
105
+ Prompts::Instructions,
106
+ ],
49
107
 
50
- def guidelines_section
51
- <<~SECTION
52
- # Guidelines
108
+ # Fallback — conservative, concise, fewer than 4 lines
109
+ "default" => [
110
+ Prompts::Identity,
111
+ Prompts::ToneAndStyle,
112
+ Prompts::Proactiveness,
113
+ Prompts::Conventions,
114
+ Prompts::CodeStyle,
115
+ Prompts::DoingTasks,
116
+ Prompts::ToolUsage,
117
+ Prompts::GitSafety,
118
+ Prompts::CodeReferences,
119
+ Prompts::Environment,
120
+ Prompts::Skills,
121
+ Prompts::Instructions,
122
+ ],
123
+ }.freeze
53
124
 
54
- - **Always read before editing**: Use `read` to examine a file before using `patch` or `write` to modify it.
55
- - **Verify your changes**: After editing, re-read the file or run tests to confirm correctness.
56
- - **Use todo_write for multi-step tasks**: Break complex work into steps and track progress.
57
- - **Use fs_search to find code**: Don't guess file locations — search first.
58
- - **Use shell for git, tests, builds**: Run `git diff`, `git status`, test suites, etc.
59
- - **Be precise with patch**: The `old_string` must match the file content exactly, including whitespace.
60
- - **Prefer patch over write**: For existing files, use `patch` to change specific sections rather than rewriting the entire file.
61
- - **Use undo to recover**: If a write or patch goes wrong, use `undo` to restore the previous version.
62
- - **Delegate research**: Use `delegate` for complex analysis that needs focused investigation.
63
- SECTION
125
+ # Infer the best prompt stack from a model name.
126
+ # Used for gateway providers that route to multiple upstream model families.
127
+ def self.infer_stack_from_model(model_name)
128
+ case model_name
129
+ when /\bclaude\b/i, /\bbig.?pickle\b/i
130
+ "anthropic"
131
+ when /\bgpt\b/i, /\bo[134]\b/i, /\bcodex\b/i
132
+ "openai"
133
+ when /\bgemini\b/i, /\bgemma\b/i
134
+ "google"
135
+ else
136
+ "default"
137
+ end
64
138
  end
65
139
 
66
- def environment_section
67
- files = Dir.entries(@cwd).reject { |f| f.start_with?(".") }.sort.first(50)
68
-
69
- <<~SECTION
70
- # Environment
140
+ def initialize(block)
141
+ @block = block
142
+ end
71
143
 
72
- - **Working directory**: #{@cwd}
73
- - **OS**: #{RUBY_PLATFORM}
74
- - **Ruby**: #{RUBY_VERSION}
75
- - **Date**: #{Time.now.strftime("%Y-%m-%d")}
76
- - **Files in cwd**: #{files.join(", ")}
77
- SECTION
144
+ # Execute the stored block with the given context and return a Result.
145
+ def prepare(ctx)
146
+ sections = []
147
+ @block.call(sections, ctx)
148
+ Result.new(sections.compact.reject { |s| s.respond_to?(:empty?) && s.empty? })
78
149
  end
79
150
 
80
- def custom_rules_section
81
- <<~SECTION
82
- # Project-Specific Rules
151
+ # Immutable result of a prepared system prompt.
152
+ Result = Struct.new(:sections) do
153
+ def to_s
154
+ sections.join("\n\n")
155
+ end
156
+
157
+ def each(&block)
158
+ sections.each(&block)
159
+ end
83
160
 
84
- #{@custom_rules}
85
- SECTION
161
+ def empty?
162
+ sections.empty?
163
+ end
86
164
  end
87
165
  end
88
166
  end
@@ -28,7 +28,31 @@ module Brute
28
28
  rounds += 1
29
29
  end
30
30
 
31
- {result: res.content}
31
+ {result: extract_content(res, sub)}
32
+ end
33
+
34
+ private
35
+
36
+ # Safely extract text content from the sub-agent response.
37
+ #
38
+ # When the LLM returns only tool calls (no text content block),
39
+ # res.content raises NoMethodError because the response adapter's
40
+ # choices array is empty (it only maps over text blocks), or
41
+ # returns nil when the response has no text. Fall back to the
42
+ # last assistant text in the conversation history.
43
+ def extract_content(res, context)
44
+ text = begin
45
+ res.content
46
+ rescue NoMethodError
47
+ nil
48
+ end
49
+ return text if text.is_a?(::String) && !text.empty?
50
+
51
+ last_assistant = context.messages.to_a
52
+ .select(&:assistant?)
53
+ .reverse
54
+ .find { |m| m.content.is_a?(::String) && !m.content.empty? }
55
+ last_assistant&.content || "(sub-agent completed but produced no text response)"
32
56
  end
33
57
  end
34
58
  end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Brute
4
+ module Tools
5
+ class Question < LLM::Tool
6
+ name "question"
7
+ description "Ask the user questions during execution. Use this to gather preferences, " \
8
+ "clarify ambiguous instructions, get decisions on implementation choices, or " \
9
+ "offer choices about direction. Users can always select \"Other\" to provide " \
10
+ "custom text input. Answers are returned as arrays of labels; set multiple: true " \
11
+ "to allow selecting more than one. If you recommend a specific option, make it " \
12
+ "the first option and add \"(Recommended)\" at the end of the label."
13
+
14
+ params do |s|
15
+ s.object(
16
+ questions: s.array(
17
+ s.object(
18
+ question: s.string.required,
19
+ header: s.string.required,
20
+ options: s.array(
21
+ s.object(
22
+ label: s.string.required,
23
+ description: s.string.required,
24
+ )
25
+ ).required,
26
+ multiple: s.boolean,
27
+ )
28
+ ).required
29
+ )
30
+ end
31
+
32
+ def call(questions:)
33
+ handler = Thread.current[:on_question]
34
+ unless handler
35
+ return { error: true, message: "Cannot ask questions in non-interactive mode" }
36
+ end
37
+
38
+ queue = Queue.new
39
+ handler.call(questions, queue)
40
+ answers = queue.pop
41
+
42
+ format_answers(questions, answers)
43
+ end
44
+
45
+ private
46
+
47
+ def format_answers(questions, answers)
48
+ pairs = questions.each_with_index.map do |q, i|
49
+ q = q.transform_keys(&:to_s) if q.is_a?(Hash)
50
+ header = q["header"]
51
+ answer = answers[i] || []
52
+ "\"#{header}\" = #{answer.join(', ')}"
53
+ end
54
+
55
+ { response: "User answered: #{pairs.join('; ')}" }
56
+ end
57
+ end
58
+ end
59
+ end
data/lib/brute/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Brute
4
- VERSION = "0.1.8"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/brute.rb CHANGED
@@ -27,6 +27,30 @@ require_relative 'brute/file_mutation_queue'
27
27
  require_relative 'brute/doom_loop'
28
28
  require_relative 'brute/hooks'
29
29
  require_relative 'brute/compactor'
30
+ require_relative 'brute/prompts/base'
31
+ require_relative 'brute/prompts/identity'
32
+ require_relative 'brute/prompts/tone_and_style'
33
+ require_relative 'brute/prompts/objectivity'
34
+ require_relative 'brute/prompts/task_management'
35
+ require_relative 'brute/prompts/doing_tasks'
36
+ require_relative 'brute/prompts/tool_usage'
37
+ require_relative 'brute/prompts/conventions'
38
+ require_relative 'brute/prompts/git_safety'
39
+ require_relative 'brute/prompts/code_references'
40
+ require_relative 'brute/prompts/environment'
41
+ require_relative 'brute/prompts/instructions'
42
+ require_relative 'brute/prompts/editing_approach'
43
+ require_relative 'brute/prompts/autonomy'
44
+ require_relative 'brute/prompts/editing_constraints'
45
+ require_relative 'brute/prompts/frontend_tasks'
46
+ require_relative 'brute/prompts/proactiveness'
47
+ require_relative 'brute/prompts/code_style'
48
+ require_relative 'brute/prompts/security_and_safety'
49
+ require_relative 'brute/prompts/skills'
50
+ require_relative 'brute/prompts/plan_reminder'
51
+ require_relative 'brute/prompts/max_steps'
52
+ require_relative 'brute/prompts/build_switch'
53
+ require_relative 'brute/skill'
30
54
  require_relative 'brute/system_prompt'
31
55
  require_relative 'brute/message_store'
32
56
  require_relative 'brute/session'
@@ -64,6 +88,14 @@ require_relative 'brute/tools/net_fetch'
64
88
  require_relative 'brute/tools/todo_write'
65
89
  require_relative 'brute/tools/todo_read'
66
90
  require_relative 'brute/tools/delegate'
91
+ require_relative 'brute/tools/question'
92
+
93
+ # Providers
94
+ require_relative 'brute/providers/shell_response'
95
+ require_relative 'brute/providers/shell'
96
+ require_relative 'brute/providers/models_dev'
97
+ require_relative 'brute/providers/opencode_zen'
98
+ require_relative 'brute/providers/opencode_go'
67
99
 
68
100
  # Orchestrator (depends on tools, middleware, and infrastructure)
69
101
  require_relative 'brute/orchestrator'
@@ -81,7 +113,8 @@ module Brute
81
113
  Tools::NetFetch,
82
114
  Tools::TodoWrite,
83
115
  Tools::TodoRead,
84
- Tools::Delegate
116
+ Tools::Delegate,
117
+ Tools::Question
85
118
  ].freeze
86
119
 
87
120
  # Default provider, resolved from environment.
@@ -94,13 +127,15 @@ module Brute
94
127
  end
95
128
 
96
129
  # Create a new orchestrator with sensible defaults.
97
- def self.agent(cwd: Dir.pwd, tools: TOOLS, session: nil, reasoning: {}, **callbacks)
130
+ def self.agent(cwd: Dir.pwd, model: nil, tools: TOOLS, session: nil, reasoning: {}, agent_name: nil, **callbacks)
98
131
  Orchestrator.new(
99
132
  provider: provider,
133
+ model: model,
100
134
  tools: tools,
101
135
  cwd: cwd,
102
136
  session: session,
103
137
  reasoning: reasoning,
138
+ agent_name: agent_name,
104
139
  **callbacks
105
140
  )
106
141
  end
@@ -111,9 +146,50 @@ module Brute
111
146
  'google' => ->(key) { LLM.google(key: key) },
112
147
  'deepseek' => ->(key) { LLM.deepseek(key: key) },
113
148
  'ollama' => ->(key) { LLM.ollama(key: key) },
114
- 'xai' => ->(key) { LLM.xai(key: key) }
149
+ 'xai' => ->(key) { LLM.xai(key: key) },
150
+ 'opencode_zen' => ->(key) { LLM::OpencodeZen.new(key: key) },
151
+ 'opencode_go' => ->(key) { LLM::OpencodeGo.new(key: key) },
152
+ 'shell' => ->(_key) { Providers::Shell.new },
115
153
  }.freeze
116
154
 
155
+ # List provider names that have API keys configured in the environment.
156
+ # The shell provider is always available (no key needed).
157
+ def self.configured_providers
158
+ PROVIDERS.keys.select { |name| api_key_for(name) }
159
+ end
160
+
161
+ # Build a provider instance for the given name using available API keys.
162
+ # Returns nil if no key is found.
163
+ def self.provider_for(name)
164
+ key = api_key_for(name)
165
+ return nil unless key
166
+
167
+ factory = PROVIDERS[name]
168
+ return nil unless factory
169
+
170
+ factory.call(key)
171
+ end
172
+
173
+ # Look up the API key for a given provider name.
174
+ def self.api_key_for(name)
175
+ # Shell provider needs no key.
176
+ return "none" if name == "shell"
177
+
178
+ # OpenCode providers: check OPENCODE_API_KEY, fall back to "public" for anonymous access.
179
+ if name == "opencode_zen" || name == "opencode_go"
180
+ return ENV["OPENCODE_API_KEY"] || "public"
181
+ end
182
+
183
+ # Explicit generic key always works
184
+ return ENV["LLM_API_KEY"] if ENV["LLM_API_KEY"]
185
+
186
+ case name
187
+ when "anthropic" then ENV["ANTHROPIC_API_KEY"]
188
+ when "openai" then ENV["OPENAI_API_KEY"]
189
+ when "google" then ENV["GOOGLE_API_KEY"]
190
+ end
191
+ end
192
+
117
193
  # Resolve the LLM provider from environment variables.
118
194
  #
119
195
  # Checks in order:
@@ -121,6 +197,7 @@ module Brute
121
197
  # 2. ANTHROPIC_API_KEY (implicit: provider = anthropic)
122
198
  # 3. OPENAI_API_KEY (implicit: provider = openai)
123
199
  # 4. GOOGLE_API_KEY (implicit: provider = google)
200
+ # 5. OPENCODE_API_KEY (implicit: provider = opencode_zen)
124
201
  #
125
202
  # Returns nil if no key is found. Error is deferred to Orchestrator#run.
126
203
  def self.resolve_provider
@@ -136,6 +213,9 @@ module Brute
136
213
  elsif ENV['GOOGLE_API_KEY']
137
214
  key = ENV['GOOGLE_API_KEY']
138
215
  name = 'google'
216
+ elsif ENV['OPENCODE_API_KEY']
217
+ key = ENV['OPENCODE_API_KEY']
218
+ name = 'opencode_zen'
139
219
  else
140
220
  return nil
141
221
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: brute
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.8
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brute Contributors
@@ -114,7 +114,54 @@ files:
114
114
  - lib/brute/patches/anthropic_tool_role.rb
115
115
  - lib/brute/patches/buffer_nil_guard.rb
116
116
  - lib/brute/pipeline.rb
117
+ - lib/brute/prompts/autonomy.rb
118
+ - lib/brute/prompts/base.rb
119
+ - lib/brute/prompts/build_switch.rb
120
+ - lib/brute/prompts/code_references.rb
121
+ - lib/brute/prompts/code_style.rb
122
+ - lib/brute/prompts/conventions.rb
123
+ - lib/brute/prompts/doing_tasks.rb
124
+ - lib/brute/prompts/editing_approach.rb
125
+ - lib/brute/prompts/editing_constraints.rb
126
+ - lib/brute/prompts/environment.rb
127
+ - lib/brute/prompts/frontend_tasks.rb
128
+ - lib/brute/prompts/git_safety.rb
129
+ - lib/brute/prompts/identity.rb
130
+ - lib/brute/prompts/instructions.rb
131
+ - lib/brute/prompts/max_steps.rb
132
+ - lib/brute/prompts/objectivity.rb
133
+ - lib/brute/prompts/plan_reminder.rb
134
+ - lib/brute/prompts/proactiveness.rb
135
+ - lib/brute/prompts/security_and_safety.rb
136
+ - lib/brute/prompts/skills.rb
137
+ - lib/brute/prompts/task_management.rb
138
+ - lib/brute/prompts/text/agents/compaction.txt
139
+ - lib/brute/prompts/text/agents/explore.txt
140
+ - lib/brute/prompts/text/agents/summary.txt
141
+ - lib/brute/prompts/text/agents/title.txt
142
+ - lib/brute/prompts/text/doing_tasks/anthropic.txt
143
+ - lib/brute/prompts/text/doing_tasks/default.txt
144
+ - lib/brute/prompts/text/doing_tasks/google.txt
145
+ - lib/brute/prompts/text/identity/anthropic.txt
146
+ - lib/brute/prompts/text/identity/default.txt
147
+ - lib/brute/prompts/text/identity/google.txt
148
+ - lib/brute/prompts/text/identity/openai.txt
149
+ - lib/brute/prompts/text/tone_and_style/anthropic.txt
150
+ - lib/brute/prompts/text/tone_and_style/default.txt
151
+ - lib/brute/prompts/text/tone_and_style/google.txt
152
+ - lib/brute/prompts/text/tone_and_style/openai.txt
153
+ - lib/brute/prompts/text/tool_usage/anthropic.txt
154
+ - lib/brute/prompts/text/tool_usage/default.txt
155
+ - lib/brute/prompts/text/tool_usage/google.txt
156
+ - lib/brute/prompts/tone_and_style.rb
157
+ - lib/brute/prompts/tool_usage.rb
158
+ - lib/brute/providers/models_dev.rb
159
+ - lib/brute/providers/opencode_go.rb
160
+ - lib/brute/providers/opencode_zen.rb
161
+ - lib/brute/providers/shell.rb
162
+ - lib/brute/providers/shell_response.rb
117
163
  - lib/brute/session.rb
164
+ - lib/brute/skill.rb
118
165
  - lib/brute/snapshot_store.rb
119
166
  - lib/brute/system_prompt.rb
120
167
  - lib/brute/todo_store.rb
@@ -126,6 +173,7 @@ files:
126
173
  - lib/brute/tools/fs_undo.rb
127
174
  - lib/brute/tools/fs_write.rb
128
175
  - lib/brute/tools/net_fetch.rb
176
+ - lib/brute/tools/question.rb
129
177
  - lib/brute/tools/shell.rb
130
178
  - lib/brute/tools/todo_read.rb
131
179
  - lib/brute/tools/todo_write.rb