rubyn-code 0.1.0 → 0.2.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 (159) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +269 -467
  3. data/db/migrations/009_create_teams.sql +6 -6
  4. data/db/migrations/011_fix_mailbox_messages_columns.rb +35 -0
  5. data/db/migrations/012_expand_mailbox_message_types.rb +37 -0
  6. data/exe/rubyn-code +1 -1
  7. data/lib/rubyn_code/agent/RUBYN.md +17 -0
  8. data/lib/rubyn_code/agent/conversation.rb +68 -19
  9. data/lib/rubyn_code/agent/loop.rb +312 -54
  10. data/lib/rubyn_code/agent/loop_detector.rb +6 -6
  11. data/lib/rubyn_code/auth/RUBYN.md +19 -0
  12. data/lib/rubyn_code/auth/oauth.rb +40 -35
  13. data/lib/rubyn_code/auth/server.rb +16 -12
  14. data/lib/rubyn_code/auth/token_store.rb +22 -22
  15. data/lib/rubyn_code/autonomous/RUBYN.md +14 -0
  16. data/lib/rubyn_code/autonomous/daemon.rb +115 -79
  17. data/lib/rubyn_code/autonomous/idle_poller.rb +4 -8
  18. data/lib/rubyn_code/autonomous/task_claimer.rb +11 -11
  19. data/lib/rubyn_code/background/RUBYN.md +13 -0
  20. data/lib/rubyn_code/background/notifier.rb +0 -2
  21. data/lib/rubyn_code/background/worker.rb +60 -15
  22. data/lib/rubyn_code/cli/RUBYN.md +30 -0
  23. data/lib/rubyn_code/cli/app.rb +85 -9
  24. data/lib/rubyn_code/cli/commands/RUBYN.md +133 -0
  25. data/lib/rubyn_code/cli/commands/base.rb +53 -0
  26. data/lib/rubyn_code/cli/commands/budget.rb +24 -0
  27. data/lib/rubyn_code/cli/commands/clear.rb +16 -0
  28. data/lib/rubyn_code/cli/commands/compact.rb +21 -0
  29. data/lib/rubyn_code/cli/commands/context.rb +44 -0
  30. data/lib/rubyn_code/cli/commands/context_info.rb +56 -0
  31. data/lib/rubyn_code/cli/commands/cost.rb +23 -0
  32. data/lib/rubyn_code/cli/commands/diff.rb +30 -0
  33. data/lib/rubyn_code/cli/commands/doctor.rb +112 -0
  34. data/lib/rubyn_code/cli/commands/help.rb +41 -0
  35. data/lib/rubyn_code/cli/commands/model.rb +37 -0
  36. data/lib/rubyn_code/cli/commands/plan.rb +22 -0
  37. data/lib/rubyn_code/cli/commands/quit.rb +17 -0
  38. data/lib/rubyn_code/cli/commands/registry.rb +64 -0
  39. data/lib/rubyn_code/cli/commands/resume.rb +51 -0
  40. data/lib/rubyn_code/cli/commands/review.rb +26 -0
  41. data/lib/rubyn_code/cli/commands/skill.rb +32 -0
  42. data/lib/rubyn_code/cli/commands/spawn.rb +24 -0
  43. data/lib/rubyn_code/cli/commands/tasks.rb +32 -0
  44. data/lib/rubyn_code/cli/commands/tokens.rb +76 -0
  45. data/lib/rubyn_code/cli/commands/undo.rb +17 -0
  46. data/lib/rubyn_code/cli/commands/version.rb +16 -0
  47. data/lib/rubyn_code/cli/daemon_runner.rb +129 -0
  48. data/lib/rubyn_code/cli/input_handler.rb +20 -23
  49. data/lib/rubyn_code/cli/renderer.rb +25 -27
  50. data/lib/rubyn_code/cli/repl.rb +161 -194
  51. data/lib/rubyn_code/cli/setup.rb +117 -0
  52. data/lib/rubyn_code/cli/spinner.rb +40 -40
  53. data/lib/rubyn_code/cli/stream_formatter.rb +29 -28
  54. data/lib/rubyn_code/cli/version_check.rb +94 -0
  55. data/lib/rubyn_code/config/RUBYN.md +14 -0
  56. data/lib/rubyn_code/config/defaults.rb +28 -19
  57. data/lib/rubyn_code/config/project_config.rb +7 -9
  58. data/lib/rubyn_code/config/settings.rb +3 -3
  59. data/lib/rubyn_code/context/RUBYN.md +20 -0
  60. data/lib/rubyn_code/context/auto_compact.rb +7 -7
  61. data/lib/rubyn_code/context/compactor.rb +2 -2
  62. data/lib/rubyn_code/context/context_collapse.rb +45 -0
  63. data/lib/rubyn_code/context/manager.rb +20 -3
  64. data/lib/rubyn_code/context/manual_compact.rb +7 -7
  65. data/lib/rubyn_code/context/micro_compact.rb +12 -12
  66. data/lib/rubyn_code/db/RUBYN.md +40 -0
  67. data/lib/rubyn_code/db/connection.rb +13 -13
  68. data/lib/rubyn_code/db/migrator.rb +67 -27
  69. data/lib/rubyn_code/db/schema.rb +6 -6
  70. data/lib/rubyn_code/debug.rb +74 -0
  71. data/lib/rubyn_code/hooks/RUBYN.md +17 -0
  72. data/lib/rubyn_code/hooks/built_in.rb +9 -9
  73. data/lib/rubyn_code/hooks/registry.rb +5 -5
  74. data/lib/rubyn_code/hooks/runner.rb +1 -1
  75. data/lib/rubyn_code/hooks/user_hooks.rb +16 -16
  76. data/lib/rubyn_code/learning/RUBYN.md +16 -0
  77. data/lib/rubyn_code/learning/extractor.rb +22 -22
  78. data/lib/rubyn_code/learning/injector.rb +17 -18
  79. data/lib/rubyn_code/learning/instinct.rb +18 -14
  80. data/lib/rubyn_code/llm/RUBYN.md +15 -0
  81. data/lib/rubyn_code/llm/client.rb +121 -55
  82. data/lib/rubyn_code/llm/message_builder.rb +19 -15
  83. data/lib/rubyn_code/llm/streaming.rb +80 -50
  84. data/lib/rubyn_code/mcp/RUBYN.md +21 -0
  85. data/lib/rubyn_code/mcp/client.rb +25 -24
  86. data/lib/rubyn_code/mcp/config.rb +7 -7
  87. data/lib/rubyn_code/mcp/sse_transport.rb +27 -26
  88. data/lib/rubyn_code/mcp/stdio_transport.rb +22 -19
  89. data/lib/rubyn_code/mcp/tool_bridge.rb +32 -32
  90. data/lib/rubyn_code/memory/RUBYN.md +17 -0
  91. data/lib/rubyn_code/memory/models.rb +3 -3
  92. data/lib/rubyn_code/memory/search.rb +17 -17
  93. data/lib/rubyn_code/memory/session_persistence.rb +49 -34
  94. data/lib/rubyn_code/memory/store.rb +17 -17
  95. data/lib/rubyn_code/observability/RUBYN.md +19 -0
  96. data/lib/rubyn_code/observability/budget_enforcer.rb +16 -15
  97. data/lib/rubyn_code/observability/cost_calculator.rb +3 -3
  98. data/lib/rubyn_code/observability/token_counter.rb +1 -1
  99. data/lib/rubyn_code/observability/usage_reporter.rb +35 -35
  100. data/lib/rubyn_code/output/RUBYN.md +11 -0
  101. data/lib/rubyn_code/output/diff_renderer.rb +6 -6
  102. data/lib/rubyn_code/output/formatter.rb +4 -4
  103. data/lib/rubyn_code/permissions/RUBYN.md +17 -0
  104. data/lib/rubyn_code/permissions/prompter.rb +8 -8
  105. data/lib/rubyn_code/protocols/RUBYN.md +14 -0
  106. data/lib/rubyn_code/protocols/interrupt_handler.rb +1 -1
  107. data/lib/rubyn_code/protocols/plan_approval.rb +9 -9
  108. data/lib/rubyn_code/protocols/shutdown_handshake.rb +9 -11
  109. data/lib/rubyn_code/skills/RUBYN.md +19 -0
  110. data/lib/rubyn_code/skills/catalog.rb +7 -7
  111. data/lib/rubyn_code/skills/document.rb +15 -15
  112. data/lib/rubyn_code/skills/loader.rb +6 -8
  113. data/lib/rubyn_code/sub_agents/RUBYN.md +12 -0
  114. data/lib/rubyn_code/sub_agents/runner.rb +15 -15
  115. data/lib/rubyn_code/sub_agents/summarizer.rb +1 -1
  116. data/lib/rubyn_code/tasks/RUBYN.md +13 -0
  117. data/lib/rubyn_code/tasks/dag.rb +12 -16
  118. data/lib/rubyn_code/tasks/manager.rb +24 -24
  119. data/lib/rubyn_code/tasks/models.rb +4 -4
  120. data/lib/rubyn_code/teams/RUBYN.md +14 -0
  121. data/lib/rubyn_code/teams/mailbox.rb +38 -18
  122. data/lib/rubyn_code/teams/manager.rb +19 -19
  123. data/lib/rubyn_code/teams/teammate.rb +3 -4
  124. data/lib/rubyn_code/tools/RUBYN.md +38 -0
  125. data/lib/rubyn_code/tools/background_run.rb +9 -11
  126. data/lib/rubyn_code/tools/base.rb +54 -3
  127. data/lib/rubyn_code/tools/bash.rb +16 -34
  128. data/lib/rubyn_code/tools/bundle_add.rb +10 -12
  129. data/lib/rubyn_code/tools/bundle_install.rb +9 -11
  130. data/lib/rubyn_code/tools/compact.rb +10 -9
  131. data/lib/rubyn_code/tools/db_migrate.rb +17 -15
  132. data/lib/rubyn_code/tools/edit_file.rb +12 -12
  133. data/lib/rubyn_code/tools/executor.rb +9 -4
  134. data/lib/rubyn_code/tools/git_commit.rb +29 -34
  135. data/lib/rubyn_code/tools/git_diff.rb +17 -18
  136. data/lib/rubyn_code/tools/git_log.rb +17 -19
  137. data/lib/rubyn_code/tools/git_status.rb +18 -20
  138. data/lib/rubyn_code/tools/glob.rb +7 -9
  139. data/lib/rubyn_code/tools/grep.rb +11 -9
  140. data/lib/rubyn_code/tools/load_skill.rb +7 -7
  141. data/lib/rubyn_code/tools/memory_search.rb +13 -12
  142. data/lib/rubyn_code/tools/memory_write.rb +14 -12
  143. data/lib/rubyn_code/tools/rails_generate.rb +16 -16
  144. data/lib/rubyn_code/tools/read_file.rb +8 -7
  145. data/lib/rubyn_code/tools/read_inbox.rb +5 -5
  146. data/lib/rubyn_code/tools/registry.rb +2 -2
  147. data/lib/rubyn_code/tools/review_pr.rb +55 -55
  148. data/lib/rubyn_code/tools/run_specs.rb +20 -19
  149. data/lib/rubyn_code/tools/schema.rb +9 -11
  150. data/lib/rubyn_code/tools/send_message.rb +10 -10
  151. data/lib/rubyn_code/tools/spawn_agent.rb +51 -23
  152. data/lib/rubyn_code/tools/spawn_teammate.rb +21 -21
  153. data/lib/rubyn_code/tools/task.rb +28 -28
  154. data/lib/rubyn_code/tools/web_fetch.rb +46 -31
  155. data/lib/rubyn_code/tools/web_search.rb +64 -66
  156. data/lib/rubyn_code/tools/write_file.rb +7 -6
  157. data/lib/rubyn_code/version.rb +1 -1
  158. data/lib/rubyn_code.rb +136 -105
  159. metadata +94 -21
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "readline"
3
+ require 'reline'
4
4
 
