rubyn-code 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +91 -3
  3. data/lib/rubyn_code/agent/background_job_handler.rb +71 -0
  4. data/lib/rubyn_code/agent/conversation.rb +55 -56
  5. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +99 -0
  6. data/lib/rubyn_code/agent/feedback_handler.rb +49 -0
  7. data/lib/rubyn_code/agent/llm_caller.rb +149 -0
  8. data/lib/rubyn_code/agent/loop.rb +175 -683
  9. data/lib/rubyn_code/agent/loop_detector.rb +50 -11
  10. data/lib/rubyn_code/agent/prompts.rb +109 -0
  11. data/lib/rubyn_code/agent/response_modes.rb +111 -0
  12. data/lib/rubyn_code/agent/response_parser.rb +111 -0
  13. data/lib/rubyn_code/agent/system_prompt_builder.rb +205 -0
  14. data/lib/rubyn_code/agent/tool_processor.rb +158 -0
  15. data/lib/rubyn_code/agent/usage_tracker.rb +59 -0
  16. data/lib/rubyn_code/auth/oauth.rb +80 -64
  17. data/lib/rubyn_code/auth/server.rb +21 -24
  18. data/lib/rubyn_code/auth/token_store.rb +31 -44
  19. data/lib/rubyn_code/autonomous/daemon.rb +29 -18
  20. data/lib/rubyn_code/autonomous/idle_poller.rb +4 -4
  21. data/lib/rubyn_code/autonomous/task_claimer.rb +36 -40
  22. data/lib/rubyn_code/background/worker.rb +64 -76
  23. data/lib/rubyn_code/cli/app.rb +128 -114
  24. data/lib/rubyn_code/cli/commands/model.rb +75 -18
  25. data/lib/rubyn_code/cli/commands/new_session.rb +45 -0
  26. data/lib/rubyn_code/cli/daemon_runner.rb +28 -11
  27. data/lib/rubyn_code/cli/renderer.rb +109 -60
  28. data/lib/rubyn_code/cli/repl.rb +42 -373
  29. data/lib/rubyn_code/cli/repl_commands.rb +176 -0
  30. data/lib/rubyn_code/cli/repl_lifecycle.rb +75 -0
  31. data/lib/rubyn_code/cli/repl_setup.rb +145 -0
  32. data/lib/rubyn_code/cli/setup.rb +6 -2
  33. data/lib/rubyn_code/cli/stream_formatter.rb +56 -49
  34. data/lib/rubyn_code/cli/version_check.rb +28 -11
  35. data/lib/rubyn_code/config/defaults.rb +10 -0
  36. data/lib/rubyn_code/config/project_profile.rb +185 -0
  37. data/lib/rubyn_code/config/settings.rb +100 -1
  38. data/lib/rubyn_code/context/auto_compact.rb +1 -1
  39. data/lib/rubyn_code/context/context_budget.rb +167 -0
  40. data/lib/rubyn_code/context/decision_compactor.rb +99 -0
  41. data/lib/rubyn_code/context/manager.rb +7 -5
  42. data/lib/rubyn_code/context/micro_compact.rb +29 -19
  43. data/lib/rubyn_code/context/schema_filter.rb +64 -0
  44. data/lib/rubyn_code/db/connection.rb +31 -26
  45. data/lib/rubyn_code/db/migrator.rb +44 -28
  46. data/lib/rubyn_code/hooks/built_in.rb +14 -10
  47. data/lib/rubyn_code/index/codebase_index.rb +245 -0
  48. data/lib/rubyn_code/learning/extractor.rb +65 -82
  49. data/lib/rubyn_code/learning/injector.rb +22 -23
  50. data/lib/rubyn_code/learning/instinct.rb +71 -42
  51. data/lib/rubyn_code/learning/shortcut.rb +95 -0
  52. data/lib/rubyn_code/llm/adapters/anthropic.rb +270 -0
  53. data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +215 -0
  54. data/lib/rubyn_code/llm/adapters/base.rb +35 -0
  55. data/lib/rubyn_code/llm/adapters/json_parsing.rb +21 -0
  56. data/lib/rubyn_code/llm/adapters/openai.rb +246 -0
  57. data/lib/rubyn_code/llm/adapters/openai_compatible.rb +46 -0
  58. data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +90 -0
  59. data/lib/rubyn_code/llm/adapters/openai_streaming.rb +141 -0
  60. data/lib/rubyn_code/llm/adapters/prompt_caching.rb +60 -0
  61. data/lib/rubyn_code/llm/client.rb +55 -252
  62. data/lib/rubyn_code/llm/model_router.rb +237 -0
  63. data/lib/rubyn_code/llm/streaming.rb +4 -227
  64. data/lib/rubyn_code/mcp/client.rb +1 -1
  65. data/lib/rubyn_code/mcp/config.rb +9 -12
  66. data/lib/rubyn_code/mcp/sse_transport.rb +15 -13
  67. data/lib/rubyn_code/mcp/stdio_transport.rb +16 -18
  68. data/lib/rubyn_code/mcp/tool_bridge.rb +31 -62
  69. data/lib/rubyn_code/memory/session_persistence.rb +59 -58
  70. data/lib/rubyn_code/memory/store.rb +42 -55
  71. data/lib/rubyn_code/observability/budget_enforcer.rb +46 -32
  72. data/lib/rubyn_code/observability/cost_calculator.rb +32 -8
  73. data/lib/rubyn_code/observability/skill_analytics.rb +116 -0
  74. data/lib/rubyn_code/observability/token_analytics.rb +130 -0
  75. data/lib/rubyn_code/observability/usage_reporter.rb +79 -61
  76. data/lib/rubyn_code/output/diff_renderer.rb +102 -77
  77. data/lib/rubyn_code/output/formatter.rb +11 -11
  78. data/lib/rubyn_code/permissions/policy.rb +11 -13
  79. data/lib/rubyn_code/permissions/prompter.rb +8 -9
  80. data/lib/rubyn_code/protocols/plan_approval.rb +25 -20
  81. data/lib/rubyn_code/skills/document.rb +33 -29
  82. data/lib/rubyn_code/skills/ttl_manager.rb +100 -0
  83. data/lib/rubyn_code/sub_agents/runner.rb +20 -25
  84. data/lib/rubyn_code/tasks/dag.rb +25 -24
  85. data/lib/rubyn_code/tools/ask_user.rb +44 -0
  86. data/lib/rubyn_code/tools/background_run.rb +2 -1
  87. data/lib/rubyn_code/tools/base.rb +26 -32
  88. data/lib/rubyn_code/tools/bash.rb +2 -1
  89. data/lib/rubyn_code/tools/edit_file.rb +74 -18
  90. data/lib/rubyn_code/tools/executor.rb +74 -24
  91. data/lib/rubyn_code/tools/file_cache.rb +95 -0
  92. data/lib/rubyn_code/tools/git_commit.rb +12 -10
  93. data/lib/rubyn_code/tools/git_log.rb +12 -10
  94. data/lib/rubyn_code/tools/glob.rb +23 -7
  95. data/lib/rubyn_code/tools/grep.rb +2 -1
  96. data/lib/rubyn_code/tools/load_skill.rb +13 -6
  97. data/lib/rubyn_code/tools/memory_search.rb +14 -13
  98. data/lib/rubyn_code/tools/memory_write.rb +2 -1
  99. data/lib/rubyn_code/tools/output_compressor.rb +185 -0
  100. data/lib/rubyn_code/tools/read_file.rb +11 -6
  101. data/lib/rubyn_code/tools/review_pr.rb +127 -80
  102. data/lib/rubyn_code/tools/run_specs.rb +26 -15
  103. data/lib/rubyn_code/tools/schema.rb +4 -10
  104. data/lib/rubyn_code/tools/spawn_agent.rb +113 -82
  105. data/lib/rubyn_code/tools/spawn_teammate.rb +107 -64
  106. data/lib/rubyn_code/tools/spec_output_parser.rb +118 -0
  107. data/lib/rubyn_code/tools/task.rb +17 -17
  108. data/lib/rubyn_code/tools/web_fetch.rb +62 -47
  109. data/lib/rubyn_code/tools/web_search.rb +66 -48
  110. data/lib/rubyn_code/tools/write_file.rb +59 -1
  111. data/lib/rubyn_code/version.rb +1 -1
  112. data/lib/rubyn_code.rb +40 -1
  113. data/skills/rubyn_self_test.md +121 -0
  114. metadata +53 -1
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module Agent
5
+ # Handles tool definition filtering, permission checks, and execution
6
+ # for the agent loop.
7
+ module ToolProcessor # rubocop:disable Metrics/ModuleLength -- tool filtering + permissions + execution + decision signals
8
+ CORE_TOOLS = %w[read_file write_file edit_file glob grep bash spawn_agent background_run].freeze
9
+ PLAN_MODE_RISK_LEVELS = %i[read].freeze
10
+
11
+ private
12
+
13
+ def tool_definitions
14
+ all_tools = @tool_executor.tool_definitions
15
+ return all_tools if all_tools.size <= CORE_TOOLS.size
16
+
17
+ @discovered_tools ||= Set.new
18
+
19
+ # Use DynamicToolSchema to filter based on detected task context
20
+ context = detect_task_context
21
+ if context
22
+ active = DynamicToolSchema.active_tools(task_context: context, discovered_tools: @discovered_tools)
23
+ return DynamicToolSchema.filter(all_tools, active_names: active)
24
+ end
25
+
26
+ all_tools.select { |t| core_or_discovered?(t) }
27
+ end
28
+
29
+ def detect_task_context # rubocop:disable Metrics/CyclomaticComplexity -- safe navigation chain
30
+ last_msg = @conversation&.messages&.reverse_each&.find { |m| m[:role] == 'user' } # rubocop:disable Style/SafeNavigationChainLength
31
+ return nil unless last_msg
32
+
33
+ text = last_msg[:content]
34
+ return nil unless text.is_a?(String)
35
+
36
+ DynamicToolSchema.detect_context(text)
37
+ rescue StandardError
38
+ nil
39
+ end
40
+
41
+ def core_or_discovered?(tool)
42
+ name = tool[:name] || tool['name']
43
+ CORE_TOOLS.include?(name) || @discovered_tools&.include?(name)
44
+ end
45
+
46
+ def discover_tool(name)
47
+ (@discovered_tools ||= Set.new).add(name)
48
+ end
49
+
50
+ def read_only_tool_definitions
51
+ Tools::Registry.all.select { |t| PLAN_MODE_RISK_LEVELS.include?(t::RISK_LEVEL) }.map(&:to_schema)
52
+ end
53
+
54
+ def process_tool_calls(tool_calls)
55
+ aggregate_chars = 0
56
+ budget = Config::Defaults::MAX_MESSAGE_TOOL_RESULTS_CHARS
57
+
58
+ tool_calls.each do |tool_call|
59
+ result, is_error = run_single_tool(tool_call)
60
+ aggregate_chars += result.to_s.length
61
+ result = truncate_tool_result(result, aggregate_chars, budget)
62
+ notify_tool_result(field(tool_call, :name), result, is_error)
63
+ record_tool_result(tool_call, result, is_error)
64
+ end
65
+ end
66
+
67
+ def run_single_tool(tool_call)
68
+ tool_name = field(tool_call, :name)
69
+ tool_input = field(tool_call, :input) || {}
70
+ decision = Permissions::Policy.check(
71
+ tool_name: tool_name, tool_input: tool_input, tier: @permission_tier, deny_list: @deny_list
72
+ )
73
+ @on_tool_call&.call(tool_name, tool_input) rescue nil # rubocop:disable Style/RescueModifier
74
+ execute_with_permission(decision, tool_name, tool_input)
75
+ end
76
+
77
+ def truncate_tool_result(result, aggregate_chars, budget)
78
+ return result unless aggregate_chars > budget
79
+
80
+ remaining = [budget - (aggregate_chars - result.to_s.length), 500].max
81
+ RubynCode::Debug.token("Tool result budget exceeded: #{aggregate_chars}/#{budget} chars")
82
+ "#{result.to_s[0, remaining]}\n\n[truncated — tool result budget exceeded (#{budget} chars/message)]"
83
+ end
84
+
85
+ def notify_tool_result(tool_name, result, is_error)
86
+ @on_tool_result&.call(tool_name, result, is_error) rescue nil # rubocop:disable Style/RescueModifier
87
+ end
88
+
89
+ def record_tool_result(tool_call, result, is_error)
90
+ tool_name = field(tool_call, :name)
91
+ @stall_detector.record(tool_name, field(tool_call, :input) || {})
92
+ @conversation.add_tool_result(field(tool_call, :id), tool_name, result, is_error: is_error)
93
+ end
94
+
95
+ def execute_with_permission(decision, tool_name, tool_input)
96
+ case decision
97
+ when :deny then ["Tool '#{tool_name}' is blocked by the deny list.", true]
98
+ when :ask then ask_and_execute(tool_name, tool_input)
99
+ when :allow then execute_tool(tool_name, tool_input)
100
+ else ["Unknown permission decision: #{decision}", true]
101
+ end
102
+ end
103
+
104
+ def ask_and_execute(tool_name, tool_input)
105
+ if prompt_user(tool_name,
106
+ tool_input)
107
+ execute_tool(tool_name,
108
+ tool_input)
109
+ else
110
+ ["User denied permission for '#{tool_name}'.", true]
111
+ end
112
+ end
113
+
114
+ def execute_tool(tool_name, tool_input)
115
+ discover_tool(tool_name)
116
+ @hook_runner.fire(:pre_tool_use, tool_name: tool_name, tool_input: tool_input)
117
+ result = @tool_executor.execute(tool_name, symbolize_keys(tool_input))
118
+ @hook_runner.fire(:post_tool_use, tool_name: tool_name, tool_input: tool_input, result: result)
119
+ signal_decision_compactor(tool_name, tool_input, result)
120
+ [result.to_s, false]
121
+ rescue StandardError => e
122
+ ["Error executing #{tool_name}: #{e.message}", true]
123
+ end
124
+
125
+ def signal_decision_compactor(tool_name, tool_input, result) # rubocop:disable Metrics/CyclomaticComplexity -- tool dispatch
126
+ return unless @decision_compactor
127
+
128
+ case tool_name
129
+ when 'edit_file', 'write_file'
130
+ path = tool_input[:path] || tool_input['path']
131
+ @decision_compactor.signal_file_edited!(path) if path
132
+ when 'run_specs'
133
+ @decision_compactor.signal_specs_passed! if result.to_s.include?('0 failures')
134
+ end
135
+ rescue StandardError
136
+ nil
137
+ end
138
+
139
+ def prompt_user(tool_name, tool_input)
140
+ risk = resolve_tool_risk(tool_name)
141
+ if risk == :destructive
142
+ Permissions::Prompter.confirm_destructive(tool_name,
143
+ tool_input)
144
+ else
145
+ Permissions::Prompter.confirm(
146
+ tool_name, tool_input
147
+ )
148
+ end
149
+ end
150
+
151
+ def resolve_tool_risk(tool_name)
152
+ Tools::Registry.get(tool_name).risk_level
153
+ rescue ToolNotFoundError
154
+ :unknown
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module Agent
5
+ # Tracks token usage and task budgets from LLM responses.
6
+ # Extracted from ResponseParser to keep module size manageable.
7
+ module UsageTracker
8
+ TASK_BUDGET_TOTAL = 100_000 # tokens per user message
9
+
10
+ private
11
+
12
+ def track_usage(response)
13
+ usage = extract_usage(response)
14
+ return unless usage
15
+
16
+ log_usage(usage)
17
+ @context_manager.track_usage(usage)
18
+ rescue NoMethodError
19
+ # context_manager does not implement track_usage yet
20
+ end
21
+
22
+ def extract_usage(response)
23
+ if response.respond_to?(:usage)
24
+ response.usage
25
+ elsif response.is_a?(Hash)
26
+ response[:usage] || response['usage']
27
+ end
28
+ end
29
+
30
+ def log_usage(usage)
31
+ input_tokens = usage.respond_to?(:input_tokens) ? usage.input_tokens : usage[:input_tokens]
32
+ output_tokens = usage.respond_to?(:output_tokens) ? usage.output_tokens : usage[:output_tokens]
33
+ cache_info = build_cache_info(usage)
34
+ RubynCode::Debug.token("in=#{input_tokens} out=#{output_tokens}#{cache_info}")
35
+ end
36
+
37
+ def build_cache_info(usage)
38
+ cache_create = usage.respond_to?(:cache_creation_input_tokens) ? usage.cache_creation_input_tokens.to_i : 0
39
+ cache_read = usage.respond_to?(:cache_read_input_tokens) ? usage.cache_read_input_tokens.to_i : 0
40
+ return '' unless cache_create.positive? || cache_read.positive?
41
+
42
+ " cache_create=#{cache_create} cache_read=#{cache_read}"
43
+ end
44
+
45
+ def update_task_budget(response)
46
+ usage = response.respond_to?(:usage) ? response.usage : nil
47
+ return unless usage
48
+
49
+ output = usage.respond_to?(:output_tokens) ? usage.output_tokens.to_i : 0
50
+ input = usage.respond_to?(:input_tokens) ? usage.input_tokens.to_i : 0
51
+
52
+ @task_budget_remaining ||= TASK_BUDGET_TOTAL
53
+ @task_budget_remaining = [@task_budget_remaining - input - output, 0].max
54
+
55
+ RubynCode::Debug.token("task_budget_remaining=#{@task_budget_remaining}/#{TASK_BUDGET_TOTAL}")
56
+ end
57
+ end
58
+ end
59
+ end
@@ -25,25 +25,11 @@ module RubynCode
25
25
  code_challenge = derive_code_challenge(code_verifier)
