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
@@ -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
 
@@ -18,8 +18,9 @@ module RubynCode
18
18
  attr_accessor :model
19
19
 
20
20
  def initialize(model: nil, provider: nil, adapter: nil)
21
- @model = model || Config::Defaults::DEFAULT_MODEL
22
- @provider = provider || Config::Defaults::DEFAULT_PROVIDER
21
+ settings = Config::Settings.new
22
+ @model = model || settings.model
23
+ @provider = provider || settings.provider
23
24
  @adapter = adapter || resolve_adapter(@provider)
24
25
  end
25
26
 
@@ -65,6 +66,22 @@ module RubynCode
65
66
 
66
67
  private
67
68
 
69
+ def build_custom_adapter(provider, config, base_url, available_models)
70
+ case config.fetch('api_format', 'openai')
71
+ when 'anthropic'
72
+ Adapters::AnthropicCompatible.new(provider: provider, base_url: base_url, available_models: available_models)
73
+ else
74
+ Adapters::OpenAICompatible.new(provider: provider, base_url: base_url, available_models: available_models)
75
+ end
76
+ end
77
+
78
+ def extract_model_names(config)
79
+ raw = config&.dig('models')
80
+ return [] unless raw
81
+
82
+ raw.is_a?(Hash) ? raw.values : Array(raw)
83
+ end
84
+
68
85
  # Builds the appropriate adapter for a given provider name.
69
86
  def resolve_adapter(provider)
70
87
  case provider
@@ -74,12 +91,20 @@ module RubynCode
74
91
  config = Config::Settings.new.provider_config(provider)
75
92
  base_url = config&.fetch('base_url', nil)
76
93
 
94
+ if config.nil?
95
+ raise ConfigError,
96
+ "Unknown provider '#{provider}'. " \
97
+ "Add it to config.yml under providers.#{provider} with base_url, env_key, and models."
98
+ end
99
+
77
100
  unless base_url
78
101
  raise ConfigError,
79
- "Unknown provider '#{provider}'. Add base_url to config.yml under providers.#{provider}"
102
+ "Provider '#{provider}' is missing base_url in config.yml. " \
103
+ "Add base_url under providers.#{provider} (e.g., base_url: https://api.#{provider}.com/v1)"
80
104
  end
81
105
 
82
- Adapters::OpenAICompatible.new(provider: provider, base_url: base_url)
106
+ available_models = extract_model_names(config)
107
+ build_custom_adapter(provider, config, base_url, available_models)
83
108
  end
84
109
  end
85
110
  end
@@ -49,7 +49,8 @@ module RubynCode
49
49
  def parse_servers(servers)
50
50
  servers.map do |name, server_def|
51
51
  { name: name, command: server_def['command'],
52
- args: Array(server_def['args']), env: expand_env(server_def['env'] || {}) }
52
+ args: Array(server_def['args']), env: expand_env(server_def['env'] || {}),
53
+ url: server_def['url'], timeout: server_def['timeout'] }
53
54
  end
54
55
  end
55
56
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
+ require_relative 'models'
4
5
 
5
6
  module RubynCode
6
7
  module Memory
