rubyn-code 0.3.0 → 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 (81) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +77 -19
  3. data/db/migrations/013_add_failed_status_to_tasks.rb +51 -0
  4. data/lib/rubyn_code/agent/conversation.rb +32 -3
  5. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +56 -3
  6. data/lib/rubyn_code/agent/llm_caller.rb +9 -1
  7. data/lib/rubyn_code/agent/loop.rb +7 -0
  8. data/lib/rubyn_code/agent/system_prompt_builder.rb +10 -4
  9. data/lib/rubyn_code/agent/tool_processor.rb +21 -1
  10. data/lib/rubyn_code/auth/key_encryption.rb +118 -0
  11. data/lib/rubyn_code/auth/token_store.rb +50 -9
  12. data/lib/rubyn_code/autonomous/daemon.rb +117 -14
  13. data/lib/rubyn_code/autonomous/idle_poller.rb +0 -20
  14. data/lib/rubyn_code/autonomous/task_claimer.rb +17 -11
  15. data/lib/rubyn_code/cli/app.rb +32 -1
  16. data/lib/rubyn_code/cli/commands/doctor.rb +73 -0
  17. data/lib/rubyn_code/cli/commands/mcp.rb +77 -0
  18. data/lib/rubyn_code/cli/commands/model.rb +32 -2
  19. data/lib/rubyn_code/cli/commands/provider.rb +123 -0
  20. data/lib/rubyn_code/cli/commands/skill.rb +52 -3
  21. data/lib/rubyn_code/cli/daemon_runner.rb +36 -0
  22. data/lib/rubyn_code/cli/first_run.rb +159 -0
  23. data/lib/rubyn_code/cli/repl.rb +6 -1
  24. data/lib/rubyn_code/cli/repl_commands.rb +2 -1
  25. data/lib/rubyn_code/cli/repl_lifecycle.rb +1 -0
  26. data/lib/rubyn_code/cli/repl_setup.rb +36 -0
  27. data/lib/rubyn_code/config/defaults.rb +1 -0
  28. data/lib/rubyn_code/config/schema.json +49 -0
  29. data/lib/rubyn_code/config/settings.rb +7 -4
  30. data/lib/rubyn_code/config/validator.rb +63 -0
  31. data/lib/rubyn_code/context/context_budget.rb +16 -1
  32. data/lib/rubyn_code/context/context_collapse.rb +34 -4
  33. data/lib/rubyn_code/context/manager.rb +37 -3
  34. data/lib/rubyn_code/context/manual_compact.rb +1 -1
  35. data/lib/rubyn_code/hooks/registry.rb +4 -0
  36. data/lib/rubyn_code/ide/adapters/tool_output.rb +330 -0
  37. data/lib/rubyn_code/ide/client.rb +110 -0
  38. data/lib/rubyn_code/ide/handlers/accept_edit_handler.rb +35 -0
  39. data/lib/rubyn_code/ide/handlers/approve_tool_use_handler.rb +34 -0
  40. data/lib/rubyn_code/ide/handlers/cancel_handler.rb +41 -0
  41. data/lib/rubyn_code/ide/handlers/config_get_handler.rb +63 -0
  42. data/lib/rubyn_code/ide/handlers/config_set_handler.rb +86 -0
  43. data/lib/rubyn_code/ide/handlers/initialize_handler.rb +79 -0
  44. data/lib/rubyn_code/ide/handlers/models_list_handler.rb +39 -0
  45. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +215 -0
  46. data/lib/rubyn_code/ide/handlers/review_handler.rb +110 -0
  47. data/lib/rubyn_code/ide/handlers/session_fork_handler.rb +49 -0
  48. data/lib/rubyn_code/ide/handlers/session_list_handler.rb +41 -0
  49. data/lib/rubyn_code/ide/handlers/session_reset_handler.rb +31 -0
  50. data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +42 -0
  51. data/lib/rubyn_code/ide/handlers/shutdown_handler.rb +37 -0
  52. data/lib/rubyn_code/ide/handlers.rb +76 -0
  53. data/lib/rubyn_code/ide/protocol.rb +111 -0
  54. data/lib/rubyn_code/ide/server.rb +186 -0
  55. data/lib/rubyn_code/index/codebase_index.rb +67 -1
  56. data/lib/rubyn_code/llm/adapters/anthropic.rb +6 -2
  57. data/lib/rubyn_code/llm/adapters/anthropic_compatible.rb +60 -0
  58. data/lib/rubyn_code/llm/adapters/openai_compatible.rb +6 -2
  59. data/lib/rubyn_code/llm/client.rb +29 -4
  60. data/lib/rubyn_code/mcp/config.rb +2 -1
  61. data/lib/rubyn_code/memory/search.rb +1 -0
  62. data/lib/rubyn_code/self_test.rb +315 -0
  63. data/lib/rubyn_code/skills/catalog.rb +66 -0
  64. data/lib/rubyn_code/skills/loader.rb +43 -0
  65. data/lib/rubyn_code/tasks/models.rb +1 -0
  66. data/lib/rubyn_code/tools/base.rb +13 -0
  67. data/lib/rubyn_code/tools/bash.rb +5 -0
  68. data/lib/rubyn_code/tools/edit_file.rb +62 -5
  69. data/lib/rubyn_code/tools/executor.rb +61 -6
  70. data/lib/rubyn_code/tools/glob.rb +6 -0
  71. data/lib/rubyn_code/tools/grep.rb +6 -0
  72. data/lib/rubyn_code/tools/ide_diagnostics.rb +51 -0
  73. data/lib/rubyn_code/tools/ide_symbols.rb +53 -0
  74. data/lib/rubyn_code/tools/output_compressor.rb +6 -1
  75. data/lib/rubyn_code/tools/read_file.rb +6 -0
  76. data/lib/rubyn_code/tools/registry.rb +11 -0
  77. data/lib/rubyn_code/tools/write_file.rb +17 -0
  78. data/lib/rubyn_code/version.rb +1 -1
  79. data/lib/rubyn_code.rb +22 -0
  80. data/skills/rubyn_self_test.md +13 -1
  81. metadata +31 -1