26
26
  state = SecureRandom.hex(24)
27
27
 
28
- auth_url = build_authorization_url(code_challenge:, state:)
29
-
30
- callback_server = Server.new
31
- open_browser(auth_url)
32
-
33
- result = callback_server.wait_for_callback(timeout: 120)
34
-
35
- unless secure_compare(result[:state], state)
36
- raise StateMismatchError, 'OAuth state parameter mismatch — possible CSRF attack'
37
- end
28
+ result = perform_browser_auth(code_challenge, state)
29
+ validate_state!(result[:state], state)
38
30
 
39
31
  tokens = exchange_code(code: result[:code], code_verifier:)
40
-
41
- TokenStore.save(
42
- access_token: tokens[:access_token],
43
- refresh_token: tokens[:refresh_token],
44
- expires_at: Time.now + tokens[:expires_in].to_i
45
- )
46
-
32
+ persist_tokens(tokens)
47
33
  tokens
48
34
  end
49
35
 
@@ -51,35 +37,13 @@ module RubynCode
51
37
  stored = TokenStore.load
52
38
  raise RefreshError, 'No stored refresh token available' unless stored&.dig(:refresh_token)
53
39
 
54
- response = http_client.post(token_url) do |req|
55
- req.headers['Content-Type'] = 'application/x-www-form-urlencoded'
56
- req.body = URI.encode_www_form(
57
- grant_type: 'refresh_token',
58
- client_id: client_id,
59
- refresh_token: stored[:refresh_token]
60
- )
61
- end
62
-
63
- unless response.success?
64
- body = parse_json(response.body)
65
- error_msg = body&.dig('error_description') || body&.dig('error') || response.body
66
- raise RefreshError, "Token refresh failed (#{response.status}): #{error_msg}"
67
- end
40
+ response = post_refresh_request(stored[:refresh_token])
41
+ raise_refresh_error(response) unless response.success?
68
42
 