@@ -0,0 +1,315 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module RubynCode
6
+ # Programmatic smoke test that exercises every major subsystem.
7
+ # Mirrors the checks in skills/rubyn_self_test.md but runs without
8
+ # the REPL or an LLM — suitable for CI and rake tasks.
9
+ #
10
+ # rubocop:disable Metrics/ClassLength -- intentionally comprehensive
11
+ class SelfTest
12
+ Result = Data.define(:name, :passed, :detail)
13
+
14
+ attr_reader :results, :project_root
15
+
16
+ def initialize(project_root: Dir.pwd)
17
+ @project_root = project_root
18
+ @results = []
19
+ end
20
+
21
+ def run! # rubocop:disable Naming/PredicateMethod -- returns bool but primary purpose is side effects
22
+ @results = []
23
+
24
+ check_file_operations
25
+ check_search
26
+ check_bash
27
+ check_git
28
+ check_specs
29
+ check_compressor_strategies
30
+ check_skills
31
+ check_config
32
+ check_codebase_index
33
+ check_slash_commands
34
+ check_architecture
35
+
36
+ print_scorecard
37
+ results.all?(&:passed)
38
+ end
39
+
40
+ private
41
+
42
+ # ── 1. File Operations ──────────────────────────────────────────
43
+
44
+ def check_file_operations
45
+ check_file_read
46
+ check_file_write_edit_cleanup
47
+ rescue StandardError => e
48
+ record('File operations', false, e.message)
49
+ end
50
+
51
+ def check_file_read
52
+ content = File.read(File.join(project_root, 'lib/rubyn_code/version.rb'))
53
+ record('File read (version.rb)', content.include?('VERSION ='))
54
+ end
55
+
56
+ def check_file_write_edit_cleanup # rubocop:disable Metrics/AbcSize -- sequential file ops
57
+ tmp = File.join(project_root, '.rubyn-code/self_test_tmp.rb')
58
+ FileUtils.mkdir_p(File.dirname(tmp))
59
+
60
+ File.write(tmp, '# self-test')
61
+ record('File write (tmp)', File.exist?(tmp))
62
+
63
+ File.write(tmp, File.read(tmp).sub('# self-test', '# self-test passed'))
64
+ record('File edit (tmp)', File.read(tmp).include?('# self-test passed'))
65
+
66
+ File.delete(tmp)
67
+ record('File cleanup (tmp)', !File.exist?(tmp))
68
+ end
69
+
70
+ # ── 2. Search ───────────────────────────────────────────────────
71
+
72
+ def check_search
73
+ rb_files = Dir.glob(File.join(project_root, 'lib/**/*.rb'))
74
+ record('Glob lib/**/*.rb', rb_files.size >= 50, "#{rb_files.size} files")
75
+
76
+ base_classes = rb_files.count { |f| File.read(f).match?(/class.*Base/) }
77
+ record('Grep class.*Base', base_classes >= 3, "#{base_classes} matches")
78
+ rescue StandardError => e
79
+ record('Search', false, e.message)
80
+ end
81
+
82
+ # ── 3. Bash ─────────────────────────────────────────────────────
83
+
84
+ def check_bash
85
+ ruby_v = `ruby --version 2>&1`.strip
86
+ record('Bash: ruby --version', ruby_v.include?('ruby'), ruby_v)
87
+
88
+ rubocop_v = `bundle exec rubocop --version 2>&1`.strip
89
+ record('Bash: rubocop --version', rubocop_v.match?(/\d+\.\d+/), rubocop_v)
90
+ rescue StandardError => e
91
+ record('Bash', false, e.message)
92
+ end
93
+
94
+ # ── 4. Git ──────────────────────────────────────────────────────
95
+
96
+ def check_git
97
+ run_cmd('git status --short')
98
+ record('Git status', true)
99
+
100
+ log = run_cmd('git log --oneline -3')
101
+ record('Git log', log.match?(/^[0-9a-f]+/), log.lines.first&.strip)
102
+
103
+ run_cmd('git diff --stat')
104
+ record('Git diff', true)
105
+ rescue StandardError => e
106
+ record('Git', false, e.message)
107
+ end
108
+
109
+ # ── 5. Specs ────────────────────────────────────────────────────
110
+
111
+ def check_specs
112
+ %w[
113
+ spec/rubyn_code/tools/output_compressor_spec.rb
114
+ spec/rubyn_code/llm/model_router_spec.rb
115
+ ].each { |spec| run_single_spec(spec) }
116
+ rescue StandardError => e
117
+ record('Specs', false, e.message)
118
+ end
119
+
120
+ def run_single_spec(spec)
121
+ path = File.join(project_root, spec)
122
+ unless File.exist?(path)
123
+ record("RSpec: #{File.basename(spec)}", false, 'file not found')
124
+ return
125
+ end
126
+ output = run_cmd("bundle exec rspec #{path} --format progress 2>&1")
127
+ record("RSpec: #{File.basename(spec)}", output.include?('0 failures'))
128
+ end
129
+
130
+ # ── 6. Output Compressor ────────────────────────────────────────
131
+
132
+ def check_compressor_strategies
133
+ compressor = Tools::OutputCompressor.new
134
+ verified = 0
135
+
136
+ verified += verify_head_tail(compressor)
137
+ verified += verify_spec_summary(compressor)
138
+ verified += verify_top_matches(compressor)
139
+ verified += verify_tree_collapse(compressor)
140
+ verified += verify_diff_hunks(compressor)
141
+
142
+ record('Compression strategies verified', verified >= 3, "#{verified}/5 active")
143
+ rescue StandardError => e
144
+ record('Output compressor', false, e.message)
145
+ end
146
+
147
+ def verify_head_tail(compressor)
148
+ big = (1..5000).to_a.join("\n")
149
+ compressed = compressor.compress('bash', big)
150
+ pass = compressed.length < big.length
151
+ record('Compressor: head_tail', pass)
152
+ pass ? 1 : 0
153
+ end
154
+
155
+ def verify_spec_summary(compressor)
156
+ spec_out = run_cmd(
157
+ 'bundle exec rspec spec/rubyn_code/tools/base_spec.rb --format documentation 2>&1'
158
+ )
159
+ compressed = compressor.compress('run_specs', spec_out)
160
+ pass = compressed.length < spec_out.length || compressed.include?('0 failures')
161
+ record('Compressor: spec_summary', pass)
162
+ pass ? 1 : 0
163
+ end
164
+
165
+ def verify_top_matches(compressor)
166
+ grep_out = rb_files_with_def.join("\n")
167
+ compressed = compressor.compress('grep', grep_out)
168
+ pass = compressed.length <= grep_out.length
169
+ record('Compressor: top_matches', pass)
170
+ pass ? 1 : 0
171
+ end
172
+
173
+ def verify_tree_collapse(compressor)
174
+ all_rb = Dir.glob(File.join(project_root, '**/*.rb')).join("\n")
175
+ compressed = compressor.compress('glob', all_rb)
176
+ pass = compressed.length <= all_rb.length
177
+ record('Compressor: tree_collapse', pass)
178
+ pass ? 1 : 0
179
+ end
180
+
181
+ def verify_diff_hunks(compressor)
182
+ diff = run_cmd('git log --oneline -1 --format=%H | xargs -I{} git diff {}~5..{} 2>/dev/null')
183
+ if diff.strip.empty?
184
+ record('Compressor: diff_hunks', true, 'SKIP — diff too small')
185
+ return 0
186
+ end
187
+ pass = compressor.compress('git_diff', diff).length <= diff.length
188
+ record('Compressor: diff_hunks', pass)
189
+ pass ? 1 : 0
190
+ end
191
+
192
+ # ── 7. Skills ───────────────────────────────────────────────────
193
+
194
+ def check_skills
195
+ catalog = Skills::Catalog.new(project_root)
196
+ skills = catalog.available
197
+ record('Skills catalog', skills.size >= 10, "#{skills.size} skills")
198
+ rescue StandardError => e
199
+ record('Skills', false, e.message)
200
+ end
201
+
202
+ # ── 8. Config ───────────────────────────────────────────────────
203
+
204
+ def check_config
205
+ config_path = File.expand_path('~/.rubyn-code/config.yml')
206
+ if File.exist?(config_path)
207
+ record('Config (config.yml)', File.read(config_path).include?('provider'))
208
+ else
209
+ record('Config (config.yml)', false, 'not found')
210
+ end
211
+
212
+ profile = File.join(project_root, '.rubyn-code/project_profile.yml')
213
+ record('Config (project_profile)', File.exist?(profile),
214
+ File.exist?(profile) ? 'exists' : 'SKIP — first session')
215
+ rescue StandardError => e
216
+ record('Config', false, e.message)
217
+ end
218
+
219
+ # ── 9. Codebase Index ───────────────────────────────────────────
220
+
221
+ def check_codebase_index
222
+ path = File.join(project_root, '.rubyn-code/codebase_index.json')
223
+ record('Codebase index', File.exist?(path),
224
+ File.exist?(path) ? 'exists' : 'SKIP — first session')
225
+ rescue StandardError => e
226
+ record('Codebase index', false, e.message)
227
+ end
228
+
229
+ # ── 10. Slash Commands ──────────────────────────────────────────
230
+
231
+ def check_slash_commands
232
+ cmd_dir = File.join(project_root, 'lib/rubyn_code/cli/commands')
233
+ infra = %w[base.rb context.rb registry.rb]
234
+ cmds = Dir.glob(File.join(cmd_dir, '*.rb')).reject { |f| infra.include?(File.basename(f)) }
235
+ record('Slash commands', cmds.size >= 15, "#{cmds.size} commands")
236
+ rescue StandardError => e
237
+ record('Slash commands', false, e.message)
238
+ end
239
+
240
+ # ── 11. Architecture ────────────────────────────────────────────
241
+
242
+ def check_architecture
243
+ check_autoloads
244
+ check_layer_dirs
245
+ check_core_modules
246
+ rescue StandardError => e
247
+ record('Architecture', false, e.message)
248
+ end
249
+
250
+ def check_autoloads
251
+ content = File.read(File.join(project_root, 'lib/rubyn_code.rb'))
252
+ autoloads = content.scan('autoload').size
253
+ record('Autoload entries', autoloads >= 40, "#{autoloads} entries")
254
+ end
255
+
256
+ def check_layer_dirs
257
+ dirs = Dir.glob(File.join(project_root, 'lib/rubyn_code/*/'))
258
+ record('Layer directories', dirs.size >= 14, "#{dirs.size} dirs")
259
+ end
260
+
261
+ def check_core_modules
262
+ content = File.read(File.join(project_root, 'lib/rubyn_code.rb'))
263
+ core = %w[Agent Tools Context Skills Memory Observability Learning]
264
+ found = core.select { |m| content.include?("module #{m}") }
265
+ record('Core modules', found.size == core.size, "#{found.size}/#{core.size}")
266
+ end
267
+
268
+ # ── Helpers ─────────────────────────────────────────────────────
269
+
270
+ def record(name, passed, detail = nil)
271
+ @results << Result.new(name: name, passed: passed, detail: detail)
272
+ end
273
+
274
+ def run_cmd(cmd)
275
+ `cd #{project_root} && #{cmd} 2>&1`.strip
276
+ end
277
+
278
+ def rb_files_with_def
279
+ Dir.glob(File.join(project_root, 'lib/**/*.rb')).flat_map do |f|
280
+ File.readlines(f).select { |l| l.include?('def ') }.map { |l| "#{f}:#{l.strip}" }
281
+ end
282
+ end
283
+
284
+ def print_scorecard
285
+ puts
286
+ puts 'Rubyn Self-Test Results'
287
+ puts '=' * 50
288
+ results.each_with_index { |r, i| print_result(r, i + 1) }
289
+ print_summary
290
+ end
291
+
292
+ def print_result(result, num)
293
+ icon = result.passed ? "\e[32m✅\e[0m" : "\e[31m❌\e[0m"
294
+ suffix = result.detail ? " — #{result.detail}" : ''
295
+ puts format(' %2<num>d. %<icon>s %<name>s%<suffix>s',
296
+ num: num, icon: icon, name: result.name, suffix: suffix)
297
+ end
298
+
299
+ def print_summary
300
+ passed = results.count(&:passed)
301
+ total = results.size
302
+ pct = total.positive? ? (passed * 100.0 / total).round : 0
303
+ failed = total - passed
304
+
305
+ puts '=' * 50
306
+ if failed.zero?
307
+ puts "\e[32mScore: #{passed}/#{total} (#{pct}%) — All systems go!\e[0m"
308
+ else
309
+ puts "\e[33mScore: #{passed}/#{total} (#{pct}%) — #{failed} failures\e[0m"
310
+ end
311
+ puts
312
+ end
313
+ end
314
+ # rubocop:enable Metrics/ClassLength
315
+ end
@@ -24,11 +24,53 @@ module RubynCode
24
24
  @index
