rubyn-code 0.2.2 → 0.4.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 (154) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +151 -5
  3. data/db/migrations/013_add_failed_status_to_tasks.rb +51 -0
  4. data/lib/rubyn_code/agent/background_job_handler.rb +71 -0
  5. data/lib/rubyn_code/agent/conversation.rb +84 -56
  6. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +152 -0
  7. data/lib/rubyn_code/agent/feedback_handler.rb +49 -0
  8. data/lib/rubyn_code/agent/llm_caller.rb +157 -0
  9. data/lib/rubyn_code/agent/loop.rb +182 -683
  10. data/lib/rubyn_code/agent/loop_detector.rb +50 -11
  11. data/lib/rubyn_code/agent/prompts.rb +109 -0
  12. data/lib/rubyn_code/agent/response_modes.rb +111 -0
  13. data/lib/rubyn_code/agent/response_parser.rb +111 -0
  14. data/lib/rubyn_code/agent/system_prompt_builder.rb +211 -0
  15. data/lib/rubyn_code/agent/tool_processor.rb +178 -0
  16. data/lib/rubyn_code/agent/usage_tracker.rb +59 -0
  17. data/lib/rubyn_code/auth/key_encryption.rb +118 -0
  18. data/lib/rubyn_code/auth/oauth.rb +80 -64
  19. data/lib/rubyn_code/auth/server.rb +21 -24
  20. data/lib/rubyn_code/auth/token_store.rb +80 -52
  21. data/lib/rubyn_code/autonomous/daemon.rb +146 -32
  22. data/lib/rubyn_code/autonomous/idle_poller.rb +4 -24
  23. data/lib/rubyn_code/autonomous/task_claimer.rb +46 -44
  24. data/lib/rubyn_code/background/worker.rb +64 -76
  25. data/lib/rubyn_code/cli/app.rb +159 -114
  26. data/lib/rubyn_code/cli/commands/doctor.rb +73 -0
  27. data/lib/rubyn_code/cli/commands/mcp.rb +77 -0
  28. data/lib/rubyn_code/cli/commands/model.rb +105 -18
  29. data/lib/rubyn_code/cli/commands/new_session.rb +45 -0
  30. data/lib/rubyn_code/cli/commands/provider.rb +123 -0
  31. data/lib/rubyn_code/cli/commands/skill.rb +52 -3
  32. data/lib/rubyn_code/cli/daemon_runner.rb +64 -11
  33. data/lib/rubyn_code/cli/first_run.rb +159 -0
  34. data/lib/rubyn_code/cli/renderer.rb +109 -60
  35. data/lib/rubyn_code/cli/repl.rb +48 -374
  36. data/lib/rubyn_code/cli/repl_commands.rb +177 -0
  37. data/lib/rubyn_code/cli/repl_lifecycle.rb +76 -0
  38. data/lib/rubyn_code/cli/repl_setup.rb +181 -0
  39. data/lib/rubyn_code/cli/setup.rb +6 -2
  40. data/lib/rubyn_code/cli/stream_formatter.rb +56 -49
  41. data/lib/rubyn_code/cli/version_check.rb +28 -11
  42. data/lib/rubyn_code/config/defaults.rb +11 -0
  43. data/lib/rubyn_code/config/project_profile.rb +185 -0
  44. data/lib/rubyn_code/config/schema.json +49 -0
  45. data/lib/rubyn_code/config/settings.rb +103 -1
  46. data/lib/rubyn_code/config/validator.rb +63 -0
  47. data/lib/rubyn_code/context/auto_compact.rb +1 -1
  48. data/lib/rubyn_code/context/context_budget.rb +182 -0
  49. data/lib/rubyn_code/context/context_collapse.rb +34 -4
  50. data/lib/rubyn_code/context/decision_compactor.rb +99 -0
  51. data/lib/rubyn_code/context/manager.rb +44 -8
  52. data/lib/rubyn_code/context/manual_compact.rb +1 -1
  53. data/lib/rubyn_code/context/micro_compact.rb +29 -19
  54. data/lib/rubyn_code/context/schema_filter.rb +64 -0
  55. data/lib/rubyn_code/db/connection.rb +31 -26
  56. data/lib/rubyn_code/db/migrator.rb +44 -28
  57. data/lib/rubyn_code/hooks/built_in.rb +14 -10
  58. data/lib/rubyn_code/hooks/registry.rb +4 -0
  59. data/lib/rubyn_code/ide/adapters/tool_output.rb +330 -0
  60. data/lib/rubyn_code/ide/client.rb +110 -0
  61. data/lib/rubyn_code/ide/handlers/accept_edit_handler.rb +35 -0
  62. data/lib/rubyn_code/ide/handlers/approve_tool_use_handler.rb +34 -0
  63. data/lib/rubyn_code/ide/handlers/cancel_handler.rb +41 -0
  64. data/lib/rubyn_code/ide/handlers/config_get_handler.rb +63 -0
  65. data/lib/rubyn_code/ide/handlers/config_set_handler.rb +86 -0
  66. data/lib/rubyn_code/ide/handlers/initialize_handler.rb +79 -0
  67. data/lib/rubyn_code/ide/handlers/models_list_handler.rb +39 -0
  68. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +215 -0
  69. data/lib/rubyn_code/ide/handlers/review_handler.rb +110 -0
  70. data/lib/rubyn_code/ide/handlers/session_fork_handler.rb +49 -0
  71. data/lib/rubyn_code/ide/handlers/session_list_handler.rb +41 -0
  72. data/lib/rubyn_code/ide/handlers/session_reset_handler.rb +31 -0
  73. data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +42 -0
  74. data/lib/rubyn_code/ide/handlers/shutdown_handler.rb +37 -0
  75. data/lib/rubyn_code/ide/handlers.rb +76 -0
  76. data/lib/rubyn_code/ide/protocol.rb +111 -0
  77. data/lib/rubyn_code/ide/server.rb +186 -0
  78. data/lib/rubyn_code/index/codebase_index.rb +311 -0
  79. data/lib/rubyn_code/learning/extractor.rb +65 -82
  80. data/lib/rubyn_code/learning/injector.rb +22 -23
  81. data/lib/rubyn_code/learning/instinct.rb +71 -42
  82. data/lib/rubyn_code/learning/shortcut.rb +95 -0
  83. data/lib/rubyn_code/llm/adapters/anthropic.rb +274 -0
  84. data/lib/rubyn_code/llm/adapters/anthropic_compatible.rb +60 -0
  85. data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +215 -0
  86. data/lib/rubyn_code/llm/adapters/base.rb +35 -0
  87. data/lib/rubyn_code/llm/adapters/json_parsing.rb +21 -0
  88. data/lib/rubyn_code/llm/adapters/openai.rb +246 -0
  89. data/lib/rubyn_code/llm/adapters/openai_compatible.rb +50 -0
  90. data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +90 -0
  91. data/lib/rubyn_code/llm/adapters/openai_streaming.rb +141 -0
  92. data/lib/rubyn_code/llm/adapters/prompt_caching.rb +60 -0
  93. data/lib/rubyn_code/llm/client.rb +75 -247
  94. data/lib/rubyn_code/llm/model_router.rb +237 -0
  95. data/lib/rubyn_code/llm/streaming.rb +4 -227
  96. data/lib/rubyn_code/mcp/client.rb +1 -1
  97. data/lib/rubyn_code/mcp/config.rb +10 -12
  98. data/lib/rubyn_code/mcp/sse_transport.rb +15 -13
  99. data/lib/rubyn_code/mcp/stdio_transport.rb +16 -18
  100. data/lib/rubyn_code/mcp/tool_bridge.rb +31 -62
  101. data/lib/rubyn_code/memory/search.rb +1 -0
  102. data/lib/rubyn_code/memory/session_persistence.rb +59 -58
  103. data/lib/rubyn_code/memory/store.rb +42 -55
  104. data/lib/rubyn_code/observability/budget_enforcer.rb +46 -32
  105. data/lib/rubyn_code/observability/cost_calculator.rb +32 -8
  106. data/lib/rubyn_code/observability/skill_analytics.rb +116 -0
  107. data/lib/rubyn_code/observability/token_analytics.rb +130 -0
  108. data/lib/rubyn_code/observability/usage_reporter.rb +79 -61
  109. data/lib/rubyn_code/output/diff_renderer.rb +102 -77
  110. data/lib/rubyn_code/output/formatter.rb +11 -11
  111. data/lib/rubyn_code/permissions/policy.rb +11 -13
  112. data/lib/rubyn_code/permissions/prompter.rb +8 -9
  113. data/lib/rubyn_code/protocols/plan_approval.rb +25 -20
  114. data/lib/rubyn_code/self_test.rb +315 -0
  115. data/lib/rubyn_code/skills/catalog.rb +66 -0
  116. data/lib/rubyn_code/skills/document.rb +33 -29
  117. data/lib/rubyn_code/skills/loader.rb +43 -0
  118. data/lib/rubyn_code/skills/ttl_manager.rb +100 -0
  119. data/lib/rubyn_code/sub_agents/runner.rb +20 -25
  120. data/lib/rubyn_code/tasks/dag.rb +25 -24
  121. data/lib/rubyn_code/tasks/models.rb +1 -0
  122. data/lib/rubyn_code/tools/ask_user.rb +44 -0
  123. data/lib/rubyn_code/tools/background_run.rb +2 -1
  124. data/lib/rubyn_code/tools/base.rb +39 -32
  125. data/lib/rubyn_code/tools/bash.rb +7 -1
  126. data/lib/rubyn_code/tools/edit_file.rb +130 -17
  127. data/lib/rubyn_code/tools/executor.rb +130 -25
  128. data/lib/rubyn_code/tools/file_cache.rb +95 -0
  129. data/lib/rubyn_code/tools/git_commit.rb +12 -10
  130. data/lib/rubyn_code/tools/git_log.rb +12 -10
  131. data/lib/rubyn_code/tools/glob.rb +29 -7
  132. data/lib/rubyn_code/tools/grep.rb +8 -1
  133. data/lib/rubyn_code/tools/ide_diagnostics.rb +51 -0
  134. data/lib/rubyn_code/tools/ide_symbols.rb +53 -0
  135. data/lib/rubyn_code/tools/load_skill.rb +13 -6
  136. data/lib/rubyn_code/tools/memory_search.rb +14 -13
  137. data/lib/rubyn_code/tools/memory_write.rb +2 -1
  138. data/lib/rubyn_code/tools/output_compressor.rb +190 -0
  139. data/lib/rubyn_code/tools/read_file.rb +17 -6
  140. data/lib/rubyn_code/tools/registry.rb +11 -0
  141. data/lib/rubyn_code/tools/review_pr.rb +127 -80
  142. data/lib/rubyn_code/tools/run_specs.rb +26 -15
  143. data/lib/rubyn_code/tools/schema.rb +4 -10
  144. data/lib/rubyn_code/tools/spawn_agent.rb +113 -82
  145. data/lib/rubyn_code/tools/spawn_teammate.rb +107 -64
  146. data/lib/rubyn_code/tools/spec_output_parser.rb +118 -0
  147. data/lib/rubyn_code/tools/task.rb +17 -17
  148. data/lib/rubyn_code/tools/web_fetch.rb +62 -47
  149. data/lib/rubyn_code/tools/web_search.rb +66 -48
  150. data/lib/rubyn_code/tools/write_file.rb +76 -1
  151. data/lib/rubyn_code/version.rb +1 -1
  152. data/lib/rubyn_code.rb +62 -1
  153. data/skills/rubyn_self_test.md +133 -0
  154. metadata +83 -1
