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
@@ -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
@@ -84,7 +84,8 @@ module RubynCode
84
84
  # @param task_type [Symbol]
85
85
  # @param client [LLM::Client, nil] active client (for provider checks)
86
86
  # @return [Hash] { provider:, model: }
87
- def resolve(task_type, client: nil) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- multi-source fallback chain
87
+ # -- multi-source fallback chain
88
+ def resolve(task_type, client: nil)
88
89
  tier = tier_for(task_type)
89
90
  active = active_provider
90
91
 
@@ -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
 
@@ -51,7 +51,7 @@ module RubynCode
51
51
  klass
52
52
  end
53
53
 
54
- def create_tool_class(tool_name, description, parameters, mcp_client, remote_name) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- dynamic class creation requires setting many constants
54
+ def create_tool_class(tool_name, description, parameters, mcp_client, remote_name) # rubocop:disable Metrics/MethodLength -- dynamic class creation requires setting many constants
55
55
  bridge = self
56
56
 
57
57
  Class.new(Tools::Base) do
@@ -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
@@ -80,7 +80,8 @@ module RubynCode
80
80
  }
81
81
  end
82
82
 
83
- def build_session_summary_lines(session_id, turns, totals) # rubocop:disable Metrics/AbcSize -- assembles multi-field summary
83
+ # -- assembles multi-field summary
84
+ def build_session_summary_lines(session_id, turns, totals)
84
85
  avg_cost = turns.positive? ? totals[:cost] / turns : 0.0
85
86
  [
86
87
  header('Session Summary'),
@@ -104,7 +105,8 @@ module RubynCode
104
105
  ).to_a
105
106
  end
106
107
 
107
- def build_daily_summary_lines(today, rows) # rubocop:disable Metrics/AbcSize -- assembles multi-field daily summary
108
+ # -- assembles multi-field daily summary
109
+ def build_daily_summary_lines(today, rows)
108
110
  total_input = rows.sum { |r| fetch_int(r, 'input_tokens') }
109
111
  total_output = rows.sum { |r| fetch_int(r, 'output_tokens') }
110
112
  total_cost = rows.sum { |r| fetch_float(r, 'cost_usd') }
@@ -97,7 +97,8 @@ module RubynCode
97
97
  table
98
98
  end
99
99
 
100
- def fill_lcs_row(table, row, old_lines, new_lines, col_count) # rubocop:disable Metrics/AbcSize -- LCS algorithm step
100
+ # -- LCS algorithm step
101
+ def fill_lcs_row(table, row, old_lines, new_lines, col_count)
101
102
  (1..col_count).each do |col|
102
103
  table[row][col] = if old_lines[row - 1] == new_lines[col - 1]
103
104
  table[row - 1][col - 1] + 1
@@ -121,7 +122,7 @@ module RubynCode
121
122
  result
122
123
  end
123
124
 
124
- def backtrack_step(result, table, old_lines, new_lines, old_idx, new_idx) # rubocop:disable Metrics/AbcSize, Metrics/ParameterLists -- LCS backtrack step requires all state
125
+ def backtrack_step(result, table, old_lines, new_lines, old_idx, new_idx) # rubocop:disable Metrics/ParameterLists -- LCS backtrack step requires all state
125
126
  if lines_match?(old_lines, new_lines, old_idx, new_idx)
126
127
  result.unshift([:equal, old_idx - 1, new_idx - 1])
127
128
  [old_idx - 1, new_idx - 1]