69
43
  body = parse_json(response.body)
70
44
  raise RefreshError, 'Invalid response from token endpoint' unless body
71
45
 
72
- TokenStore.save(
73
- access_token: body['access_token'],
74
- refresh_token: body['refresh_token'] || stored[:refresh_token],
75
- expires_at: Time.now + body['expires_in'].to_i
76
- )
77
-
78
- {
79
- access_token: body['access_token'],
80
- refresh_token: body['refresh_token'] || stored[:refresh_token],
81
- expires_in: body['expires_in']
82
- }
46
+ save_refreshed_tokens(body, stored)
83
47
  end
84
48
 
85
49
  private
@@ -108,40 +72,92 @@ module RubynCode
108
72
  end
109
73
 
110
74
  def exchange_code(code:, code_verifier:)
111
- response = http_client.post(token_url) do |req|
75
+ response = post_code_exchange(code, code_verifier)
76
+ raise_exchange_error(response) unless response.success?
77
+
78
+ body = parse_json(response.body)
79
+ raise TokenExchangeError, 'Invalid response from token endpoint' unless body
80
+
81
+ { access_token: body['access_token'], refresh_token: body['refresh_token'], expires_in: body['expires_in'] }
82
+ end
83
+
84
+ def post_code_exchange(code, code_verifier)
85
+ http_client.post(token_url) do |req|
112
86
  req.headers['Content-Type'] = 'application/x-www-form-urlencoded'
