rubyn-code 0.4.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +186 -2
  3. data/lib/rubyn_code/agent/conversation.rb +2 -1
  4. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +2 -1
  5. data/lib/rubyn_code/agent/llm_caller.rb +4 -2
  6. data/lib/rubyn_code/agent/loop.rb +7 -3
  7. data/lib/rubyn_code/agent/response_modes.rb +2 -1
  8. data/lib/rubyn_code/agent/system_prompt_builder.rb +39 -0
  9. data/lib/rubyn_code/agent/tool_processor.rb +4 -2
  10. data/lib/rubyn_code/cli/app.rb +85 -11
  11. data/lib/rubyn_code/cli/commands/install_skills.rb +44 -0
  12. data/lib/rubyn_code/cli/commands/list_skills.rb +149 -0
  13. data/lib/rubyn_code/cli/commands/provider.rb +2 -1
  14. data/lib/rubyn_code/cli/commands/remove_skills.rb +35 -0
  15. data/lib/rubyn_code/cli/commands/skill.rb +4 -2
  16. data/lib/rubyn_code/cli/commands/skills.rb +104 -0
  17. data/lib/rubyn_code/cli/repl.rb +11 -1
  18. data/lib/rubyn_code/cli/repl_commands.rb +2 -1
  19. data/lib/rubyn_code/cli/repl_setup.rb +38 -1
  20. data/lib/rubyn_code/config/defaults.rb +2 -0
  21. data/lib/rubyn_code/config/settings.rb +5 -2
  22. data/lib/rubyn_code/context/context_budget.rb +2 -1
  23. data/lib/rubyn_code/context/manager.rb +3 -3
  24. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +6 -3
  25. data/lib/rubyn_code/ide/handlers/review_handler.rb +19 -2
  26. data/lib/rubyn_code/ide/protocol.rb +2 -1
  27. data/lib/rubyn_code/index/codebase_index.rb +2 -1
  28. data/lib/rubyn_code/learning/extractor.rb +4 -2
  29. data/lib/rubyn_code/llm/model_router.rb +2 -1
  30. data/lib/rubyn_code/mcp/tool_bridge.rb +1 -1
  31. data/lib/rubyn_code/observability/usage_reporter.rb +4 -2
  32. data/lib/rubyn_code/output/diff_renderer.rb +3 -2
  33. data/lib/rubyn_code/self_test.rb +2 -1
  34. data/lib/rubyn_code/skills/auto_suggest.rb +131 -0
  35. data/lib/rubyn_code/skills/catalog.rb +10 -0
  36. data/lib/rubyn_code/skills/document.rb +8 -2
  37. data/lib/rubyn_code/skills/gemfile_parser.rb +40 -0
  38. data/lib/rubyn_code/skills/loader.rb +1 -1
  39. data/lib/rubyn_code/skills/matcher.rb +89 -0
  40. data/lib/rubyn_code/skills/pack_context.rb +163 -0
  41. data/lib/rubyn_code/skills/pack_installer.rb +194 -0
  42. data/lib/rubyn_code/skills/pack_manager.rb +230 -0
  43. data/lib/rubyn_code/skills/registry_autoload.rb +112 -0
  44. data/lib/rubyn_code/skills/registry_client.rb +241 -0
  45. data/lib/rubyn_code/tools/executor.rb +4 -2
  46. data/lib/rubyn_code/tools/grep.rb +2 -1
  47. data/lib/rubyn_code/tools/ide_diagnostics.rb +3 -1
  48. data/lib/rubyn_code/tools/ide_symbols.rb +3 -1
  49. data/lib/rubyn_code/tools/load_skill.rb +2 -1
  50. data/lib/rubyn_code/tools/output_compressor.rb +3 -6
  51. data/lib/rubyn_code/tools/review_pr.rb +15 -4
  52. data/lib/rubyn_code/tools/web_search.rb +2 -1
  53. data/lib/rubyn_code/version.rb +1 -1
  54. data/lib/rubyn_code.rb +12 -0
  55. data/skills/rubyn_self_test.md +75 -0
  56. metadata +13 -1