@@ -7,7 +7,7 @@ require 'time'
7
7
 
8
8
  module RubynCode
9
9
  module Auth
10
- module TokenStore
10
+ module TokenStore # rubocop:disable Metrics/ModuleLength -- single-responsibility credential store
11
11
  EXPIRY_BUFFER_SECONDS = 300 # 5 minutes
12
12
  KEYCHAIN_SERVICE = 'Claude Code-credentials'
13
13
 
@@ -21,25 +21,44 @@ module RubynCode
21
21
  end
22
22
 
23
23
  # Load API key for a given provider. Anthropic uses the full fallback chain.
24
+ # Other providers: stored key → env var.
24
25
  def load_for_provider(provider)
25
26
  return load if provider == 'anthropic'
26
27
 
28
+ stored = load_provider_key(provider)
29
+ return { access_token: stored, type: :api_key, source: :stored } if stored
30
+
27
31
  env_key = resolve_env_key(provider)
28
32
  api_key = ENV.fetch(env_key, nil)
29
33
  api_key&.empty? == false ? { access_token: api_key, type: :api_key, source: :env } : nil
30
34
  end
31
35
 
32
- def save(access_token:, refresh_token:, expires_at:)
36
+ # Store an API key for a provider in tokens.yml (encrypted at rest).
37
+ def save_provider_key(provider, key)
33
38
  ensure_directory!
39
+ data = load_tokens_file || {}
40
+ data['provider_keys'] ||= {}
41
+ data['provider_keys'][provider.to_s] = KeyEncryption.encrypt(key)
42
+ write_tokens_file(data)
43
+ end
34
44
 