25
25
  end
26
26
 
27
+ def list
28
+ available.map { |e| e[:name] }
29
+ end
30
+
27
31
  def find(name)
28
32
  entry = available.find { |e| e[:name] == name.to_s }
29
33
  entry&.fetch(:path)
30
34
  end
31
35
 
36
+ # Search skill content — matches against names, descriptions, and tags.
37
+ # Returns matching entries sorted by relevance (number of field matches).
38
+ #
39
+ # @param term [String] search term (case-insensitive)
40
+ # @return [Array<Hash>] matching entries with :name, :description, :path, :relevance
41
+ def search(term)
42
+ pattern = /#{Regexp.escape(term)}/i
43
+ matches = available.filter_map do |entry|
44
+ relevance = compute_relevance(entry, pattern)
45
+ next if relevance.zero?
46
+
47
+ entry.merge(relevance: relevance)
48
+ end
49
+ matches.sort_by { |e| -e[:relevance] }
50
+ end
51
+
52
+ # Filter skills by category (subdirectory name).
53
+ # Skills are organized in subdirectories under each skills_dir.
54
+ #
55
+ # @param category [String] category/directory name (e.g. "rails", "testing")
56
+ # @return [Array<Hash>] matching entries
57
+ def by_category(category)
58
+ normalized = category.to_s.downcase
59
+ available.select do |entry|
60
+ path_category(entry[:path]).downcase == normalized
61
+ end
62
+ end
63
+
64
+ # Return the list of unique categories derived from skill file paths.
65
+ #
66
+ # @return [Array<String>] sorted category names
67
+ def categories
68
+ available.map { |e| path_category(e[:path]) }
69
+ .reject(&:empty?)
70
+ .uniq
71
+ .sort
72
+ end
73
+
32
74
  private