@@ -0,0 +1,241 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+ require 'faraday'
5
+ require 'json'
6
+
7
+ module RubynCode
8
+ module Skills
9
+ # HTTP client for the rubyn.ai skill packs registry API.
10
+ # Supports ETag-based conditional requests for efficient cache
11
+ # validation and offline resilience.
12
+ class RegistryClient
13
+ LEADING_SLASHES_REGEX = %r{\A/+}
14
+ DEFAULT_BASE_URL = 'https://rubyn.ai'
15
+ TIMEOUT_SECONDS = 10
16
+ USER_ACCEPT_HEADER = 'Rubyn Code'
17
+
18
+ attr_reader :base_url
19
+
20
+ def initialize(base_url: nil)
21
+ @base_url = base_url || ENV.fetch('RUBYN_REGISTRY_URL', DEFAULT_BASE_URL)
22
+ end
23
+
24
+ # List all available packs (returns flat array for CLI commands).
25
+ #
26
+ # @param etag [String, nil] cached ETag for conditional request
27
+ # @return [Array<Hash>] array of pack metadata
28
+ # @raise [RegistryError] on network or parse failure
29
+ def list_packs(etag: nil)
30
+ fetch_catalog(etag: etag)[:data] || []
31
+ end
32
+
33
+ # Fetch the full catalog of available skill packs.
34
+ #
35
+ # @param etag [String, nil] cached ETag for conditional request
36
+ # @return [Hash] { data: Array<Hash>, etag: String|nil, not_modified: Boolean }
37
+ # @raise [RegistryError] on network or parse failure
38
+ def fetch_catalog(etag: nil)
39
+ response = conditional_get('/api/v1/skills/packs.json', etag: etag)
40
+ return not_modified_result if response.status == 304
41
+
42
+ data = validate_and_parse(response)
43
+ packs = normalize_packs(data)
44
+ { data: packs, etag: response.headers['etag'], not_modified: false }
45
+ rescue Faraday::Error => e
46
+ raise RegistryError, "Failed to fetch skill catalog: #{e.message}"
47
+ end
48
+
49
+ # Search packs by keyword.
50
+ # Note: The registry API does not support server-side search.
51
+ # This method fetches the catalog and filters locally.
52
+ #
53
+ # @param query [String]
54
+ # @param etag [String, nil] cached ETag for conditional request
55
+ # @return [Hash] { data: Array<Hash>, etag: String|nil, not_modified: Boolean }
56
+ # @raise [RegistryError] on network or parse failure
57
+ def search_packs(query, etag: nil)
58
+ catalog = fetch_catalog(etag: etag)
59
+ return catalog if catalog[:not_modified]
60
+
61
+ q = query.to_s.downcase
62
+ filtered = catalog[:data].select { |pack| matches_query?(pack, q) }
63
+ { data: filtered, etag: catalog[:etag], not_modified: false }
64
+ end
65
+
66
+ # Fetch pack suggestions based on detected gems.
67
+ #
68
+ # @param gems [Array<String>] list of gem names detected in the project
69
+ # @return [Array<Hash>] array of { name: String, reason: String }
70
+ # @raise [RegistryError] on network or parse failure
71
+ def fetch_suggestions(gems)
72
+ return [] if gems.empty?
73
+
74
+ gems_param = gems.join(',')
75
+ response = connection.get('/api/v1/skills/packs/suggest', { gems: gems_param })
76
+ return [] if response.status == 404
77
+
78
+ data = validate_and_parse(response)
79
+ suggestions = data[:suggestions] || data['suggestions'] || []
80
+ suggestions.is_a?(Array) ? suggestions : []
81
+ rescue Faraday::Error => e
82
+ raise RegistryError, "Failed to fetch suggestions: #{e.message}"
83
+ end
84
+
85
+ # Fetch a single pack's full content for installation.
86
+ # Fetches pack metadata and all skill file contents.
87
+ #
88
+ # @param name [String] pack name (validated for safe characters)
89
+ # @param etag [String, nil] cached ETag for conditional request
90
+ # @return [Hash] { data: Hash, etag: String|nil, not_modified: Boolean }
91
+ # @raise [RegistryError] on not found, validation, or network failure
92
+ def fetch_pack(name, etag: nil)
93
+ validate_pack_name!(name)
94
+ response = conditional_get("/api/v1/skills/packs/#{encode_name(name)}.json", etag: etag)
95
+ return not_modified_result if response.status == 304
96
+
97
+ data = validate_and_parse(response)
98
+ validate_pack_response!(data, name)
99
+
100
+ # Fetch individual file contents. Manifests may list files under
101
+ # :files (as { path: ... } hashes) or :skills (as filename strings).
102
+ files = fetch_key(data, :files) || fetch_key(data, :skills) || []
103
+ files = files.map { |f| f.is_a?(String) ? { path: f } : f }
104
+ data[:files] = fetch_files_with_content(name, files)
105
+
106
+ { data: data, etag: response.headers['etag'], not_modified: false }
107
+ rescue Faraday::Error => e
108
+ raise RegistryError, "Failed to fetch pack '#{name}': #{e.message}"
109
+ end
110
+
111
+ # Fetch a single skill file's markdown content.
112
+ #
113
+ # @param pack_name [String]
114
+ # @param file_path [String]
115
+ # @param etag [String, nil] cached ETag for conditional request
116
+ # @return [Hash] { content: String, etag: String|nil, not_modified: Boolean }
117
+ # @raise [RegistryError] on not found or network failure
118
+ def fetch_file(pack_name, file_path, etag: nil)
119
+ validate_pack_name!(pack_name)
120
+ safe_path = file_path.to_s.gsub('..', '').gsub(LEADING_SLASHES_REGEX, '')
121
+ response = connection.get(
122
+ "/api/v1/skills/packs/#{encode_name(pack_name)}/files/#{ERB::Util.url_encode(safe_path)}"
123
+ ) do |req|
124
+ req.headers['If-None-Match'] = etag if etag
125
+ end
126
+
127
+ return { content: nil, etag: nil, not_modified: true } if response.status == 304
128
+ return { content: response.body, etag: response.headers['etag'], not_modified: false } if response.success?
129
+
130
+ raise RegistryError, "Failed to fetch file '#{file_path}' from pack '#{pack_name}'"
131
+ rescue Faraday::Error => e
132
+ raise RegistryError, "Failed to fetch file '#{file_path}': #{e.message}"
133
+ end
134
+
135
+ private
136
+
137
+ def connection
138
+ @connection ||= Faraday.new(url: base_url) do |f|
139
+ f.request :url_encoded
140
+ f.response :raise_error
141
+ f.options.timeout = TIMEOUT_SECONDS
142
+ f.options.open_timeout = TIMEOUT_SECONDS
143
+ f.headers['Accept'] = 'application/json'
144
+ f.headers['User-Accept'] = USER_ACCEPT_HEADER
145
+ f.headers['User-Agent'] = "rubyn-code/#{RubynCode::VERSION}"
146
+ end
147
+ end
148
+
149
+ # Perform a GET with optional ETag-based conditional caching.
150
+ # Sends If-None-Match when an ETag is provided; server returns
151
+ # 304 Not Modified if content hasn't changed.
152
+ def conditional_get(path, etag: nil, params: {})
153
+ connection.get(path) do |req|
154
+ req.params.merge!(params) unless params.empty?
155
+ req.headers['If-None-Match'] = etag if etag
156
+ end
157
+ end
158
+
159
+ def validate_and_parse(response)
160
+ body = response.body.to_s.strip
161
+ raise RegistryError, 'Empty response from registry' if body.empty?
162
+
163
+ content_type = response.headers['content-type'].to_s
164
+ if !content_type.include?('json') && body.start_with?('<')
165
+ raise RegistryError,
166
+ 'Registry endpoint returned HTML instead of JSON ' \
167
+ "(content-type: #{content_type}). The skill packs API may not be available at #{base_url}."
168
+ end
169
+
170
+ JSON.parse(body, symbolize_names: true)
171
+ rescue JSON::ParserError => e
172
+ raise RegistryError, "Invalid response from registry: #{e.message}"
173
+ end
174
+
175
+ # Normalize response into an array of pack hashes.
176
+ # Handles both bare arrays and { packs: [...] } wrapper shapes.
177
+ def normalize_packs(data)
178
+ return data if data.is_a?(Array)
179
+ return data[:packs] if data.is_a?(Hash) && data[:packs].is_a?(Array)
180
+
181
+ raise RegistryError, 'Unexpected catalog format from registry'
182
+ end
183
+
184
+ def validate_pack_response!(data, name)
185
+ return if data.is_a?(Hash) && (data[:name] || data[:files])
186
+
187
+ raise RegistryError, "Invalid pack response for '#{name}': missing name or files"
188
+ end
189
+
190
+ # Only allow alphanumeric, hyphens, underscores, and forward slashes in pack names.
191
+ # Forward slashes are required for namespaced packs like 'stripe/webhooks'.
192
+ def validate_pack_name!(name)
193
+ return if name.to_s.match?(%r{\A[a-zA-Z0-9_\-/]+\z})
194
+
195
+ raise RegistryError,
196
+ "Invalid pack name: '#{name}'. Only letters, numbers, hyphens, underscores, and slashes allowed."
197
+ end
198
+
199
+ def encode_name(name)
200
+ ERB::Util.url_encode(name.to_s)
201
+ end
202
+
203
+ def fetch_key(hash, key)
204
+ hash[key] || hash[key.to_s]
205
+ end
206
+
207
+ def matches_query?(pack, query)
208
+ pack_name = pack[:name].to_s.downcase
209
+ pack_display = pack[:displayName].to_s.downcase
210
+ pack_desc = pack[:description].to_s.downcase
211
+ pack_tags = pack[:tags] || []
212
+
213
+ pack_name.include?(query) ||
214
+ pack_display.include?(query) ||
215
+ pack_desc.include?(query) ||
216
+ pack_tags.any? { |t| t.to_s.downcase.include?(query) }
217
+ end
218
+
219
+ # Fetch markdown content for each file in the pack and transform
220
+ # into the format expected by PackManager: { filename, content }
221
+ def fetch_files_with_content(pack_name, files)
222
+ return files if files.empty?
223
+
224
+ files.map do |file|
225
+ path = fetch_key(file, :path)
226
+ result = fetch_file(pack_name, path)
227
+ { filename: path, content: result[:content] }
228
+ rescue RegistryError
229
+ # Skip files that fail to load
230
+ nil
231
+ end.compact
232
+ end
233
+
234
+ def not_modified_result
235
+ { data: nil, etag: nil, not_modified: true }
236
+ end
237
+ end
238
+
239
+ class RegistryError < RubynCode::Error; end
240
+ end
241
+ end
@@ -16,7 +16,8 @@ module RubynCode
16
16
  Registry.load_all!