113
87
  req.body = URI.encode_www_form(
114
- grant_type: 'authorization_code',
115
- client_id: client_id,
116
- code: code,
117
- redirect_uri: redirect_uri,
118
- code_verifier: code_verifier
88
+ grant_type: 'authorization_code', client_id: client_id,
89
+ code: code, redirect_uri: redirect_uri, code_verifier: code_verifier
119
90
  )
120
91
  end
92
+ end
93
+
94
+ def raise_exchange_error(response)
95
+ body = parse_json(response.body)
96
+ error_msg = body&.dig('error_description') || body&.dig('error') || response.body
97
+ raise TokenExchangeError, "Code exchange failed (#{response.status}): #{error_msg}"
98
+ end
99
+
100
+ def perform_browser_auth(code_challenge, state)
101
+ auth_url = build_authorization_url(code_challenge:, state:)
102
+ callback_server = Server.new
103
+ open_browser(auth_url)
104
+ callback_server.wait_for_callback(timeout: 120)
105
+ end
106
+
107
+ def validate_state!(received, expected)
108
+ return if secure_compare(received, expected)
109
+
110
+ raise StateMismatchError, 'OAuth state parameter mismatch — possible CSRF attack'
111
+ end
121
112
 
122
- unless response.success?
123
- body = parse_json(response.body)
124
- error_msg = body&.dig('error_description') || body&.dig('error') || response.body
125
- raise TokenExchangeError, "Code exchange failed (#{response.status}): #{error_msg}"
113
+ def persist_tokens(tokens)
114
+ TokenStore.save(
115
+ access_token: tokens[:access_token],
116
+ refresh_token: tokens[:refresh_token],
117
+ expires_at: Time.now + tokens[:expires_in].to_i
118
+ )
119
+ end
120
+
121
+ def post_refresh_request(refresh_token)
122
+ http_client.post(token_url) do |req|
123
+ req.headers['Content-Type'] = 'application/x-www-form-urlencoded'
124
+ req.body = URI.encode_www_form(
125
+ grant_type: 'refresh_token',
126
+ client_id: client_id,
127
+ refresh_token: refresh_token
128
+ )
126
129
  end
