rails_agent_console 0.1.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +80 -0
- data/LICENSE.txt +21 -0
- data/README.md +414 -0
- data/exe/rails-agent +7 -0
- data/lib/generators/rails_agent_console/install_generator.rb +17 -0
- data/lib/generators/rails_agent_console/templates/rails_agent_console.rb +21 -0
- data/lib/rails_agent_console/agent.rb +277 -0
- data/lib/rails_agent_console/cli.rb +114 -0
- data/lib/rails_agent_console/configuration.rb +170 -0
- data/lib/rails_agent_console/console_installer.rb +49 -0
- data/lib/rails_agent_console/console_methods.rb +54 -0
- data/lib/rails_agent_console/console_session.rb +41 -0
- data/lib/rails_agent_console/credentials.rb +66 -0
- data/lib/rails_agent_console/diagnosis/extra.rb +72 -0
- data/lib/rails_agent_console/diagnosis.rb +149 -0
- data/lib/rails_agent_console/errors.rb +20 -0
- data/lib/rails_agent_console/executor.rb +190 -0
- data/lib/rails_agent_console/history.rb +65 -0
- data/lib/rails_agent_console/model_picker.rb +166 -0
- data/lib/rails_agent_console/prompt/dates.rb +34 -0
- data/lib/rails_agent_console/prompt/follow_up.rb +38 -0
- data/lib/rails_agent_console/prompt.rb +122 -0
- data/lib/rails_agent_console/proposal.rb +208 -0
- data/lib/rails_agent_console/providers/anthropic.rb +48 -0
- data/lib/rails_agent_console/providers/base.rb +114 -0
- data/lib/rails_agent_console/providers/callable.rb +28 -0
- data/lib/rails_agent_console/providers/gemini.rb +45 -0
- data/lib/rails_agent_console/providers/ollama.rb +84 -0
- data/lib/rails_agent_console/providers/openai.rb +51 -0
- data/lib/rails_agent_console/providers.rb +28 -0
- data/lib/rails_agent_console/query_validator/model_suggestion.rb +35 -0
- data/lib/rails_agent_console/query_validator/parse_error.rb +23 -0
- data/lib/rails_agent_console/query_validator/result.rb +55 -0
- data/lib/rails_agent_console/query_validator/rules.rb +108 -0
- data/lib/rails_agent_console/query_validator/sensitive_columns.rb +22 -0
- data/lib/rails_agent_console/query_validator.rb +305 -0
- data/lib/rails_agent_console/railtie.rb +26 -0
- data/lib/rails_agent_console/rewriter/arguments.rb +192 -0
- data/lib/rails_agent_console/rewriter/chain.rb +126 -0
- data/lib/rails_agent_console/rewriter/date_ranges.rb +21 -0
- data/lib/rails_agent_console/rewriter/distinct.rb +41 -0
- data/lib/rails_agent_console/rewriter/joined.rb +42 -0
- data/lib/rails_agent_console/rewriter/qualifier.rb +116 -0
- data/lib/rails_agent_console/rewriter/quotes.rb +29 -0
- data/lib/rails_agent_console/rewriter/sql_repair.rb +114 -0
- data/lib/rails_agent_console/rewriter.rb +183 -0
- data/lib/rails_agent_console/schema.rb +275 -0
- data/lib/rails_agent_console/setup.rb +40 -0
- data/lib/rails_agent_console/ui.rb +125 -0
- data/lib/rails_agent_console/value_check.rb +85 -0
- data/lib/rails_agent_console/version.rb +5 -0
- data/lib/rails_agent_console/wizard.rb +235 -0
- data/lib/rails_agent_console.rb +58 -0
- metadata +145 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAgentConsole
|
|
4
|
+
class Agent
|
|
5
|
+
attr_reader :config, :ui, :history
|
|
6
|
+
|
|
7
|
+
def initialize(config = RailsAgentConsole.configuration, ui: nil, history: nil)
|
|
8
|
+
@config = config
|
|
9
|
+
@ui = ui || UI.new(config)
|
|
10
|
+
@history = history || History.new
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def run(prompt, write: config.write_mode?)
|
|
14
|
+
return usage if prompt.to_s.strip.empty?
|
|
15
|
+
|
|
16
|
+
ui.heading "Inspecting application..."
|
|
17
|
+
proposal = propose(prompt, write: write)
|
|
18
|
+
return decline(proposal, write: write) if proposal.empty?
|
|
19
|
+
|
|
20
|
+
present(proposal)
|
|
21
|
+
|
|
22
|
+
return refuse_or_repair(prompt, proposal, write: write) unless proposal.safe?
|
|
23
|
+
return unless confirmed?(proposal)
|
|
24
|
+
|
|
25
|
+
execute(prompt, proposal, write: write)
|
|
26
|
+
rescue ConfigurationError, ProviderError, InvalidResponseError => e
|
|
27
|
+
ui.failure e.message
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def ask(question)
|
|
32
|
+
return usage if question.to_s.strip.empty?
|
|
33
|
+
|
|
34
|
+
ensure_configured!
|
|
35
|
+
ui.heading "Thinking..."
|
|
36
|
+
answer = provider.chat(
|
|
37
|
+
system: Prompt.explain_system(question),
|
|
38
|
+
messages: [{ role: "user", content: Prompt::FollowUp.for_ask(question.to_s, history) }]
|
|
39
|
+
)
|
|
40
|
+
ui.blank
|
|
41
|
+
ui.say answer.strip
|
|
42
|
+
ui.blank
|
|
43
|
+
nil
|
|
44
|
+
rescue ConfigurationError, ProviderError, InvalidResponseError => e
|
|
45
|
+
ui.failure e.message
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def explain(code, question: nil)
|
|
50
|
+
source = code.is_a?(String) ? code : safe_inspect(code)
|
|
51
|
+
return usage if source.to_s.strip.empty?
|
|
52
|
+
|
|
53
|
+
ask(Prompt.explain_code_user(source, question))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def propose(prompt, write: config.write_mode?, focus: prompt)
|
|
57
|
+
ensure_configured!
|
|
58
|
+
request = Prompt::Dates.annotate(prompt.to_s)
|
|
59
|
+
request = Prompt::FollowUp.annotate(request, history.last&.code) if prompt.equal?(focus)
|
|
60
|
+
raw = provider.chat(
|
|
61
|
+
system: Prompt.query_system(focus, write_mode: write),
|
|
62
|
+
messages: history.messages + [{ role: "user", content: request }]
|
|
63
|
+
)
|
|
64
|
+
Proposal.parse(raw, write_mode: write)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def provider
|
|
68
|
+
@provider ||= Providers.build(config)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def reset_provider!
|
|
72
|
+
@provider = nil
|
|
73
|
+
self
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def reset!
|
|
77
|
+
history.clear
|
|
78
|
+
Executor.reset!
|
|
79
|
+
@provider = nil
|
|
80
|
+
self
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def ensure_configured!
|
|
86
|
+
return config.validate! if config.api_key?
|
|
87
|
+
|
|
88
|
+
Setup.run(ui: ui) if Setup.interactive?(config)
|
|
89
|
+
config.validate!
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def present(proposal)
|
|
93
|
+
narrate(proposal.models.first(3))
|
|
94
|
+
ui.say ui.bold(proposal.writes? ? "Proposed command:" : "Proposed query:")
|
|
95
|
+
ui.blank
|
|
96
|
+
ui.code proposal.code
|
|
97
|
+
ui.blank
|
|
98
|
+
present_notes(proposal)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def present_notes(proposal)
|
|
102
|
+
ui.note proposal.explanation if proposal.explanation
|
|
103
|
+
proposal.assumptions.each { |assumption| ui.note "- #{assumption}" }
|
|
104
|
+
proposal.corrections.each { |correction| ui.note "Corrected: #{correction}" }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def narrate(models)
|
|
108
|
+
ui.tree(Schema.tree(models)) if models.any?
|
|
109
|
+
ui.blank
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def decline(proposal, write:)
|
|
113
|
+
ui.blank
|
|
114
|
+
ui.warn "No query to run."
|
|
115
|
+
ui.note(proposal.explanation || retry_hint(proposal, write: write))
|
|
116
|
+
ui.blank
|
|
117
|
+
nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def refuse(proposal, write:)
|
|
121
|
+
ui.blank
|
|
122
|
+
ui.warn "Refusing to run this code."
|
|
123
|
+
proposal.violations.each { |violation| ui.say " - #{violation}" }
|
|
124
|
+
ui.blank
|
|
125
|
+
ui.note retry_hint(proposal, write: write)
|
|
126
|
+
nil
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def retry_hint(proposal, write:)
|
|
130
|
+
if proposal.writes? && !write
|
|
131
|
+
"Read-only mode is on. Use `ai!` for a single write, or set " \
|
|
132
|
+
"RailsAgentConsole.config.write_mode = true."
|
|
133
|
+
else
|
|
134
|
+
"Rephrase the request, or allow the call with " \
|
|
135
|
+
"RailsAgentConsole.config.extra_allowed_methods << #{unknown_call(proposal).inspect}."
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def unknown_call(proposal)
|
|
140
|
+
proposal.validation.unknown.first || "method_name"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def confirmed?(proposal)
|
|
144
|
+
return true if config.auto_confirm?
|
|
145
|
+
return destructive_confirmed?(proposal) if proposal.destructive?
|
|
146
|
+
|
|
147
|
+
report_preflight(proposal)
|
|
148
|
+
ui.blank
|
|
149
|
+
ui.yes?("Execute?")
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def report_preflight(proposal)
|
|
153
|
+
count = Executor.preflight_count(proposal.code)
|
|
154
|
+
return if count.nil?
|
|
155
|
+
|
|
156
|
+
ui.blank
|
|
157
|
+
ui.say "Found #{count} #{count == 1 ? "record" : "records"}."
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def destructive_confirmed?(proposal)
|
|
161
|
+
estimate = Executor.impact_estimate(proposal.code)
|
|
162
|
+
ui.blank
|
|
163
|
+
ui.warn "DESTRUCTIVE OPERATION"
|
|
164
|
+
ui.say " This will change approximately #{estimate} records." if estimate
|
|
165
|
+
ui.say " Affected calls: #{proposal.validation.writes.join(", ")}"
|
|
166
|
+
ui.blank
|
|
167
|
+
word = config.destructive_confirmation_word
|
|
168
|
+
answer = ui.ask("Type \"#{word}\" to continue:")
|
|
169
|
+
return true if answer.to_s.strip == word
|
|
170
|
+
|
|
171
|
+
ui.note "Cancelled."
|
|
172
|
+
false
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def execute(prompt, proposal, write:, attempts_left: config.max_repair_attempts.to_i, attempted: [])
|
|
176
|
+
outcome = Executor.run(proposal.code)
|
|
177
|
+
ui.blank
|
|
178
|
+
summary = outcome.ok? ? report_success(outcome) : report_failure(outcome)
|
|
179
|
+
remember(prompt, proposal, summary)
|
|
180
|
+
|
|
181
|
+
if !outcome.ok? && attempts_left.positive?
|
|
182
|
+
tried = attempted + [attempt(proposal, failure_reason(outcome, proposal.code))]
|
|
183
|
+
repair(prompt, tried, write: write, attempts_left: attempts_left)
|
|
184
|
+
else
|
|
185
|
+
ui.blank
|
|
186
|
+
outcome.value
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def attempt(proposal, reason)
|
|
191
|
+
{ code: proposal.code, reason: reason }
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def failure_reason(outcome, code)
|
|
195
|
+
error = outcome.error
|
|
196
|
+
message = "#{error.class}: #{error.message.to_s.lines.first.to_s.strip}"
|
|
197
|
+
hint = Diagnosis.hint(code, error.message)
|
|
198
|
+
hint ? "#{message}\n#{hint}" : message
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def refuse_or_repair(prompt, proposal, write:, attempted: [], attempts_left: config.max_repair_attempts.to_i)
|
|
202
|
+
refuse(proposal, write: write)
|
|
203
|
+
return unless attempts_left.positive? && proposal.validation.repairable?
|
|
204
|
+
|
|
205
|
+
tried = attempted + [attempt(proposal, refusal_reason(proposal))]
|
|
206
|
+
repair(prompt, tried, write: write, attempts_left: attempts_left)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def refusal_reason(proposal)
|
|
210
|
+
"The code was refused before it ran: #{proposal.violations.join("; ")}"
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def repair(prompt, attempted, write:, attempts_left:)
|
|
214
|
+
ui.blank
|
|
215
|
+
ui.heading "Fixing the query..."
|
|
216
|
+
fixed = propose(Prompt.repair_user(prompt, attempted), write: write, focus: prompt)
|
|
217
|
+
return unrepairable(fixed, attempted) if fixed.empty? || tried?(fixed, attempted)
|
|
218
|
+
|
|
219
|
+
present(fixed)
|
|
220
|
+
|
|
221
|
+
if fixed.safe?
|
|
222
|
+
return unless confirmed?(fixed)
|
|
223
|
+
|
|
224
|
+
execute(prompt, fixed, write: write, attempts_left: attempts_left - 1, attempted: attempted)
|
|
225
|
+
else
|
|
226
|
+
refuse_or_repair(prompt, fixed, write: write, attempted: attempted, attempts_left: attempts_left - 1)
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def tried?(proposal, attempted)
|
|
231
|
+
attempted.any? { |earlier| earlier[:code] == proposal.code }
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def unrepairable(fixed, attempted)
|
|
235
|
+
ui.blank
|
|
236
|
+
ui.note(if tried?(fixed, attempted)
|
|
237
|
+
"The model came back with a query it had already tried."
|
|
238
|
+
else
|
|
239
|
+
fixed.explanation || "No corrected query to run."
|
|
240
|
+
end)
|
|
241
|
+
ui.blank
|
|
242
|
+
nil
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def report_success(outcome)
|
|
246
|
+
summary = Executor.describe(outcome.value)
|
|
247
|
+
ui.success "#{summary} in #{format("%.0f", outcome.duration * 1000)}ms"
|
|
248
|
+
ui.note outcome.note if outcome.note
|
|
249
|
+
summary
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def report_failure(outcome)
|
|
253
|
+
ui.failure "#{outcome.error.class}: #{outcome.error.message.to_s.lines.first}"
|
|
254
|
+
"failed: #{outcome.error.class}"
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def remember(prompt, proposal, summary)
|
|
258
|
+
history.add(prompt: prompt, code: proposal.code,
|
|
259
|
+
explanation: proposal.explanation, outcome: summary)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def usage
|
|
263
|
+
ui.note 'Usage: ai "how many users signed up this month?"'
|
|
264
|
+
nil
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def safe_inspect(value)
|
|
268
|
+
if Executor.relation?(value)
|
|
269
|
+
"An ActiveRecord::Relation on #{value.klass.name}, generating:\n#{value.to_sql}"
|
|
270
|
+
else
|
|
271
|
+
value.inspect
|
|
272
|
+
end
|
|
273
|
+
rescue StandardError
|
|
274
|
+
value.to_s
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAgentConsole
|
|
4
|
+
class CLI
|
|
5
|
+
USAGE = <<~TEXT
|
|
6
|
+
Usage: rails-agent <command>
|
|
7
|
+
|
|
8
|
+
configure Pick a provider and store an API key in ~/.rails_agent_console/config
|
|
9
|
+
schema Print the schema context the agent sends to the model (needs a Rails app)
|
|
10
|
+
doctor Show the resolved configuration and check connectivity
|
|
11
|
+
version Print the gem version
|
|
12
|
+
TEXT
|
|
13
|
+
|
|
14
|
+
def initialize(argv, config: RailsAgentConsole.configuration, ui: nil)
|
|
15
|
+
@argv = argv
|
|
16
|
+
@config = config
|
|
17
|
+
@ui = ui || UI.new(config)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def run
|
|
21
|
+
case @argv.first
|
|
22
|
+
when "configure" then configure
|
|
23
|
+
when "schema" then schema
|
|
24
|
+
when "doctor" then doctor
|
|
25
|
+
when "version", "-v", "--version" then version
|
|
26
|
+
when nil, "help", "-h", "--help" then help
|
|
27
|
+
else
|
|
28
|
+
@ui.failure "Unknown command #{@argv.first.inspect}"
|
|
29
|
+
help
|
|
30
|
+
1
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def configure
|
|
37
|
+
Setup.run(config: @config, ui: @ui) ? 0 : 1
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def schema
|
|
41
|
+
return 1 unless load_rails_app
|
|
42
|
+
|
|
43
|
+
@ui.say Schema.context
|
|
44
|
+
0
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def doctor
|
|
48
|
+
@ui.heading "Rails Agent Console #{VERSION}"
|
|
49
|
+
@ui.say "provider: #{@config.provider}"
|
|
50
|
+
@ui.say "model: #{@config.model}"
|
|
51
|
+
@ui.say "api key: #{key_status}"
|
|
52
|
+
@ui.say "config file: #{Credentials.path}#{" (not created yet)" unless Credentials.exist?}"
|
|
53
|
+
@ui.say "mode: #{@config.write_mode? ? "write" : "read-only"}"
|
|
54
|
+
@ui.blank
|
|
55
|
+
probe
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def probe
|
|
59
|
+
unless @config.api_key?
|
|
60
|
+
@ui.failure "No API key. Run `rails-agent configure`."
|
|
61
|
+
return 1
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
answer = Providers.build(@config).chat(
|
|
65
|
+
system: "You are a health check. Answer with the single word: ok",
|
|
66
|
+
messages: [{ role: "user", content: "ping" }]
|
|
67
|
+
)
|
|
68
|
+
@ui.success "#{@config.provider} responded: #{answer.strip[0, 40]}"
|
|
69
|
+
0
|
|
70
|
+
rescue Error => e
|
|
71
|
+
@ui.failure e.message
|
|
72
|
+
1
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def key_status
|
|
76
|
+
return "not set" unless @config.api_key
|
|
77
|
+
|
|
78
|
+
env_key = Configuration::ENV_KEYS[@config.provider]
|
|
79
|
+
source = ENV[env_key].to_s.empty? ? "config file" : env_key
|
|
80
|
+
"#{mask(@config.api_key)} (from #{source})"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def mask(key)
|
|
84
|
+
return "****" if key.to_s.length < 8
|
|
85
|
+
|
|
86
|
+
"#{key[0, 3]}#{"*" * 10}#{key[-4, 4]}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def load_rails_app
|
|
90
|
+
path = File.expand_path("config/environment.rb", Dir.pwd)
|
|
91
|
+
unless File.exist?(path)
|
|
92
|
+
@ui.failure "No config/environment.rb here - run this from a Rails application root."
|
|
93
|
+
return false
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
ENV["RAILS_ENV"] ||= "development"
|
|
97
|
+
require path
|
|
98
|
+
true
|
|
99
|
+
rescue StandardError, LoadError => e
|
|
100
|
+
@ui.failure "Could not load the Rails application: #{e.message}"
|
|
101
|
+
false
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def help
|
|
105
|
+
@ui.say USAGE
|
|
106
|
+
0
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def version
|
|
110
|
+
@ui.say VERSION
|
|
111
|
+
0
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAgentConsole
|
|
4
|
+
class Configuration
|
|
5
|
+
PROVIDERS = %i[openai anthropic gemini ollama].freeze
|
|
6
|
+
|
|
7
|
+
DEFAULT_MODELS = {
|
|
8
|
+
openai: "gpt-4o-mini",
|
|
9
|
+
anthropic: "claude-3-5-sonnet-latest",
|
|
10
|
+
gemini: "gemini-2.0-flash",
|
|
11
|
+
ollama: "qwen2.5-coder:7b"
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
ENV_KEYS = {
|
|
15
|
+
openai: "OPENAI_API_KEY",
|
|
16
|
+
anthropic: "ANTHROPIC_API_KEY",
|
|
17
|
+
gemini: "GEMINI_API_KEY",
|
|
18
|
+
ollama: "OLLAMA_API_KEY"
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
MODEL_OWNERS = {
|
|
22
|
+
openai: /\A(gpt-(\d|4o)|chatgpt-|o\d+(-|\z))/i,
|
|
23
|
+
anthropic: /\Aclaude-/i,
|
|
24
|
+
gemini: /\Agemini-/i
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
def self.provider_for(model)
|
|
28
|
+
name = model.to_s.strip
|
|
29
|
+
return if name.include?(":")
|
|
30
|
+
|
|
31
|
+
MODEL_OWNERS.find { |_, pattern| pattern.match?(name) }&.first
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
attr_writer :model, :api_key, :api_base
|
|
35
|
+
attr_accessor :temperature, :request_timeout,
|
|
36
|
+
:write_mode, :auto_confirm, :destructive_confirmation_word,
|
|
37
|
+
:extra_allowed_methods, :extra_allowed_constants, :execution_timeout, :preflight_count,
|
|
38
|
+
:max_models, :max_focused_models, :include_internal_models, :excluded_models, :extra_context,
|
|
39
|
+
:max_history, :max_repair_attempts,
|
|
40
|
+
:color, :banner, :setup_on_boot, :input, :output, :inspect_limit, :console_helpers,
|
|
41
|
+
:client
|
|
42
|
+
|
|
43
|
+
def initialize
|
|
44
|
+
@api_base = nil
|
|
45
|
+
@temperature = 0.0
|
|
46
|
+
@request_timeout = 60
|
|
47
|
+
|
|
48
|
+
@write_mode = false
|
|
49
|
+
@auto_confirm = false
|
|
50
|
+
@destructive_confirmation_word = "execute"
|
|
51
|
+
@extra_allowed_methods = []
|
|
52
|
+
@extra_allowed_constants = []
|
|
53
|
+
@execution_timeout = 30
|
|
54
|
+
@preflight_count = true
|
|
55
|
+
|
|
56
|
+
@max_models = 60
|
|
57
|
+
@max_focused_models = 8
|
|
58
|
+
@include_internal_models = false
|
|
59
|
+
@excluded_models = []
|
|
60
|
+
@extra_context = nil
|
|
61
|
+
|
|
62
|
+
@max_history = 6
|
|
63
|
+
@max_repair_attempts = 3
|
|
64
|
+
|
|
65
|
+
@color = nil # nil means "decide from the output stream"
|
|
66
|
+
@banner = true
|
|
67
|
+
@setup_on_boot = true
|
|
68
|
+
@input = nil
|
|
69
|
+
@output = nil
|
|
70
|
+
@inspect_limit = 20
|
|
71
|
+
@console_helpers = %i[ai ai! ask explain run ai_history ai_reset ai_schema ai_model]
|
|
72
|
+
|
|
73
|
+
@client = nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def provider
|
|
77
|
+
@provider ||= detected_provider
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def provider=(name)
|
|
81
|
+
@provider = name&.to_sym
|
|
82
|
+
@api_key = nil
|
|
83
|
+
@model = nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def model
|
|
87
|
+
@model ||= Credentials.read.dig(provider.to_s, "model") ||
|
|
88
|
+
DEFAULT_MODELS[provider] ||
|
|
89
|
+
DEFAULT_MODELS[:openai]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def api_key
|
|
93
|
+
@api_key ||= blank_to_nil(ENV.fetch(ENV_KEYS.fetch(provider, ""), nil)) ||
|
|
94
|
+
blank_to_nil(Credentials.read.dig(provider.to_s, "api_key"))
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def api_base
|
|
98
|
+
@api_base || blank_to_nil(Credentials.read.dig(provider.to_s, "base_url"))
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def snapshot
|
|
102
|
+
{ provider: provider, model: @model, api_key: @api_key, api_base: @api_base }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def restore(state)
|
|
106
|
+
@provider, @model, @api_key, @api_base = state.values_at(:provider, :model, :api_key, :api_base)
|
|
107
|
+
self
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def api_key?
|
|
111
|
+
!api_key.nil? || provider == :ollama || !client.nil?
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def key_for?(name)
|
|
115
|
+
return true if name.to_sym == :ollama
|
|
116
|
+
|
|
117
|
+
!blank_to_nil(ENV.fetch(ENV_KEYS.fetch(name.to_sym, ""), nil)).nil? ||
|
|
118
|
+
!blank_to_nil(Credentials.read.dig(name.to_s, "api_key")).nil?
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def write_mode?
|
|
122
|
+
!!@write_mode
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def auto_confirm?
|
|
126
|
+
!!@auto_confirm
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def color?
|
|
130
|
+
return @color unless @color.nil?
|
|
131
|
+
|
|
132
|
+
out = output || $stdout
|
|
133
|
+
out.respond_to?(:tty?) && out.tty?
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def in
|
|
137
|
+
@input || $stdin
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def out
|
|
141
|
+
@output || $stdout
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def validate!
|
|
145
|
+
unless PROVIDERS.include?(provider)
|
|
146
|
+
raise ConfigurationError, "Unknown provider #{provider.inspect}. Supported: #{PROVIDERS.join(", ")}."
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
return true if api_key?
|
|
150
|
+
|
|
151
|
+
raise ConfigurationError,
|
|
152
|
+
"No API key for #{provider}. Set #{ENV_KEYS[provider]}, run `rails-agent configure`, " \
|
|
153
|
+
"or set RailsAgentConsole.config.api_key."
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
private
|
|
157
|
+
|
|
158
|
+
def blank_to_nil(value)
|
|
159
|
+
value.to_s unless value.to_s.strip.empty?
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def detected_provider
|
|
163
|
+
from_file = Credentials.read["default_provider"]
|
|
164
|
+
return from_file.to_sym if from_file && PROVIDERS.include?(from_file.to_sym)
|
|
165
|
+
|
|
166
|
+
detected = PROVIDERS.find { |name| !blank_to_nil(ENV.fetch(ENV_KEYS[name], nil)).nil? }
|
|
167
|
+
detected || :openai
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAgentConsole
|
|
4
|
+
module ConsoleInstaller
|
|
5
|
+
class << self
|
|
6
|
+
def install!(names = RailsAgentConsole.configuration.console_helpers)
|
|
7
|
+
helpers = ConsoleMethods.installable(names)
|
|
8
|
+
|
|
9
|
+
if rails_console_methods?
|
|
10
|
+
::Rails::ConsoleMethods.include(helpers)
|
|
11
|
+
elsif irb_helper_methods?
|
|
12
|
+
register_irb_helpers(helpers)
|
|
13
|
+
else
|
|
14
|
+
::TOPLEVEL_BINDING.receiver.extend(helpers)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
helpers
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def rails_console_methods?
|
|
23
|
+
defined?(::Rails::ConsoleMethods)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def irb_helper_methods?
|
|
27
|
+
defined?(::IRB::HelperMethod) && ::IRB::HelperMethod.respond_to?(:register)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def register_irb_helpers(helpers)
|
|
31
|
+
receiver = Object.new.extend(helpers)
|
|
32
|
+
|
|
33
|
+
helpers.instance_methods(false).each do |name|
|
|
34
|
+
::IRB::HelperMethod.register(name, helper_class_for(name, receiver))
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def helper_class_for(name, receiver)
|
|
39
|
+
Class.new(::IRB::HelperMethod::Base) do
|
|
40
|
+
description "rails_agent_console: #{name}"
|
|
41
|
+
|
|
42
|
+
define_method(:execute) do |*args, **options, &block|
|
|
43
|
+
receiver.public_send(name, *args, **options, &block)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAgentConsole
|
|
4
|
+
module ConsoleMethods
|
|
5
|
+
def ai(prompt = nil)
|
|
6
|
+
RailsAgentConsole.agent.run(prompt)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def ai!(prompt = nil)
|
|
10
|
+
RailsAgentConsole.agent.run(prompt, write: true)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def run(prompt = nil)
|
|
14
|
+
ai(prompt)
|
|
15
|
+
end
|
|
16
|
+
alias run_ai run
|
|
17
|
+
|
|
18
|
+
def ask(question = nil)
|
|
19
|
+
RailsAgentConsole.agent.ask(question)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def explain(code = nil, question: nil)
|
|
23
|
+
RailsAgentConsole.agent.explain(code, question: question)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def ai_history
|
|
27
|
+
RailsAgentConsole.configuration.out.puts(RailsAgentConsole.agent.history.to_s)
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def ai_reset
|
|
32
|
+
RailsAgentConsole.agent.reset!
|
|
33
|
+
RailsAgentConsole.configuration.out.puts("Conversation cleared.")
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def ai_schema(prompt = nil)
|
|
38
|
+
Schema.print(prompt)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def ai_model(target = nil)
|
|
42
|
+
ModelPicker.run(target)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
ALL = instance_methods(false).freeze
|
|
46
|
+
|
|
47
|
+
def self.installable(names = RailsAgentConsole.configuration.console_helpers)
|
|
48
|
+
wanted = Array(names).map(&:to_sym)
|
|
49
|
+
copy = dup
|
|
50
|
+
(ALL - wanted).each { |name| copy.send(:remove_method, name) }
|
|
51
|
+
copy
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAgentConsole
|
|
4
|
+
module ConsoleSession
|
|
5
|
+
class << self
|
|
6
|
+
def start(config: RailsAgentConsole.configuration, ui: UI.new(config))
|
|
7
|
+
banner(ui, config) if config.banner
|
|
8
|
+
return unless config.setup_on_boot && Setup.needed?(config)
|
|
9
|
+
|
|
10
|
+
if Setup.interactive?(config)
|
|
11
|
+
Setup.run(config: config, ui: ui)
|
|
12
|
+
else
|
|
13
|
+
ui.note "rails_agent_console: no API key found. Run `rails-agent configure` to set one up."
|
|
14
|
+
end
|
|
15
|
+
rescue Interrupt
|
|
16
|
+
ui.blank
|
|
17
|
+
ui.note "Skipped. Run `rails-agent configure` later."
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def banner(ui, config)
|
|
23
|
+
ui.blank
|
|
24
|
+
ui.heading "Rails Agent Console #{VERSION}"
|
|
25
|
+
details = ["#{config.provider}/#{config.model}"]
|
|
26
|
+
details << (config.write_mode? ? "WRITE MODE" : "read-only")
|
|
27
|
+
ui.note " #{details.join(" | ")}"
|
|
28
|
+
ui.note ' ai "how many users signed up this month?" ask "..." explain User.all'
|
|
29
|
+
ui.note " ai_model to switch model or provider#{elsewhere(config)}"
|
|
30
|
+
ui.blank
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def elsewhere(config)
|
|
34
|
+
others = (Configuration::PROVIDERS - [config.provider]).select do |name|
|
|
35
|
+
name == :ollama || config.key_for?(name)
|
|
36
|
+
end
|
|
37
|
+
others.empty? ? "" : " (#{others.join(", ")} also on hand)"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|