35
- data = {
36
- 'access_token' => access_token,
37
- 'refresh_token' => refresh_token,
38
- 'expires_at' => expires_at.is_a?(Time) ? expires_at.iso8601 : expires_at.to_s
39
- }
45
+ # Retrieve a stored API key for a provider (decrypted transparently).
46
+ def load_provider_key(provider)
47
+ data = load_tokens_file
48
+ value = data&.dig('provider_keys', provider.to_s)
49
+ return nil unless value
40
50
 
41
- File.write(tokens_path, YAML.dump(data))
42
- File.chmod(0o600, tokens_path)
51
+ migrate_plaintext_key!(data, provider, value) unless KeyEncryption.encrypted?(value)
52
+ KeyEncryption.decrypt(value)
53
+ end
54
+
55
+ def save(access_token:, refresh_token:, expires_at:)
56
+ ensure_directory!
57
+ data = load_tokens_file || {}
58
+ data['access_token'] = access_token
59
+ data['refresh_token'] = refresh_token
60
+ data['expires_at'] = expires_at.is_a?(Time) ? expires_at.iso8601 : expires_at.to_s
61
+ write_tokens_file(data)
43
62
  data
44
63
  end
45
64
 
@@ -118,6 +137,28 @@ module RubynCode
118
137
  { access_token: api_key, refresh_token: nil, expires_at: nil, type: :api_key, source: :env }
119
138
  end
120
139
 
140
+ def write_tokens_file(data)
141
+ File.write(tokens_path, YAML.dump(data))
142
+ File.chmod(0o600, tokens_path)
143
+ end
144
+
145
+ # Auto-encrypt a plaintext key from a pre-encryption install.
146
+ def migrate_plaintext_key!(data, provider, plaintext)
147
+ data['provider_keys'][provider.to_s] = KeyEncryption.encrypt(plaintext)
148
+ write_tokens_file(data)
149
+ rescue StandardError
150
+ nil # don't break reads if migration fails
151
+ end
152
+
153
+ def load_tokens_file
154
+ return nil unless File.exist?(tokens_path)
155
+
156
+ data = YAML.safe_load_file(tokens_path, permitted_classes: [Time])
157
+ data.is_a?(Hash) ? data : nil
158
+ rescue Psych::SyntaxError, Errno::EACCES
159
+ nil
160
+ end
161
+
121
162
  def tokens_path = Config::Defaults::TOKENS_FILE
122
163
 
123
164
  def ensure_directory!
@@ -14,8 +14,9 @@ module RubynCode
14
14
  #
15
15
  # Unlike the REPL, the daemon runs a full Agent::Loop per task — meaning
16
16
  # it can read files, write code, run specs, and use every tool available.
17
- class Daemon
17
+ class Daemon # rubocop:disable Metrics/ClassLength -- daemon lifecycle + retry + audit + cost
18
18
  LIFECYCLE_STATES = %i[spawned working idle shutting_down stopped].freeze
19
+ MAX_TASK_RETRIES = 3
19
20
 
20
21
  attr_reader :agent_name, :role, :state, :runs_completed, :total_cost
21
22
 
@@ -32,14 +33,17 @@ module RubynCode
32
33
  # @param on_state_change [Proc, nil] callback invoked with (old_state, new_state)
33
34
  # @param on_task_complete [Proc, nil] callback invoked with (task, result_text)
34
35
  # @param on_task_error [Proc, nil] callback invoked with (task, error)
36
+ # @param session_persistence [Memory::SessionPersistence, nil] optional audit trail persistence
35
37
  def initialize( # rubocop:disable Metrics/ParameterLists
36
38
  agent_name:, role:, llm_client:, project_root:, task_manager:, mailbox:,
37
39
  max_runs: 100, max_cost: 10.0, poll_interval: 5, idle_timeout: 60,
38
- on_state_change: nil, on_task_complete: nil, on_task_error: nil
40
+ on_state_change: nil, on_task_complete: nil, on_task_error: nil,
41
+ session_persistence: nil
39
42
  )
