rubyn-code 0.2.2 → 0.3.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 (114) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +91 -3
  3. data/lib/rubyn_code/agent/background_job_handler.rb +71 -0
  4. data/lib/rubyn_code/agent/conversation.rb +55 -56
  5. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +99 -0
  6. data/lib/rubyn_code/agent/feedback_handler.rb +49 -0
  7. data/lib/rubyn_code/agent/llm_caller.rb +149 -0
  8. data/lib/rubyn_code/agent/loop.rb +175 -683
  9. data/lib/rubyn_code/agent/loop_detector.rb +50 -11
  10. data/lib/rubyn_code/agent/prompts.rb +109 -0
  11. data/lib/rubyn_code/agent/response_modes.rb +111 -0
  12. data/lib/rubyn_code/agent/response_parser.rb +111 -0
  13. data/lib/rubyn_code/agent/system_prompt_builder.rb +205 -0
  14. data/lib/rubyn_code/agent/tool_processor.rb +158 -0
  15. data/lib/rubyn_code/agent/usage_tracker.rb +59 -0
  16. data/lib/rubyn_code/auth/oauth.rb +80 -64
  17. data/lib/rubyn_code/auth/server.rb +21 -24
  18. data/lib/rubyn_code/auth/token_store.rb +31 -44
  19. data/lib/rubyn_code/autonomous/daemon.rb +29 -18
  20. data/lib/rubyn_code/autonomous/idle_poller.rb +4 -4
  21. data/lib/rubyn_code/autonomous/task_claimer.rb +36 -40
  22. data/lib/rubyn_code/background/worker.rb +64 -76
  23. data/lib/rubyn_code/cli/app.rb +128 -114
  24. data/lib/rubyn_code/cli/commands/model.rb +75 -18
  25. data/lib/rubyn_code/cli/commands/new_session.rb +45 -0
  26. data/lib/rubyn_code/cli/daemon_runner.rb +28 -11
  27. data/lib/rubyn_code/cli/renderer.rb +109 -60
  28. data/lib/rubyn_code/cli/repl.rb +42 -373
  29. data/lib/rubyn_code/cli/repl_commands.rb +176 -0
  30. data/lib/rubyn_code/cli/repl_lifecycle.rb +75 -0
  31. data/lib/rubyn_code/cli/repl_setup.rb +145 -0
  32. data/lib/rubyn_code/cli/setup.rb +6 -2
  33. data/lib/rubyn_code/cli/stream_formatter.rb +56 -49
  34. data/lib/rubyn_code/cli/version_check.rb +28 -11
  35. data/lib/rubyn_code/config/defaults.rb +10 -0
  36. data/lib/rubyn_code/config/project_profile.rb +185 -0
  37. data/lib/rubyn_code/config/settings.rb +100 -1
  38. data/lib/rubyn_code/context/auto_compact.rb +1 -1
  39. data/lib/rubyn_code/context/context_budget.rb +167 -0
  40. data/lib/rubyn_code/context/decision_compactor.rb +99 -0
  41. data/lib/rubyn_code/context/manager.rb +7 -5
  42. data/lib/rubyn_code/context/micro_compact.rb +29 -19
  43. data/lib/rubyn_code/context/schema_filter.rb +64 -0
  44. data/lib/rubyn_code/db/connection.rb +31 -26
  45. data/lib/rubyn_code/db/migrator.rb +44 -28
  46. data/lib/rubyn_code/hooks/built_in.rb +14 -10
  47. data/lib/rubyn_code/index/codebase_index.rb +245 -0
  48. data/lib/rubyn_code/learning/extractor.rb +65 -82
  49. data/lib/rubyn_code/learning/injector.rb +22 -23
  50. data/lib/rubyn_code/learning/instinct.rb +71 -42
  51. data/lib/rubyn_code/learning/shortcut.rb +95 -0
  52. data/lib/rubyn_code/llm/adapters/anthropic.rb +270 -0
  53. data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +215 -0
  54. data/lib/rubyn_code/llm/adapters/base.rb +35 -0
  55. data/lib/rubyn_code/llm/adapters/json_parsing.rb +21 -0
  56. data/lib/rubyn_code/llm/adapters/openai.rb +246 -0
  57. data/lib/rubyn_code/llm/adapters/openai_compatible.rb +46 -0
  58. data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +90 -0
  59. data/lib/rubyn_code/llm/adapters/openai_streaming.rb +141 -0
  60. data/lib/rubyn_code/llm/adapters/prompt_caching.rb +60 -0
  61. data/lib/rubyn_code/llm/client.rb +55 -252
  62. data/lib/rubyn_code/llm/model_router.rb +237 -0
  63. data/lib/rubyn_code/llm/streaming.rb +4 -227
  64. data/lib/rubyn_code/mcp/client.rb +1 -1
  65. data/lib/rubyn_code/mcp/config.rb +9 -12
  66. data/lib/rubyn_code/mcp/sse_transport.rb +15 -13
  67. data/lib/rubyn_code/mcp/stdio_transport.rb +16 -18
  68. data/lib/rubyn_code/mcp/tool_bridge.rb +31 -62
  69. data/lib/rubyn_code/memory/session_persistence.rb +59 -58
  70. data/lib/rubyn_code/memory/store.rb +42 -55
  71. data/lib/rubyn_code/observability/budget_enforcer.rb +46 -32
  72. data/lib/rubyn_code/observability/cost_calculator.rb +32 -8
  73. data/lib/rubyn_code/observability/skill_analytics.rb +116 -0
  74. data/lib/rubyn_code/observability/token_analytics.rb +130 -0
  75. data/lib/rubyn_code/observability/usage_reporter.rb +79 -61
  76. data/lib/rubyn_code/output/diff_renderer.rb +102 -77
  77. data/lib/rubyn_code/output/formatter.rb +11 -11
  78. data/lib/rubyn_code/permissions/policy.rb +11 -13
  79. data/lib/rubyn_code/permissions/prompter.rb +8 -9
  80. data/lib/rubyn_code/protocols/plan_approval.rb +25 -20
  81. data/lib/rubyn_code/skills/document.rb +33 -29
  82. data/lib/rubyn_code/skills/ttl_manager.rb +100 -0
  83. data/lib/rubyn_code/sub_agents/runner.rb +20 -25
  84. data/lib/rubyn_code/tasks/dag.rb +25 -24
  85. data/lib/rubyn_code/tools/ask_user.rb +44 -0
  86. data/lib/rubyn_code/tools/background_run.rb +2 -1
  87. data/lib/rubyn_code/tools/base.rb +26 -32
  88. data/lib/rubyn_code/tools/bash.rb +2 -1
  89. data/lib/rubyn_code/tools/edit_file.rb +74 -18
  90. data/lib/rubyn_code/tools/executor.rb +74 -24
  91. data/lib/rubyn_code/tools/file_cache.rb +95 -0
  92. data/lib/rubyn_code/tools/git_commit.rb +12 -10
  93. data/lib/rubyn_code/tools/git_log.rb +12 -10
  94. data/lib/rubyn_code/tools/glob.rb +23 -7
  95. data/lib/rubyn_code/tools/grep.rb +2 -1
  96. data/lib/rubyn_code/tools/load_skill.rb +13 -6
  97. data/lib/rubyn_code/tools/memory_search.rb +14 -13
  98. data/lib/rubyn_code/tools/memory_write.rb +2 -1
  99. data/lib/rubyn_code/tools/output_compressor.rb +185 -0
  100. data/lib/rubyn_code/tools/read_file.rb +11 -6
  101. data/lib/rubyn_code/tools/review_pr.rb +127 -80
  102. data/lib/rubyn_code/tools/run_specs.rb +26 -15
  103. data/lib/rubyn_code/tools/schema.rb +4 -10
  104. data/lib/rubyn_code/tools/spawn_agent.rb +113 -82
  105. data/lib/rubyn_code/tools/spawn_teammate.rb +107 -64
  106. data/lib/rubyn_code/tools/spec_output_parser.rb +118 -0
  107. data/lib/rubyn_code/tools/task.rb +17 -17
  108. data/lib/rubyn_code/tools/web_fetch.rb +62 -47
  109. data/lib/rubyn_code/tools/web_search.rb +66 -48
  110. data/lib/rubyn_code/tools/write_file.rb +59 -1
  111. data/lib/rubyn_code/version.rb +1 -1
  112. data/lib/rubyn_code.rb +40 -1
  113. data/skills/rubyn_self_test.md +121 -0
  114. metadata +53 -1