@@ -0,0 +1,316 @@
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
+ # -- sequential file ops
57
+ def check_file_write_edit_cleanup
58
+ tmp = File.join(project_root, '.rubyn-code/self_test_tmp.rb')
59
+ FileUtils.mkdir_p(File.dirname(tmp))
60
+
61
+ File.write(tmp, '# self-test')
62
+ record('File write (tmp)', File.exist?(tmp))
63
+
64
+ File.write(tmp, File.read(tmp).sub('# self-test', '# self-test passed'))
65
+ record('File edit (tmp)', File.read(tmp).include?('# self-test passed'))
66
+
67
+ File.delete(tmp)
68
+ record('File cleanup (tmp)', !File.exist?(tmp))
69
+ end
70
+
71
+ # ── 2. Search ───────────────────────────────────────────────────
72
+
73
+ def check_search
74
+ rb_files = Dir.glob(File.join(project_root, 'lib/**/*.rb'))
75
+ record('Glob lib/**/*.rb', rb_files.size >= 50, "#{rb_files.size} files")
76
+
77
+ base_classes = rb_files.count { |f| File.read(f).match?(/class.*Base/) }
78
+ record('Grep class.*Base', base_classes >= 3, "#{base_classes} matches")
79
+ rescue StandardError => e
80
+ record('Search', false, e.message)
81
+ end
82
+
83
+ # ── 3. Bash ─────────────────────────────────────────────────────
84
+
85
+ def check_bash
86
+ ruby_v = `ruby --version 2>&1`.strip
87
+ record('Bash: ruby --version', ruby_v.include?('ruby'), ruby_v)
88
+
89
+ rubocop_v = `bundle exec rubocop --version 2>&1`.strip
90
+ record('Bash: rubocop --version', rubocop_v.match?(/\d+\.\d+/), rubocop_v)
91
+ rescue StandardError => e
92
+ record('Bash', false, e.message)
93
+ end
94
+
95
+ # ── 4. Git ──────────────────────────────────────────────────────
96
+
97
+ def check_git
98
+ run_cmd('git status --short')
99
+ record('Git status', true)
100
+
101
+ log = run_cmd('git log --oneline -3')
102
+ record('Git log', log.match?(/^[0-9a-f]+/), log.lines.first&.strip)
103
+
104
+ run_cmd('git diff --stat')
105
+ record('Git diff', true)
106
+ rescue StandardError => e
107
+ record('Git', false, e.message)
108
+ end
109
+
110
+ # ── 5. Specs ────────────────────────────────────────────────────
111
+
112
+ def check_specs
113
+ %w[
114
+ spec/rubyn_code/tools/output_compressor_spec.rb
115
+ spec/rubyn_code/llm/model_router_spec.rb
116
+ ].each { |spec| run_single_spec(spec) }
117
+ rescue StandardError => e
118
+ record('Specs', false, e.message)
119
+ end
120
+
121
+ def run_single_spec(spec)
122
+ path = File.join(project_root, spec)
123
+ unless File.exist?(path)
124
+ record("RSpec: #{File.basename(spec)}", false, 'file not found')
125
+ return
126
+ end
127
+ output = run_cmd("bundle exec rspec #{path} --format progress 2>&1")
128
+ record("RSpec: #{File.basename(spec)}", output.include?('0 failures'))
129
+ end
130
+
131
+ # ── 6. Output Compressor ────────────────────────────────────────
132
+
133
+ def check_compressor_strategies
134
+ compressor = Tools::OutputCompressor.new
135
+ verified = 0
136
+
137
+ verified += verify_head_tail(compressor)
138
+ verified += verify_spec_summary(compressor)
139
+ verified += verify_top_matches(compressor)
140
+ verified += verify_tree_collapse(compressor)
141
+ verified += verify_diff_hunks(compressor)
142
+
143
+ record('Compression strategies verified', verified >= 3, "#{verified}/5 active")
144
+ rescue StandardError => e
145
+ record('Output compressor', false, e.message)
146
+ end
147
+
148
+ def verify_head_tail(compressor)
149
+ big = (1..5000).to_a.join("\n")
150
+ compressed = compressor.compress('bash', big)
151
+ pass = compressed.length < big.length
152
+ record('Compressor: head_tail', pass)
153
+ pass ? 1 : 0
154
+ end
155
+
156
+ def verify_spec_summary(compressor)
157
+ spec_out = run_cmd(
158
+ 'bundle exec rspec spec/rubyn_code/tools/base_spec.rb --format documentation 2>&1'
159
+ )
160
+ compressed = compressor.compress('run_specs', spec_out)
161
+ pass = compressed.length < spec_out.length || compressed.include?('0 failures')
162
+ record('Compressor: spec_summary', pass)
163
+ pass ? 1 : 0
164
+ end
165
+
166
+ def verify_top_matches(compressor)
167
+ grep_out = rb_files_with_def.join("\n")
168
+ compressed = compressor.compress('grep', grep_out)
169
+ pass = compressed.length <= grep_out.length
170
+ record('Compressor: top_matches', pass)
171
+ pass ? 1 : 0
172
+ end
173
+
174
+ def verify_tree_collapse(compressor)
175
+ all_rb = Dir.glob(File.join(project_root, '**/*.rb')).join("\n")
176
+ compressed = compressor.compress('glob', all_rb)
177
+ pass = compressed.length <= all_rb.length
178
+ record('Compressor: tree_collapse', pass)
179
+ pass ? 1 : 0
180
+ end
181
+
182
+ def verify_diff_hunks(compressor)
183
+ diff = run_cmd('git log --oneline -1 --format=%H | xargs -I{} git diff {}~5..{} 2>/dev/null')
184
+ if diff.strip.empty?
185
+ record('Compressor: diff_hunks', true, 'SKIP — diff too small')
186
+ return 0
187
+ end
188
+ pass = compressor.compress('git_diff', diff).length <= diff.length
189
+ record('Compressor: diff_hunks', pass)
190
+ pass ? 1 : 0
191
+ end
192
+
193
+ # ── 7. Skills ───────────────────────────────────────────────────
194
+
195
+ def check_skills
196
+ catalog = Skills::Catalog.new(project_root)
197
+ skills = catalog.available
198
+ record('Skills catalog', skills.size >= 10, "#{skills.size} skills")
199
+ rescue StandardError => e
200
+ record('Skills', false, e.message)
201
+ end
202
+
203
+ # ── 8. Config ───────────────────────────────────────────────────
204
+
205
+ def check_config
206
+ config_path = File.expand_path('~/.rubyn-code/config.yml')
207
+ if File.exist?(config_path)
208
+ record('Config (config.yml)', File.read(config_path).include?('provider'))
209
+ else
210
+ record('Config (config.yml)', false, 'not found')
211
+ end
212
+
213
+ profile = File.join(project_root, '.rubyn-code/project_profile.yml')
214
+ record('Config (project_profile)', File.exist?(profile),
215
+ File.exist?(profile) ? 'exists' : 'SKIP — first session')
216
+ rescue StandardError => e
217
+ record('Config', false, e.message)
218
+ end
219
+
220
+ # ── 9. Codebase Index ───────────────────────────────────────────
221
+
222
+ def check_codebase_index
223
+ path = File.join(project_root, '.rubyn-code/codebase_index.json')
224
+ record('Codebase index', File.exist?(path),
225
+ File.exist?(path) ? 'exists' : 'SKIP — first session')
226
+ rescue StandardError => e
227
+ record('Codebase index', false, e.message)
228
+ end
229
+
230
+ # ── 10. Slash Commands ──────────────────────────────────────────
231
+
232
+ def check_slash_commands
233
+ cmd_dir = File.join(project_root, 'lib/rubyn_code/cli/commands')
234
+ infra = %w[base.rb context.rb registry.rb]
235
+ cmds = Dir.glob(File.join(cmd_dir, '*.rb')).reject { |f| infra.include?(File.basename(f)) }
236
+ record('Slash commands', cmds.size >= 15, "#{cmds.size} commands")
237
+ rescue StandardError => e
238
+ record('Slash commands', false, e.message)
239
+ end
240
+
241
+ # ── 11. Architecture ────────────────────────────────────────────
242
+
243
+ def check_architecture
244
+ check_autoloads
245
+ check_layer_dirs
246
+ check_core_modules
247
+ rescue StandardError => e
248
+ record('Architecture', false, e.message)
249
+ end
250
+
251
+ def check_autoloads
252
+ content = File.read(File.join(project_root, 'lib/rubyn_code.rb'))
253
+ autoloads = content.scan('autoload').size
254
+ record('Autoload entries', autoloads >= 40, "#{autoloads} entries")
255
+ end
256
+
257
+ def check_layer_dirs
258
+ dirs = Dir.glob(File.join(project_root, 'lib/rubyn_code/*/'))
259
+ record('Layer directories', dirs.size >= 14, "#{dirs.size} dirs")
260
+ end
261
+
262
+ def check_core_modules
263
+ content = File.read(File.join(project_root, 'lib/rubyn_code.rb'))
264
+ core = %w[Agent Tools Context Skills Memory Observability Learning]
265
+ found = core.select { |m| content.include?("module #{m}") }
266
+ record('Core modules', found.size == core.size, "#{found.size}/#{core.size}")
267
+ end
268
+
269
+ # ── Helpers ─────────────────────────────────────────────────────
270
+
271
+ def record(name, passed, detail = nil)
272
+ @results << Result.new(name: name, passed: passed, detail: detail)
273
+ end
274
+
275
+ def run_cmd(cmd)
276
+ `cd #{project_root} && #{cmd} 2>&1`.strip
277
+ end
278
+
279
+ def rb_files_with_def
280
+ Dir.glob(File.join(project_root, 'lib/**/*.rb')).flat_map do |f|
281
+ File.readlines(f).select { |l| l.include?('def ') }.map { |l| "#{f}:#{l.strip}" }
282
+ end
283
+ end
284
+
285
+ def print_scorecard
286
+ puts
287
+ puts 'Rubyn Self-Test Results'
288
+ puts '=' * 50
289
+ results.each_with_index { |r, i| print_result(r, i + 1) }
290
+ print_summary
291
+ end
292
+
293
+ def print_result(result, num)
294
+ icon = result.passed ? "\e[32m✅\e[0m" : "\e[31m❌\e[0m"
295
+ suffix = result.detail ? " — #{result.detail}" : ''
296
+ puts format(' %2<num>d. %<icon>s %<name>s%<suffix>s',
297
+ num: num, icon: icon, name: result.name, suffix: suffix)
298
+ end
299
+
300
+ def print_summary
301
+ passed = results.count(&:passed)
302
+ total = results.size
303
+ pct = total.positive? ? (passed * 100.0 / total).round : 0
304
+ failed = total - passed
305
+
306
+ puts '=' * 50
307
+ if failed.zero?
308
+ puts "\e[32mScore: #{passed}/#{total} (#{pct}%) — All systems go!\e[0m"
309
+ else
310
+ puts "\e[33mScore: #{passed}/#{total} (#{pct}%) — #{failed} failures\e[0m"
311
+ end
312
+ puts
313
+ end
314
+ end
315
+ # rubocop:enable Metrics/ClassLength
316
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative 'gemfile_parser'
5
+
6
+ module RubynCode
7
+ module Skills
8
+ # Suggests skill packs based on gems detected in the project's Gemfile.
9
+ #
10
+ # On session start, parses the Gemfile, queries the registry for matching
11
+ # packs, and shows a one-time suggestion. Tracks shown suggestions in
12
+ # `.rubyn-code/suggested.json` to avoid repeating.
13
+ class AutoSuggest
14
+ SUGGESTED_FILE = 'suggested.json'
15
+
16
+ # @param project_root [String]
17
+ # @param registry_client [RegistryClient]
18
+ def initialize(project_root:, registry_client: nil)
19
+ @project_root = project_root
20
+ @client = registry_client || RegistryClient.new
21
+ end
22
+
23
+ # Check for suggestable packs and return a display message if any.
24
+ # Returns nil if no suggestions or if all have been shown/dismissed.
25
+ #
26
+ # This method never raises — registry failures are silently swallowed
27
+ # to avoid blocking session start.
28
+ #
29
+ # @return [String, nil] suggestion message or nil
30
+ def check
31
+ gems = parse_gemfile
32
+ return nil if gems.empty?
33
+
34
+ suggestions = fetch_suggestions(gems)
35
+ return nil if suggestions.empty?
36
+
37
+ new_suggestions = filter_shown(suggestions)
38
+ return nil if new_suggestions.empty?
39
+
40
+ record_shown(new_suggestions)
41
+ format_message(new_suggestions)
42
+ rescue StandardError
43
+ nil
44
+ end
45
+
46
+ # Mark a pack as installed so it won't be suggested again.
47
+ #
48
+ # @param name [String] pack name
49
+ def mark_installed(name)
50
+ state = load_state
51
+ state['installed'] ||= []
52
+ state['installed'] << name unless state['installed'].include?(name)
53
+ save_state(state)
54
+ end
55
+
56
+ # Mark a suggestion as dismissed.
57
+ #
58
+ # @param name [String] pack name
59
+ def mark_dismissed(name)
60
+ state = load_state
61
+ state['dismissed'] ||= []
62
+ state['dismissed'] << name unless state['dismissed'].include?(name)
63
+ save_state(state)
64
+ end
65
+
66
+ private
67
+
68
+ def parse_gemfile
69
+ gemfile_path = File.join(@project_root, 'Gemfile')
70
+ return [] unless File.exist?(gemfile_path)
71
+
72
+ GemfileParser.gems(File.read(gemfile_path))
73
+ rescue StandardError
74
+ []
75
+ end
76
+
77
+ def fetch_suggestions(gems)
78
+ @client.fetch_suggestions(gems)
79
+ rescue RegistryError
80
+ []
81
+ end
82
+
83
+ def filter_shown(suggestions)
84
+ state = load_state
85
+ shown = Array(state['shown'])
86
+ installed = Array(state['installed'])
87
+ dismissed = Array(state['dismissed'])
88
+ skip = (shown + installed + dismissed).uniq
89
+
90
+ suggestions.reject { |s| skip.include?(s['name']) }
91
+ end
92
+
93
+ def record_shown(suggestions)
94
+ state = load_state
95
+ state['shown'] ||= []
96
+ suggestions.each do |s|
97
+ state['shown'] << s['name'] unless state['shown'].include?(s['name'])
98
+ end
99
+ save_state(state)
100
+ end
101
+
102
+ def format_message(suggestions)
103
+ gem_names = suggestions.map { |s| s['name'] }.join(', ')
104
+ details = suggestions.map { |s| "#{s['name']} (#{s['reason']})" }.join(', ')
105
+ install_cmd = "/install-skills #{suggestions.map { |s| s['name'] }.join(' ')}"
106
+
107
+ "Skill packs available: #{details}\n" \
108
+ "Run #{install_cmd} to install."
109
+ end
110
+
111
+ def load_state
112
+ path = state_path
113
+ return {} unless File.exist?(path)
114
+
115
+ JSON.parse(File.read(path))
116
+ rescue JSON::ParserError
117
+ {}
118
+ end
119
+
120
+ def save_state(state)
121
+ dir = File.dirname(state_path)
122
+ FileUtils.mkdir_p(dir)
123
+ File.write(state_path, JSON.pretty_generate(state))
124
+ end
125
+
126
+ def state_path
127
+ File.join(@project_root, '.rubyn-code', SUGGESTED_FILE)
128
+ end
129
+ end
130
+ end
131
+ end
@@ -24,11 +24,60 @@ module RubynCode
24
24
  @index