@@ -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,128 @@ 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 --ide Start IDE server (VS Code extension)
30
+ rubyn-code --permission-mode MODE Set permission mode (default, accept_edits, plan_only, auto, dont_ask, bypass)
31
+ rubyn-code --version Show version
32
+ rubyn-code --help Show this help
33
+
34
+ Daemon Mode:
35
+ rubyn-code daemon Start autonomous daemon (GOLEM)
36
+ rubyn-code daemon --name NAME Agent name (default: golem-<random>)
37
+ rubyn-code daemon --role ROLE Agent role description
38
+ rubyn-code daemon --max-runs N Max tasks before shutdown (default: 100)
39
+ rubyn-code daemon --max-cost N Max USD spend before shutdown (default: 10.0)
40
+ rubyn-code daemon --idle-timeout N Seconds idle before shutdown (default: 60)
41
+ rubyn-code daemon --poll-interval N Seconds between polls (default: 5)
42
+
43
+ Interactive Commands:
44
+ /help Show available commands
45
+ /quit Exit
46
+ /compact Compress context
47
+ /cost Show usage costs
48
+ /tasks List tasks
49
+ /skill [name] Load or list skills
50
+
51
+ Environment:
52
+ Config: ~/.rubyn-code/config.yml
53
+ Data: ~/.rubyn-code/rubyn_code.db
54
+ Tokens: ~/.rubyn-code/tokens.yml
55
+ HELP
56
+
57
+ SIMPLE_FLAGS = {
58
+ '--version' => :version, '-v' => :version,
59
+ '--help' => :help, '-h' => :help,
60
+ '--auth' => :auth, '--setup' => :setup
61
+ }.freeze
62
+ BOOLEAN_FLAGS = { '--yolo' => :yolo, '--debug' => :debug, '--skip-setup' => :skip_setup, '--ide' => :ide }.freeze
63
+ VALUE_FLAGS = { '--permission-mode' => :permission_mode }.freeze
64
+ DAEMON_INT_FLAGS = { '--max-runs' => :max_runs, '--idle-timeout' => :idle_timeout,
65
+ '--poll-interval' => :poll_interval }.freeze
66
+ DAEMON_STR_FLAGS = { '--name' => :agent_name, '--role' => :role }.freeze
67
+
36
68
  private
