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,235 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAgentConsole
4
+ class Wizard
5
+ PLACES = [
6
+ [:local, "On this machine, with Ollama. Nothing leaves your laptop, no key needed."],
7
+ [:hosted, "A hosted API: OpenAI, Anthropic or Gemini."],
8
+ [:custom, "My own OpenAI-compatible endpoint: OpenRouter, LM Studio, vLLM, a company gateway."]
9
+ ].freeze
10
+ HOSTED = %i[openai anthropic gemini].freeze
11
+ LISTED = 15
12
+
13
+ def self.run(config: RailsAgentConsole.configuration, ui: UI.new(config))
14
+ new(config, ui).run
15
+ end
16
+
17
+ def initialize(config, ui)
18
+ @config = config
19
+ @ui = ui
20
+ @before = config.snapshot
21
+ @typed_key = nil
22
+ @step = 0
23
+ end
24
+
25
+ def run
26
+ place = choose_place
27
+ provider = place == :hosted ? choose_hosted : { local: :ollama, custom: :openai }[place]
28
+ return abandon("Unknown choice.") if provider.nil?
29
+
30
+ use(provider)
31
+ choose_base_url(place)
32
+ return abandon("No key entered, nothing changed.") unless key_ready?
33
+
34
+ @config.model = choose_model
35
+ return abandon("Nothing changed.") unless answers?
36
+
37
+ RailsAgentConsole.agent.reset_provider!
38
+ @ui.success "Now using #{label}."
39
+ offer_to_save
40
+ true
41
+ end
42
+
43
+ private
44
+
45
+ def step(title)
46
+ @step += 1
47
+ @ui.blank
48
+ @ui.say @ui.bold("#{@step}. #{title}")
49
+ end
50
+
51
+ def choose_place
52
+ step "Where should the model run?"
53
+ current = current_place
54
+ PLACES.each_with_index do |(name, text), index|
55
+ @ui.say " #{index + 1}. #{text}#{@ui.dim(" (now)") if name == current}"
56
+ end
57
+ answer = @ui.ask(">", default: (PLACES.index { |name, _| name == current } + 1).to_s)
58
+ PLACES.dig(answer.to_i - 1, 0) || PLACES.find { |name, _| name.to_s == answer.downcase }&.first
59
+ end
60
+
61
+ def current_place
62
+ return :local if @config.provider == :ollama
63
+ return :custom if @config.provider == :openai && @config.api_base && @config.api_base != default_base(:openai)
64
+
65
+ :hosted
66
+ end
67
+
68
+ def choose_hosted
69
+ step "Which provider?"
70
+ HOSTED.each_with_index do |name, index|
71
+ @ui.say " #{index + 1}. #{Setup::PROVIDER_LABELS[name].ljust(10)} #{@ui.dim(key_source(name))}"
72
+ end
73
+ default = HOSTED.index(@config.provider) || 0
74
+ answer = @ui.ask(">", default: (default + 1).to_s)
75
+ return HOSTED[answer.to_i - 1] if answer.to_i.between?(1, HOSTED.size)
76
+
77
+ HOSTED.find { |name| name.to_s == answer.downcase }
78
+ end
79
+
80
+ def use(provider)
81
+ same = provider == @before[:provider]
82
+ @config.provider = provider
83
+ @config.api_key = @before[:api_key] if same && @before[:api_key]
84
+ end
85
+
86
+ def choose_base_url(place)
87
+ step "Base URL"
88
+ stored = Credentials.read.dig(@config.provider.to_s, "base_url")
89
+ default = place == :custom ? stored : stored || default_base(@config.provider)
90
+ @ui.note " Where the requests go. Enter keeps it." unless default.to_s.empty?
91
+ @ui.note " The part before /chat/completions, e.g. https://openrouter.ai/api/v1" if place == :custom
92
+ answer = @ui.ask(default.to_s.empty? ? "Base URL:" : "Base URL [#{default}]:", default: default).to_s
93
+ @config.api_base = answer.chomp("/") == default_base(@config.provider) ? nil : answer.chomp("/")
94
+ check_ollama if @config.provider == :ollama
95
+ end
96
+
97
+ def check_ollama
98
+ found = provider.models
99
+ if found.any?
100
+ return @ui.success "Ollama answers on #{base}, #{found.size} #{found.one? ? "model" : "models"} installed."
101
+ end
102
+
103
+ @ui.warn "Nothing answered on #{base}. Start Ollama with `ollama serve`, then carry on."
104
+ end
105
+
106
+ def key_ready?
107
+ return true if @config.provider == :ollama
108
+
109
+ step "API key"
110
+ source = existing_key
111
+ @ui.success "Using #{source}." if source
112
+ source ? true : typed_key?
113
+ end
114
+
115
+ def existing_key
116
+ env = Configuration::ENV_KEYS[@config.provider]
117
+ return env_key_choice(env) if env_key? && @config.api_base.nil?
118
+
119
+ "the key already set for #{@config.provider}" if @config.api_key && !replace_key?
120
+ end
121
+
122
+ def env_key_choice(env)
123
+ @ui.say " Found a key in #{env}."
124
+ return "the key from #{env}" if @ui.yes?("Use it?", default: true)
125
+
126
+ @ui.note " The key you type is used from now on in this console. #{env} is still set, so a new " \
127
+ "session goes back to it unless you unset it or change it where it is defined (.env, shell profile)."
128
+ nil
129
+ end
130
+
131
+ def replace_key?
132
+ @ui.yes?("A key is already set for #{@config.provider}. Type a different one?", default: false)
133
+ end
134
+
135
+ def typed_key?
136
+ skip = env_key? ? "" : " Set #{Configuration::ENV_KEYS[@config.provider]} to skip this step."
137
+ @ui.note " Not echoed, and never written into the application.#{skip}"
138
+ key = @ui.secret("#{Setup::PROVIDER_LABELS[@config.provider]} API key:")
139
+ return false if key.empty?
140
+
141
+ @typed_key = @config.api_key = key
142
+ true
143
+ end
144
+
145
+ def choose_model
146
+ step "Model"
147
+ found = provider.models
148
+ default = default_model(found)
149
+ return ask_any_model(default) if found.empty?
150
+
151
+ shown = (([default] & found) + (found - [default])).first(LISTED)
152
+ shown.each_with_index { |name, index| @ui.say " #{index + 1}. #{name}" }
153
+ @ui.note " ...and #{found.size - LISTED} more; type any name." if found.size > LISTED
154
+ numbered(shown, @ui.ask("Number or name [#{default}]:", default: default))
155
+ end
156
+
157
+ def numbered(shown, answer)
158
+ answer.match?(/\A\d+\z/) && answer.to_i.between?(1, shown.size) ? shown[answer.to_i - 1] : answer
159
+ end
160
+
161
+ def ask_any_model(default)
162
+ @ui.note " Could not list the models here, so type the name the API expects."
163
+ @ui.note " For Ollama, get one first with `ollama pull #{default}`." if @config.provider == :ollama
164
+ @ui.ask("Model [#{default}]:", default: default)
165
+ end
166
+
167
+ def default_model(found)
168
+ current = @before[:provider] == @config.provider ? @before[:model] : nil
169
+ preferred = [current, Credentials.read.dig(@config.provider.to_s, "model"),
170
+ Configuration::DEFAULT_MODELS[@config.provider]].compact
171
+ return preferred.first if found.empty?
172
+
173
+ preferred.find { |name| found.include?(name) } ||
174
+ found.find { |name| name.start_with?(Providers::Ollama::RECOMMENDED) } ||
175
+ found.find { |name| name.include?("coder") } || found.first
176
+ end
177
+
178
+ def answers?
179
+ @ui.blank
180
+ @ui.say "Checking #{label}..."
181
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
182
+ provider.chat(system: "You are a health check. Answer with the single word: ok",
183
+ messages: [{ role: "user", content: "ping" }])
184
+ @ui.success format("It answered in %.1fs.", Process.clock_gettime(Process::CLOCK_MONOTONIC) - started)
185
+ true
186
+ rescue Error => e
187
+ @ui.failure e.message
188
+ @ui.yes?("Keep #{label} anyway?", default: false)
189
+ end
190
+
191
+ def offer_to_save
192
+ return unless @ui.yes?("Make it the default in #{Credentials.path}?", default: true)
193
+
194
+ path = Credentials.store(provider: @config.provider, api_key: @typed_key, model: @config.model,
195
+ base_url: @config.api_base.to_s)
196
+ @ui.success "Saved to #{path} (chmod 600)."
197
+ rescue StandardError => e
198
+ @ui.failure "Could not write #{Credentials.path}: #{e.message}"
199
+ end
200
+
201
+ def abandon(message)
202
+ @config.restore(@before)
203
+ @ui.failure(@config.api_key? ? "#{message} Still on #{label}." : message)
204
+ nil
205
+ end
206
+
207
+ def provider
208
+ Providers::REGISTRY.fetch(@config.provider).new(@config)
209
+ end
210
+
211
+ def default_base(name)
212
+ Providers::REGISTRY.fetch(name)::DEFAULT_BASE
213
+ end
214
+
215
+ def base
216
+ @config.api_base || default_base(@config.provider)
217
+ end
218
+
219
+ def env_key?
220
+ !ENV.fetch(Configuration::ENV_KEYS[@config.provider], "").strip.empty?
221
+ end
222
+
223
+ def key_source(name)
224
+ env = Configuration::ENV_KEYS[name]
225
+ return "key from #{env}" unless ENV.fetch(env, "").strip.empty?
226
+ return "key stored in #{Credentials.path}" if Credentials.read.dig(name.to_s, "api_key").to_s.strip != ""
227
+
228
+ "no key yet"
229
+ end
230
+
231
+ def label
232
+ "#{@config.provider}/#{@config.model}"
233
+ end
234
+ end
235
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rails_agent_console/version"
4
+ require_relative "rails_agent_console/errors"
5
+ require_relative "rails_agent_console/configuration"
6
+ require_relative "rails_agent_console/credentials"
7
+ require_relative "rails_agent_console/ui"
8
+ require_relative "rails_agent_console/schema"
9
+ require_relative "rails_agent_console/prompt"
10
+ require_relative "rails_agent_console/query_validator"
11
+ require_relative "rails_agent_console/executor"
12
+ require_relative "rails_agent_console/value_check"
13
+ require_relative "rails_agent_console/diagnosis"
14
+ require_relative "rails_agent_console/history"
15
+ require_relative "rails_agent_console/rewriter"
16
+ require_relative "rails_agent_console/proposal"
17
+ require_relative "rails_agent_console/providers"
18
+ require_relative "rails_agent_console/agent"
19
+ require_relative "rails_agent_console/wizard"
20
+ require_relative "rails_agent_console/setup"
21
+ require_relative "rails_agent_console/model_picker"
22
+ require_relative "rails_agent_console/console_methods"
23
+
24
+ # Usage:
25
+ # ai "How many users registered this month?" # propose + confirm + run
26
+ # ask "Why is User.includes(:orders) faster?" # explain only, never runs
27
+ # explain User.where(active: true) # explain an existing query
28
+ module RailsAgentConsole
29
+ class << self
30
+ def configuration
31
+ @configuration ||= Configuration.new
32
+ end
33
+
34
+ def configure
35
+ yield configuration if block_given?
36
+ configuration
37
+ end
38
+
39
+ alias config configuration
40
+
41
+ def agent
42
+ @agent ||= Agent.new
43
+ end
44
+
45
+ def history
46
+ agent.history
47
+ end
48
+
49
+ def reset!
50
+ @configuration = nil
51
+ @agent = nil
52
+ Schema.reset!
53
+ self
54
+ end
55
+ end
56
+ end
57
+
58
+ require_relative "rails_agent_console/railtie" if defined?(Rails::Railtie)
metadata ADDED
@@ -0,0 +1,145 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rails_agent_console
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Ivan Blažević
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 2026-09-29 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activerecord
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.0'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '9'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '7.0'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '9'
32
+ - !ruby/object:Gem::Dependency
33
+ name: railties
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '7.0'
39
+ - - "<"
40
+ - !ruby/object:Gem::Version
41
+ version: '9'
42
+ type: :runtime
43
+ prerelease: false
44
+ version_requirements: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '7.0'
49
+ - - "<"
50
+ - !ruby/object:Gem::Version
51
+ version: '9'
52
+ description: |
53
+ rails_agent_console adds `ai`, `ai!`, `ask` and `explain` to the Rails console you already use.
54
+ It describes your real schema (models, columns, associations) to an LLM, gets back one
55
+ ActiveRecord query, checks it call by call against a read-only allowlist, fixes the model's
56
+ common mistakes in code, and runs it only after you confirm. Works with OpenAI, Anthropic,
57
+ Gemini, Ollama or any OpenAI-compatible endpoint, at about 1,700 tokens per question.
58
+ email:
59
+ - ivan.blazevic@rubycode.co
60
+ executables:
61
+ - rails-agent
62
+ extensions: []
63
+ extra_rdoc_files: []
64
+ files:
65
+ - CHANGELOG.md
66
+ - LICENSE.txt
67
+ - README.md
68
+ - exe/rails-agent
69
+ - lib/generators/rails_agent_console/install_generator.rb
70
+ - lib/generators/rails_agent_console/templates/rails_agent_console.rb
71
+ - lib/rails_agent_console.rb
72
+ - lib/rails_agent_console/agent.rb
73
+ - lib/rails_agent_console/cli.rb
74
+ - lib/rails_agent_console/configuration.rb
75
+ - lib/rails_agent_console/console_installer.rb
76
+ - lib/rails_agent_console/console_methods.rb
77
+ - lib/rails_agent_console/console_session.rb
78
+ - lib/rails_agent_console/credentials.rb
79
+ - lib/rails_agent_console/diagnosis.rb
80
+ - lib/rails_agent_console/diagnosis/extra.rb
81
+ - lib/rails_agent_console/errors.rb
82
+ - lib/rails_agent_console/executor.rb
83
+ - lib/rails_agent_console/history.rb
84
+ - lib/rails_agent_console/model_picker.rb
85
+ - lib/rails_agent_console/prompt.rb
86
+ - lib/rails_agent_console/prompt/dates.rb
87
+ - lib/rails_agent_console/prompt/follow_up.rb
88
+ - lib/rails_agent_console/proposal.rb
89
+ - lib/rails_agent_console/providers.rb
90
+ - lib/rails_agent_console/providers/anthropic.rb
91
+ - lib/rails_agent_console/providers/base.rb
92
+ - lib/rails_agent_console/providers/callable.rb
93
+ - lib/rails_agent_console/providers/gemini.rb
94
+ - lib/rails_agent_console/providers/ollama.rb
95
+ - lib/rails_agent_console/providers/openai.rb
96
+ - lib/rails_agent_console/query_validator.rb
97
+ - lib/rails_agent_console/query_validator/model_suggestion.rb
98
+ - lib/rails_agent_console/query_validator/parse_error.rb
99
+ - lib/rails_agent_console/query_validator/result.rb
100
+ - lib/rails_agent_console/query_validator/rules.rb
101
+ - lib/rails_agent_console/query_validator/sensitive_columns.rb
102
+ - lib/rails_agent_console/railtie.rb
103
+ - lib/rails_agent_console/rewriter.rb
104
+ - lib/rails_agent_console/rewriter/arguments.rb
105
+ - lib/rails_agent_console/rewriter/chain.rb
106
+ - lib/rails_agent_console/rewriter/date_ranges.rb
107
+ - lib/rails_agent_console/rewriter/distinct.rb
108
+ - lib/rails_agent_console/rewriter/joined.rb
109
+ - lib/rails_agent_console/rewriter/qualifier.rb
110
+ - lib/rails_agent_console/rewriter/quotes.rb
111
+ - lib/rails_agent_console/rewriter/sql_repair.rb
112
+ - lib/rails_agent_console/schema.rb
113
+ - lib/rails_agent_console/setup.rb
114
+ - lib/rails_agent_console/ui.rb
115
+ - lib/rails_agent_console/value_check.rb
116
+ - lib/rails_agent_console/version.rb
117
+ - lib/rails_agent_console/wizard.rb
118
+ homepage: https://github.com/blaz1988/rails-agent-console
119
+ licenses:
120
+ - MIT
121
+ metadata:
122
+ source_code_uri: https://github.com/blaz1988/rails-agent-console
123
+ documentation_uri: https://github.com/blaz1988/rails-agent-console#readme
124
+ changelog_uri: https://github.com/blaz1988/rails-agent-console/blob/main/CHANGELOG.md
125
+ bug_tracker_uri: https://github.com/blaz1988/rails-agent-console/issues
126
+ rubygems_mfa_required: 'true'
127
+ rdoc_options: []
128
+ require_paths:
129
+ - lib
130
+ required_ruby_version: !ruby/object:Gem::Requirement
131
+ requirements:
132
+ - - ">="
133
+ - !ruby/object:Gem::Version
134
+ version: 3.1.0
135
+ required_rubygems_version: !ruby/object:Gem::Requirement
136
+ requirements:
137
+ - - ">="
138
+ - !ruby/object:Gem::Version
139
+ version: '0'
140
+ requirements: []
141
+ rubygems_version: 3.6.4
142
+ specification_version: 4
143
+ summary: Plain-English questions in rails console, answered with ActiveRecord you
144
+ read before it runs.
145
+ test_files: []