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.
- checksums.yaml +4 -4
- data/README.md +263 -21
- data/db/migrations/013_add_failed_status_to_tasks.rb +51 -0
- data/lib/rubyn_code/agent/conversation.rb +34 -4
- data/lib/rubyn_code/agent/dynamic_tool_schema.rb +57 -3
- data/lib/rubyn_code/agent/llm_caller.rb +11 -1
- data/lib/rubyn_code/agent/loop.rb +14 -3
- data/lib/rubyn_code/agent/response_modes.rb +2 -1
- data/lib/rubyn_code/agent/system_prompt_builder.rb +49 -4
- data/lib/rubyn_code/agent/tool_processor.rb +25 -3
- data/lib/rubyn_code/auth/key_encryption.rb +118 -0
- data/lib/rubyn_code/auth/token_store.rb +50 -9
- data/lib/rubyn_code/autonomous/daemon.rb +117 -14
- data/lib/rubyn_code/autonomous/idle_poller.rb +0 -20
- data/lib/rubyn_code/autonomous/task_claimer.rb +17 -11
- data/lib/rubyn_code/cli/app.rb +116 -11
- data/lib/rubyn_code/cli/commands/doctor.rb +73 -0
- 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/mcp.rb +77 -0
- data/lib/rubyn_code/cli/commands/model.rb +32 -2
- data/lib/rubyn_code/cli/commands/provider.rb +124 -0
- data/lib/rubyn_code/cli/commands/remove_skills.rb +35 -0
- data/lib/rubyn_code/cli/commands/skill.rb +54 -3
- data/lib/rubyn_code/cli/commands/skills.rb +104 -0
- data/lib/rubyn_code/cli/daemon_runner.rb +36 -0
- data/lib/rubyn_code/cli/first_run.rb +159 -0
- data/lib/rubyn_code/cli/repl.rb +15 -0
- data/lib/rubyn_code/cli/repl_commands.rb +3 -1
- data/lib/rubyn_code/cli/repl_lifecycle.rb +1 -0
- data/lib/rubyn_code/cli/repl_setup.rb +74 -1
- data/lib/rubyn_code/config/defaults.rb +3 -0
- data/lib/rubyn_code/config/schema.json +49 -0
- data/lib/rubyn_code/config/settings.rb +12 -6
- data/lib/rubyn_code/config/validator.rb +63 -0
- data/lib/rubyn_code/context/context_budget.rb +18 -2
- data/lib/rubyn_code/context/context_collapse.rb +34 -4
- data/lib/rubyn_code/context/manager.rb +37 -3
- data/lib/rubyn_code/context/manual_compact.rb +1 -1
- data/lib/rubyn_code/hooks/registry.rb +4 -0
- data/lib/rubyn_code/ide/adapters/tool_output.rb +330 -0
- data/lib/rubyn_code/ide/client.rb +110 -0
- data/lib/rubyn_code/ide/handlers/accept_edit_handler.rb +35 -0
- data/lib/rubyn_code/ide/handlers/approve_tool_use_handler.rb +34 -0
- data/lib/rubyn_code/ide/handlers/cancel_handler.rb +41 -0
- data/lib/rubyn_code/ide/handlers/config_get_handler.rb +63 -0
- data/lib/rubyn_code/ide/handlers/config_set_handler.rb +86 -0
- data/lib/rubyn_code/ide/handlers/initialize_handler.rb +79 -0
- data/lib/rubyn_code/ide/handlers/models_list_handler.rb +39 -0
- data/lib/rubyn_code/ide/handlers/prompt_handler.rb +218 -0
- data/lib/rubyn_code/ide/handlers/review_handler.rb +127 -0
- data/lib/rubyn_code/ide/handlers/session_fork_handler.rb +49 -0
- data/lib/rubyn_code/ide/handlers/session_list_handler.rb +41 -0
- data/lib/rubyn_code/ide/handlers/session_reset_handler.rb +31 -0
- data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +42 -0
- data/lib/rubyn_code/ide/handlers/shutdown_handler.rb +37 -0
- data/lib/rubyn_code/ide/handlers.rb +76 -0
- data/lib/rubyn_code/ide/protocol.rb +112 -0
- data/lib/rubyn_code/ide/server.rb +186 -0
- data/lib/rubyn_code/index/codebase_index.rb +69 -2
- data/lib/rubyn_code/learning/extractor.rb +4 -2
- data/lib/rubyn_code/llm/adapters/anthropic.rb +6 -2
- data/lib/rubyn_code/llm/adapters/anthropic_compatible.rb +60 -0
- data/lib/rubyn_code/llm/adapters/openai_compatible.rb +6 -2
- data/lib/rubyn_code/llm/client.rb +29 -4
- data/lib/rubyn_code/llm/model_router.rb +2 -1
- data/lib/rubyn_code/mcp/config.rb +2 -1
- data/lib/rubyn_code/mcp/tool_bridge.rb +1 -1
- data/lib/rubyn_code/memory/search.rb +1 -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 +316 -0
- data/lib/rubyn_code/skills/auto_suggest.rb +131 -0
- data/lib/rubyn_code/skills/catalog.rb +76 -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 +43 -0
- 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/tasks/models.rb +1 -0
- data/lib/rubyn_code/tools/base.rb +13 -0
- data/lib/rubyn_code/tools/bash.rb +5 -0
- data/lib/rubyn_code/tools/edit_file.rb +62 -5
- data/lib/rubyn_code/tools/executor.rb +65 -8
- data/lib/rubyn_code/tools/glob.rb +6 -0
- data/lib/rubyn_code/tools/grep.rb +7 -0
- data/lib/rubyn_code/tools/ide_diagnostics.rb +53 -0
- data/lib/rubyn_code/tools/ide_symbols.rb +55 -0
- data/lib/rubyn_code/tools/load_skill.rb +2 -1
- data/lib/rubyn_code/tools/output_compressor.rb +9 -7
- data/lib/rubyn_code/tools/read_file.rb +6 -0
- data/lib/rubyn_code/tools/registry.rb +11 -0
- data/lib/rubyn_code/tools/review_pr.rb +15 -4
- data/lib/rubyn_code/tools/web_search.rb +2 -1
- data/lib/rubyn_code/tools/write_file.rb +17 -0
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +34 -0
- data/skills/rubyn_self_test.md +88 -1
- metadata +43 -1
|
@@ -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
|
|
@@ -37,6 +37,19 @@ module RubynCode
|
|
|
37
37
|
input_schema: Schema.build(parameters)
|
|
38
38
|
}
|
|
39
39
|
end
|
|
40
|
+
|
|
41
|
+
# One-line summary of a successful invocation, shown in the IDE's
|
|
42
|
+
# chat card. Default is empty so the UI renders a clean "Done"
|
|
43
|
+
# indicator. Override in subclasses that have a useful one-liner
|
|
44
|
+
# (e.g. "Edited app.rb (1 replacement)"). The full output still
|
|
45
|
+
# goes to the conversation untouched — this only affects the UI.
|
|
46
|
+
#
|
|
47
|
+
# @param output [String] what execute(**) returned
|
|
48
|
+
# @param args [Hash] the tool arguments (string-keyed)
|
|
49
|
+
# @return [String]
|
|
50
|
+
def summarize(_output, _args)
|
|
51
|
+
''
|
|
52
|
+
end
|
|
40
53
|
end
|
|
41
54
|
|
|
42
55
|
attr_reader :project_root
|
|
@@ -18,6 +18,11 @@ module RubynCode
|
|
|
18
18
|
RISK_LEVEL = :execute
|
|
19
19
|
REQUIRES_CONFIRMATION = true
|
|
20
20
|
|
|
21
|
+
def self.summarize(_output, args)
|
|
22
|
+
cmd = args['command'] || args[:command] || ''
|
|
23
|
+
"$ #{cmd[0, 180]}"
|
|
24
|
+
end
|
|
25
|
+
|
|
21
26
|
def execute(command:, timeout: 120)
|
|
22
27
|
validate_command!(command)
|
|
23
28
|
|