17
17
  end
18
18
 
19
- def execute(tool_name, params) # rubocop:disable Metrics/AbcSize -- maps tool errors to results
19
+ # -- maps tool errors to results
20
+ def execute(tool_name, params)
20
21
  # File cache intercept: serve cached reads, invalidate on writes
21
22
  cached = try_file_cache(tool_name, params)
22
23
  return cached if cached
@@ -73,7 +74,8 @@ module RubynCode
73
74
  allowed.empty? ? symbolized : symbolized.slice(*allowed)
74
75
  end
75
76
 
76
- def inject_dependencies(tool, tool_name) # rubocop:disable Metrics/CyclomaticComplexity -- tool-specific dependency injection
77
+ # -- tool-specific dependency injection
78
+ def inject_dependencies(tool, tool_name)
77
79
  case tool_name
78
80
  when 'spawn_agent', 'spawn_teammate'
79
81
  inject_agent_deps(tool)
@@ -23,7 +23,8 @@ module RubynCode
23
23
  def self.summarize(output, args)
24
24
  pattern = args['pattern'] || args[:pattern] || ''
25
25
  count = output.to_s.lines.count
26
- count.zero? || output.to_s.start_with?('No matches') ? "grep #{pattern} (0 matches)" : "grep #{pattern} (#{count} lines)"
26
+ no_matches = count.zero? || output.to_s.start_with?('No matches')
27
+ no_matches ? "grep #{pattern} (0 matches)" : "grep #{pattern} (#{count} lines)"
27
28
  end