@@ -35,9 +35,9 @@ module RubynCode
35
35
  return :shutdown if monotonic_now >= deadline
36
36
 
37
37
  # Messages always take priority over tasks.
38
- return :resume if has_pending_messages?
38
+ return :resume if pending_messages?
39
39
 
40
- return :resume if has_claimable_task?
40
+ return :resume if claimable_task?
41
41
 
42
42
  remaining = deadline - monotonic_now
43
43
  return :shutdown if remaining <= 0
@@ -76,7 +76,7 @@ module RubynCode
76
76
  private
77
77
 
78
78
  # @return [Boolean]
79
- def has_pending_messages?
79
+ def pending_messages?
80
80
  messages = @mailbox.pending_for(@agent_name)
81
81
  messages.is_a?(Array) ? !messages.empty? : false
82
82
  rescue StandardError
@@ -84,7 +84,7 @@ module RubynCode
84
84
  end
85
85
 
86
86
  # @return [Boolean]
87
- def has_claimable_task?
87
+ def claimable_task?
88
88
  rows = @task_manager.db.query(<<~SQL).to_a
89
89
  SELECT 1 FROM tasks
90
90
  WHERE status = 'pending'
@@ -15,47 +15,9 @@ module RubynCode
15
15
  # @return [Tasks::Task, nil] the claimed task, or nil if none available