130
+ end
127
131
 
132
+ def raise_refresh_error(response)
128
133
  body = parse_json(response.body)
129
- raise TokenExchangeError, 'Invalid response from token endpoint' unless body
134
+ error_msg = body&.dig('error_description') || body&.dig('error') || response.body
135
+ raise RefreshError, "Token refresh failed (#{response.status}): #{error_msg}"
136
+ end
137
+
138
+ def save_refreshed_tokens(body, stored)
139
+ effective_refresh = body['refresh_token'] || stored[:refresh_token]
140
+
141
+ TokenStore.save(
142
+ access_token: body['access_token'],
143
+ refresh_token: effective_refresh,
144
+ expires_at: Time.now + body['expires_in'].to_i
145
+ )
130
146
 
131
147
  {
132
148
  access_token: body['access_token'],
133
- refresh_token: body['refresh_token'],
149
+ refresh_token: effective_refresh,
134
150
  expires_in: body['expires_in']
135
151
  }
136
152
  end
137
153
 
138
154
  def open_browser(url)
139
155
  launcher = case RUBY_PLATFORM
140
- when /darwin/ then 'open'
141
- when /linux/ then 'xdg-open'
142
- when /mingw|mswin/ then 'start'
143
- else 'xdg-open'
156
+ when /darwin/ then 'open'
157
+ when /linux/ then 'xdg-open'
158
+ when /mingw|mswin/ then 'start'
144
159
  end