5
5
  module RubynCode
6
6
  module CLI
@@ -14,13 +14,19 @@ module RubynCode
14
14
  @running = true
15
15
  @session_id = session_id
16
16
  @permission_tier = yolo ? :unrestricted : :allow_read
17
+ @plan_mode = false
17
18
 
18
- setup_readline!
19
19
  setup_components!
20
+ setup_command_registry!
21
+ setup_readline!
20
22
  end
21
23
 
22
24
  def run
25
+ @version_check = VersionCheck.new(renderer: @renderer)
26
+ @version_check.start
27
+
23
28
  @renderer.welcome
29
+ @version_check.notify
24
30
 
25
31
  at_exit { shutdown! }
26
32
 
@@ -43,7 +49,7 @@ module RubynCode
43
49
  end
44
50
  @last_interrupt = now
45
51
  puts
46
- @renderer.info("Press Ctrl-C again to exit, or type /quit")
52
+ @renderer.info('Press Ctrl-C again to exit, or type /quit')
47
53
  end
48
54
  end
49
55
 
@@ -52,6 +58,8 @@ module RubynCode
52
58
 
53
59
  private
54
60
 
61
+ # ── Component Setup ──────────────────────────────────────────────
62
+
55
63
  def setup_components!
56
64
  ensure_home_dir!