40
43
  assign_core_attrs(agent_name:, role:, llm_client:, project_root:, task_manager:, mailbox:)
41
44
  assign_limits(max_runs:, max_cost:, poll_interval:, idle_timeout:)
42
45
  assign_callbacks_and_state(on_state_change, on_task_complete, on_task_error)
46
+ @session_persistence = session_persistence
43
47
  end
44
48
 
45
49
  # Enters the work-idle-work cycle. Blocks the calling thread until
@@ -153,16 +157,18 @@ module RubynCode
153
157
  agent_loop = build_agent_loop
154
158
  result_text = agent_loop.send_message(build_work_prompt(task))
155
159
 
156
- # Accumulate cost from the budget enforcer
157
- track_cost_from_enforcer(agent_loop)
160
+ # Accumulate cost via CostCalculator using actual token counts
161
+ track_cost_from_context_manager(agent_loop)
158
162
 
159
163
  # Mark the task as completed with the agent's result.
160
164
  @task_manager.complete(task.id, result: result_text)
165
+
166
+ # Persist conversation as an audit trail
167
+ persist_session_audit(task, agent_loop)
168
+
161
169
  @on_task_complete&.call(task, result_text)
162
170
  rescue StandardError => e
163
- # On failure, release the task so another agent (or retry) can pick it up.
164
- @task_manager.update(task.id, status: 'pending', owner: nil, result: "Error: #{e.message}")
165
- @on_task_error&.call(task, e)
171
+ handle_task_error(task, e)
166
172
  end
167
173
 
168
174
  # Builds a fresh Agent::Loop wired with all the real tools.
@@ -192,19 +198,116 @@ module RubynCode
192
198
  )
193
199
  end
194
200
 
195
- # Accumulates cost tracked by the Agent::Loop's context manager.
201
+ # Computes USD cost from the context manager's token counts using
202
+ # Observability::CostCalculator. The old approach checked for a
203
+ # `total_cost` method that never existed on Context::Manager, so
204
+ # @total_cost was always 0.0 and the max_cost safety limit never fired.
196
205
  #
197
206
  # @param agent_loop [Agent::Loop]
198
207
  # @return [void]
199
- def track_cost_from_enforcer(agent_loop)
200
- # The context manager tracks token usage; we extract cost if available.
201
- # This is best-effort — the daemon's own total_cost is an approximation.
208
+ def track_cost_from_context_manager(agent_loop)
202
209
  cm = agent_loop.instance_variable_get(:@context_manager)
203
- return unless cm.respond_to?(:total_cost)
210
+ return unless cm
211
+
212
+ tokens = extract_token_counts(cm)
213
+ return if tokens.values.all?(&:zero?)
214
+
215
+ model = @llm_client.respond_to?(:model) ? @llm_client.model : 'claude-sonnet-4-6'
216
+ @total_cost += Observability::CostCalculator.calculate(model: model, **tokens)
217
+ rescue StandardError
218
+ # Non-critical — cost tracking is best-effort
219
+ end
220
+
221
+ # @param context_mgr [Context::Manager]
222
+ # @return [Hash] :input_tokens, :output_tokens
223
+ def extract_token_counts(context_mgr)
224
+ {
225
+ input_tokens: context_mgr.respond_to?(:total_input_tokens) ? context_mgr.total_input_tokens.to_i : 0,
226
+ output_tokens: context_mgr.respond_to?(:total_output_tokens) ? context_mgr.total_output_tokens.to_i : 0
227
+ }
228
+ end
229
+
230
+ # Handles a task error with retry backoff. Increments the retry count
231
+ # in the task's metadata. After MAX_TASK_RETRIES, marks the task as
232
+ # failed instead of releasing it back to pending.
233
+ #
234
+ # @param task [Tasks::Task]
235
+ # @param error [StandardError]
236
+ # @return [void]
237
+ def handle_task_error(task, error)
238
+ retry_count = extract_retry_count(task) + 1
239
+
240
+ metadata = build_retry_metadata(task, retry_count)
241
+ if retry_count >= MAX_TASK_RETRIES
242
+ @task_manager.update(
243
+ task.id,
244
+ status: 'failed',
245
+ owner: nil,
246
+ result: "Failed after #{retry_count} retries. Last error: #{error.message}",
247
+ metadata: JSON.generate(metadata)
248
+ )
249
+ else
250
+ @task_manager.update(
251
+ task.id,
252
+ status: 'pending',
253
+ owner: nil,
254
+ result: "Error (retry #{retry_count}/#{MAX_TASK_RETRIES}): #{error.message}",
255
+ metadata: JSON.generate(metadata)
256
+ )
257
+ end
258
+ @on_task_error&.call(task, error)
259
+ end
204
260
 