16
16
  def self.call(task_manager:, agent_name:)
17
17
  db = task_manager.db
18
-
19
- # Atomically claim the first eligible task. The WHERE conditions
20
- # ensure that only pending tasks with no current owner are touched,
21
- # avoiding race conditions with other agents.
22
- db.execute(<<~SQL, [agent_name])
23
- UPDATE tasks
24
- SET owner = ?,
25
- status = 'in_progress',
26
- updated_at = datetime('now')
27
- WHERE id = (
28
- SELECT id FROM tasks
29
- WHERE status = 'pending'
30
- AND (owner IS NULL OR owner = '')
31
- ORDER BY priority DESC, created_at ASC
32
- LIMIT 1
33
- )
34
- AND status = 'pending'
35
- AND (owner IS NULL OR owner = '')
36
- SQL
37
-
38
- # Fetch the task we just claimed. Using owner + status filters
39
- # ensures we only retrieve a task that *this* agent successfully
40
- # claimed (another agent cannot have flipped it in between).
41
- rows = db.query(<<~SQL, [agent_name]).to_a
42
- SELECT id, session_id, title, description, status,
43
- priority, owner, result, metadata, created_at, updated_at
44
- FROM tasks
45
- WHERE owner = ?
46
- AND status = 'in_progress'
47
- ORDER BY updated_at DESC
48
- LIMIT 1
49
- SQL
50
-
51
- return nil if rows.empty?
52
-
53
- row = rows.first
54
- build_task(row)
18
+ claim_next_pending_task(db, agent_name)
19
+ fetch_claimed_task(db, agent_name)
55
20
  rescue StandardError => e
56
- # If anything goes wrong (e.g. task was already claimed between
57
- # our SELECT and UPDATE, or a constraint violation) we treat it
58
- # as "no work available" rather than crashing the daemon.
59
21
  RubynCode.logger.warn("TaskClaimer: failed to claim task: #{e.message}") if RubynCode.respond_to?(:logger)
60
22
  nil
61
23
  end
@@ -63,6 +25,40 @@ module RubynCode
63
25
  class << self
64
26
  private
65
27
 
28
+ def claim_next_pending_task(db, agent_name)
29
+ db.execute(<<~SQL, [agent_name])
30
+ UPDATE tasks
31
+ SET owner = ?,
32
+ status = 'in_progress',
33
+ updated_at = datetime('now')
34
+ WHERE id = (
35
+ SELECT id FROM tasks
36
+ WHERE status = 'pending'
37
+ AND (owner IS NULL OR owner = '')
38
+ ORDER BY priority DESC, created_at ASC
39
+ LIMIT 1
40
+ )
41
+ AND status = 'pending'
42
+ AND (owner IS NULL OR owner = '')
43
+ SQL
44
+ end
45
+
46
+ def fetch_claimed_task(db, agent_name)
47
+ rows = db.query(<<~SQL, [agent_name]).to_a
48
+ SELECT id, session_id, title, description, status,
49
+ priority, owner, result, metadata, created_at, updated_at
50
+ FROM tasks
51
+ WHERE owner = ?
52
+ AND status = 'in_progress'
53
+ ORDER BY updated_at DESC
54
+ LIMIT 1
55
+ SQL
56
+
57
+ return nil if rows.empty?
58
+
59
+ build_task(rows.first)
60
+ end
61
+
66
62
  # @param row [Hash] a database row hash
