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.
- checksums.yaml +4 -4
- data/README.md +186 -2
- data/lib/rubyn_code/agent/conversation.rb +2 -1
- data/lib/rubyn_code/agent/dynamic_tool_schema.rb +2 -1
- data/lib/rubyn_code/agent/llm_caller.rb +4 -2
- data/lib/rubyn_code/agent/loop.rb +7 -3
- data/lib/rubyn_code/agent/response_modes.rb +2 -1
- data/lib/rubyn_code/agent/system_prompt_builder.rb +39 -0
- data/lib/rubyn_code/agent/tool_processor.rb +4 -2
- data/lib/rubyn_code/cli/app.rb +85 -11
- data/lib/rubyn_code/cli/commands/install_skills.rb +44 -0
- data/lib/rubyn_code/cli/commands/list_skills.rb +149 -0
- data/lib/rubyn_code/cli/commands/provider.rb +2 -1
- data/lib/rubyn_code/cli/commands/remove_skills.rb +35 -0
- data/lib/rubyn_code/cli/commands/skill.rb +4 -2
- data/lib/rubyn_code/cli/commands/skills.rb +104 -0
- data/lib/rubyn_code/cli/repl.rb +11 -1
- data/lib/rubyn_code/cli/repl_commands.rb +2 -1
- data/lib/rubyn_code/cli/repl_setup.rb +38 -1
- data/lib/rubyn_code/config/defaults.rb +2 -0
- data/lib/rubyn_code/config/settings.rb +5 -2
- data/lib/rubyn_code/context/context_budget.rb +2 -1
- data/lib/rubyn_code/context/manager.rb +3 -3
- data/lib/rubyn_code/ide/handlers/prompt_handler.rb +6 -3
- data/lib/rubyn_code/ide/handlers/review_handler.rb +19 -2
- data/lib/rubyn_code/ide/protocol.rb +2 -1
- data/lib/rubyn_code/index/codebase_index.rb +2 -1
- data/lib/rubyn_code/learning/extractor.rb +4 -2
- data/lib/rubyn_code/llm/model_router.rb +2 -1
- data/lib/rubyn_code/mcp/tool_bridge.rb +1 -1
- data/lib/rubyn_code/observability/usage_reporter.rb +4 -2
- data/lib/rubyn_code/output/diff_renderer.rb +3 -2
- data/lib/rubyn_code/self_test.rb +2 -1
- data/lib/rubyn_code/skills/auto_suggest.rb +131 -0
- data/lib/rubyn_code/skills/catalog.rb +10 -0
- data/lib/rubyn_code/skills/document.rb +8 -2
- data/lib/rubyn_code/skills/gemfile_parser.rb +40 -0
- data/lib/rubyn_code/skills/loader.rb +1 -1
- data/lib/rubyn_code/skills/matcher.rb +89 -0
- data/lib/rubyn_code/skills/pack_context.rb +163 -0
- data/lib/rubyn_code/skills/pack_installer.rb +194 -0
- data/lib/rubyn_code/skills/pack_manager.rb +230 -0
- data/lib/rubyn_code/skills/registry_autoload.rb +112 -0
- data/lib/rubyn_code/skills/registry_client.rb +241 -0
- data/lib/rubyn_code/tools/executor.rb +4 -2
- data/lib/rubyn_code/tools/grep.rb +2 -1
- data/lib/rubyn_code/tools/ide_diagnostics.rb +3 -1
- data/lib/rubyn_code/tools/ide_symbols.rb +3 -1
- data/lib/rubyn_code/tools/load_skill.rb +2 -1
- data/lib/rubyn_code/tools/output_compressor.rb +3 -6
- data/lib/rubyn_code/tools/review_pr.rb +15 -4
- data/lib/rubyn_code/tools/web_search.rb +2 -1
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +12 -0
- data/skills/rubyn_self_test.md +75 -0
- 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
|
-
|
|
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
|
-
|
|
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')
|
|
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 =
|
|
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 =
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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 =
|
|
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
|
-
|
|
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
|
data/lib/rubyn_code/version.rb
CHANGED
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
|
|
data/skills/rubyn_self_test.md
CHANGED
|
@@ -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
|
+
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
|