205
- @total_cost += cm.total_cost.to_f
261
+ # @param task [Tasks::Task]
262
+ # @return [Integer]
263
+ def extract_retry_count(task)
264
+ meta = parse_task_metadata(task)
265
+ (meta[:retry_count] || meta['retry_count'] || 0).to_i
266
+ end
267
+
268
+ # @param task [Tasks::Task]
269
+ # @param retry_count [Integer]
270
+ # @return [Hash]
271
+ def build_retry_metadata(task, retry_count)
272
+ meta = parse_task_metadata(task)
273
+ meta.merge(retry_count: retry_count)
274
+ end
275
+
276
+ # @param task [Tasks::Task]
277
+ # @return [Hash]
278
+ def parse_task_metadata(task)
279
+ raw = task.metadata
280
+ case raw
281
+ when Hash then raw
282
+ when String then JSON.parse(raw, symbolize_names: true)
283
+ else {}
284
+ end
285
+ rescue JSON::ParserError
286
+ {}
287
+ end
288
+
289
+ # Persists the agent's conversation as a session audit trail after
290
+ # completing a task, so there's a record of what the daemon did.
291
+ #
292
+ # @param task [Tasks::Task]
293
+ # @param agent_loop [Agent::Loop]
294
+ # @return [void]
295
+ def persist_session_audit(task, agent_loop)
296
+ return unless @session_persistence
297
+
298
+ conversation = agent_loop.instance_variable_get(:@conversation)
299
+ return unless conversation.respond_to?(:messages)
300
+
301
+ session_id = "daemon-#{@agent_name}-#{task.id}"
302
+ @session_persistence.save_session(
303
+ session_id: session_id,
304
+ project_path: @project_root,
305
+ messages: conversation.messages,
306
+ title: "Daemon: #{task.title}",
307
+ metadata: { agent_name: @agent_name, task_id: task.id, task_title: task.title }
308
+ )
206
309
  rescue StandardError
207
- # Non-critical
310
+ # Non-critical — audit persistence is best-effort
208
311
  end
209
312
 
210
313
  # ── Idle phase ───────────────────────────────────────────────
@@ -53,26 +53,6 @@ module RubynCode
53
53
  @interrupted = true
54
54
  end
55
55
 
56
- # Re-injects the agent's identity message when the conversation
57
- # context has been compressed (i.e. the messages array is very short).
58
- # This ensures the agent still knows who it is after compaction.
59
- #
60
- # @param messages [Array<Hash>] the current conversation messages
61
- # @param identity [String] the identity/system prompt to re-inject
62
- # @param threshold [Integer] message count below which re-injection triggers (default 3)
63
- # @return [void]
64
- def self.reinject_identity(messages, identity:, threshold: 3)
65
- return if messages.length >= threshold
66
- return if identity.nil? || identity.empty?
67
-
68
- # Only re-inject if the identity is not already present as the
69
- # first user message.
70
- first_user = messages.find { |m| m[:role] == 'user' }
71
- return if first_user && first_user[:content].to_s.include?(identity[0, 100])
72
-
73
- messages.unshift({ role: 'user', content: identity })
74
- end
75
-
76
56
  private