160
+ launcher ||= 'xdg-open'
145
161
 
146
162
  system(launcher, url, exception: false)
147
163
  end
@@ -160,13 +176,13 @@ module RubynCode
160
176
  nil
161
177
  end
162
178
 
163
- def secure_compare(a, b)
164
- return false if a.nil? || b.nil?
165
- return false unless a.bytesize == b.bytesize
179
+ def secure_compare(left, right) # rubocop:disable Naming/PredicateMethod -- constant-time comparison, not a predicate
180
+ return false if left.nil? || right.nil?
181
+ return false unless left.bytesize == right.bytesize
166
182
 
167
- l = a.unpack('C*')
168
- r = b.unpack('C*')
169
- l.zip(r).reduce(0) { |acc, (x, y)| acc | (x ^ y) }.zero?
183
+ left_bytes = left.unpack('C*')
184
+ right_bytes = right.unpack('C*')
185
+ left_bytes.zip(right_bytes).reduce(0) { |acc, (lhs, rhs)| acc | (lhs ^ rhs) }.zero?
170
186
  end
171
187
 
172
188
  def client_id = Config::Defaults::OAUTH_CLIENT_ID
@@ -56,35 +56,32 @@ module RubynCode
56
56
 
57
57
  def handle_callback(req, res, server)
58
58
  params = parse_query(req.query_string)
59
- code = params['code']
60
- state = params['state']
61
-
62
- if code
63
- @mutex.synchronize do
64
- @result = { code: code, state: state }
65
- @condvar.signal
66
- end
67
-
68
- res.status = 200
69
- res.content_type = 'text/html; charset=utf-8'
70
- res.body = success_html
59
+
60
+ if params['code']
61
+ handle_success_callback(params, res)
71
62
  else
72
- error = params['error'] || 'unknown'
73
- description = params['error_description'] || 'No authorization code received'
63
+ handle_error_callback(params, res)
64
+ end
74
65
 
