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
@@ -66,20 +66,20 @@ module RubynCode
66
66
  # @param row [Hash] a database row hash
67
67
  # @return [Tasks::Task]
68
68
  def build_task(row)
69
- metadata = parse_json(row["metadata"])
69
+ metadata = parse_json(row['metadata'])
70
70
 
71
71
  Tasks::Task.new(
72
- id: row["id"],
73
- session_id: row["session_id"],
74
- title: row["title"],
75
- description: row["description"],
76
- status: row["status"],
77
- priority: row["priority"].to_i,
78
- owner: row["owner"],
79
- result: row["result"],
72
+ id: row['id'],
73
+ session_id: row['session_id'],
74
+ title: row['title'],
75
+ description: row['description'],
76
+ status: row['status'],
77
+ priority: row['priority'].to_i,
78
+ owner: row['owner'],
79
+ result: row['result'],
80
80
  metadata: metadata,
81
- created_at: row["created_at"],
82
- updated_at: row["updated_at"]
81
+ created_at: row['created_at'],
82
+ updated_at: row['updated_at']
83
83
  )
84
84
  end
85
85
 
@@ -0,0 +1,13 @@
1
+ # Layer 8: Background
2
+
3
+ Background job execution for long-running commands.
4
+
5
+ ## Classes
6
+
7
+ - **`Worker`** — Manages background processes. Spawns commands in subprocesses,
8
+ tracks their PIDs, and collects output when complete.
9
+
10
+ - **`Job`** — Represents a single background job: command, PID, status, output.
11
+
12
+ - **`Notifier`** — Delivers background job results back to the agent. Injects completed
13
+ job output into the conversation before the next LLM call.
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "thread"
4
-
5
3
  module RubynCode
6
4
  module Background
7
5
  # Thread-safe notification queue for background job completions.
@@ -1,10 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "open3"
4
- require "securerandom"
5
- require "timeout"
6
- require_relative "job"
7
- require_relative "notifier"
3
+ require 'open3'
4
+ require 'securerandom'
5
+ require 'timeout'
6
+ require_relative 'job'
7
+ require_relative 'notifier'
8
8
 
9
9
  module RubynCode
10
10
  module Background
@@ -93,14 +93,59 @@ module RubynCode
93
93
  private
94
94
 
95
95
  def execute_job(job_id, command, timeout_seconds)
96
- stdout, stderr, process_status = nil
96
+ stdout, stderr, = nil
97
97
  final_status = :completed
98
98
 
99
99
  begin
100
- Timeout.timeout(timeout_seconds) do
101
- stdout, stderr, process_status = Open3.capture3(command, chdir: @project_root)
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
114
+
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
137
+
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
102
144
  end
103
145
 
146
+ stdout = out_buf
147
+ stderr = err_buf
148
+ process_status = wait_thr.value
104
149
  final_status = process_status.success? ? :completed : :error
105
150
  rescue Timeout::Error
106
151
  final_status = :timeout
@@ -127,19 +172,19 @@ module RubynCode
127
172
  end
128
173
 
129
174
  @notifier.push({
130
- type: :job_completed,
131
- job_id: job_id,
132
- status: final_status,
133
- result: result,
134
- duration: completed_job.duration
135
- })
175
+ type: :job_completed,
176
+ job_id: job_id,
177
+ status: final_status,
178
+ result: result,
179
+ duration: completed_job.duration
180
+ })
136
181
  end
137
182
 
138
183
  def build_result(stdout, stderr)
139
184
  parts = []
140
185
  parts << stdout if stdout && !stdout.empty?
141
186
  parts << "STDERR: #{stderr}" if stderr && !stderr.empty?
142
- parts.empty? ? "(no output)" : parts.join("\n")
187
+ parts.empty? ? '(no output)' : parts.join("\n")
143
188
  end
144
189
  end
145
190
  end
