rubyn-code 0.4.0 → 0.5.1
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 +247 -9
- 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 +87 -13
- 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/megaplan.rb +50 -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 +3 -1
- data/lib/rubyn_code/cli/repl_setup.rb +38 -1
- data/lib/rubyn_code/cli/setup.rb +13 -0
- 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/plan_interview_answer_handler.rb +65 -0
- data/lib/rubyn_code/ide/handlers/plan_interview_cancel_handler.rb +22 -0
- data/lib/rubyn_code/ide/handlers/plan_interview_start_handler.rb +53 -0
- data/lib/rubyn_code/ide/handlers/plan_propose_handler.rb +41 -0
- data/lib/rubyn_code/ide/handlers/prompt_handler.rb +6 -3
- data/lib/rubyn_code/ide/handlers/recover_ci_handler.rb +132 -0
- data/lib/rubyn_code/ide/handlers/review_handler.rb +19 -2
- data/lib/rubyn_code/ide/handlers.rb +17 -2
- data/lib/rubyn_code/ide/protocol.rb +17 -1
- data/lib/rubyn_code/ide/server.rb +39 -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/megaplan/ci_recovery.rb +104 -0
- data/lib/rubyn_code/megaplan/interview_session.rb +245 -0
- data/lib/rubyn_code/megaplan/plan_proposer.rb +153 -0
- 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 +20 -0
- data/skills/megaplan/megaplan.md +156 -0
- data/skills/rubyn_self_test.md +75 -0
- metadata +25 -4
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module RubynCode
|
|
7
|
+
module Skills
|
|
8
|
+
# Manages local installation, removal, and updates of skill packs
|
|
9
|
+
# with ETag caching and offline fallback support.
|
|
10
|
+
#
|
|
11
|
+
# Installed packs live under ~/.rubyn-code/skill-packs/<pack-name>/.
|
|
12
|
+
# A manifest.json in each pack directory records metadata for listing,
|
|
13
|
+
# version tracking, and ETag-based conditional updates.
|
|
14
|
+
class PackManager
|
|
15
|
+
PACKS_DIR = File.join(Config::Defaults::HOME_DIR, 'skill-packs')
|
|
16
|
+
MANIFEST_FILE = 'manifest.json'
|
|
17
|
+
ETAG_CACHE_FILE = '.etags.json'
|
|
18
|
+
SAFE_NAME_RE = /\A[a-zA-Z0-9_-]+\z/
|
|
19
|
+
|
|
20
|
+
def initialize(packs_dir: PACKS_DIR)
|
|
21
|
+
@packs_dir = packs_dir
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Install a pack from registry response data.
|
|
25
|
+
#
|
|
26
|
+
# @param pack_data [Hash] from RegistryClient#fetch_pack[:data]
|
|
27
|
+
# Expected keys: :name, :description, :version, :files
|
|
28
|
+
# Each file: { filename: "name.md", content: "..." }
|
|
29
|
+
# @param etag [String, nil] ETag from registry response for cache tracking
|
|
30
|
+
# @return [Hash] installed pack metadata
|
|
31
|
+
def install(pack_data, etag: nil)
|
|
32
|
+
name = fetch_key(pack_data, :name)
|
|
33
|
+
raise ArgumentError, 'Pack data must include a name' if name.nil? || name.empty?
|
|
34
|
+
|
|
35
|
+
validate_name!(name)
|
|
36
|
+
pack_dir = pack_path(name)
|
|
37
|
+
FileUtils.mkdir_p(pack_dir)
|
|
38
|
+
|
|
39
|
+
write_files(pack_dir, pack_data)
|
|
40
|
+
write_manifest(pack_dir, pack_data, etag: etag)
|
|
41
|
+
store_etag(name, etag) if etag
|
|
42
|
+
|
|
43
|
+
manifest(name)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Update a single installed pack using ETag-based conditional fetch.
|
|
47
|
+
# Returns :updated, :up_to_date, or :not_installed.
|
|
48
|
+
#
|
|
49
|
+
# @param name [String] pack name
|
|
50
|
+
# @param registry [RegistryClient] registry client to fetch from
|
|
51
|
+
# @return [Symbol] update result
|
|
52
|
+
def update(name, registry)
|
|
53
|
+
validate_name!(name)
|
|
54
|
+
return :not_installed unless installed?(name)
|
|
55
|
+
|
|
56
|
+
cached_etag = load_etag(name)
|
|
57
|
+
result = registry.fetch_pack(name, etag: cached_etag)
|
|
58
|
+
|
|
59
|
+
return :up_to_date if result[:not_modified]
|
|
60
|
+
|
|
61
|
+
install(result[:data], etag: result[:etag])
|
|
62
|
+
:updated
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Update all installed packs. Returns a hash of { name => status }.
|
|
66
|
+
#
|
|
67
|
+
# @param registry [RegistryClient]
|
|
68
|
+
# @return [Hash<String, Symbol>]
|
|
69
|
+
def update_all(registry)
|
|
70
|
+
installed.each_with_object({}) do |pack, results|
|
|
71
|
+
name = pack[:name]
|
|
72
|
+
results[name] = update(name, registry)
|
|
73
|
+
rescue RegistryError => e
|
|
74
|
+
results[name] = :"error: #{e.message}"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Remove an installed pack with path traversal protection.
|
|
79
|
+
#
|
|
80
|
+
# @param name [String] pack name
|
|
81
|
+
# @return [Boolean] true if removed, false if not found
|
|
82
|
+
def remove(name)
|
|
83
|
+
validate_name!(name)
|
|
84
|
+
pack_dir = pack_path(name)
|
|
85
|
+
return false unless File.directory?(pack_dir)
|
|
86
|
+
|
|
87
|
+
# Verify the resolved path is within packs_dir to prevent traversal
|
|
88
|
+
real_pack = File.realpath(pack_dir)
|
|
89
|
+
real_base = File.realpath(@packs_dir)
|
|
90
|
+
unless real_pack.start_with?("#{real_base}/")
|
|
91
|
+
raise ArgumentError, "Pack directory is outside the skill-packs directory"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
FileUtils.rm_rf(pack_dir)
|
|
95
|
+
remove_etag(name)
|
|
96
|
+
true
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# List all installed packs.
|
|
100
|
+
#
|
|
101
|
+
# @return [Array<Hash>] each with :name, :description, :version, :installed_at
|
|
102
|
+
def installed
|
|
103
|
+
return [] unless File.directory?(@packs_dir)
|
|
104
|
+
|
|
105
|
+
Dir.children(@packs_dir)
|
|
106
|
+
.select { |d| File.directory?(File.join(@packs_dir, d)) }
|
|
107
|
+
.reject { |d| d.start_with?('.') }
|
|
108
|
+
.filter_map { |d| manifest(d) }
|
|
109
|
+
.sort_by { |m| m[:name] }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Check if a pack is installed.
|
|
113
|
+
#
|
|
114
|
+
# @param name [String]
|
|
115
|
+
# @return [Boolean]
|
|
116
|
+
def installed?(name)
|
|
117
|
+
manifest_path = File.join(@packs_dir, name.to_s, MANIFEST_FILE)
|
|
118
|
+
File.exist?(manifest_path)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Return the skills directory for a pack (for catalog integration).
|
|
122
|
+
#
|
|
123
|
+
# @param name [String]
|
|
124
|
+
# @return [String, nil] path to pack directory or nil
|
|
125
|
+
def pack_skills_dir(name)
|
|
126
|
+
dir = pack_path(name)
|
|
127
|
+
File.directory?(dir) ? dir : nil
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Return all installed pack directories (for skill loader integration).
|
|
131
|
+
#
|
|
132
|
+
# @return [Array<String>]
|
|
133
|
+
def all_pack_dirs
|
|
134
|
+
installed.map { |pack| pack_path(pack[:name]) }.select { |d| File.directory?(d) }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
def pack_path(name)
|
|
140
|
+
File.join(@packs_dir, name.to_s)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Validate pack name contains only safe characters.
|
|
144
|
+
def validate_name!(name)
|
|
145
|
+
return if name.to_s.match?(SAFE_NAME_RE)
|
|
146
|
+
|
|
147
|
+
raise ArgumentError,
|
|
148
|
+
"Invalid pack name: '#{name}'. Only letters, numbers, hyphens, and underscores allowed."
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def fetch_key(hash, key)
|
|
152
|
+
hash[key] || hash[key.to_s]
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def manifest(name)
|
|
156
|
+
path = File.join(@packs_dir, name.to_s, MANIFEST_FILE)
|
|
157
|
+
return nil unless File.exist?(path)
|
|
158
|
+
|
|
159
|
+
JSON.parse(File.read(path), symbolize_names: true)
|
|
160
|
+
rescue JSON::ParserError
|
|
161
|
+
nil
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def write_files(pack_dir, pack_data)
|
|
165
|
+
files = fetch_key(pack_data, :files) || []
|
|
166
|
+
files.each { |file| write_single_file(pack_dir, file) }
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def write_single_file(pack_dir, file)
|
|
170
|
+
filename = fetch_key(file, :filename)
|
|
171
|
+
content = fetch_key(file, :content)
|
|
172
|
+
return if filename.nil? || content.nil?
|
|
173
|
+
|
|
174
|
+
# Prevent path traversal in filenames
|
|
175
|
+
safe_name = File.basename(filename)
|
|
176
|
+
File.write(File.join(pack_dir, safe_name), content)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def write_manifest(pack_dir, pack_data, etag: nil)
|
|
180
|
+
manifest_data = {
|
|
181
|
+
name: fetch_key(pack_data, :name),
|
|
182
|
+
description: fetch_key(pack_data, :description) || '',
|
|
183
|
+
version: fetch_key(pack_data, :version) || '1.0.0',
|
|
184
|
+
skillCount: (fetch_key(pack_data, :files) || []).size,
|
|
185
|
+
etag: etag,
|
|
186
|
+
installed_at: Time.now.iso8601,
|
|
187
|
+
updated_at: Time.now.iso8601
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
File.write(
|
|
191
|
+
File.join(pack_dir, MANIFEST_FILE),
|
|
192
|
+
JSON.pretty_generate(manifest_data)
|
|
193
|
+
)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# ── ETag Cache ────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
def etag_cache_path
|
|
199
|
+
File.join(@packs_dir, ETAG_CACHE_FILE)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def load_etag_cache
|
|
203
|
+
return {} unless File.exist?(etag_cache_path)
|
|
204
|
+
|
|
205
|
+
JSON.parse(File.read(etag_cache_path))
|
|
206
|
+
rescue JSON::ParserError
|
|
207
|
+
{}
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def load_etag(name)
|
|
211
|
+
load_etag_cache[name.to_s]
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def store_etag(name, etag)
|
|
215
|
+
FileUtils.mkdir_p(@packs_dir)
|
|
216
|
+
cache = load_etag_cache
|
|
217
|
+
cache[name.to_s] = etag
|
|
218
|
+
File.write(etag_cache_path, JSON.pretty_generate(cache))
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def remove_etag(name)
|
|
222
|
+
return unless File.exist?(etag_cache_path)
|
|
223
|
+
|
|
224
|
+
cache = load_etag_cache
|
|
225
|
+
cache.delete(name.to_s)
|
|
226
|
+
File.write(etag_cache_path, JSON.pretty_generate(cache))
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module Skills
|
|
5
|
+
# Web fallback for trigger-based skill autoload.
|
|
6
|
+
#
|
|
7
|
+
# When the user message matches a skill pack in the registry that the
|
|
8
|
+
# user hasn't installed locally, fetch the pack, install it under
|
|
9
|
+
# ~/.rubyn-code/skill-packs/, refresh the loader's catalog, and let the
|
|
10
|
+
# local matcher pick up the freshly-available skills.
|
|
11
|
+
#
|
|
12
|
+
# Pack-level matching (decided up front): substring-match the user
|
|
13
|
+
# message against each pack's :name and :tags. Skill-level triggers
|
|
14
|
+
# aren't in the registry catalog yet, so we pull packs at the coarser
|
|
15
|
+
# grain and let the local matcher take over once files land on disk.
|
|
16
|
+
#
|
|
17
|
+
# Failure modes are silent: a registry error returns no matches so the
|
|
18
|
+
# turn proceeds as if the web fallback weren't there.
|
|
19
|
+
class RegistryAutoload
|
|
20
|
+
# @param loader [Skills::Loader] supplies the live catalog to refresh after install
|
|
21
|
+
# @param matcher [Skills::Matcher] re-run after install to extract real skill matches
|
|
22
|
+
# @param registry_client [Skills::RegistryClient, nil] defaults to a new client
|
|
23
|
+
# @param pack_manager [Skills::PackManager, nil] defaults to a new manager
|
|
24
|
+
# @param on_fetching [Proc, nil] called as on_fetching.call(pack_name) before each fetch
|
|
25
|
+
def initialize(loader:, matcher:, registry_client: nil, pack_manager: nil, on_fetching: nil)
|
|
26
|
+
@loader = loader
|
|
27
|
+
@matcher = matcher
|
|
28
|
+
@client = registry_client || RegistryClient.new
|
|
29
|
+
@pack_manager = pack_manager || PackManager.new
|
|
30
|
+
@on_fetching = on_fetching || ->(_name) {}
|
|
31
|
+
@attempted = Set.new
|
|
32
|
+
@catalog_cache = nil
|
|
33
|
+
@catalog_fetch_failed = false
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Attempt to install and match uninstalled packs whose name or tags
|
|
37
|
+
# appear in the user message.
|
|
38
|
+
#
|
|
39
|
+
# @param user_input [String]
|
|
40
|
+
# @return [Array<Hash>] catalog entries (from the local matcher) that
|
|
41
|
+
# became matchable after install. Empty if nothing fetched or the
|
|
42
|
+
# registry is unreachable.
|
|
43
|
+
def try(user_input)
|
|
44
|
+
text = user_input.to_s.downcase
|
|
45
|
+
return [] if text.empty?
|
|
46
|
+
|
|
47
|
+
candidates = uninstalled_packs_matching(text)
|
|
48
|
+
return [] if candidates.empty?
|
|
49
|
+
|
|
50
|
+
installed_any = candidates.any? { |pack| attempt_install(pack) }
|
|
51
|
+
return [] unless installed_any
|
|
52
|
+
|
|
53
|
+
@loader.catalog.refresh!
|
|
54
|
+
@matcher.match(user_input)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def uninstalled_packs_matching(text)
|
|
60
|
+
packs = registry_catalog
|
|
61
|
+
return [] if packs.nil?
|
|
62
|
+
|
|
63
|
+
packs.select do |pack|
|
|
64
|
+
name = pack_name(pack)
|
|
65
|
+
next false if name.nil? || name.empty?
|
|
66
|
+
next false if @pack_manager.installed?(name)
|
|
67
|
+
next false if @attempted.include?(name)
|
|
68
|
+
|
|
69
|
+
pack_matches?(pack, name, text)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def pack_matches?(pack, name, text)
|
|
74
|
+
return true if text.include?(name.downcase)
|
|
75
|
+
|
|
76
|
+
Array(pack[:tags] || pack['tags']).any? do |tag|
|
|
77
|
+
text.include?(tag.to_s.downcase)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def attempt_install(pack)
|
|
82
|
+
name = pack_name(pack)
|
|
83
|
+
@attempted << name
|
|
84
|
+
|
|
85
|
+
@on_fetching.call(name)
|
|
86
|
+
result = @client.fetch_pack(name)
|
|
87
|
+
@pack_manager.install(result[:data], etag: result[:etag])
|
|
88
|
+
true
|
|
89
|
+
rescue StandardError => e
|
|
90
|
+
RubynCode::Debug.warn("Web autoload failed for '#{name}': #{e.message}")
|
|
91
|
+
false
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Cache the registry catalog for the session. One failure flips
|
|
95
|
+
# @catalog_fetch_failed so we don't hammer the registry on every turn.
|
|
96
|
+
def registry_catalog
|
|
97
|
+
return nil if @catalog_fetch_failed
|
|
98
|
+
return @catalog_cache if @catalog_cache
|
|
99
|
+
|
|
100
|
+
@catalog_cache = @client.fetch_catalog[:data] || []
|
|
101
|
+
rescue StandardError => e
|
|
102
|
+
RubynCode::Debug.warn("Web autoload registry fetch failed: #{e.message}")
|
|
103
|
+
@catalog_fetch_failed = true
|
|
104
|
+
nil
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def pack_name(pack)
|
|
108
|
+
(pack[:name] || pack['name']).to_s
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -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 = []
|