75
- res.status = 400
76
- res.content_type = 'text/html; charset=utf-8'
77
- res.body = error_html(error, description)
66
+ Thread.new { sleep(0.5) && server.shutdown }
67
+ end
78
68
 
79
- @mutex.synchronize do
80
- @condvar.signal
81
- end
69
+ def handle_success_callback(params, res)
70
+ @mutex.synchronize do
71
+ @result = { code: params['code'], state: params['state'] }
72
+ @condvar.signal
82
73
  end
74
+ res.status = 200
75
+ res.content_type = 'text/html; charset=utf-8'
76
+ res.body = success_html
77
+ end
83
78
 
84
- Thread.new do
85
- sleep(0.5)
86
- server.shutdown
87
- end
79
+ def handle_error_callback(params, res)
80
+ res.status = 400
81
+ res.content_type = 'text/html; charset=utf-8'
82
+ res.body = error_html(params['error'] || 'unknown',
83
+ params['error_description'] || 'No authorization code received')
84
+ @mutex.synchronize { @condvar.signal }
88
85
  end
89
86
 
90
87
  def parse_query(query_string)
@@ -20,6 +20,15 @@ module RubynCode
20
20
  load_from_keychain || load_from_file || load_from_env
21
21
  end
22
22
 
23
+ # Load API key for a given provider. Anthropic uses the full fallback chain.
24
+ def load_for_provider(provider)
25
+ return load if provider == 'anthropic'
26
+
27
+ env_key = resolve_env_key(provider)
28
+ api_key = ENV.fetch(env_key, nil)
29
+ api_key&.empty? == false ? { access_token: api_key, type: :api_key, source: :env } : nil
30
+ end
31
+
23
32
  def save(access_token:, refresh_token:, expires_at:)
24
33
  ensure_directory!
25
34
 
@@ -34,68 +43,56 @@ module RubynCode
34
43
  data
35
44
  end
36
45
 
37
- def clear!
46
+ def clear! # rubocop:disable Naming/PredicateMethod -- destructive action, not a predicate
38
47
  FileUtils.rm_f(tokens_path)
39
48
  true
40
49
  end
41
50
 
42
51
  def valid?
43
52
  tokens = self.load
44
- return false unless tokens
45
- return false unless tokens[:access_token]
46
-
47
- # API keys don't expire
53
+ return false unless tokens&.fetch(:access_token, nil)
48
54
  return true if tokens[:type] == :api_key
49
-
50
- # OAuth tokens need expiry check
51
55
  return true unless tokens[:expires_at]
52
56
 
53
57
  tokens[:expires_at] > Time.now + EXPIRY_BUFFER_SECONDS
54
58
  end
55
59
 
56
- def exists?
57
- valid?
58
- end
60
+ def exists? = valid?
61
+ def access_token = self.load&.fetch(:access_token, nil)
59
62
 
60
- def access_token
61
- tokens = self.load
62
- tokens&.fetch(:access_token, nil)
63
- end
63
+ private
64
64
 
65
- def token_type
66
- tokens = self.load
67
- tokens&.fetch(:type, :oauth)
65
+ def resolve_env_key(provider)
66
+ default = Config::Defaults::PROVIDER_ENV_KEYS.fetch(provider, "#{provider.upcase}_API_KEY")
67
+ Config::Settings.new.provider_config(provider)&.fetch('env_key', nil) || default
68
+ rescue StandardError
69
+ default
68
70
  end
69
71
 
70
- private
71
-
72
- # Read Claude Code's OAuth token from macOS Keychain
73
72
  def load_from_keychain
74
73
  return nil unless RUBY_PLATFORM.include?('darwin')
75
74
 
76
75
  output = `security find-generic-password -s "#{KEYCHAIN_SERVICE}" -w 2>/dev/null`.strip
77
76
  return nil if output.empty?
78
77
 
79
- data = JSON.parse(output)
80
- oauth = data['claudeAiOauth']
81
- return nil unless oauth && oauth['accessToken']
78
+ oauth = JSON.parse(output)['claudeAiOauth']
79
+ return nil unless oauth&.dig('accessToken')
82
80
 
83
- expires_at = if oauth['expiresAt']
84
- Time.at(oauth['expiresAt'] / 1000.0) # milliseconds to seconds
85
- end
81
+ build_keychain_tokens(oauth)
82
+ rescue StandardError
83
+ nil
84
+ end
86
85
 