@@ -0,0 +1,30 @@
1
+ # CLI Layer
2
+
3
+ Terminal interface. Entry point → REPL → rendering.
4
+
5
+ ## Classes
6
+
7
+ - **`App`** — Parses ARGV into commands (`:version`, `:auth`, `:help`, `:run`, `:repl`) and dispatches.
8
+ `App.start(ARGV)` is the gem's entry point from `exe/rubyn-code`.
9
+
10
+ - **`REPL`** — Read-eval-print loop. Wires up InputHandler for parsing, Agent::Loop for execution,
11
+ Renderer for output. Delegates `/slash` commands to the Commands::Registry.
12
+
13
+ - **`InputHandler`** — Maps user input to `Command` structs (a `Data.define`). Classifies input
14
+ as `:command`, `:message`, or `:quit`. Registry-driven — no hardcoded command list.
15
+
16
+ - **`Renderer`** — Renders LLM responses to the terminal. Uses Pastel for colors,
17
+ Rouge (Monokai theme) for syntax highlighting. Has a `yolo` writer for permission bypass display.
18
+
19
+ - **`Spinner`** — TTY::Spinner wrapper for thinking/working indicators.
20
+
21
+ - **`StreamFormatter`** — Handles real-time streaming output from the LLM, buffering partial
22
+ markdown and flushing complete lines with syntax highlighting.
23
+
24
+ ## Commands Subsystem
25
+
26
+ See [`commands/RUBYN.md`](commands/RUBYN.md) for full docs.
27
+
28
+ 19 slash commands, each in its own file under `cli/commands/`. Registry-based dispatch
29
+ with tab-completion. Commands return optional **action hashes** for REPL state changes
30
+ (model switch, plan mode toggle, budget updates, etc.).
@@ -13,15 +13,21 @@ module RubynCode
13
13
  end
14
14
 
15
15
  def run
16
+ RubynCode::Debug.enable! if @options[:debug]
17
+
16
18
  case @options[:command]
17
19
  when :version
18
20
  puts "rubyn-code #{RubynCode::VERSION}"
19
21
  when :auth
20
22
  run_auth
23
+ when :setup
24
+ run_setup
21
25
  when :help
22
26
  display_help
23
27
  when :run
24
28
  run_single_prompt(@options[:prompt])
29
+ when :daemon
30
+ run_daemon
25
31
  when :repl
26
32
  run_repl
27
33
  end
@@ -29,27 +35,35 @@ module RubynCode
29
35
 
30
36
  private
31
37
 
32
- def parse_options(argv)
38
+ def parse_options(argv) # rubocop:disable Metrics/MethodLength,Metrics/CyclomaticComplexity
33
39
  options = { command: :repl }
34
40
 
35
41
  i = 0
36
42
  while i < argv.length
37
43
  case argv[i]
38
- when "--version", "-v"
44
+ when '--version', '-v'
39
45
  options[:command] = :version
40
- when "--help", "-h"
46
+ when '--help', '-h'
41
47
  options[:command] = :help
42
- when "--auth"
48
+ when '--auth'
43
49
  options[:command] = :auth
44
- when "--resume", "-r"
50
+ when '--resume', '-r'
45
51
  options[:session_id] = argv[i + 1]
46
52
  i += 1
47
- when "-p", "--prompt"
53
+ when '-p', '--prompt'
48
54
  options[:command] = :run
49
55
  options[:prompt] = argv[i + 1]
50
56
  i += 1
51
- when "--yolo"
57
+ when '--yolo'
52
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
53
67
  end
54
68
  i += 1
55
69
  end
@@ -57,19 +71,67 @@ module RubynCode
57
71
  options
58
72
  end
59
73
 
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] = {
82
+ max_runs: 100,
83
+ max_cost: 10.0,
84
+ idle_timeout: 60,
85
+ poll_interval: 5,
86
+ agent_name: "golem-#{SecureRandom.hex(4)}",
87
+ role: 'autonomous coding agent'
88
+ }
89
+
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
115
+ end
116
+ end
117
+
60
118
  def run_auth