67
63
  # @return [Tasks::Task]
68
64
  def build_task(row)
@@ -93,91 +93,79 @@ module RubynCode
93
93
  private
94
94
 
95
95
  def execute_job(job_id, command, timeout_seconds)
96
- stdout, stderr, = nil
97
- final_status = :completed
98
-
99
- begin
100
- stdin_io, stdout_io, stderr_io, wait_thr = Open3.popen3(command, chdir: @project_root)
101
- stdin_io.close
102
- out_buf = +''
103
- err_buf = +''
104
- out_reader = Thread.new do
105
- out_buf << stdout_io.read
106
- rescue StandardError
107
- nil
108
- end
109
- err_reader = Thread.new do
110
- err_buf << stderr_io.read
111
- rescue StandardError
112
- nil
113
- end
96
+ stdout, stderr, final_status = run_process(command, timeout_seconds)
114
97
 
115
- unless wait_thr.join(timeout_seconds)
116
- begin
117
- Process.kill('TERM', wait_thr.pid)
118
- rescue StandardError
119
- nil
120
- end
121
- sleep 0.1
122
- begin
123
- Process.kill('KILL', wait_thr.pid)
124
- rescue StandardError
125
- nil
126
- end
127
- wait_thr.join(5)
128
- out_reader.join(2)
129
- err_reader.join(2)
130
- [stdout_io, stderr_io].each do |io|
131
- io.close
132
- rescue StandardError
133
- nil
134
- end
135
- raise Timeout::Error
136
- end
98
+ result = build_result(stdout, stderr)
99
+ completed_job = finalize_job(job_id, command, final_status, result)
137
100
 
138
- out_reader.join(5)
139
- err_reader.join(5)
140
- [stdout_io, stderr_io].each do |io|
141
- io.close
142
- rescue StandardError
143
- nil
144
- end
101
+ @notifier.push({
102
+ type: :job_completed, job_id: job_id,
103
+ status: final_status, result: result,
104
+ duration: completed_job.duration
105
+ })
106
+ end
107
+
108
+ def run_process(command, timeout_seconds)
109
+ stdin_io, stdout_io, stderr_io, wait_thr = Open3.popen3(command, chdir: @project_root)
110
+ stdin_io.close
111
+ io_state = { stdout_io: stdout_io, stderr_io: stderr_io }
112
+ out_reader, err_reader, out_buf, err_buf = start_readers(stdout_io, stderr_io)
113
+ io_state.merge!(out_reader: out_reader, err_reader: err_reader)
114
+
115
+ handle_wait(wait_thr, timeout_seconds, io_state)
116
+
117
+ status = wait_thr.value.success? ? :completed : :error
118
+ [out_buf, err_buf, status]
119
+ rescue Timeout::Error
120
+ [nil, "Command timed out after #{timeout_seconds} seconds", :timeout]
121
+ rescue StandardError => e
122
+ [nil, e.message, :error]
123
+ end
145
124
 
146
- stdout = out_buf
147
- stderr = err_buf
148
- process_status = wait_thr.value
149
- final_status = process_status.success? ? :completed : :error
150
- rescue Timeout::Error
151
- final_status = :timeout
152
- stdout = nil
153
- stderr = "Command timed out after #{timeout_seconds} seconds"
154
- rescue StandardError => e
155
- final_status = :error
156
- stdout = nil
157
- stderr = e.message
125
+ def start_readers(stdout_io, stderr_io)
126
+ out_buf = +''
127
+ err_buf = +''
128
+ out_reader = Thread.new { out_buf << stdout_io.read rescue nil } # rubocop:disable Style/RescueModifier
129
+ err_reader = Thread.new { err_buf << stderr_io.read rescue nil } # rubocop:disable Style/RescueModifier
130
+ [out_reader, err_reader, out_buf, err_buf]
131
+ end
132
+
133
+ def handle_wait(wait_thr, timeout_seconds, io_state)
134
+ unless wait_thr.join(timeout_seconds)
135
+ kill_process(wait_thr)
136
+ cleanup_io(io_state)
137
+ raise Timeout::Error
158
138
  end
