rubyn-code 0.3.0 → 0.5.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 (103) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +263 -21
  3. data/db/migrations/013_add_failed_status_to_tasks.rb +51 -0
  4. data/lib/rubyn_code/agent/conversation.rb +34 -4
  5. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +57 -3
  6. data/lib/rubyn_code/agent/llm_caller.rb +11 -1
  7. data/lib/rubyn_code/agent/loop.rb +14 -3
  8. data/lib/rubyn_code/agent/response_modes.rb +2 -1
  9. data/lib/rubyn_code/agent/system_prompt_builder.rb +49 -4
  10. data/lib/rubyn_code/agent/tool_processor.rb +25 -3
  11. data/lib/rubyn_code/auth/key_encryption.rb +118 -0
  12. data/lib/rubyn_code/auth/token_store.rb +50 -9
  13. data/lib/rubyn_code/autonomous/daemon.rb +117 -14
  14. data/lib/rubyn_code/autonomous/idle_poller.rb +0 -20
  15. data/lib/rubyn_code/autonomous/task_claimer.rb +17 -11
  16. data/lib/rubyn_code/cli/app.rb +116 -11
  17. data/lib/rubyn_code/cli/commands/doctor.rb +73 -0
  18. data/lib/rubyn_code/cli/commands/install_skills.rb +44 -0
  19. data/lib/rubyn_code/cli/commands/list_skills.rb +149 -0
  20. data/lib/rubyn_code/cli/commands/mcp.rb +77 -0
  21. data/lib/rubyn_code/cli/commands/model.rb +32 -2
  22. data/lib/rubyn_code/cli/commands/provider.rb +124 -0
  23. data/lib/rubyn_code/cli/commands/remove_skills.rb +35 -0
  24. data/lib/rubyn_code/cli/commands/skill.rb +54 -3
  25. data/lib/rubyn_code/cli/commands/skills.rb +104 -0
  26. data/lib/rubyn_code/cli/daemon_runner.rb +36 -0
  27. data/lib/rubyn_code/cli/first_run.rb +159 -0
  28. data/lib/rubyn_code/cli/repl.rb +15 -0
  29. data/lib/rubyn_code/cli/repl_commands.rb +3 -1
  30. data/lib/rubyn_code/cli/repl_lifecycle.rb +1 -0
  31. data/lib/rubyn_code/cli/repl_setup.rb +74 -1
  32. data/lib/rubyn_code/config/defaults.rb +3 -0
  33. data/lib/rubyn_code/config/schema.json +49 -0
  34. data/lib/rubyn_code/config/settings.rb +12 -6
  35. data/lib/rubyn_code/config/validator.rb +63 -0
  36. data/lib/rubyn_code/context/context_budget.rb +18 -2
  37. data/lib/rubyn_code/context/context_collapse.rb +34 -4
  38. data/lib/rubyn_code/context/manager.rb +37 -3
  39. data/lib/rubyn_code/context/manual_compact.rb +1 -1
  40. data/lib/rubyn_code/hooks/registry.rb +4 -0
  41. data/lib/rubyn_code/ide/adapters/tool_output.rb +330 -0
  42. data/lib/rubyn_code/ide/client.rb +110 -0
  43. data/lib/rubyn_code/ide/handlers/accept_edit_handler.rb +35 -0
  44. data/lib/rubyn_code/ide/handlers/approve_tool_use_handler.rb +34 -0
  45. data/lib/rubyn_code/ide/handlers/cancel_handler.rb +41 -0
  46. data/lib/rubyn_code/ide/handlers/config_get_handler.rb +63 -0
  47. data/lib/rubyn_code/ide/handlers/config_set_handler.rb +86 -0
  48. data/lib/rubyn_code/ide/handlers/initialize_handler.rb +79 -0
  49. data/lib/rubyn_code/ide/handlers/models_list_handler.rb +39 -0
  50. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +218 -0
  51. data/lib/rubyn_code/ide/handlers/review_handler.rb +127 -0
  52. data/lib/rubyn_code/ide/handlers/session_fork_handler.rb +49 -0
  53. data/lib/rubyn_code/ide/handlers/session_list_handler.rb +41 -0
  54. data/lib/rubyn_code/ide/handlers/session_reset_handler.rb +31 -0
  55. data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +42 -0
  56. data/lib/rubyn_code/ide/handlers/shutdown_handler.rb +37 -0
  57. data/lib/rubyn_code/ide/handlers.rb +76 -0
  58. data/lib/rubyn_code/ide/protocol.rb +112 -0
  59. data/lib/rubyn_code/ide/server.rb +186 -0
  60. data/lib/rubyn_code/index/codebase_index.rb +69 -2
  61. data/lib/rubyn_code/learning/extractor.rb +4 -2
  62. data/lib/rubyn_code/llm/adapters/anthropic.rb +6 -2
  63. data/lib/rubyn_code/llm/adapters/anthropic_compatible.rb +60 -0
  64. data/lib/rubyn_code/llm/adapters/openai_compatible.rb +6 -2
  65. data/lib/rubyn_code/llm/client.rb +29 -4
  66. data/lib/rubyn_code/llm/model_router.rb +2 -1
  67. data/lib/rubyn_code/mcp/config.rb +2 -1
  68. data/lib/rubyn_code/mcp/tool_bridge.rb +1 -1
  69. data/lib/rubyn_code/memory/search.rb +1 -0
  70. data/lib/rubyn_code/observability/usage_reporter.rb +4 -2
  71. data/lib/rubyn_code/output/diff_renderer.rb +3 -2
  72. data/lib/rubyn_code/self_test.rb +316 -0
  73. data/lib/rubyn_code/skills/auto_suggest.rb +131 -0
  74. data/lib/rubyn_code/skills/catalog.rb +76 -0
  75. data/lib/rubyn_code/skills/document.rb +8 -2
  76. data/lib/rubyn_code/skills/gemfile_parser.rb +40 -0
  77. data/lib/rubyn_code/skills/loader.rb +43 -0
  78. data/lib/rubyn_code/skills/matcher.rb +89 -0
  79. data/lib/rubyn_code/skills/pack_context.rb +163 -0
  80. data/lib/rubyn_code/skills/pack_installer.rb +194 -0
  81. data/lib/rubyn_code/skills/pack_manager.rb +230 -0
  82. data/lib/rubyn_code/skills/registry_autoload.rb +112 -0
  83. data/lib/rubyn_code/skills/registry_client.rb +241 -0
  84. data/lib/rubyn_code/tasks/models.rb +1 -0
  85. data/lib/rubyn_code/tools/base.rb +13 -0
  86. data/lib/rubyn_code/tools/bash.rb +5 -0
  87. data/lib/rubyn_code/tools/edit_file.rb +62 -5
  88. data/lib/rubyn_code/tools/executor.rb +65 -8
  89. data/lib/rubyn_code/tools/glob.rb +6 -0
  90. data/lib/rubyn_code/tools/grep.rb +7 -0
  91. data/lib/rubyn_code/tools/ide_diagnostics.rb +53 -0
  92. data/lib/rubyn_code/tools/ide_symbols.rb +55 -0
  93. data/lib/rubyn_code/tools/load_skill.rb +2 -1
  94. data/lib/rubyn_code/tools/output_compressor.rb +9 -7
  95. data/lib/rubyn_code/tools/read_file.rb +6 -0
  96. data/lib/rubyn_code/tools/registry.rb +11 -0
  97. data/lib/rubyn_code/tools/review_pr.rb +15 -4
  98. data/lib/rubyn_code/tools/web_search.rb +2 -1
  99. data/lib/rubyn_code/tools/write_file.rb +17 -0
  100. data/lib/rubyn_code/version.rb +1 -1
  101. data/lib/rubyn_code.rb +34 -0
  102. data/skills/rubyn_self_test.md +88 -1
  103. metadata +43 -1
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module IDE
5
+ module Handlers
6
+ # Handles the "session/resume" JSON-RPC request.
7
+ #
8
+ # Loads a previously persisted session from SessionPersistence and
9
+ # pre-populates the PromptHandler's conversation cache so that the
10
+ # next prompt continues from where the session left off.
11
+ class SessionResumeHandler
12
+ def initialize(server)
13
+ @server = server
14
+ end
15
+
16
+ def call(params)
17
+ session_id = params['sessionId']
18
+ return { 'resumed' => false, 'error' => 'Missing sessionId' } unless session_id
19
+
20
+ persistence = @server.session_persistence
21
+ return { 'resumed' => false, 'error' => 'Session persistence not available' } unless persistence
22
+
23
+ data = persistence.load_session(session_id)
24
+ return { 'resumed' => false, 'error' => 'Session not found' } unless data
25
+
26
+ messages = data[:messages] || []
27
+
28
+ # Pre-populate the prompt handler's conversation cache so the next
29
+ # prompt picks up from the restored history.
30
+ prompt = @server.handler_instance(:prompt)
31
+ if prompt
32
+ conversation = Agent::Conversation.new
33
+ messages.each { |msg| conversation.messages << msg }
34
+ prompt.inject_conversation(session_id, conversation)
35
+ end
36
+
37
+ { 'resumed' => true, 'sessionId' => session_id, 'messages' => messages }
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module IDE
5
+ module Handlers
6
+ # Handles the "shutdown" JSON-RPC request.
7
+ #
8
+ # Triggers session persistence, signals the server to stop its
9
+ # read loop, and returns confirmation.
10
+ class ShutdownHandler
11
+ def initialize(server)
12
+ @server = server
13
+ end
14
+
15
+ def call(_params)
16
+ warn '[ShutdownHandler] shutdown requested'
17
+
18
+ save_session!
19
+ @server.stop!
20
+
21
+ { 'shutdown' => true }
22
+ end
23
+
24
+ private
25
+
26
+ def save_session!
27
+ return unless defined?(RubynCode::Memory::SessionPersistence)
28
+
29
+ persistence = @server.session_persistence
30
+ persistence&.save
31
+ rescue StandardError => e
32
+ warn "[ShutdownHandler] session save failed: #{e.message}"
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'handlers/initialize_handler'
4
+ require_relative 'handlers/prompt_handler'
5
+ require_relative 'handlers/cancel_handler'
6
+ require_relative 'handlers/review_handler'
7
+ require_relative 'handlers/approve_tool_use_handler'
8
+ require_relative 'handlers/accept_edit_handler'
9
+ require_relative 'handlers/shutdown_handler'
10
+ require_relative 'handlers/config_get_handler'
11
+ require_relative 'handlers/config_set_handler'
12
+ require_relative 'handlers/models_list_handler'
13
+ require_relative 'handlers/session_reset_handler'
14
+ require_relative 'handlers/session_list_handler'
15
+ require_relative 'handlers/session_resume_handler'
16
+ require_relative 'handlers/session_fork_handler'
17
+
18
+ module RubynCode
19
+ module IDE
20
+ module Handlers
21
+ # Method name => Handler class mapping.
22
+ REGISTRY = {
23
+ 'initialize' => InitializeHandler,
24
+ 'prompt' => PromptHandler,
25
+ 'cancel' => CancelHandler,
26
+ 'review' => ReviewHandler,
27
+ 'approveToolUse' => ApproveToolUseHandler,
28
+ 'acceptEdit' => AcceptEditHandler,
29
+ 'shutdown' => ShutdownHandler,
30
+ 'config/get' => ConfigGetHandler,
31
+ 'config/set' => ConfigSetHandler,
32
+ 'models/list' => ModelsListHandler,
33
+ 'session/reset' => SessionResetHandler,
34
+ 'session/list' => SessionListHandler,
35
+ 'session/resume' => SessionResumeHandler,
36
+ 'session/fork' => SessionForkHandler
37
+ }.freeze
38
+
39
+ # Short name => method name mapping (for handler_instance lookups).
40
+ SHORT_NAMES = {
41
+ prompt: 'prompt',
42
+ cancel: 'cancel',
43
+ review: 'review',
44
+ approve_tool_use: 'approveToolUse',
45
+ accept_edit: 'acceptEdit',
46
+ shutdown: 'shutdown',
47
+ initialize: 'initialize',
48
+ config_get: 'config/get',
49
+ config_set: 'config/set',
50
+ models_list: 'models/list',
51
+ session_reset: 'session/reset',
52
+ session_list: 'session/list',
53
+ session_resume: 'session/resume',
54
+ session_fork: 'session/fork'
55
+ }.freeze
56
+
57
+ # Register all handlers on the given server instance.
58
+ #
59
+ # @param server [RubynCode::IDE::Server] the IDE server
60
+ def self.register_all(server)
61
+ instances = {}
62
+
63
+ REGISTRY.each do |method, handler_class|
64
+ handler = handler_class.new(server)
65
+ instances[method] = handler
66
+
67
+ server.on(method) do |params, _id|
68
+ handler.call(params)
69
+ end
70
+ end
71
+
72
+ server.handler_instances = instances
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module RubynCode
6
+ module IDE
7
+ # JSON-RPC 2.0 protocol layer for the IDE server.
8
+ # Pure data — no side effects, no I/O beyond JSON serialisation.
9
+ module Protocol
10
+ JSONRPC_VERSION = '2.0'
11
+
12
+ # ── Standard JSON-RPC 2.0 error codes ──────────────────────────────
13
+ PARSE_ERROR = -32_700
14
+ INVALID_REQUEST = -32_600
15
+ METHOD_NOT_FOUND = -32_601
16
+ INVALID_PARAMS = -32_602
17
+ INTERNAL_ERROR = -32_603
18
+
19
+ # ── Custom error codes ─────────────────────────────────────────────
20
+ AGENT_BUSY = -1
21
+ SESSION_NOT_FOUND = -2
22
+ BUDGET_EXCEEDED = -3
23
+
24
+ module_function
25
+
26
+ # Parse a JSON string into a request hash.
27
+ # Returns either a valid request hash or an error response hash.
28
+ # -- JSON-RPC validation checks
29
+ def parse(line)
30
+ begin
31
+ data = JSON.parse(line)
32
+ rescue JSON::ParserError
33
+ return error(nil, PARSE_ERROR, 'Parse error: invalid JSON')
34
+ end
35
+
36
+ return error(nil, INVALID_REQUEST, 'Invalid request: expected JSON object') unless data.is_a?(Hash)
37
+
38
+ unless data['jsonrpc'] == JSONRPC_VERSION
39
+ return error(data['id'], INVALID_REQUEST, 'Invalid request: missing or wrong "jsonrpc" version')
40
+ end
41
+
42
+ # Response objects (containing "result" or "error") are valid
43
+ # JSON-RPC 2.0 messages that don't carry a "method".
44
+ is_response = data.key?('result') || data.key?('error')
45
+
46
+ unless is_response || data['method'].is_a?(String)
47
+ return error(data['id'], INVALID_REQUEST, 'Invalid request: "method" must be a string')
48
+ end
49
+
50
+ if data.key?('params') && !data['params'].is_a?(Hash) && !data['params'].is_a?(Array)
51
+ return error(data['id'], INVALID_PARAMS, 'Invalid params: "params" must be an object or array')
52
+ end
53
+
54
+ data
55
+ end
56
+
57
+ # Build a success response hash.
58
+ def response(id, result)
59
+ {
60
+ 'jsonrpc' => JSONRPC_VERSION,
61
+ 'id' => id,
62
+ 'result' => stringify_keys_deep(result)
63
+ }
64
+ end
65
+
66
+ # Build an error response hash.
67
+ def error(id, code, message)
68
+ {
69
+ 'jsonrpc' => JSONRPC_VERSION,
70
+ 'id' => id,
71
+ 'error' => {
72
+ 'code' => code,
73
+ 'message' => message
74
+ }
75
+ }
76
+ end
77
+
78
+ # Build a notification hash (no id).
79
+ def notification(method, params)
80
+ {
81
+ 'jsonrpc' => JSONRPC_VERSION,
82
+ 'method' => method,
83
+ 'params' => stringify_keys_deep(params)
84
+ }
85
+ end
86
+
87
+ # Serialise a hash to a JSON string terminated by a newline.
88
+ def serialize(hash)
89
+ "#{JSON.generate(hash)}\n"
90
+ end
91
+
92
+ # ── Helpers ────────────────────────────────────────────────────────
93
+
94
+ # Recursively convert symbol keys to strings so every hash that
95
+ # leaves this module uses string keys for JSON compatibility.
96
+ def stringify_keys_deep(obj)
97
+ case obj
98
+ when Hash
99
+ obj.each_with_object({}) do |(k, v), memo|
100
+ memo[k.to_s] = stringify_keys_deep(v)
101
+ end
102
+ when Array
103
+ obj.map { |v| stringify_keys_deep(v) }
104
+ else
105
+ obj
106
+ end
107
+ end
108
+
109
+ private_class_method :stringify_keys_deep
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative 'protocol'
5
+ require_relative 'client'
6
+ require_relative 'handlers'
7
+
8
+ module RubynCode
9
+ module IDE
10
+ # JSON-RPC 2.0 server for the VS Code extension.
11
+ #
12
+ # Reads newline-delimited JSON from $stdin, dispatches each request
13
+ # to a handler, and writes JSON-RPC responses/notifications to $stdout.
14
+ # All debug output goes to $stderr — never protocol data.
15
+ #
16
+ # Processes one request at a time on the main thread.
17
+ class Server
18
+ # Attributes set by handlers during the session lifecycle.
19
+ attr_accessor :workspace_path, :extension_version, :client_capabilities,
20
+ :session_persistence, :handler_instances, :tool_output_adapter,
21
+ :permission_mode
22
+ attr_reader :ide_client
23
+
24
+ def initialize(permission_mode: :default, yolo: false)
25
+ @permission_mode = yolo ? :bypass : permission_mode.to_sym
26
+ @running = false
27
+ @write_mutex = Mutex.new
28
+ @handlers = {}
29
+ @handler_instances = {}
30
+ @workspace_path = nil
31
+ @extension_version = nil
32
+ @client_capabilities = {}
33
+ @session_persistence = nil
34
+ @tool_output_adapter = nil
35
+ @ide_client = Client.new(self)
36
+
37
+ Handlers.register_all(self)
38
+ end
39
+
40
+ # Backward-compatible reader: true when permission_mode is :bypass.
41
+ def yolo
42
+ @permission_mode == :bypass
43
+ end
44
+
45
+ def run
46
+ @running = true
47
+ setup_signal_traps!
48
+
49
+ warn "[IDE::Server] started (pid=#{Process.pid})"
50
+ $stdout.sync = true
51
+
52
+ read_loop
53
+ ensure
54
+ graceful_shutdown!
55
+ end
56
+
57
+ # ── Public helpers ──────────────────────────────────────────────
58
+
59
+ # Send a JSON-RPC notification (no id) to stdout.
60
+ def notify(method, params = {})
61
+ write(Protocol.notification(method, params))
62
+ end
63
+
64
+ # Register a handler for a given JSON-RPC method.
65
+ # The block receives (params, id) and must return a result hash.
66
+ def on(method, &block)
67
+ @handlers[method] = block
68
+ end
69
+
70
+ # Look up a handler instance by its short name (e.g. :prompt, :cancel).
71
+ # Returns nil if the handler is not registered.
72
+ def handler_instance(short_name)
73
+ method_name = Handlers::SHORT_NAMES[short_name.to_sym]
74
+ return nil unless method_name
75
+
76
+ @handler_instances[method_name]
77
+ end
78
+
79
+ # Signal the server to stop its read loop.
80
+ def stop!
81
+ @running = false
82
+ end
83
+
84
+ private
85
+
86
+ # ── Main loop ───────────────────────────────────────────────────
87
+
88
+ def read_loop
89
+ while @running
90
+ line = $stdin.gets
91
+ break if line.nil? # EOF — client disconnected
92
+
93
+ line = line.strip
94
+ next if line.empty?
95
+
96
+ handle_line(line)
97
+ end
98
+ end
99
+
100
+ def handle_line(line)
101
+ msg = Protocol.parse(line)
102
+
103
+ # Protocol.parse returns an error response hash when parsing fails.
104
+ if msg.key?('error')
105
+ write(msg)
106
+ return
107
+ end
108
+
109
+ dispatch(msg)
110
+ rescue StandardError => e
111
+ warn "[IDE::Server] error handling message: #{e.message}"
112
+ warn e.backtrace&.first(5)&.join("\n")
113
+
114
+ id = msg.is_a?(Hash) ? msg['id'] : nil
115
+ write(Protocol.error(id, Protocol::INTERNAL_ERROR, "Internal error: #{e.message}"))
116
+ end
117
+
118
+ # ── Dispatch ────────────────────────────────────────────────────
119
+
120
+ def dispatch(msg)
121
+ # Response messages from the extension (for our outbound requests via ide_client).
122
+ # These have id + (result or error) but no method.
123
+ if !msg.key?('method') && msg.key?('id') && (msg.key?('result') || msg.key?('error'))
124
+ @ide_client.resolve(
125
+ msg['id'],
126
+ result: msg['result'],
127
+ error: msg['error']
128
+ )
129
+ return
130
+ end
131
+
132
+ method = msg['method']
133
+ params = msg['params'] || {}
134
+ id = msg['id']
135
+
136
+ handler = @handlers[method]
137
+
138
+ unless handler
139
+ write(Protocol.error(id, Protocol::METHOD_NOT_FOUND, "Method not found: #{method}")) if id
140
+ return
141
+ end
142
+
143
+ result = handler.call(params, id)
144
+
145
+ # Only send a response for requests (those with an id).
146
+ # Notifications (no id) do not get responses.
147
+ write(Protocol.response(id, result)) if id
148
+ end
149
+
150
+ # ── Wire output ─────────────────────────────────────────────────
151
+
152
+ def write(hash)
153
+ serialized = Protocol.serialize(hash)
154
+ @write_mutex.synchronize do
155
+ $stdout.write(serialized)
156
+ $stdout.flush
157
+ end
158
+ end
159
+
160
+ # ── Signal handling ─────────────────────────────────────────────
161
+
162
+ def setup_signal_traps!
163
+ %w[TERM INT].each do |sig|
164
+ trap(sig) do
165
+ warn "[IDE::Server] received SIG#{sig}, shutting down"
166
+ @running = false
167
+ end
168
+ end
169
+ end
170
+
171
+ # ── Shutdown ────────────────────────────────────────────────────
172
+
173
+ def graceful_shutdown!
174
+ warn '[IDE::Server] shutting down'
175
+ save_session!
176
+ end
177
+
178
+ def save_session!
179
+ # Delegate to Memory::SessionPersistence if available.
180
+ @session_persistence.save if defined?(RubynCode::Memory::SessionPersistence) && @session_persistence
181
+ rescue StandardError => e
182
+ warn "[IDE::Server] session save failed: #{e.message}"
183
+ end
184
+ end
185
+ end
186
+ end
@@ -9,9 +9,10 @@ module RubynCode
9
9
  # Stores classes, modules, methods, associations, and Rails edges in a