61
119
  renderer = Renderer.new
62
- renderer.info("Starting Claude OAuth authentication...")
120
+ renderer.info('Starting Claude OAuth authentication...')
63
121
 
64
122
  begin
65
123
  Auth::OAuth.new.authenticate!
66
- renderer.success("Authentication successful! Token stored.")
124
+ renderer.success('Authentication successful! Token stored.')
67
125
  rescue AuthenticationError => e
68
126
  renderer.error("Authentication failed: #{e.message}")
69
127
  exit(1)
70
128
  end
71
129
  end
72
130
 
131
+ def run_setup
132
+ Setup.run
133
+ end
134
+
73
135
  def run_single_prompt(prompt)
74
136
  return display_help unless prompt
75
137
 
@@ -79,6 +141,10 @@ module RubynCode
79
141
  puts response
80
142
  end
81
143
 
144
+ def run_daemon
145
+ DaemonRunner.new(@options).run
146
+ end
147
+
82
148
  def run_repl
83
149
  REPL.new(
84
150
  session_id: @options[:session_id],
@@ -95,10 +161,20 @@ module RubynCode
95
161
  rubyn-code Start interactive REPL
96
162
  rubyn-code -p "prompt" Run a single prompt and exit
97
163
  rubyn-code --resume [ID] Resume a previous session
164
+ rubyn-code --setup Pin rubyn-code to bypass rbenv/rvm
98
165
  rubyn-code --auth Authenticate with Claude
99
166
  rubyn-code --version Show version
100
167
  rubyn-code --help Show this help
101
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
+
102
178
  Interactive Commands:
103
179
  /help Show available commands
104
180
  /quit Exit
@@ -0,0 +1,133 @@
1
+ # CLI::Commands — Slash Command System
2
+
3
+ > Registry-based command dispatch for the REPL. Same Base/Registry pattern as the tool system.
4
+
5
+ ## Architecture
6
+
7
+ ```
8
+ User types /help
9
+
10
+ InputHandler.classify → :command
11
+
12
+ REPL#handle_command
13
+
14
+ Registry#dispatch('/help', args, context)
15
+
16
+ Help#execute(args, context)
17
+
18
+ (optional) → action hash back to REPL for state changes
19
+ ```
20
+
21
+ ### Core Infrastructure
22
+
23
+ | File | Class | Purpose |
24
+ |------|-------|---------|
25
+ | `base.rb` | `Base` | Abstract command — `command_name`, `description`, `aliases`, `execute(args, ctx)` |
26
+ | `registry.rb` | `Registry` | Discovers, registers, dispatches commands. Provides tab-completion list |
27
+ | `context.rb` | `Context` | `Data.define` value object with all deps a command needs |
28
+
29
+ ### Context Object
30
+
31
+ `Context` is a frozen `Data.define` carrying everything a command might need:
32
+
33
+ ```ruby
34
+ Context = Data.define(
35
+ :renderer, :conversation, :agent_loop, :context_manager,
36
+ :budget_enforcer, :llm_client, :db, :session_id,
37
+ :project_root, :skill_loader, :session_persistence,
38
+ :background_worker, :permission_tier, :plan_mode
39
+ )
40
+ ```
41
+
42
+ Commands receive it as the second argument to `execute`. Never mutate it — use
43
+ `with_message_handler` to attach a message callback if the command needs to send
44
+ prompts back to the agent loop.
45
+
46
+ ## Commands
47
+
48
+ | Command | File | Description |
49
+ |---------|------|-------------|
50
+ | `/budget` | `budget.rb` | Show or set session budget (/budget [amount]) |
51
+ | `/clear` | `clear.rb` | Clear the terminal |
52
+ | `/compact` | `compact.rb` | Compress conversation context |
53
+ | `/context` | `context_info.rb` | Show context window usage |
54
+ | `/cost` | `cost.rb` | Show token usage and costs |
55
+ | `/diff` | `diff.rb` | Show git diff (staged, unstaged, or vs branch) |
56
+ | `/doctor` | `doctor.rb` | Environment health check |
57
+ | `/help` | `help.rb` | Show this help message |
58
+ | `/model` | `model.rb` | Show or switch model (/model [name]) |
59
+ | `/plan` | `plan.rb` | Toggle plan mode (think before acting) |
60
+ | `/quit` | `quit.rb` | Exit Rubyn Code (aliases: `/exit`, `/q`) |
61
+ | `/resume` | `resume.rb` | Resume a session or list recent sessions |
62
+ | `/review` | `review.rb` | Review current branch against best practices |
63
+ | `/skill` | `skill.rb` | Load a skill or list available skills |
64
+ | `/spawn` | `spawn.rb` | Spawn a teammate agent (/spawn \<name\> [role]) |
65
+ | `/tasks` | `tasks.rb` | List all tasks |
66
+ | `/tokens` | `tokens.rb` | Show token usage and context window estimate |
67
+ | `/undo` | `undo.rb` | Remove last exchange |
68
+ | `/version` | `version.rb` | Show version info |
69
+
70
+ ## Action Hashes
71
+
72
+ Some commands can't change REPL state directly (they don't have access to the loop
73
+ or session). Instead, they return an **action hash** that the REPL processes:
74
+
75
+ ```ruby
76
+ # Plan mode toggle
77
+ { action: :set_plan_mode, enabled: true }
78
+
79
+ # Model switch
80
+ { action: :set_model, model: 'claude-sonnet-4-20250514' }
81
+
82
+ # Budget update
83
+ { action: :set_budget, amount: 10.0 }
84
+
85
+ # Spawn teammate
86
+ { action: :spawn_teammate, name: 'alice', role: 'coder' }
87
+
88
+ # Resume session
89
+ { action: :set_session_id, session_id: 'abc123' }
90
+ ```
91
+
92
+ The REPL's `handle_command` method pattern-matches on these and applies the state change.
93
+
94
+ ## Adding a New Command
95
+
96
+ 1. Create `lib/rubyn_code/cli/commands/my_command.rb`
97
+ 2. Inherit from `Base`, define `command_name` (with `/` prefix), `description`, `execute`
98
+ 3. Add autoload entry in `lib/rubyn_code.rb` under `module Commands`
99
+ 4. Register it in `REPL#setup_command_registry!`
100
+ 5. Add spec in `spec/rubyn_code/cli/commands/my_command_spec.rb`
101
+
102
+ ```ruby
103
+ # frozen_string_literal: true
104
+
105
+ module RubynCode
106
+ module CLI
107
+ module Commands
108
+ class MyCommand < Base
109
+ def self.command_name = '/mycommand'
110
+ def self.description = 'Does a thing'
111
+
112
+ def execute(args, ctx)
113
+ ctx.renderer.info('Did the thing!')
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
119
+ ```
120
+
121
+ ## Plan Mode
122
+
123
+ `/plan` toggles plan mode via an action hash. When enabled:
124
+
125
+ - `Agent::Loop` sends only **read-only tools** (risk level `:read`) to Claude
126
+ - Claude can read files, grep, glob, check git status/log/diff — full exploration
127
+ - Claude **cannot** write, edit, execute, or modify anything
128
+ - A plan-mode system prompt is injected reinforcing these boundaries
129
+ - Claude responds with analysis, proposed steps, and gathered context
130
+ - Toggle off with `/plan` again to resume normal execution
131
+
132
+ Read-only tools in plan mode: `read_file`, `grep`, `glob`, `git_diff`, `git_log`,
133
+ `git_status`, `review_pr`, `memory_search`, `web_search`, `web_fetch`
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ module Commands
6
+ # Abstract base class for all slash commands.
7
+ #
8
+ # Subclasses must implement:
9
+ # - self.command_name → String (e.g. '/doctor')
10
+ # - self.description → String (one-liner for /help)
11
+ # - execute(args, ctx) → void
12
+ #
13
+ # Optionally override:
14
+ # - self.aliases → Array<String> (e.g. ['/q', '/exit'])
15
+ # - self.hidden? → Boolean (hide from /help listing)
16
+ class Base
17
+ class << self
18
+ def command_name
19
+ raise NotImplementedError, "#{name} must define self.command_name"
20
+ end
21
+
22
+ def description
23
+ raise NotImplementedError, "#{name} must define self.description"
24
+ end
25
+
26
+ def aliases
27
+ [].freeze
28
+ end
29
+
30
+ def hidden?
31
+ false
32
+ end
33
+
34
+ # All names this command responds to (primary + aliases).
35
+ #
36
+ # @return [Array<String>]
37
+ def all_names
38
+ [command_name, *aliases].freeze
39
+ end
40
+ end
41
+
42
+ # Execute the command.
43
+ #
44
+ # @param args [Array<String>] arguments passed after the command name
45
+ # @param ctx [Commands::Context] shared context with REPL dependencies
46
+ # @return [void]
47
+ def execute(args, ctx)
48
+ raise NotImplementedError, "#{self.class.name}#execute must be implemented"
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ module Commands
6
+ class Budget < Base
7
+ def self.command_name = '/budget'
8
+ def self.description = 'Show or set session budget (/budget [amount])'
9
+
10
+ def execute(args, ctx)
11
+ amount = args.first
12
+
13
+ if amount
14
+ ctx.renderer.info("Session budget set to $#{amount}")
15
+ { action: :set_budget, amount: amount.to_f }
16
+ else
17
+ remaining = ctx.budget_enforcer.remaining_budget
18
+ ctx.renderer.info("Remaining budget: $#{format('%.4f', remaining)}")
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ module Commands
6
+ class Clear < Base
7
+ def self.command_name = '/clear'
8
+ def self.description = 'Clear the terminal'
9
+
10
+ def execute(_args, _ctx)
11
+ system('clear')
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ module Commands
6
+ class Compact < Base
7
+ def self.command_name = '/compact'
8
+ def self.description = 'Compress conversation context'
9
+
10
+ def execute(args, ctx)
11
+ focus = args.first
12
+
13
+ compactor = ::RubynCode::Context::Compactor.new(llm_client: ctx.llm_client)
14
+ new_messages = compactor.manual_compact!(ctx.conversation.messages, focus: focus)
15
+ ctx.conversation.replace!(new_messages)
16
+ ctx.renderer.info("Context compacted. #{ctx.conversation.length} messages remaining.")
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ module Commands
6
+ # Immutable context object passed to every slash command.
7
+ # Provides access to all REPL dependencies without coupling
8
+ # commands to the REPL class itself.
9
+ Context = Data.define(
10
+ :renderer,
11
+ :conversation,
12
+ :agent_loop,
13
+ :context_manager,
14
+ :budget_enforcer,
15
+ :llm_client,
16
+ :db,
17
+ :session_id,
18
+ :project_root,
19
+ :skill_loader,
20
+ :session_persistence,
21
+ :background_worker,
22
+ :permission_tier,
23
+ :plan_mode,
24
+ :message_handler
25
+ ) do
26
+ # Convenience: return a new Context with a message handler attached.
27
+ # Used by commands like /review that delegate to the LLM.
28
+ #
29
+ # @param handler [Proc] the REPL's handle_message proc
30
+ def with_message_handler(handler)
31
+ with(message_handler: handler)
32
+ end
33
+
34
+ # @param text [String] message to send through the agent loop
35
+ def send_message(text)
36
+ message_handler&.call(text)
37
+ end
38
+
39
+ # @return [Boolean]
40
+ def plan_mode? = plan_mode
41
+ end
42
+ end
43
+ end
44
+ end