159
139
 
160
- result = build_result(stdout, stderr)
161
- completed_at = Time.now
140
+ cleanup_io(io_state)
141
+ end
142
+
143
+ def cleanup_io(io_state)
144
+ cleanup_readers(io_state[:out_reader], io_state[:err_reader],
145
+ io_state[:stdout_io], io_state[:stderr_io])
146
+ end
147
+
148
+ def kill_process(wait_thr)
149
+ Process.kill('TERM', wait_thr.pid) rescue nil # rubocop:disable Style/RescueModifier
150
+ sleep 0.1
151
+ Process.kill('KILL', wait_thr.pid) rescue nil # rubocop:disable Style/RescueModifier
152
+ wait_thr.join(5)
153
+ end
154
+
155
+ def cleanup_readers(out_reader, err_reader, stdout_io, stderr_io)
156
+ out_reader.join(5)
157
+ err_reader.join(5)
158
+ [stdout_io, stderr_io].each { |io| io.close rescue nil } # rubocop:disable Style/RescueModifier
159
+ end
162
160
 
163
- completed_job = @mutex.synchronize do
161
+ def finalize_job(job_id, command, final_status, result)
162
+ @mutex.synchronize do
164
163
  @jobs[job_id] = Job.new(
165
- id: job_id,
166
- command: command,
167
- status: final_status,
168
- result: result,
169
- started_at: @jobs[job_id].started_at,
170
- completed_at: completed_at
164
+ id: job_id, command: command, status: final_status,
165
+ result: result, started_at: @jobs[job_id].started_at,
166
+ completed_at: Time.now
171
167
  )
172
168
  end
173
-
174
- @notifier.push({
175
- type: :job_completed,
176
- job_id: job_id,
177
- status: final_status,
178
- result: result,
179
- duration: completed_job.duration
180
- })
181
169
  end
182
170
 
183
171
  def build_result(stdout, stderr)
@@ -14,71 +14,120 @@ module RubynCode
14
14
 
15
15
  def run
16
16
  RubynCode::Debug.enable! if @options[:debug]
17
-
18
- case @options[:command]
19
- when :version
20
- puts "rubyn-code #{RubynCode::VERSION}"
21
- when :auth
22
- run_auth
23
- when :setup
24
- run_setup
25
- when :help
26
- display_help
27
- when :run
28
- run_single_prompt(@options[:prompt])
29
- when :daemon
30
- run_daemon
31
- when :repl
32
- run_repl
33
- end
17
+ dispatch_command(@options[:command])
34
18
  end
35
19
 
20
+ HELP_TEXT = <<~HELP
21
+ rubyn-code - Ruby & Rails Agentic Coding Assistant
22
+
23
+ Usage:
24
+ rubyn-code Start interactive REPL
25
+ rubyn-code -p "prompt" Run a single prompt and exit
26
+ rubyn-code --resume [ID] Resume a previous session
27
+ rubyn-code --setup Pin rubyn-code to bypass rbenv/rvm
28
+ rubyn-code --auth Authenticate with Claude
29
+ rubyn-code --version Show version
30
+ rubyn-code --help Show this help
31
+
32
+ Daemon Mode:
33
+ rubyn-code daemon Start autonomous daemon (GOLEM)
34
+ rubyn-code daemon --name NAME Agent name (default: golem-<random>)
35
+ rubyn-code daemon --role ROLE Agent role description
36
+ rubyn-code daemon --max-runs N Max tasks before shutdown (default: 100)
37
+ rubyn-code daemon --max-cost N Max USD spend before shutdown (default: 10.0)
38
+ rubyn-code daemon --idle-timeout N Seconds idle before shutdown (default: 60)
39
+ rubyn-code daemon --poll-interval N Seconds between polls (default: 5)
40
+
41
+ Interactive Commands:
42
+ /help Show available commands
43
+ /quit Exit
44
+ /compact Compress context
45
+ /cost Show usage costs
46
+ /tasks List tasks
47
+ /skill [name] Load or list skills
48
+
49
+ Environment:
50
+ Config: ~/.rubyn-code/config.yml
51
+ Data: ~/.rubyn-code/rubyn_code.db
52
+ Tokens: ~/.rubyn-code/tokens.yml
53
+ HELP
54
+
55
+ SIMPLE_FLAGS = {
56
+ '--version' => :version, '-v' => :version,
57
+ '--help' => :help, '-h' => :help,
58
+ '--auth' => :auth, '--setup' => :setup
59
+ }.freeze
60
+ BOOLEAN_FLAGS = { '--yolo' => :yolo, '--debug' => :debug }.freeze
61
+ DAEMON_INT_FLAGS = { '--max-runs' => :max_runs, '--idle-timeout' => :idle_timeout,
62
+ '--poll-interval' => :poll_interval }.freeze
63
+ DAEMON_STR_FLAGS = { '--name' => :agent_name, '--role' => :role }.freeze
64
+
36
65
  private
