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.
Files changed (55) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +80 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +414 -0
  5. data/exe/rails-agent +7 -0
  6. data/lib/generators/rails_agent_console/install_generator.rb +17 -0
  7. data/lib/generators/rails_agent_console/templates/rails_agent_console.rb +21 -0
  8. data/lib/rails_agent_console/agent.rb +277 -0
  9. data/lib/rails_agent_console/cli.rb +114 -0
  10. data/lib/rails_agent_console/configuration.rb +170 -0
  11. data/lib/rails_agent_console/console_installer.rb +49 -0
  12. data/lib/rails_agent_console/console_methods.rb +54 -0
  13. data/lib/rails_agent_console/console_session.rb +41 -0
  14. data/lib/rails_agent_console/credentials.rb +66 -0
  15. data/lib/rails_agent_console/diagnosis/extra.rb +72 -0
  16. data/lib/rails_agent_console/diagnosis.rb +149 -0
  17. data/lib/rails_agent_console/errors.rb +20 -0
  18. data/lib/rails_agent_console/executor.rb +190 -0
  19. data/lib/rails_agent_console/history.rb +65 -0
  20. data/lib/rails_agent_console/model_picker.rb +166 -0
  21. data/lib/rails_agent_console/prompt/dates.rb +34 -0
  22. data/lib/rails_agent_console/prompt/follow_up.rb +38 -0
  23. data/lib/rails_agent_console/prompt.rb +122 -0
  24. data/lib/rails_agent_console/proposal.rb +208 -0
  25. data/lib/rails_agent_console/providers/anthropic.rb +48 -0
  26. data/lib/rails_agent_console/providers/base.rb +114 -0
  27. data/lib/rails_agent_console/providers/callable.rb +28 -0
  28. data/lib/rails_agent_console/providers/gemini.rb +45 -0
  29. data/lib/rails_agent_console/providers/ollama.rb +84 -0
  30. data/lib/rails_agent_console/providers/openai.rb +51 -0
  31. data/lib/rails_agent_console/providers.rb +28 -0
  32. data/lib/rails_agent_console/query_validator/model_suggestion.rb +35 -0
  33. data/lib/rails_agent_console/query_validator/parse_error.rb +23 -0
  34. data/lib/rails_agent_console/query_validator/result.rb +55 -0
  35. data/lib/rails_agent_console/query_validator/rules.rb +108 -0
  36. data/lib/rails_agent_console/query_validator/sensitive_columns.rb +22 -0
  37. data/lib/rails_agent_console/query_validator.rb +305 -0
  38. data/lib/rails_agent_console/railtie.rb +26 -0
  39. data/lib/rails_agent_console/rewriter/arguments.rb +192 -0
  40. data/lib/rails_agent_console/rewriter/chain.rb +126 -0
  41. data/lib/rails_agent_console/rewriter/date_ranges.rb +21 -0
  42. data/lib/rails_agent_console/rewriter/distinct.rb +41 -0
  43. data/lib/rails_agent_console/rewriter/joined.rb +42 -0
  44. data/lib/rails_agent_console/rewriter/qualifier.rb +116 -0
  45. data/lib/rails_agent_console/rewriter/quotes.rb +29 -0
  46. data/lib/rails_agent_console/rewriter/sql_repair.rb +114 -0
  47. data/lib/rails_agent_console/rewriter.rb +183 -0
  48. data/lib/rails_agent_console/schema.rb +275 -0
  49. data/lib/rails_agent_console/setup.rb +40 -0
  50. data/lib/rails_agent_console/ui.rb +125 -0
  51. data/lib/rails_agent_console/value_check.rb +85 -0
  52. data/lib/rails_agent_console/version.rb +5 -0
  53. data/lib/rails_agent_console/wizard.rb +235 -0
  54. data/lib/rails_agent_console.rb +58 -0
  55. 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