33
75
 
34
76
  def build_index
@@ -60,11 +102,35 @@ module RubynCode
60
102
  {
61
103
  name: name,
62
104
  description: doc.description,
105
+ tags: doc.tags,
63
106
  path: File.expand_path(path)
64
107
  }
65
108
  rescue StandardError
66
109
  nil
67
110
  end
111
+
112
+ def compute_relevance(entry, pattern)
113
+ score = 0
114
+ score += 3 if entry[:name].to_s.match?(pattern)
115
+ score += 2 if entry[:description].to_s.match?(pattern)
116
+ Array(entry[:tags]).each { |tag| score += 1 if tag.to_s.match?(pattern) }
117
+ score
118
+ end
119
+
120
+ # Derive a category from the skill file path.
121
+ # The category is the immediate parent directory name relative to one of
122
+ # the skills_dirs. Skills at the top level of a skills_dir have no category.
123
+ def path_category(path)
124
+ skills_dirs.each do |dir|
125
+ expanded = File.expand_path(dir)
126
+ next unless path.start_with?(expanded)
127
+
128
+ relative = path.delete_prefix("#{expanded}/")
129
+ parts = relative.split('/')
130
+ return parts.size > 1 ? parts.first : ''
131
+ end
132
+ ''
133
+ end
68
134
  end