37
66
 
38
- def parse_options(argv) # rubocop:disable Metrics/MethodLength,Metrics/CyclomaticComplexity
67
+ def dispatch_command(command) # rubocop:disable Metrics/CyclomaticComplexity -- unavoidable dispatch switch
68
+ case command
69
+ when :version then puts "rubyn-code #{RubynCode::VERSION}"
70
+ when :auth then run_auth
71
+ when :setup then run_setup
72
+ when :help then display_help
73
+ when :run then run_single_prompt(@options[:prompt])
74
+ when :daemon then run_daemon
75
+ when :repl then run_repl
76
+ end
77
+ end
78
+
79
+ def parse_options(argv)
39
80
  options = { command: :repl }
81
+ idx = 0
82
+ while idx < argv.length
83
+ idx = parse_single_option(argv, idx, options)
84
+ idx += 1
85
+ end
86
+ options
87
+ end
40
88
 
41
- i = 0
42
- while i < argv.length
43
- case argv[i]
44
- when '--version', '-v'
45
- options[:command] = :version
46
- when '--help', '-h'
47
- options[:command] = :help
48
- when '--auth'
49
- options[:command] = :auth
50
- when '--resume', '-r'
51
- options[:session_id] = argv[i + 1]
52
- i += 1
53
- when '-p', '--prompt'
54
- options[:command] = :run
55
- options[:prompt] = argv[i + 1]
56
- i += 1
57
- when '--yolo'
58
- options[:yolo] = true
59
- when '--debug'
60
- options[:debug] = true
61
- when '--setup'
62
- options[:command] = :setup
63
- when 'daemon'
64
- options[:command] = :daemon
65
- parse_daemon_options!(argv, i + 1, options)
66
- break
67
- end
68
- i += 1
89
+ # -- option parser
90
+ def parse_single_option(argv, idx, options)
91
+ arg = argv[idx]
92
+ if SIMPLE_FLAGS.key?(arg)
93
+ options[:command] = SIMPLE_FLAGS[arg]
94
+ elsif BOOLEAN_FLAGS.key?(arg)
95
+ options[BOOLEAN_FLAGS[arg]] = true
96
+ else
97
+ idx = parse_value_option(argv, idx, options)
69
98
  end
99
+ idx
100
+ end
70
101
 
71
- options
102
+ def parse_value_option(argv, idx, options)
103
+ case argv[idx]
104
+ when '--resume', '-r'
105
+ options[:session_id] = argv[idx + 1]
106
+ idx + 1
107
+ when '-p', '--prompt'
108
+ options[:command] = :run
109
+ options[:prompt] = argv[idx + 1]
110
+ idx + 1
111
+ when 'daemon'
112
+ options[:command] = :daemon
113
+ parse_daemon_options!(argv, idx + 1, options)
114
+ argv.length - 1
115
+ else
116
+ idx
117
+ end
118
+ end
119
+
120
+ def parse_daemon_options!(argv, start, options)
121
+ options[:daemon] = default_daemon_options
122
+ idx = start
123
+ while idx < argv.length
124
+ idx = parse_single_daemon_option(argv, idx, options)
125
+ idx += 1
126
+ end
72
127
  end
73
128
 