37
69
 
38
- def parse_options(argv) # rubocop:disable Metrics/MethodLength,Metrics/CyclomaticComplexity
70
+ def dispatch_command(command) # rubocop:disable Metrics/CyclomaticComplexity -- unavoidable dispatch switch
71
+ case command
72
+ when :version then puts "rubyn-code #{RubynCode::VERSION}"
73
+ when :auth then run_auth
74
+ when :setup then run_setup
75
+ when :help then display_help
76
+ when :run then run_single_prompt(@options[:prompt])
77
+ when :ide then run_ide
78
+ when :daemon then run_daemon
79
+ when :repl then run_repl
80
+ end
81
+ end
82
+
83
+ def parse_options(argv)
39
84
  options = { command: :repl }
85
+ idx = 0
86
+ while idx < argv.length
87
+ idx = parse_single_option(argv, idx, options)
88
+ idx += 1
89
+ end
90
+ options[:command] = :ide if options[:ide]
91
+ options
92
+ end
40
93
 
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
94
+ # -- option parser
95
+ def parse_single_option(argv, idx, options)
96
+ arg = argv[idx]
97
+ if SIMPLE_FLAGS.key?(arg)
98
+ options[:command] = SIMPLE_FLAGS[arg]
99
+ elsif BOOLEAN_FLAGS.key?(arg)
100
+ options[BOOLEAN_FLAGS[arg]] = true
101
+ elsif VALUE_FLAGS.key?(arg)
102
+ options[VALUE_FLAGS[arg]] = argv[idx + 1]
103
+ idx += 1
104
+ else
105
+ idx = parse_value_option(argv, idx, options)
69
106
  end