86
+ def build_keychain_tokens(oauth)
87
87
  {
88
88
  access_token: oauth['accessToken'],
89
89
  refresh_token: oauth['refreshToken'],
90
- expires_at: expires_at,
90
+ expires_at: oauth['expiresAt'] ? Time.at(oauth['expiresAt'] / 1000.0) : nil,
91
91
  type: :oauth,
92
92
  source: :keychain
93
93
  }
94
- rescue JSON::ParserError, StandardError
95
- nil
96
94
  end
97
95
 
98
- # Read from local YAML token file
99
96
  def load_from_file
100
97
  return nil unless File.exist?(tokens_path)
101
98
 
@@ -114,28 +111,18 @@ module RubynCode
114
111
  nil
115
112
  end
116
113
 
117
- # Fall back to ANTHROPIC_API_KEY environment variable
118
114
  def load_from_env
119
115
  api_key = ENV.fetch('ANTHROPIC_API_KEY', nil)
120
116
  return nil unless api_key && !api_key.empty?
121
117
 
122
- {
123
- access_token: api_key,
124
- refresh_token: nil,
125
- expires_at: nil,
126
- type: :api_key,
127
- source: :env
128
- }
118
+ { access_token: api_key, refresh_token: nil, expires_at: nil, type: :api_key, source: :env }
129
119
  end
130
120
 
131
- def tokens_path
132
- Config::Defaults::TOKENS_FILE
133
- end
121
+ def tokens_path = Config::Defaults::TOKENS_FILE
134
122
 
135
123
  def ensure_directory!
136
- dir = File.dirname(tokens_path)
137
- FileUtils.mkdir_p(dir)
138
- File.chmod(0o700, dir)
124
+ FileUtils.mkdir_p(File.dirname(tokens_path))
125
+ File.chmod(0o700, File.dirname(tokens_path))
139
126
  end
140
127
 
141
128
  def parse_time(value)
@@ -37,24 +37,9 @@ module RubynCode
37
37
  max_runs: 100, max_cost: 10.0, poll_interval: 5, idle_timeout: 60,
38
38
  on_state_change: nil, on_task_complete: nil, on_task_error: nil
39
39
  )
40
- @agent_name = agent_name
41
- @role = role
42
- @llm_client = llm_client
43
- @project_root = File.expand_path(project_root)
44
- @task_manager = task_manager
45
- @mailbox = mailbox
46
- @max_runs = max_runs
47
- @max_cost = max_cost
48
- @poll_interval = poll_interval
49
- @idle_timeout = idle_timeout
50
- @on_state_change = on_state_change
51
- @on_task_complete = on_task_complete
52
- @on_task_error = on_task_error
53
-
54
- @state = :spawned
55
- @runs_completed = 0
56
- @total_cost = 0.0
57
- @stop_requested = false
40
+ assign_core_attrs(agent_name:, role:, llm_client:, project_root:, task_manager:, mailbox:)
41
+ assign_limits(max_runs:, max_cost:, poll_interval:, idle_timeout:)
42
+ assign_callbacks_and_state(on_state_change, on_task_complete, on_task_error)
58
43
  end
59
44
 
60
45
  # Enters the work-idle-work cycle. Blocks the calling thread until
@@ -121,6 +106,32 @@ module RubynCode
121
106
 
122
107
  # ── Signal handling ──────────────────────────────────────────
123
108
 
109
+ def assign_core_attrs(agent_name:, role:, llm_client:, project_root:, task_manager:, mailbox:) # rubocop:disable Metrics/ParameterLists -- mirrors constructor keyword args
110
+ @agent_name = agent_name
111
+ @role = role
112
+ @llm_client = llm_client
113
+ @project_root = File.expand_path(project_root)
114
+ @task_manager = task_manager
115
+ @mailbox = mailbox
116
+ end
117
+
118
+ def assign_limits(max_runs:, max_cost:, poll_interval:, idle_timeout:)
119
+ @max_runs = max_runs
120
+ @max_cost = max_cost
121
+ @poll_interval = poll_interval
122
+ @idle_timeout = idle_timeout
123
+ end
124
+
125
+ def assign_callbacks_and_state(on_state_change, on_task_complete, on_task_error)
126
+ @on_state_change = on_state_change
127
+ @on_task_complete = on_task_complete
128
+ @on_task_error = on_task_error
129
+ @state = :spawned
130
+ @runs_completed = 0
131
+ @total_cost = 0.0
132
+ @stop_requested = false
133
+ end
134
+
124
135
  def install_signal_handlers!
125
136
  %w[INT TERM].each do |sig|
126
137
  Signal.trap(sig) { stop! }