69
135
  end
70
136
  end
@@ -33,6 +33,31 @@ module RubynCode
33
33
  catalog.descriptions
34
34
  end
35
35
 
36
+ # Suggest skills based on what the codebase index reveals about the project.
37
+ #
38
+ # Inspects class names, parent classes, and file paths in the index to
39
+ # detect common Rails patterns (Devise, ActionMailer, ActiveJob, etc.)
40
+ # and returns matching skill names.
41
+ #
42
+ # @param codebase_index [RubynCode::Index::CodebaseIndex, nil]
43
+ # @param project_profile [Object, nil] reserved for future profile-based hints
44
+ # @return [Array<String>] suggested skill names (not loaded automatically)
45
+ def suggest_skills(codebase_index: nil, project_profile: nil) # rubocop:disable Lint/UnusedMethodArgument, Metrics/CyclomaticComplexity -- project_profile reserved for future use
46
+ return [] unless codebase_index
47
+
48
+ suggestions = []
49
+ node_names = codebase_index.nodes.map { |n| n['name'].to_s }
50
+ node_files = codebase_index.nodes.map { |n| n['file'].to_s }
51
+
52
+ suggestions << 'authentication' if detect_devise?(node_names, node_files)
53
+ suggestions << 'mailer' if detect_action_mailer?(node_names, node_files)
54
+ suggestions << 'background-job' if detect_active_job?(node_names, node_files)
55
+
56
+ suggestions
57
+ rescue StandardError
58
+ []
59
+ end
60
+
36
61
  private