107
+ idx
108
+ end
70
109
 
71
- options
110
+ def parse_value_option(argv, idx, options)
111
+ case argv[idx]
112
+ when '--resume', '-r'
113
+ options[:session_id] = argv[idx + 1]
114
+ idx + 1
115
+ when '-p', '--prompt'
116
+ options[:command] = :run
117
+ options[:prompt] = argv[idx + 1]
118
+ idx + 1
119
+ when 'daemon'
120
+ options[:command] = :daemon
121
+ parse_daemon_options!(argv, idx + 1, options)
122
+ argv.length - 1
123
+ else
124
+ idx
125
+ end
72
126
  end
73
127
 
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] = {
128
+ def parse_daemon_options!(argv, start, options)
129
+ options[:daemon] = default_daemon_options
130
+ idx = start
131
+ while idx < argv.length
132
+ idx = parse_single_daemon_option(argv, idx, options)
133
+ idx += 1
134
+ end
135
+ end
136
+
137
+ def default_daemon_options
138
+ {
82
139
  max_runs: 100,
83
140
  max_cost: 10.0,
84
141
  idle_timeout: 60,
@@ -86,33 +143,31 @@ module RubynCode
86
143
  agent_name: "golem-#{SecureRandom.hex(4)}",
87
144
  role: 'autonomous coding agent'
88
145
  }
146
+ end
89
147
 
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
148
+ def parse_single_daemon_option(argv, idx, options)
149
+ case argv[idx]
150
+ when '--debug'
151
+ options[:debug] = true
152
+ else
153
+ idx = parse_daemon_value_option(argv, idx, options)
115
154
  end
155
+ idx
156
+ end
157
+
158
+ def parse_daemon_value_option(argv, idx, options) # rubocop:disable Metrics/AbcSize -- option dispatch with hash lookup
159
+ arg = argv[idx]
160
+ daemon = options[:daemon]
161
+ if DAEMON_INT_FLAGS.key?(arg)
162
+ daemon[DAEMON_INT_FLAGS[arg]] = argv[idx + 1].to_i
163
+ elsif arg == '--max-cost'
164
+ daemon[:max_cost] = argv[idx + 1].to_f
165
+ elsif DAEMON_STR_FLAGS.key?(arg)
166
+ daemon[DAEMON_STR_FLAGS[arg]] = argv[idx + 1]
167
+ else
168
+ return idx
169
+ end
170
+ idx + 1
116
171
  end
117
172
 
118
173
  def run_auth
@@ -141,11 +196,17 @@ module RubynCode
141
196
  puts response
142
197
  end
143
198
 
199
+ def run_ide
200
+ mode = resolve_permission_mode
201
+ IDE::Server.new(permission_mode: mode).run
202
+ end
203
+
144
204
  def run_daemon
145
205
  DaemonRunner.new(@options).run
146
206
  end
147
207
 
148
208
  def run_repl
209
+ maybe_first_run!
149
210
  REPL.new(
150
211
  session_id: @options[:session_id],
151
212
  project_root: Dir.pwd,
@@ -153,41 +214,25 @@ module RubynCode
153
214
  ).run
154
215
  end
155
216
 
217
+ def maybe_first_run!
218
+ return unless FirstRun.needed?
219
+ return if FirstRun.skipped?(skip_flag: @options[:skip_setup])
220
+
221
+ FirstRun.new.run
222
+ end
223
+
224
+ def resolve_permission_mode
225
+ if @options[:permission_mode]
226
+ @options[:permission_mode].to_sym
227
+ elsif @options[:yolo]
228
+ :bypass
229
+ else
230
+ :default
231
+ end
232
+ end
233
+
156
234
  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
235
+ puts HELP_TEXT
191
236
  end
192
237
  end
193
238
  end
@@ -14,6 +14,9 @@ module RubynCode
14
14
  check_auth
15
15
  check_skills
16
16
  check_project
17
+ check_mcp
18
+ check_codebase_index
19
+ check_skill_catalog
17
20
  ].freeze