10
10
  # JSON file for fast session startup. First build scans all .rb files;
11
11
  # incremental updates re-index only changed files.
12
- class CodebaseIndex
12
+ class CodebaseIndex # rubocop:disable Metrics/ClassLength -- structural summary methods
13
13
  INDEX_DIR = '.rubyn-code'
14
14
  INDEX_FILE = 'codebase_index.json'
15
+ CHARS_PER_TOKEN = 4
15
16
 
16
17
  attr_reader :nodes, :edges, :index_path
17
18
 
@@ -103,6 +104,20 @@ module RubynCode
103
104
  lines.join("\n")
104
105
  end
105
106
 
107
+ # Structural map for system prompt: model names with associations,
108
+ # controllers, and service objects. Capped to stay within token budget.
109
+ def to_structural_summary(max_tokens: 500)
110
+ budget = max_tokens * CHARS_PER_TOKEN
111
+ lines = ['Codebase Structure:']
112
+
113
+ append_model_section(lines)
114
+ append_controller_section(lines)
115
+ append_service_section(lines)
116
+ append_stats_section(lines)
117
+
118
+ truncate_to_budget(lines, budget)
119
+ end
120
+
106
121
  def stats
107
122
  {
108
123
  files_indexed: @file_mtimes.size,
@@ -113,6 +128,57 @@ module RubynCode
113
128
 
114
129
  private
115
130
 
131
+ def append_model_section(lines)
132
+ models = @nodes.select { |n| n['type'] == 'model' && (n['name'] || '').match?(/\A[A-Z]/) }
133
+ return if models.empty?
134
+
135
+ lines << 'Models:'
136
+ models.each do |model|
137
+ assocs = associations_for_file(model['file'])
138
+ desc = assocs.empty? ? model['name'] : "#{model['name']} #{assocs.join(', ')}"
139
+ lines << " #{desc}"
140
+ end
141
+ end
142
+
143
+ def append_controller_section(lines)
144
+ controllers = @nodes.select { |n| n['type'] == 'controller' && (n['name'] || '').match?(/\A[A-Z]/) }
145
+ return if controllers.empty?
146
+
147
+ lines << 'Controllers:'
148
+ controllers.each { |c| lines << " #{c['name']} (#{c['file']})" }
149
+ end
150
+
151
+ def append_service_section(lines)
152
+ services = @nodes.select { |n| n['type'] == 'service' && (n['name'] || '').match?(/\A[A-Z]/) }
153
+ return if services.empty?
154
+
155
+ lines << 'Services:'
156
+ services.each { |s| lines << " #{s['name']} (#{s['file']})" }
157
+ end
158
+
159
+ def append_stats_section(lines)
160
+ counts = node_type_counts
161
+ lines << "Stats: #{counts['class'] || 0} classes, #{counts['method'] || 0} methods, #{@edges.size} edges"
162
+ end
163
+
164
+ def associations_for_file(file)
165
+ @edges.select { |e| e['from'] == file && e['relationship'] == 'association' }
166
+ .map { |e| "#{e['type']} :#{e['to']}" }
167
+ end
168
+
169
+ def truncate_to_budget(lines, budget)
170
+ result = []
171
+ total = 0
172
+ lines.each do |line|
173
+ line_size = line.bytesize + 1 # +1 for newline
174
+ break if total + line_size > budget
175
+
176
+ result << line
177
+ total += line_size
178
+ end
179
+ result.join("\n")
180
+ end
181
+
116
182
  def edges_involving(names)
117
183
  @edges.select do |e|
118
184
  names.include?(e['from']) || names.include?(e['to'])
@@ -194,7 +260,8 @@ module RubynCode
194
260
  end
195
261
  end
196
262
 
197
- def classify_node(file, type) # rubocop:disable Metrics/CyclomaticComplexity -- Rails directory mapping
263
+ # -- Rails directory mapping
264
+ def classify_node(file, type)
198
265
  return 'model' if file.include?('app/models/')
199
266
  return 'controller' if file.include?('app/controllers/')
200
267
  return 'service' if file.include?('app/services/')
@@ -82,7 +82,8 @@ module RubynCode
82
82
  messages.map { |m| format_turn(m) }.join("\n\n")
83
83
  end
84
84
 
85
- def format_turn(msg) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- content polymorphism
85
+ # -- content polymorphism
86
+ def format_turn(msg)
86
87
  role = (msg[:role] || msg['role'] || 'unknown').capitalize
87
88
  content = msg[:content] || msg['content']
88
89
  text = if content.is_a?(Array)
@@ -121,7 +122,8 @@ module RubynCode
121
122
  )