57
65
  @db = DB::Connection.instance
@@ -80,7 +88,7 @@ module RubynCode
80
88
  @tool_executor.db = @db
81
89
  @sub_agent_tool_count = 0
82
90
  @in_sub_agent = false
83
- @tool_executor.on_agent_status = ->(type, msg) {
91
+ @tool_executor.on_agent_status = lambda { |type, msg|
84
92
  case type
85
93
  when :started
86
94
  @spinner.stop
@@ -113,7 +121,7 @@ module RubynCode
113
121
  budget_enforcer: @budget_enforcer,
114
122
  background_manager: @background_worker,
115
123
  stall_detector: @stall_detector,
116
- on_tool_call: ->(name, params) {
124
+ on_tool_call: lambda { |name, params|
117
125
  @spinner.stop
118
126
  unless @streaming_first_chunk
119
127
  @stream_formatter&.flush
@@ -123,69 +131,138 @@ module RubynCode
123
131
  end
124
132
  @renderer.tool_call(name, params)
125
133
  },
126
- on_tool_result: ->(name, result, is_error) {
127
- unless @in_sub_agent
128
- @renderer.tool_result(name, result)
129
- end
130
- @streaming_first_chunk = true
131
- @spinner.start unless @in_sub_agent
134
+ on_tool_result: lambda { |name, result, _is_error = false|
135
+ @renderer.tool_result(name, result)
136
+ @spinner.start
132
137
  },
133
- on_text: ->(text) {
138
+ on_text: lambda { |text|
139
+ @spinner.stop
134
140
  if @streaming_first_chunk
135
- @spinner.stop
141
+ @stream_formatter = StreamFormatter.new
142
+ puts
136
143
  @streaming_first_chunk = false
137
- @stream_formatter ||= StreamFormatter.new(@renderer)
138
144
  end
139
- @spinner.stop if @spinner.spinning?
140
- @stream_formatter.feed(text)
145
+ @stream_formatter&.feed(text)
141
146
  },
142
147
  skill_loader: @skill_loader,
143
148
  project_root: @project_root
144
149
  )
150
+ end
145
151
 
146
- resume_session! if @session_id
152
+ # ── Command Registry ─────────────────────────────────────────────
153
+
154
+ def setup_command_registry!
155
+ @command_registry = Commands::Registry.new
156
+
157
+ # Register all commands
158
+ [
159
+ Commands::Help,
160
+ Commands::Quit,
161
+ Commands::Compact,
162
+ Commands::Cost,
163
+ Commands::Clear,
164
+ Commands::Undo,
165
+ Commands::Tasks,
166
+ Commands::Budget,
167
+ Commands::Skill,
168
+ Commands::Version,
169
+ Commands::Review,
170
+ Commands::Resume,
171
+ Commands::Spawn,
172
+ Commands::Doctor,
173
+ Commands::Tokens,
174
+ Commands::Plan,
175
+ Commands::ContextInfo,
176
+ Commands::Diff,
177
+ Commands::Model
178
+ ].each { |cmd| @command_registry.register(cmd) }
179
+
180
+ # Give Help access to the registry for listing commands
181
+ Commands::Help.registry = @command_registry
182
+
183
+ # Update input handler to use registry for parsing
184
+ @input_handler = InputHandler.new(command_registry: @command_registry)
147
185
  end
148
186
 
187
+ # ── Command Dispatch ─────────────────────────────────────────────
188
+
149
189
  def handle_command(command)
150
190
  case command.action
151
191
  when :quit
152
192
  @running = false
153
193
  when :message
154
194
  handle_message(command.args.first)
155
- when :compact
156
- handle_compact(command.args.first)
157
- when :cost
158
- handle_cost
159
- when :clear
160
- system("clear")
161
- when :undo
162
- @conversation.undo_last!
163
- @renderer.info("Last exchange removed.")
164
- when :help
165
- display_help
166
- when :tasks
167
- handle_tasks
168
- when :budget
169
- handle_budget(command.args.first)
170
- when :skill
171
- handle_skill(command.args.first)
172
- when :version
173
- @renderer.info("Rubyn Code v#{RubynCode::VERSION}")
174
- when :review
175
- handle_review(command.args)
176
- when :spawn_teammate
177
- handle_spawn_teammate(command.args)
178
- when :resume
179
- handle_resume(command.args.first)
180
195
  when :empty
181
196
  nil
182
197
  when :list_commands
183
198
  display_commands
184
199
  when :unknown_command
185
200
  @renderer.warning("Unknown command: #{command.args.first}. Type / to see available commands.")
201
+ when :slash_command
202
+ dispatch_slash_command(command.args[0], command.args[1..])
186
203
  end
187
204
  end
188
205
 
206
+ def dispatch_slash_command(name, args)
207
+ ctx = build_context
208
+ result = @command_registry.dispatch(name, args, ctx)
209
+
210
+ case result
211
+ when :quit
212
+ @running = false
213
+ when :unknown
214
+ @renderer.warning("Unknown command: #{name}. Type / to see available commands.")
215
+ when Hash
216
+ handle_command_result(result)
217
+ end
218
+ end
219
+
220
+ def build_context
221
+ Commands::Context.new(
222
+ renderer: @renderer,
223
+ conversation: @conversation,
224
+ agent_loop: @agent_loop,
225
+ context_manager: @context_manager,
226
+ budget_enforcer: @budget_enforcer,
227
+ llm_client: @llm_client,
228
+ db: @db,
229
+ session_id: current_session_id,
230
+ project_root: @project_root,
231
+ skill_loader: @skill_loader,
232
+ session_persistence: @session_persistence,
233
+ background_worker: @background_worker,
234
+ permission_tier: @permission_tier,
235
+ plan_mode: @plan_mode,
236
+ message_handler: method(:handle_message)
237
+ )
238
+ end
239
+
240
+ # Handle structured results from commands that need to mutate REPL state
241
+ def handle_command_result(result)
242
+ case result
243
+ in { action: :set_budget, amount: Float => amount }
244
+ @budget_enforcer = Observability::BudgetEnforcer.new(
245
+ @db,
246
+ session_id: current_session_id,
247
+ session_limit: amount
248
+ )
249
+ in { action: :set_plan_mode, enabled: true | false => enabled }
250
+ @plan_mode = enabled
251
+ @agent_loop.plan_mode = enabled if @agent_loop.respond_to?(:plan_mode=)
252
+ in { action: :set_session_id, session_id: String => sid }
253
+ @session_id = sid
254
+ in { action: :set_model, model: String => model }
255
+ @llm_client.model = model if @llm_client.respond_to?(:model=)
256
+ @renderer.info("Model set to #{model}")
257
+ in { action: :spawn_teammate, name: String => name, role: String => role }
258
+ spawn_teammate(name, role)
259
+ else
260
+ # Unknown result hash — ignore
261
+ end
262
+ end
263
+
264
+ # ── Message Handling ─────────────────────────────────────────────
265
+
189
266
  def handle_message(input)
190
267
  @spinner.start
191
268
  @streaming_first_chunk = true
@@ -210,105 +287,9 @@ module RubynCode
210
287
  @renderer.error("Error: #{e.message}")
211
288
  end
212
289
 
213
- def handle_compact(focus = nil)
214
- @spinner.start("Compacting context...")
215
- compactor = Context::Compactor.new(llm_client: @llm_client)
216
- new_messages = compactor.manual_compact!(@conversation.messages, focus: focus)
217
- @conversation.replace!(new_messages)
218
- @spinner.success
219
- @renderer.info("Context compacted. #{@conversation.length} messages remaining.")
220
- end
221
-
222
- def handle_cost
223
- @renderer.cost_summary(
224
- session_cost: @budget_enforcer.session_cost,
225
- daily_cost: @budget_enforcer.daily_cost,
226
- tokens: {
227
- input: @context_manager.total_input_tokens,
228
- output: @context_manager.total_output_tokens
229
- }
230
- )
231
- end
232
-
233
- def handle_tasks
234
- task_manager = Tasks::Manager.new(@db)
235
- tasks = task_manager.list
236
- if tasks.empty?
237
- @renderer.info("No tasks.")
238
- else
239
- tasks.each do |t|
240
- status_color = case t[:status]
241
- when "completed" then :green
242
- when "in_progress" then :yellow
243
- when "blocked" then :red
244
- else :white
245
- end
246
- puts " [#{t[:status]}] #{t[:title]} (#{t[:id][0..7]})"
247
- end
248
- end
249
- end
250
-
251
- def handle_budget(amount)
252
- if amount
253
- @budget_enforcer = Observability::BudgetEnforcer.new(
254
- @db,
255
- session_id: current_session_id,
256
- session_limit: amount.to_f
257
- )
258
- @renderer.info("Session budget set to $#{amount}")
259
- else
260
- @renderer.info("Remaining budget: $#{'%.4f' % @budget_enforcer.remaining_budget}")
261
- end
262
- end
263
-
264
- def handle_skill(name)
265
- if name
266
- content = @skill_loader.load(name)
267
- @renderer.info("Loaded skill: #{name}")
268
- @conversation.add_user_message("<skill>#{content}</skill>")
269
- else
270
- @renderer.info("Available skills:")
271
- puts @skill_loader.descriptions_for_prompt
272
- end
273
- end
274
-
275
- def handle_resume(session_id)
276
- if session_id
277
- data = @session_persistence.load_session(session_id)
278
- if data
279
- @conversation.replace!(data[:messages])
280
- @session_id = session_id
281
- @renderer.info("Resumed session #{session_id[0..7]}")
282
- else
283
- @renderer.error("Session not found: #{session_id}")
284
- end
285
- else
286
- sessions = @session_persistence.list_sessions(project_path: @project_root, limit: 10)
287
- if sessions.empty?
288
- @renderer.info("No previous sessions.")
289
- else
290
- sessions.each do |s|
291
- puts " #{s[:id][0..7]} | #{s[:title] || 'untitled'} | #{s[:created_at]}"
292
- end
293
- end
294
- end
295
- end
296
-
297
- def handle_review(args)
298
- base = args[0] || "main"
299
- focus = args[1] || "all"
300
- handle_message("Use the review_pr tool to review my current branch against #{base}. Focus: #{focus}. Load relevant best practice skills for any issues you find.")
301
- end
302
-
303
- def handle_spawn_teammate(args)
304
- name = args[0]
305
- unless name
306
- @renderer.error("Usage: /spawn <name> [role]")
307
- return
308
- end
309
-
310
- role = args[1] || "coder"
290
+ # ── Teammate Handling ────────────────────────────────────────────
311
291
 
292
+ def spawn_teammate(name, role)
312
293
  mailbox = Teams::Mailbox.new(@db)
313
294
  manager = Teams::Manager.new(@db, mailbox: mailbox)
314
295
  teammate = manager.spawn(name: name, role: role)
@@ -350,62 +331,46 @@ module RubynCode
350
331
  sleep 5
351
332
  end
352
333
  rescue StandardError => e
353
- $stderr.puts "[Teammate #{teammate.name}] Error: #{e.message}" if ENV["RUBYN_DEBUG"]
334
+ RubynCode::Debug.agent("Teammate #{teammate.name} error: #{e.message}")
354
335
  end
355
336
 
337
+ # ── Display ──────────────────────────────────────────────────────
338
+
356
339
  def display_commands
357
- @renderer.info("Available commands:")
358
- CLI::InputHandler::SLASH_COMMANDS.each do |cmd, action|
359
- puts " #{cmd.ljust(15)} #{action}"
340
+ @renderer.info('Available commands:')
341
+ @command_registry.visible_commands.each do |cmd_class|
342
+ names = cmd_class.all_names.join(', ')
343
+ puts " #{names.ljust(25)} #{cmd_class.description}"
360
344
  end
361
- puts ""
345
+ puts
362
346
  end
363
347
 
364
- def display_help
365
- puts <<~HELP
366
- Commands:
367
- /help Show this help message
368
- /quit Exit Rubyn Code
369
- /compact Compress conversation context
370
- /cost Show token usage and costs
371
- /clear Clear the terminal
372
- /undo Remove last exchange
373
- /tasks List all tasks
374
- /budget [amt] Show or set session budget
375
- /skill [name] Load a skill or list available skills
376
- /resume [id] Resume a session or list recent sessions
377
- /version Show version
378
-
379
- Tips:
380
- - Use @filename to include file contents in your message
381
- - End a line with \\ for multiline input
382
- HELP
383
- end
348
+ # ── Readline ─────────────────────────────────────────────────────
384
349
 
385
350
  def setup_readline!
386
- slash_commands = CLI::InputHandler::SLASH_COMMANDS.keys
351
+ completions = @command_registry.completions
387
352
 
388
- Readline.completion_proc = proc do |input|
389
- if input.start_with?("/")
390
- slash_commands.select { |c| c.start_with?(input) }
353
+ Reline.completion_proc = proc do |input|
354
+ if input.start_with?('/')
355
+ completions.select { |c| c.start_with?(input) }
391
356
  else
392
357
  []
393
358
  end
394
359
  end
395
- Readline.completion_append_character = " "
360
+ Reline.completion_append_character = ' '
396
361
  end
397
362
 
398
363
  def read_input
399
364
  lines = []
400
- prompt_str = lines.empty? ? @renderer.prompt : " ... "
365
+ prompt_str = lines.empty? ? @renderer.prompt : ' ... '
401
366
 
402
367
  loop do
403
- line = Readline.readline(prompt_str, true)
368
+ line = Reline.readline(prompt_str, true)
404
369
  return nil if line.nil?
405
370
 
406
371
  if @input_handler.multiline?(line)
407
372
  lines << @input_handler.strip_continuation(line)
408
- prompt_str = " ... "
373
+ prompt_str = ' ... '
409
374
  else
410
375
  lines << line
411
376
  break
@@ -415,9 +380,11 @@ module RubynCode
415
380
  lines.join("\n")
416
381
  end
417
382
 
383
+ # ── Utilities ────────────────────────────────────────────────────
384
+
418
385
  def ensure_home_dir!
419
386
  dir = Config::Defaults::HOME_DIR
420
- FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
387
+ FileUtils.mkdir_p(dir)
421
388
  end
422
389
 
423
390
  def ensure_auth!
@@ -428,25 +395,25 @@ module RubynCode
428
395
  return true
429
396
  end
430
397
 
431
- @renderer.error("No valid authentication found.")
432
- @renderer.info("Options:")
433
- @renderer.info(" 1. Run Claude Code once to authenticate (Rubyn Code reads the keychain token)")
434
- @renderer.info(" 2. Set ANTHROPIC_API_KEY environment variable")
398
+ @renderer.error('No valid authentication found.')
399
+ @renderer.info('Options:')
400
+ @renderer.info(' 1. Run Claude Code once to authenticate (Rubyn Code reads the keychain token)')
401
+ @renderer.info(' 2. Set ANTHROPIC_API_KEY environment variable')
435
402
  @renderer.info(" 3. Run 'rubyn-code --auth' to enter an API key")
436
403
  exit(1)
437
404
  end
438
405
 
439
406
  def skill_dirs
440
- dirs = [File.expand_path("../../../skills", __dir__)]
441
- project_skills = File.join(@project_root, ".rubyn-code", "skills")
407
+ dirs = [File.expand_path('../../../skills', __dir__)]
408
+ project_skills = File.join(@project_root, '.rubyn-code', 'skills')
442
409
  dirs << project_skills if Dir.exist?(project_skills)
443
- user_skills = File.join(Config::Defaults::HOME_DIR, "skills")
410
+ user_skills = File.join(Config::Defaults::HOME_DIR, 'skills')
444
411
  dirs << user_skills if Dir.exist?(user_skills)
445
412
  dirs
446
413
  end
447
414
 
448
415
  def current_session_id
449
- @session_id ||= SecureRandom.hex(16)
416
+ @current_session_id ||= SecureRandom.hex(16)
450
417
  end
451
418
 
452
419
  def save_session!
@@ -467,14 +434,14 @@ module RubynCode
467
434
  end
468
435
 
469
436
  GOODBYE_MESSAGES = [
470
- "Freezing strings and saving memories... See ya! 💎",
471
- "Memoizing this session... Until next time! 🧠",
472
- "Committing learnings to memory... Later! 🤙",
473
- "Saving state, yielding control... Bye for now! 👋",
474
- "Session.save! && Rubyn.sleep... Catch you later! 😴",
475
- "GC.start on this session... Stay Ruby, friend! ✌️",
437
+ 'Freezing strings and saving memories... See ya! 💎',
438
+ 'Memoizing this session... Until next time! 🧠',
439
+ 'Committing learnings to memory... Later! 🤙',
440
+ 'Saving state, yielding control... Bye for now! 👋',
441
+ 'Session.save! && Rubyn.sleep... Catch you later! 😴',
442
+ "GC.start on this session... Stay Ruby, friend! ✌\uFE0F",
476
443
  "Writing instincts to disk... Don't forget me! 💾",
477
- "at_exit { puts 'Thanks for coding with Rubyn!' } 🎸",
444
+ "at_exit { puts 'Thanks for coding with Rubyn!' } 🎸"
478
445
  ].freeze
479
446
 
480
447
  def shutdown!
@@ -485,21 +452,21 @@ module RubynCode
485
452
  puts
486
453
  @renderer.info(GOODBYE_MESSAGES.sample)
487
454
 
488
- @renderer.info("Saving session...")
455
+ @renderer.info('Saving session...')
489
456
  save_session!
490
457
  @background_worker&.shutdown!
491
458
 
492
459
  if @conversation.length > 5
493
460
  begin
494
- @renderer.info("Extracting learnings from this session...")
461
+ @renderer.info('Extracting learnings from this session...')
495
462
  Learning::Extractor.call(
496
463
  @conversation.messages,
497
464
  llm_client: @llm_client,
498
465
  project_path: @project_root
499
466
  )
500
- @renderer.success("Instincts saved.")
467
+ @renderer.success('Instincts saved.')
501
468
  rescue StandardError => e
502
- @renderer.warning("Instinct extraction skipped: #{e.message}") if ENV["RUBYN_DEBUG"]
469
+ RubynCode::Debug.warn("Instinct extraction skipped: #{e.message}")
503
470
  end
504
471
  end
505
472
 
@@ -510,7 +477,7 @@ module RubynCode
510
477
  # Silent — decay is best-effort
511
478
  end
512
479
 
513
- @renderer.info("Session saved. Rubyn out. ✌️")
480
+ @renderer.info("Session saved. Rubyn out. ✌\uFE0F")
514
481
  rescue StandardError
515
482
  # Best effort on shutdown
516
483
  end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module RubynCode
6
+ module CLI
7
+ # Installs a pinned launcher that bypasses rbenv/rvm version switching.
8
+ #
9
+ # When rubyn-code is installed as a gem, RubyGems generates a wrapper in
10
+ # the Ruby version's bin dir (e.g. ~/.rbenv/versions/3.4.5/bin/rubyn-code)
11
+ # with a shebang pointing to the correct Ruby. However, rbenv's shim at
12
+ # ~/.rbenv/shims/rubyn-code intercepts the command and re-resolves Ruby
13
+ # based on .ruby-version in the current directory — which breaks rubyn-code
14
+ # when you cd into a project using a different Ruby.
15
+ #
16
+ # This setup creates a launcher in ~/.local/bin (which typically appears
17
+ # before ~/.rbenv/shims in PATH) that calls the gem wrapper directly,
18
+ # bypassing the shim entirely.
19
+ class Setup
20
+ INSTALL_DIR = File.expand_path('~/.local/bin')
21
+ LAUNCHER_NAME = 'rubyn-code'
22
+
23
+ def self.run
24
+ new.run
25
+ end
26
+
27
+ def run
28
+ gem_wrapper = find_gem_wrapper
29
+ unless gem_wrapper
30
+ warn 'Error: Could not find the rubyn-code gem wrapper.'
31
+ warn 'Make sure rubyn-code is installed: gem install rubyn-code'
32
+ exit 1
33
+ end
34
+
35
+ pinned_ruby = extract_ruby_from_wrapper(gem_wrapper)
36
+ unless pinned_ruby
37
+ warn "Error: Could not determine Ruby path from #{gem_wrapper}"
38
+ exit 1
39
+ end
40
+
41
+ launcher_path = File.join(INSTALL_DIR, LAUNCHER_NAME)
42
+
43
+ FileUtils.mkdir_p(INSTALL_DIR)
44
+ write_launcher(launcher_path, gem_wrapper, pinned_ruby)
45
+ File.chmod(0o755, launcher_path)
46
+
47
+ puts "Installed pinned launcher to #{launcher_path}"
48
+ puts " Ruby: #{pinned_ruby}"
49
+ puts " Wrapper: #{gem_wrapper}"
50
+ puts
51
+
52
+ verify_path(launcher_path)
53
+ end
54
+
55
+ private
56
+
57
+ def find_gem_wrapper
58
+ # The gem wrapper lives in the same bin dir as the current Ruby
59
+ ruby_bin = RbConfig::CONFIG['bindir']
60
+ candidate = File.join(ruby_bin, LAUNCHER_NAME)
61
+ return candidate if File.exist?(candidate)
62
+
63
+ # Fallback: search gem paths
64
+ Gem.path.each do |gem_path|
65
+ bin = File.join(gem_path, 'bin', LAUNCHER_NAME)
66
+ return bin if File.exist?(bin)
67
+ end
68
+
69
+ nil
70
+ end
71
+
72
+ def extract_ruby_from_wrapper(wrapper_path)
73
+ first_line = File.open(wrapper_path, &:readline).strip
74
+ if first_line.start_with?('#!')
75
+ ruby_path = first_line.sub(/\A#!\s*/, '')
76
+ return ruby_path if File.executable?(ruby_path)
77
+ end
78
+
79
+ # Fallback to the Ruby running this process
80
+ RbConfig.ruby
81
+ rescue EOFError
82
+ nil
83
+ end
84
+
85
+ def write_launcher(path, gem_wrapper, pinned_ruby)
86
+ File.write(path, <<~BASH)
87
+ #!/usr/bin/env bash
88
+ # Rubyn Code launcher — pinned to Ruby #{File.basename(File.dirname(pinned_ruby, 2))}
89
+ # Generated by: rubyn-code --setup
90
+ # Bypasses rbenv/rvm so rubyn-code works in any project.
91
+ #
92
+ # To regenerate: rubyn-code --setup
93
+ # To remove: rm #{path}
94
+ exec "#{gem_wrapper}" "$@"
95
+ BASH
96
+ end
97
+
98
+ def verify_path(launcher_path)
99
+ install_dir = File.dirname(launcher_path)
100
+ path_dirs = ENV['PATH'].split(File::PATH_SEPARATOR)
101
+ shim_index = path_dirs.index { |d| d.include?('rbenv/shims') || d.include?('rvm') }
102
+ install_index = path_dirs.index(install_dir)
103
+
104
+ if install_index.nil?
105
+ puts "Warning: #{install_dir} is not in your PATH."
106
+ puts 'Add this to your shell profile:'
107
+ puts " export PATH=\"#{install_dir}:$PATH\""
108
+ elsif shim_index && install_index > shim_index
109
+ puts "Warning: #{install_dir} comes AFTER rbenv shims in PATH."
110
+ puts 'Move it before the shims in your shell profile so it takes priority.'
111
+ else
112
+ puts "PATH looks good — #{install_dir} takes priority over version manager shims."
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end