77
57
 
78
58
  # @return [Boolean]
@@ -6,16 +6,19 @@ module RubynCode
6
6
  # Uses optimistic locking to handle race conditions when multiple
7
7
  # agents attempt to claim the same task concurrently.
8
8
  module TaskClaimer
9
- # Finds the first ready (pending, unowned) task, claims it for the
10
- # given agent, and returns the updated Task. Returns nil if no work
11
- # is available.
9
+ MAX_RETRIES = 3
10
+
11
+ # Finds the first ready (pending, unowned) task that hasn't exceeded
12
+ # max retries, claims it for the given agent, and returns the updated
13
+ # Task. Returns nil if no work is available.
12
14
  #
13
15
  # @param task_manager [#db, #update_task, #list_tasks] task persistence layer
14
16
  # @param agent_name [String] unique identifier of the claiming agent
17
+ # @param max_retries [Integer] maximum retry count before skipping a task
15
18
  # @return [Tasks::Task, nil] the claimed task, or nil if none available
16
- def self.call(task_manager:, agent_name:)
19
+ def self.call(task_manager:, agent_name:, max_retries: MAX_RETRIES)
17
20
  db = task_manager.db
18
- claim_next_pending_task(db, agent_name)
21
+ claim_next_pending_task(db, agent_name, max_retries)
19
22
  fetch_claimed_task(db, agent_name)
20
23
  rescue StandardError => e
21
24
  RubynCode.logger.warn("TaskClaimer: failed to claim task: #{e.message}") if RubynCode.respond_to?(:logger)
@@ -25,17 +28,20 @@ module RubynCode
25
28
  class << self
26
29
  private
27
30
 
28
- def claim_next_pending_task(db, agent_name)
29
- db.execute(<<~SQL, [agent_name])
31
+ def claim_next_pending_task(db, agent_name, max_retries)
32
+ db.execute(<<~SQL, [agent_name, max_retries])
30
33
  UPDATE tasks
31
34
  SET owner = ?,
32
35
  status = 'in_progress',
33
36
  updated_at = datetime('now')
34
37
  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
38
+ SELECT t.id FROM tasks t
39
+ WHERE t.status = 'pending'
40
+ AND (t.owner IS NULL OR t.owner = '')
41
+ AND COALESCE(
42
+ json_extract(t.metadata, '$.retry_count'), 0
43
+ ) < ?
44
+ ORDER BY t.priority DESC, t.created_at ASC
39
45
  LIMIT 1
40
46
  )
41
47
  AND status = 'pending'
@@ -26,6 +26,8 @@ module RubynCode
26
26
  rubyn-code --resume [ID] Resume a previous session
27
27
  rubyn-code --setup Pin rubyn-code to bypass rbenv/rvm
28
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)
29
31
  rubyn-code --version Show version
30
32
  rubyn-code --help Show this help
31
33
 
@@ -57,7 +59,8 @@ module RubynCode
57
59
  '--help' => :help, '-h' => :help,
58
60
  '--auth' => :auth, '--setup' => :setup
59
61
  }.freeze
60
- BOOLEAN_FLAGS = { '--yolo' => :yolo, '--debug' => :debug }.freeze
62
+ BOOLEAN_FLAGS = { '--yolo' => :yolo, '--debug' => :debug, '--skip-setup' => :skip_setup, '--ide' => :ide }.freeze
63
+ VALUE_FLAGS = { '--permission-mode' => :permission_mode }.freeze
61
64
  DAEMON_INT_FLAGS = { '--max-runs' => :max_runs, '--idle-timeout' => :idle_timeout,
62
65
  '--poll-interval' => :poll_interval }.freeze
63
66
  DAEMON_STR_FLAGS = { '--name' => :agent_name, '--role' => :role }.freeze