122
123
  end
123
124
 
124
- def parse_response(response) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- response parsing with multiple fallbacks
125
+ # -- response parsing with multiple fallbacks
126
+ def parse_response(response)
125
127
  return [] if response.nil?
126
128
 
127
129
  text = if response.respond_to?(:content)
@@ -48,6 +48,10 @@ module RubynCode
48
48
 
49
49
  private
50
50
 
51
+ def api_url
52
+ API_URL
53
+ end
54
+
51
55
  # -- Auth ---------------------------------------------------------
52
56
 
53
57
  def oauth_token?
@@ -88,7 +92,7 @@ module RubynCode
88
92
  end
89
93
 
90
94
  def post_request(body)
91
- connection.post(API_URL) do |req|
95
+ connection.post(api_url) do |req|
92
96
  apply_headers(req)
93
97
  req.body = JSON.generate(body)
94
98
  end
@@ -113,7 +117,7 @@ module RubynCode
113
117
  streamer = build_streamer(on_text)
114
118
  error_chunks = []
115
119
 
116
- response = streaming_connection.post(API_URL) do |req|
120
+ response = streaming_connection.post(api_url) do |req|
117
121
  apply_headers(req)
118
122
  req.body = JSON.generate(body)
119
123
  req.options.on_data = on_data_proc(streamer, error_chunks)
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module LLM
5
+ module Adapters
6
+ # Adapter for Anthropic-compatible providers that use the Messages API format.
7
+ #
8
+ # Inherits all Anthropic logic but overrides the base URL, provider name,
9
+ # available models, and API key resolution.
10
+ class AnthropicCompatible < Anthropic
11
+ def initialize(provider:, base_url:, api_key: nil, available_models: [])
12
+ super()
13
+ @provider = provider
14
+ @base_url = base_url
15
+ @api_key = api_key
16
+ @available_models = available_models.freeze
17
+ end
18
+
19
+ def provider_name
20
+ @provider
21
+ end
22
+
23
+ def models
24
+ @available_models
25
+ end
26
+
27
+ private
28
+
29
+ def api_url
30
+ "#{@base_url}/messages"
31
+ end
32
+
33
+ def ensure_valid_token!
34
+ resolve_api_key # raises if missing
35
+ end
36
+
37
+ def oauth_token?
38
+ false
39
+ end
40
+
41
+ def access_token
42
+ resolve_api_key
43
+ end
44
+
45
+ def resolve_api_key
46
+ return @api_key if @api_key
47
+
48
+ stored = Auth::TokenStore.load_provider_key(@provider)
49
+ return stored if stored
50
+
51
+ env_key = "#{@provider.upcase.tr('-', '_')}_API_KEY"
52
+ ENV.fetch(env_key) do
53
+ raise Client::AuthExpiredError,
54
+ "No #{@provider} API key configured. Set with: /provider set-key #{@provider} <key>"
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -27,11 +27,15 @@ module RubynCode
27
27
  def resolve_api_key
28
28
  return @api_key if @api_key
29
29
 
30
- env_key = "#{@provider.upcase}_API_KEY"
30
+ stored = Auth::TokenStore.load_provider_key(@provider)
31
+ return stored if stored
32
+
33
+ env_key = "#{@provider.upcase.tr('-', '_')}_API_KEY"
31
34
  ENV.fetch(env_key) do
32
35
  return 'no-key-required' if local_provider?
33
36
 
34
- raise Client::AuthExpiredError, "No #{@provider} API key configured. Set #{env_key}."
37
+ raise Client::AuthExpiredError,
38
+ "No #{@provider} API key configured. Set with: /provider set-key #{@provider} <key>"
35
39
  end
36
40
  end
37
41