18
21
 
19
22
  def execute(_args, ctx)
@@ -97,6 +100,76 @@ module RubynCode
97
100
  ['Project detected', true, "#{type} at #{ctx.project_root}"]
98
101
  end
99
102
 
103
+ def check_mcp(ctx)
104
+ config_path = File.join(ctx.project_root, MCP::Config::CONFIG_FILENAME)
105
+ return ['MCP connectivity', false, 'mcp.json not found'] unless File.exist?(config_path)
106
+
107
+ servers = MCP::Config.load(ctx.project_root)
108
+ return ['MCP connectivity', false, 'no servers configured'] if servers.empty?
109
+
110
+ reachable = servers.count { |s| mcp_server_reachable?(s) }
111
+ detail = "#{reachable}/#{servers.size} servers reachable"
112
+ ['MCP connectivity', reachable == servers.size, detail]
113
+ rescue StandardError => e
114
+ ['MCP connectivity', false, e.message]
115
+ end
116
+
117
+ def mcp_server_reachable?(server)
118
+ command = server[:command]
119
+ return false if command.nil? || command.empty?
120
+
121
+ # Check if the command binary exists on PATH
122
+ system("command -v #{command} > /dev/null 2>&1")
123
+ end
124
+
125
+ def check_codebase_index(ctx)
126
+ index_path = File.join(ctx.project_root, Index::CodebaseIndex::INDEX_DIR,
127
+ Index::CodebaseIndex::INDEX_FILE)
128
+ return ['Codebase index', false, 'index not found'] unless File.exist?(index_path)
129
+
130
+ mtime = File.mtime(index_path)
131
+ age_hours = ((Time.now - mtime) / 3600).round(1)
132
+ stale = age_hours > 24
133
+ detail = "#{age_hours}h old#{' (stale — consider reindexing)' if stale}"
134
+ ['Codebase index', !stale, detail]
135
+ rescue StandardError => e
136
+ ['Codebase index', false, e.message]
137
+ end
138
+
139
+ def check_skill_catalog(ctx)
140
+ catalog = ctx.skill_loader.catalog
141
+ entries = catalog.available
142
+ return ['Skill catalog', false, 'no skills found'] if entries.empty?
143
+
144
+ malformed = count_malformed_skills(catalog.skills_dirs)
145
+ detail = "#{entries.size} skills loaded"
146
+ detail += ", #{malformed} malformed" if malformed.positive?
147
+ ['Skill catalog', malformed.zero?, detail]
148
+ rescue StandardError => e
149
+ ['Skill catalog', false, e.message]
150
+ end
151
+
152
+ def count_malformed_skills(skills_dirs)
153
+ count = 0
154
+ skills_dirs.each do |dir|
155
+ next unless File.directory?(dir)
156
+
157
+ Dir.glob(File.join(dir, '**/*.md')).each do |path|
158
+ count += 1 unless valid_skill_file?(path)
159
+ end
160
+ end
161
+ count
162
+ end
163
+
164
+ def valid_skill_file?(path)
165
+ content = File.read(path, 1024, encoding: 'UTF-8')
166
+ .encode('UTF-8', invalid: :replace, undef: :replace, replace: '')
167
+ doc = Skills::Document.parse(content, filename: path)
168
+ !doc.name.nil? && !doc.name.empty?
169
+ rescue StandardError
170
+ false
171
+ end
172
+
100
173
  def detect_project_type(root)