37
62
 
38
63
  def format_skill(doc)
@@ -50,6 +75,24 @@ module RubynCode
50
75
  .gsub('>', '&gt;')
51
76
  .gsub('"', '&quot;')
52
77
  end
78
+
79
+ # Devise detection: look for Devise-related class names or config files.
80
+ def detect_devise?(node_names, node_files)
81
+ node_names.any? { |n| n.match?(/\bDevise\b/i) } ||
82
+ node_files.any? { |f| f.include?('devise') }
83
+ end
84
+
85
+ # ActionMailer detection: look for mailer classes or mailer directory.
86
+ def detect_action_mailer?(node_names, node_files)
87
+ node_names.any? { |n| n.match?(/Mailer\b/) } ||
88
+ node_files.any? { |f| f.include?('app/mailers/') }
89
+ end
90
+
91
+ # ActiveJob detection: look for job classes or jobs directory.
92
+ def detect_active_job?(node_names, node_files)
93
+ node_names.any? { |n| n.match?(/Job\b/) } ||
94
+ node_files.any? { |f| f.include?('app/jobs/') }
95
+ end
53
96
  end
54
97
  end
55
98
  end
@@ -10,6 +10,7 @@ module RubynCode
10
10
  def in_progress? = status == 'in_progress'
11
11
  def completed? = status == 'completed'
12
12
  def blocked? = status == 'blocked'
13
+ def failed? = status == 'failed'
13
14
 
14
15
  def to_h
15
16
  {
@@ -37,6 +37,19 @@ module RubynCode
37
37
  input_schema: Schema.build(parameters)
38
38
  }
39
39
  end
40
+
41
+ # One-line summary of a successful invocation, shown in the IDE's
42
+ # chat card. Default is empty so the UI renders a clean "Done"
43
+ # indicator. Override in subclasses that have a useful one-liner
44
+ # (e.g. "Edited app.rb (1 replacement)"). The full output still
45
+ # goes to the conversation untouched — this only affects the UI.
46
+ #
47
+ # @param output [String] what execute(**) returned
48
+ # @param args [Hash] the tool arguments (string-keyed)
49
+ # @return [String]
50
+ def summarize(_output, _args)
51
+ ''
52
+ end
40
53
  end
41
54
 
42
55
  attr_reader :project_root
@@ -18,6 +18,11 @@ module RubynCode
18
18
  RISK_LEVEL = :execute
19
19
  REQUIRES_CONFIRMATION = true
20
20
 
21
+ def self.summarize(_output, args)
22
+ cmd = args['command'] || args[:command] || ''
23
+ "$ #{cmd[0, 180]}"
24
+ end
25
+
21
26
  def execute(command:, timeout: 120)
22
27
  validate_command!(command)
23
28