74
- # Parses daemon-specific flags from the argv starting at the given index.
75
- #
76
- # @param argv [Array<String>]
77
- # @param start [Integer]
78
- # @param options [Hash]
79
- # @return [void]
80
- def parse_daemon_options!(argv, start, options) # rubocop:disable Metrics/MethodLength,Metrics/CyclomaticComplexity
81
- options[:daemon] = {
129
+ def default_daemon_options
130
+ {
82
131
  max_runs: 100,
83
132
  max_cost: 10.0,
84
133
  idle_timeout: 60,
@@ -86,33 +135,31 @@ module RubynCode
86
135
  agent_name: "golem-#{SecureRandom.hex(4)}",
87
136
  role: 'autonomous coding agent'
88
137
  }
138
+ end
139
+
140
+ def parse_single_daemon_option(argv, idx, options)
141
+ case argv[idx]
142
+ when '--debug'
143
+ options[:debug] = true
144
+ else
145
+ idx = parse_daemon_value_option(argv, idx, options)
146
+ end
147
+ idx
148
+ end
89
149
 
90
- i = start
91
- while i < argv.length
92
- case argv[i]
93
- when '--max-runs'
94
- options[:daemon][:max_runs] = argv[i + 1].to_i
95
- i += 1
96
- when '--max-cost'
97
- options[:daemon][:max_cost] = argv[i + 1].to_f
98
- i += 1
99
- when '--idle-timeout'
100
- options[:daemon][:idle_timeout] = argv[i + 1].to_i
101
- i += 1
102
- when '--poll-interval'
103
- options[:daemon][:poll_interval] = argv[i + 1].to_i
104
- i += 1
105
- when '--name'
106
- options[:daemon][:agent_name] = argv[i + 1]
107
- i += 1
108
- when '--role'
109
- options[:daemon][:role] = argv[i + 1]
110
- i += 1
111
- when '--debug'
112
- options[:debug] = true
113
- end
114
- i += 1
150
+ def parse_daemon_value_option(argv, idx, options) # rubocop:disable Metrics/AbcSize -- option dispatch with hash lookup
151
+ arg = argv[idx]
152
+ daemon = options[:daemon]
153
+ if DAEMON_INT_FLAGS.key?(arg)
154
+ daemon[DAEMON_INT_FLAGS[arg]] = argv[idx + 1].to_i
155
+ elsif arg == '--max-cost'
156
+ daemon[:max_cost] = argv[idx + 1].to_f
157
+ elsif DAEMON_STR_FLAGS.key?(arg)
158
+ daemon[DAEMON_STR_FLAGS[arg]] = argv[idx + 1]
159
+ else
160
+ return idx
115
161
  end
162
+ idx + 1
116
163
  end
117
164
 
118
165
  def run_auth
@@ -154,40 +201,7 @@ module RubynCode
154
201
  end
155
202
 
156
203
  def display_help
157
- puts <<~HELP
158
- rubyn-code - Ruby & Rails Agentic Coding Assistant
159
-
160
- Usage:
161
- rubyn-code Start interactive REPL
162
- rubyn-code -p "prompt" Run a single prompt and exit
163
- rubyn-code --resume [ID] Resume a previous session
164
- rubyn-code --setup Pin rubyn-code to bypass rbenv/rvm
165
- rubyn-code --auth Authenticate with Claude
166
- rubyn-code --version Show version
167
- rubyn-code --help Show this help
168
-
169
- Daemon Mode:
170
- rubyn-code daemon Start autonomous daemon (GOLEM)
171
- rubyn-code daemon --name NAME Agent name (default: golem-<random>)
172
- rubyn-code daemon --role ROLE Agent role description
173
- rubyn-code daemon --max-runs N Max tasks before shutdown (default: 100)
174
- rubyn-code daemon --max-cost N Max USD spend before shutdown (default: 10.0)
175
- rubyn-code daemon --idle-timeout N Seconds idle before shutdown (default: 60)
176
- rubyn-code daemon --poll-interval N Seconds between polls (default: 5)
177
-
178
- Interactive Commands:
179
- /help Show available commands
180
- /quit Exit
181
- /compact Compress context
182
- /cost Show usage costs
183
- /tasks List tasks
184
- /skill [name] Load or list skills
185
-
186
- Environment:
187
- Config: ~/.rubyn-code/config.yml
188
- Data: ~/.rubyn-code/rubyn_code.db
189
- Tokens: ~/.rubyn-code/tokens.yml
190
- HELP
204
+ puts HELP_TEXT
191
205
  end
192
206
  end
193
207
  end
@@ -5,30 +5,87 @@ module RubynCode
5
5
  module Commands
6
6
  class Model < Base
7
7
  def self.command_name = '/model'
8
- def self.description = 'Show or switch model (/model [name])'
9
-
10
- KNOWN_MODELS = %w[
11
- claude-haiku-4-5
12
- claude-sonnet-4-20250514
13
- claude-opus-4-20250514
14
- ].freeze
8
+ def self.description = 'Show or switch model (/model [provider:model])'
15
9
 
16
10
  def execute(args, ctx)
17
11
  name = args.first
12
+ return show_current(ctx) unless name
13
+
14
+ provider, model = parse_model_arg(name)
15
+ switch_model(provider, model, ctx)
16
+ end
18
17
 
19
- if name
20
- unless KNOWN_MODELS.include?(name)
21
- ctx.renderer.warning("Unknown model: #{name}")
22
- ctx.renderer.info("Known models: #{KNOWN_MODELS.join(', ')}")
23
- return
24
- end
18
+ private
19
+
20
+ # Parse "provider:model" or just "model".
21
+ # Examples:
22
+ # "openai:gpt-4o" → ["openai", "gpt-4o"]
23
+ # "claude-sonnet-4-20250514" → [nil, "claude-sonnet-4-20250514"]
24
+ # "anthropic:" → ["anthropic", nil]
25
+ def parse_model_arg(arg)
26
+ return [arg.chomp(':'), nil] if arg.end_with?(':')
27
+ return [Regexp.last_match(1), Regexp.last_match(2)] if arg.match(/\A([^:]+):(.+)\z/)
28
+
29
+ [nil, arg]
30
+ end
25
31
 
26
- ctx.renderer.info("Model switched to #{name}")
27
- { action: :set_model, model: name }
32
+ def switch_model(provider, model, ctx)
33
+ if provider
34
+ switch_provider_and_model(provider, model, ctx)
28
35
  else
29
- current = Config::Defaults::DEFAULT_MODEL
30
- ctx.renderer.info("Current model: #{current}")
31
- ctx.renderer.info("Available: #{KNOWN_MODELS.join(', ')}")
36
+ switch_model_only(model, ctx)
37
+ end
38
+ end
39
+
40
+ def switch_provider_and_model(provider, model, ctx)
41
+ validate_model_for_provider!(provider, model, ctx) if model
42
+ ctx.renderer.info("Switched to provider: #{provider}#{", model: #{model}" if model}")
43
+ { action: :set_provider, provider: provider, model: model }
44
+ end
45
+
46
+ def switch_model_only(model, ctx)
47
+ unless known_model?(model, ctx)
48
+ ctx.renderer.warning("Unknown model: #{model}")
49
+ show_available(ctx)
50
+ return
51
+ end
52
+
53
+ ctx.renderer.info("Model switched to #{model}")
54
+ { action: :set_model, model: model }
55
+ end
56
+
57
+ def validate_model_for_provider!(provider, model, ctx)
58
+ adapter_models = models_for_provider(provider)
59
+ return if adapter_models.empty? # Unknown provider — can't validate
60
+ return if adapter_models.include?(model)
61
+
62
+ ctx.renderer.warning("Unknown model '#{model}' for #{provider}. Known: #{adapter_models.join(', ')}")
63
+ end
64
+
65
+ def show_current(ctx)
66
+ client = ctx.llm_client
67
+ provider = client.provider_name
68
+ current = client.model
69
+ ctx.renderer.info("Provider: #{provider}")
70
+ ctx.renderer.info("Current model: #{current}")
71
+ show_available(ctx)
72
+ end
73
+
74
+ def show_available(ctx)
75
+ client = ctx.llm_client
76
+ ctx.renderer.info("Available: #{client.models.join(', ')}")
77
+ ctx.renderer.info('Tip: /model provider:model to switch providers (e.g., /model openai:gpt-4o)')
78
+ end
79
+
80
+ def known_model?(model, ctx)
81
+ ctx.llm_client.models.include?(model)
82
+ end
83
+
84
+ def models_for_provider(provider)
85
+ case provider
86
+ when 'anthropic' then LLM::Adapters::Anthropic::AVAILABLE_MODELS
87
+ when 'openai' then LLM::Adapters::OpenAI::AVAILABLE_MODELS
88
+ else []
32
89
  end
33
90
  end
34
91
  end