25
25
  end
26
26
 
27
+ # Force the index to be rebuilt on next access. Used after installing
28
+ # a skill pack so newly-written files become discoverable in the same
29
+ # session.
30
+ def refresh!
31
+ @index = nil
32
+ end
33
+
34
+ def list
35
+ available.map { |e| e[:name] }
36
+ end
37
+
27
38
  def find(name)
28
39
  entry = available.find { |e| e[:name] == name.to_s }
29
40
  entry&.fetch(:path)
30
41
  end
31
42
 
43
+ # Search skill content — matches against names, descriptions, and tags.
44
+ # Returns matching entries sorted by relevance (number of field matches).
45
+ #
46
+ # @param term [String] search term (case-insensitive)
47
+ # @return [Array<Hash>] matching entries with :name, :description, :path, :relevance
48
+ def search(term)
49
+ pattern = /#{Regexp.escape(term)}/i
50
+ matches = available.filter_map do |entry|
51
+ relevance = compute_relevance(entry, pattern)
52
+ next if relevance.zero?
53
+
54
+ entry.merge(relevance: relevance)
55
+ end
56
+ matches.sort_by { |e| -e[:relevance] }
57
+ end
58
+
59
+ # Filter skills by category (subdirectory name).
60
+ # Skills are organized in subdirectories under each skills_dir.
61
+ #
62
+ # @param category [String] category/directory name (e.g. "rails", "testing")
63
+ # @return [Array<Hash>] matching entries
64
+ def by_category(category)
65
+ normalized = category.to_s.downcase
66
+ available.select do |entry|
67
+ path_category(entry[:path]).downcase == normalized
68
+ end
69
+ end
70
+
71
+ # Return the list of unique categories derived from skill file paths.
72
+ #
73
+ # @return [Array<String>] sorted category names
74
+ def categories
75
+ available.map { |e| path_category(e[:path]) }
76
+ .reject(&:empty?)
77
+ .uniq
78
+ .sort
79
+ end
80
+
32
81
  private