@@ -71,6 +74,7 @@ module RubynCode
71
74
  when :setup then run_setup
72
75
  when :help then display_help
73
76
  when :run then run_single_prompt(@options[:prompt])
77
+ when :ide then run_ide
74
78
  when :daemon then run_daemon
75
79
  when :repl then run_repl
76
80
  end
@@ -83,6 +87,7 @@ module RubynCode
83
87
  idx = parse_single_option(argv, idx, options)
84
88
  idx += 1
85
89
  end
90
+ options[:command] = :ide if options[:ide]
86
91
  options
87
92
  end
88
93
 
@@ -93,6 +98,9 @@ module RubynCode
93
98
  options[:command] = SIMPLE_FLAGS[arg]
94
99
  elsif BOOLEAN_FLAGS.key?(arg)
95
100
  options[BOOLEAN_FLAGS[arg]] = true
101
+ elsif VALUE_FLAGS.key?(arg)
102
+ options[VALUE_FLAGS[arg]] = argv[idx + 1]
103
+ idx += 1
96
104
  else
97
105
  idx = parse_value_option(argv, idx, options)
98
106
  end
@@ -188,11 +196,17 @@ module RubynCode
188
196
  puts response
189
197
  end
190
198
 
199
+ def run_ide
200
+ mode = resolve_permission_mode
201
+ IDE::Server.new(permission_mode: mode).run
202
+ end
203
+
191
204
  def run_daemon
192
205
  DaemonRunner.new(@options).run
193
206
  end
194
207
 
195
208
  def run_repl
209
+ maybe_first_run!
196
210
  REPL.new(
197
211
  session_id: @options[:session_id],
198
212
  project_root: Dir.pwd,
@@ -200,6 +214,23 @@ module RubynCode
200
214
  ).run
201
215
  end
202
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
+
203
234
  def display_help
204
235
  puts HELP_TEXT
205
236
  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
@@ -68,7 +68,28 @@ module RubynCode
68
68
  current = client.model
69
69
  ctx.renderer.info("Provider: #{provider}")
70
70
  ctx.renderer.info("Current model: #{current}")
71
- show_available(ctx)
71
+ ctx.renderer.info("Available: #{client.models.join(', ')}")
72
+ show_other_providers(provider, ctx)
73
+ ctx.renderer.info('Tip: /model provider:model to switch providers (e.g., /model openai:gpt-4o)')
74
+ end
75
+
76
+ def show_other_providers(current_provider, ctx)
77
+ others = other_provider_entries(current_provider)
78
+ return if others.empty?
79
+
80
+ ctx.renderer.info('')
81
+ ctx.renderer.info('Other providers:')
82
+ others.each { |label| ctx.renderer.info(" #{label}") }
83
+ end
84
+
85
+ def other_provider_entries(current_provider)
86
+ providers = Config::Settings.new.data['providers']
87
+ return [] unless providers.is_a?(Hash)
88
+
89
+ providers.reject { |name, _| name == current_provider }.map do |name, cfg|
90
+ models = extract_config_models(cfg)
91
+ models.empty? ? name : "#{name}: #{models.join(', ')}"
92
+ end
72
93
  end
73
94
 
74
95
  def show_available(ctx)
@@ -85,9 +106,18 @@ module RubynCode
85
106
  case provider
86
107
  when 'anthropic' then LLM::Adapters::Anthropic::AVAILABLE_MODELS
87
108
  when 'openai' then LLM::Adapters::OpenAI::AVAILABLE_MODELS
88
- else []
109
+ else
110
+ cfg = Config::Settings.new.provider_config(provider)
111
+ extract_config_models(cfg)
89
112
  end
90
113
  end
114
+
115
+ def extract_config_models(cfg)
116
+ raw = cfg&.dig('models')
117
+ return [] unless raw
118
+
119
+ raw.is_a?(Hash) ? raw.values : Array(raw)
120
+ end
91
121
  end
92
122
  end
93
123
  end