101
174
  return 'Rails' if File.exist?(File.join(root, 'config', 'application.rb'))
102
175
  return 'Ruby' if File.exist?(File.join(root, 'Rakefile'))
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ module Commands
6
+ class Mcp < Base
7
+ def self.command_name = '/mcp'
8
+ def self.description = 'MCP server status'
9
+
10
+ def execute(_args, ctx)
11
+ configs = load_configs(ctx.project_root)
12
+
13
+ if configs.empty?
14
+ ctx.renderer.info('No MCP servers configured.')
15
+ puts ' Add servers to .rubyn-code/mcp.json — see docs/MCP.md for details.'
16
+ return
17
+ end
18
+
19
+ ctx.renderer.info("MCP servers (#{configs.size}):")
20
+ puts
21
+
22
+ configs.each { |cfg| render_server(cfg) }
23
+ end
24
+
25
+ private
26
+
27
+ def load_configs(project_root)
28
+ MCP::Config.load(project_root)
29
+ end
30
+
31
+ def render_server(cfg)
32
+ client = build_client(cfg)
33
+ status, tool_count = probe_server(client)
34
+ icon = status_icon(status)
35
+ tools_label = tool_count ? " (#{tool_count} tools)" : ''
36
+
37
+ puts " #{icon} #{cfg[:name]} [#{status}]#{tools_label}"
38
+ render_transport_info(cfg)
39
+ ensure
40
+ client&.disconnect! if client&.connected?
41
+ end
42
+
43
+ def build_client(cfg)
44
+ MCP::Client.from_config(cfg)
45
+ end
46
+
47
+ def probe_server(client)
48
+ client.connect!
49
+ tool_count = client.tools.size
50
+ [:connected, tool_count]
51
+ rescue StandardError
52
+ [:error, nil]
53
+ end
54
+
55
+ def render_transport_info(cfg)
56
+ if cfg[:url]
57
+ puts " transport: SSE url: #{cfg[:url]}"
58
+ else
59
+ puts " transport: stdio command: #{cfg[:command]} #{cfg[:args].join(' ')}".rstrip
60
+ end
61
+ end
62
+
63
+ def status_icon(status)
64
+ case status
65
+ when :connected then green('*')
66
+ when :error then red('x')
67
+ else yellow('?')
68
+ end
69
+ end
70
+
71
+ def green(text) = "\e[32m#{text}\e[0m"
72
+ def red(text) = "\e[31m#{text}\e[0m"
73
+ def yellow(text) = "\e[33m#{text}\e[0m"
74
+ end
75
+ end
76
+ end
77
+ end