33
82
 
34
83
  def build_index
@@ -60,11 +109,38 @@ module RubynCode
60
109
  {
61
110
  name: name,
62
111
  description: doc.description,
112
+ tags: doc.tags,
113
+ triggers: doc.triggers,
114
+ gems: doc.gems,
115
+ rails: doc.rails,
63
116
  path: File.expand_path(path)
64
117
  }
65
118
  rescue StandardError
66
119
  nil
67
120
  end
121
+
122
+ def compute_relevance(entry, pattern)
123
+ score = 0
124
+ score += 3 if entry[:name].to_s.match?(pattern)
125
+ score += 2 if entry[:description].to_s.match?(pattern)
126
+ Array(entry[:tags]).each { |tag| score += 1 if tag.to_s.match?(pattern) }
127
+ score
128
+ end
129
+
130
+ # Derive a category from the skill file path.
131
+ # The category is the immediate parent directory name relative to one of
132
+ # the skills_dirs. Skills at the top level of a skills_dir have no category.
133
+ def path_category(path)
134
+ skills_dirs.each do |dir|
135
+ expanded = File.expand_path(dir)
136
+ next unless path.start_with?(expanded)
137
+
138
+ relative = path.delete_prefix("#{expanded}/")
139
+ parts = relative.split('/')
140
+ return parts.size > 1 ? parts.first : ''
141
+ end
142
+ ''
143
+ end
68
144
  end
69
145
  end
70
146
  end