28
29
 
29
30
  def execute(pattern:, path: nil, glob_filter: nil, max_results: 50)
@@ -6,7 +6,9 @@ module RubynCode
6
6
  # via the IDE RPC bridge. Only available when running in IDE mode.
7
7
  class IdeDiagnostics < Base
8
8
  TOOL_NAME = 'ide_diagnostics'
9
- DESCRIPTION = 'Get VS Code diagnostics (errors, warnings) for a file or the whole workspace. Only available in IDE mode.'
9
+ DESCRIPTION =
10
+ 'Get VS Code diagnostics (errors, warnings) for a file or the whole workspace. ' \
11
+ 'Only available in IDE mode.'
10
12
  PARAMETERS = {
11
13
  file: {
12
14
  type: 'string',
@@ -6,7 +6,9 @@ module RubynCode
6
6
  # Only available when running in IDE mode.
7
7
  class IdeSymbols < Base
8
8
  TOOL_NAME = 'ide_symbols'
9
- DESCRIPTION = 'Search workspace symbols (classes, methods, modules) via VS Code language server. Only available in IDE mode.'
9
+ DESCRIPTION =
10
+ 'Search workspace symbols (classes, methods, modules) via VS Code language server. ' \
11
+ 'Only available in IDE mode.'
10
12
  PARAMETERS = {
11
13
  query: {
12
14
  type: 'string',
@@ -36,7 +36,8 @@ module RubynCode
36
36
  skills_dirs = [
37
37
  File.expand_path('../../../skills', __dir__), # bundled gem skills
38
38
  File.join(project_root, '.rubyn-code', 'skills'), # project skills
39
- File.join(Dir.home, '.rubyn-code', 'skills') # global user skills
39
+ File.join(Dir.home, '.rubyn-code', 'skills'), # global user skills
40
+ File.join(Dir.home, '.rubyn-code', 'skill-packs') # registry-installed packs
40
41
  ]
41
42
  catalog = Skills::Catalog.new(skills_dirs.select { |d| Dir.exist?(d) })
42
43
  Skills::Loader.new(catalog)
@@ -103,7 +103,7 @@ module RubynCode
103
103
  failures
104
104
  end
105
105
 
106
- # rubocop:disable Metrics/AbcSize -- head/tail splitting requires coordinated arithmetic
106
+ # -- head/tail splitting requires coordinated arithmetic
107
107
  def head_tail(output, max_chars)
108
108
  lines = output.lines
109
109
  return output if lines.size <= 10
@@ -120,9 +120,8 @@ module RubynCode
120
120
  parts << tail_lines.join
121
121
  parts.join
122
122
  end
123
- # rubocop:enable Metrics/AbcSize
124
123
 
125
- # rubocop:disable Metrics/AbcSize -- diff hunk iteration with header extraction
124
+ # -- diff hunk iteration with header extraction
126
125
  def compress_diff(output, max_chars)
127
126
  hunks = output.split(/^(?=diff --git)/)
128
127
  return head_tail(output, max_chars) if hunks.size <= 1
@@ -140,7 +139,6 @@ module RubynCode
140
139
 
141
140
  result
142
141
  end
143
- # rubocop:enable Metrics/AbcSize
144
142
 
145
143
  def top_matches(output, max_chars)
146
144
  lines = output.lines
@@ -152,7 +150,7 @@ module RubynCode
152
150
  result
153
151
  end
154
152
 
155
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity -- multi-step tree collapse
153
+ # -- multi-step tree collapse
156
154
  def collapse_tree(output, max_chars)
157
155
  paths = output.lines.map(&:strip).reject(&:empty?)
158
156
  return output if output.length <= max_chars
@@ -164,7 +162,6 @@ module RubynCode
164
162
 
165
163
  head_tail(result, max_chars)
166
164
  end
167
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity
168
165
 
169
166
  def take_lines_up_to(lines, max_chars)
170
167
  taken = []
@@ -24,7 +24,7 @@ module RubynCode
24
24
  }.freeze
25
25
  RISK_LEVEL = :read
26
26
 
27
- def execute(base_branch: 'main', focus: 'all')
27
+ def execute(base_branch: 'main', focus: 'all', pack_context: nil)
28
28
  error = validate_git_repo
29
29
  return error if error
30
30
 
@@ -37,7 +37,7 @@ module RubynCode
37
37
  diff = run_git("diff #{base_branch}...HEAD")
38
38
  return "No changes found between #{current} and #{base_branch}." if diff.strip.empty?
39
39
 
40
- build_full_review(current, base_branch, diff, focus)
40
+ build_full_review(current, base_branch, diff, focus, pack_context: pack_context)
41
41
  end
42
42
 
43
43
  FILE_CATEGORIES = [
@@ -80,8 +80,9 @@ module RubynCode
80
80
  [nil, "Error: Base branch '#{base_branch}' not found."]
81
81
  end
82
82
 
83
- def build_full_review(current, base_branch, diff, focus)
84
- review = build_review_header(current, base_branch)
83
+ def build_full_review(current, base_branch, diff, focus, pack_context: nil)
84
+ review = build_pack_context_section(pack_context)
85
+ review.concat(build_review_header(current, base_branch))
85
86
  review.concat(build_file_categories(base_branch))
86
87
  review.concat(build_focus_section(focus))
87
88
  review.concat(build_diff_section(diff))
@@ -158,6 +159,16 @@ module RubynCode
158
159
  ]
159
160
  end
160
161
 
162
+ def build_pack_context_section(pack_context)
163
+ return [] if pack_context.nil? || pack_context.strip.empty?
164
+
165
+ [
166
+ '## Skill Pack Context',
167
+ pack_context.strip,
168
+ ''
169
+ ]
170
+ end
171
+
161
172
  def run_git(command)
162
173
  `cd #{project_root} && git #{command} 2>/dev/null`
163
174
  end
@@ -85,7 +85,8 @@ module RubynCode
85
85
  html.scan(%r{<a[^>]+href="(https?://(?!lite\.duckduckgo)[^"]+)"[^>]*>(.*?)</a>}i)
86
86
  end
87
87
 
88
- def build_ddg_results(links, snippets, max) # rubocop:disable Metrics/AbcSize -- HTML parsing with filtering
88
+ # -- HTML parsing with filtering
89
+ def build_ddg_results(links, snippets, max)
89
90
  results = []
90
91
  links.each_with_index do |match, idx|
91
92
  break if results.length >= max
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubynCode
4
- VERSION = '0.4.0'
4
+ VERSION = '0.5.0'
5
5
  end
data/lib/rubyn_code.rb CHANGED
@@ -137,7 +137,16 @@ module RubynCode
137
137
  autoload :Loader, 'rubyn_code/skills/loader'
138
138
  autoload :Catalog, 'rubyn_code/skills/catalog'
139
139
  autoload :Document, 'rubyn_code/skills/document'
140
+ autoload :Matcher, 'rubyn_code/skills/matcher'
141
+ autoload :RegistryAutoload, 'rubyn_code/skills/registry_autoload'
140
142
  autoload :TtlManager, 'rubyn_code/skills/ttl_manager'
143
+ autoload :RegistryClient, 'rubyn_code/skills/registry_client'
144
+ autoload :PackManager, 'rubyn_code/skills/pack_manager'
145
+ autoload :PackInstaller, 'rubyn_code/skills/pack_installer'
146
+ autoload :PackContext, 'rubyn_code/skills/pack_context'
147
+ autoload :GemfileParser, 'rubyn_code/skills/gemfile_parser'
148
+ autoload :AutoSuggest, 'rubyn_code/skills/auto_suggest'
149
+ autoload :RegistryError, 'rubyn_code/skills/registry_client'
141
150
  end
142
151
 
143
152
  # Layer 6: Sub-Agents
@@ -284,6 +293,9 @@ module RubynCode
284
293
  autoload :Model, 'rubyn_code/cli/commands/model'
285
294
  autoload :NewSession, 'rubyn_code/cli/commands/new_session'
286
295
  autoload :Provider, 'rubyn_code/cli/commands/provider'
296
+ autoload :InstallSkills, 'rubyn_code/cli/commands/install_skills'
297
+ autoload :RemoveSkills, 'rubyn_code/cli/commands/remove_skills'
298
+ autoload :Skills, 'rubyn_code/cli/commands/skills'
287
299
  end
288
300
  end
289
301
 
@@ -115,6 +115,81 @@ Score: 18/22 (82%) — 4 failures
115
115
  - **glob**: Check that all 16 layer directories exist under `lib/rubyn_code/`. PASS if at least 14 found.
116
116
  - **read_file**: Read `lib/rubyn_code.rb` and verify it has modules for Agent, Tools, Context, Skills, Memory, Observability, Learning. PASS if all 7 found.
117
117
 
118
+ ### 15. Skill-Pack Autoload — Live Registry Roundtrip
119
+
120
+ End-to-end exercise of the autoload pipeline against the real registry at `rubyn.ai`: fetch the catalog, install a pack the user does **not** already have, verify it works on disk + in the catalog + through the matcher, then remove it. The user's pre-existing installed packs are not touched.
121
+
122
+ - **Live roundtrip**: `bash` with the script below. PASS if the final line is `ROUNDTRIP: PASS`. SKIP if the final line starts with `SKIP:` (means every registry pack is already installed locally — rare).
123
+
124
+ ```bash
125
+ bundle exec ruby -e '
126
+ require_relative "lib/rubyn_code"
127
+
128
+ client = RubynCode::Skills::RegistryClient.new
129
+ pack_manager = RubynCode::Skills::PackManager.new
130
+
131
+ catalog_packs = client.fetch_catalog[:data] || []
132
+ abort "FAIL: empty registry catalog" if catalog_packs.empty?
133
+
134
+ already_installed = catalog_packs.map { |p| p[:name] || p["name"] }
135
+ .select { |n| pack_manager.installed?(n) }
136
+ target = catalog_packs.find { |p| !already_installed.include?(p[:name] || p["name"]) }
137
+ abort "SKIP: every registry pack is already installed" if target.nil?
138
+
139
+ name = target[:name] || target["name"]
140
+ puts "TEST PACK: #{name}"
141
+
142
+ success = false
143
+ begin
144
+ result = client.fetch_pack(name)
145
+ pack_manager.install(result[:data], etag: result[:etag])
146
+ raise "install did not create directory" unless pack_manager.installed?(name)
147
+ puts "STEP install: PASS"
148
+
149
+ catalog_obj = RubynCode::Skills::Catalog.new(
150
+ File.join(Dir.home, ".rubyn-code", "skill-packs")
151
+ )
152
+ skills = catalog_obj.available.select { |e| e[:path].include?("/#{name}/") }
153
+ raise "catalog sees no skills" if skills.empty?
154
+ raise "no skills have triggers" if skills.none? { |s| !s[:triggers].empty? }
155
+ puts "STEP catalog: PASS (#{skills.size} skills, #{skills.count { |s| !s[:triggers].empty? }} with triggers)"
156
+
157
+ sample = skills.find { |s| !s[:triggers].empty? }
158
+ matcher = RubynCode::Skills::Matcher.new(catalog: catalog_obj)
159
+ hits = matcher.match("question about #{sample[:triggers].first}")
160
+ raise "matcher did not hit on \"#{sample[:triggers].first}\"" if hits.none? { |h| h[:name] == sample[:name] }
161
+ puts "STEP matcher: PASS (hit \"#{sample[:name]}\" via \"#{sample[:triggers].first}\")"
162
+ success = true
163
+ ensure
164
+ pack_manager.remove(name)
165
+ if pack_manager.installed?(name)
166
+ puts "STEP cleanup: FAIL (pack still on disk)"
167
+ else
168
+ puts "STEP cleanup: PASS"
169
+ end
170
+ end
171
+
172
+ puts(success ? "ROUNDTRIP: PASS" : "ROUNDTRIP: FAIL")
173
+ '
174
+ ```
175
+
176
+ The script:
177
+ 1. Fetches the registry catalog (proves the static API at `rubyn.ai/api/v1/skills/packs.json` is reachable).
178
+ 2. Picks the first pack the user does **not** have installed, so we never touch their existing packs.
179
+ 3. Installs the pack via `RegistryClient#fetch_pack` + `PackManager#install` — exactly the path the autoload pipeline uses.
180
+ 4. Reads the disk back through a fresh `Skills::Catalog` and confirms the new skills are visible with parsed triggers.
181
+ 5. Runs `Skills::Matcher#match` against a real trigger from one of the freshly-installed skills, confirming the matcher would have fired in a real session.
182
+ 6. Removes the pack in an `ensure` block so a partial failure still leaves the user's system clean.
183
+
184
+ PASS criteria: `STEP install`, `STEP catalog`, `STEP matcher`, and `STEP cleanup` all `PASS`, with a final `ROUNDTRIP: PASS`.
185
+
186
+ - **Live autoload notification (manual verification, not scored)**: This isn't a tool call — it's a hint to surface in the scorecard for the user. Tell them: after the self-test completes, send a follow-up prompt that includes a trigger word from any pack in the registry (e.g. `"explain turbo drive"`). The Rubyn renderer should print:
187
+ ```
188
+ 📥 Fetching skill pack 'hotwire' from registry…
189
+ 📚 Loaded: turbo-drive
190
+ ```
191
+ before the next response (the `📥` line appears only if the pack wasn't already installed). Do **not** count this as PASS/FAIL — just mention it in the scorecard so the user can verify the renderer side themselves.
192
+
118
193
  ## Scoring
119
194
 
120
195
  Count total PASS results out of total tests run. Report the percentage.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubyn-code
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - fadedmaturity
@@ -226,6 +226,8 @@ files:
226
226
  - lib/rubyn_code/cli/commands/diff.rb
227
227
  - lib/rubyn_code/cli/commands/doctor.rb
228
228
  - lib/rubyn_code/cli/commands/help.rb
229
+ - lib/rubyn_code/cli/commands/install_skills.rb
230
+ - lib/rubyn_code/cli/commands/list_skills.rb
229
231
  - lib/rubyn_code/cli/commands/mcp.rb
230
232
  - lib/rubyn_code/cli/commands/model.rb
231
233
  - lib/rubyn_code/cli/commands/new_session.rb
@@ -233,9 +235,11 @@ files:
233
235
  - lib/rubyn_code/cli/commands/provider.rb
234
236
  - lib/rubyn_code/cli/commands/quit.rb
235
237
  - lib/rubyn_code/cli/commands/registry.rb
238
+ - lib/rubyn_code/cli/commands/remove_skills.rb
236
239
  - lib/rubyn_code/cli/commands/resume.rb
237
240
  - lib/rubyn_code/cli/commands/review.rb
238
241
  - lib/rubyn_code/cli/commands/skill.rb
242
+ - lib/rubyn_code/cli/commands/skills.rb
239
243
  - lib/rubyn_code/cli/commands/spawn.rb
240
244
  - lib/rubyn_code/cli/commands/tasks.rb
241
245
  - lib/rubyn_code/cli/commands/tokens.rb
@@ -353,9 +357,17 @@ files:
353
357
  - lib/rubyn_code/protocols/shutdown_handshake.rb
354
358
  - lib/rubyn_code/self_test.rb
355
359
  - lib/rubyn_code/skills/RUBYN.md
360
+ - lib/rubyn_code/skills/auto_suggest.rb
356
361
  - lib/rubyn_code/skills/catalog.rb
357
362
  - lib/rubyn_code/skills/document.rb
363
+ - lib/rubyn_code/skills/gemfile_parser.rb
358
364
  - lib/rubyn_code/skills/loader.rb
365
+ - lib/rubyn_code/skills/matcher.rb
366
+ - lib/rubyn_code/skills/pack_context.rb
367
+ - lib/rubyn_code/skills/pack_installer.rb
368
+ - lib/rubyn_code/skills/pack_manager.rb
369
+ - lib/rubyn_code/skills/registry_autoload.rb
370
+ - lib/rubyn_code/skills/registry_client.rb
359
371
  - lib/rubyn_code/skills/ttl_manager.rb
360
372
  - lib/rubyn_code/sub_agents/RUBYN.md
361
373
  - lib/rubyn_code/sub_agents/runner.rb