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,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
|
|
6
|
+
module RubynCode
|
|
7
|
+
module Skills
|
|
8
|
+
# Downloads and installs skill packs from the registry.
|
|
9
|
+
#
|
|
10
|
+
# Handles ETag caching, version comparison, and offline fallback.
|
|
11
|
+
# Installs to `.rubyn-code/skills/<pack>/` (project) by default,
|
|
12
|
+
# or `~/.rubyn-code/skills/<pack>/` with the --global flag.
|
|
13
|
+
class PackInstaller
|
|
14
|
+
MANIFEST_FILE = '.manifest.json'
|
|
15
|
+
|
|
16
|
+
# @param registry_client [RegistryClient]
|
|
17
|
+
# @param project_root [String] path to the project root
|
|
18
|
+
# @param global [Boolean] install to ~/.rubyn-code/skills/ instead of project
|
|
19
|
+
def initialize(registry_client:, project_root:, global: false)
|
|
20
|
+
@client = registry_client
|
|
21
|
+
@project_root = project_root
|
|
22
|
+
@global = global
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Install one or more packs by name.
|
|
26
|
+
#
|
|
27
|
+
# @param names [Array<String>] pack names
|
|
28
|
+
# @param update [Boolean] update without prompting if already installed
|
|
29
|
+
# @yield [event, data] progress events
|
|
30
|
+
# @yieldparam event [Symbol] :fetching, :downloading, :installed, :up_to_date, :error
|
|
31
|
+
# @yieldparam data [Hash] event-specific data
|
|
32
|
+
# @return [Array<Hash>] results per pack ({ name:, status:, files: })
|
|
33
|
+
def install(names, update: false, &block)
|
|
34
|
+
names.map { |name| install_single(name, update: update, &block) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Update all installed packs to their latest versions.
|
|
38
|
+
#
|
|
39
|
+
# @yield [event, data] progress events
|
|
40
|
+
# @return [Array<Hash>] results per pack
|
|
41
|
+
def update_all(&block)
|
|
42
|
+
installed = installed_packs
|
|
43
|
+
return [] if installed.empty?
|
|
44
|
+
|
|
45
|
+
installed.map { |pack| install_single(pack['name'], update: true, &block) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Remove an installed pack.
|
|
49
|
+
#
|
|
50
|
+
# @param name [String] pack name
|
|
51
|
+
# @return [Boolean] true if removed
|
|
52
|
+
def remove(name)
|
|
53
|
+
dir = pack_dir(name)
|
|
54
|
+
return false unless Dir.exist?(dir)
|
|
55
|
+
|
|
56
|
+
FileUtils.rm_rf(dir)
|
|
57
|
+
true
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# List installed packs with their metadata.
|
|
61
|
+
#
|
|
62
|
+
# @return [Array<Hash>] installed pack manifests
|
|
63
|
+
def installed_packs
|
|
64
|
+
dir = skills_base_dir
|
|
65
|
+
return [] unless Dir.exist?(dir)
|
|
66
|
+
|
|
67
|
+
Dir.children(dir)
|
|
68
|
+
.select { |d| File.directory?(File.join(dir, d)) }
|
|
69
|
+
.filter_map { |d| read_manifest(d) }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Check if a specific pack is installed.
|
|
73
|
+
#
|
|
74
|
+
# @param name [String]
|
|
75
|
+
# @return [Boolean]
|
|
76
|
+
def installed?(name)
|
|
77
|
+
File.exist?(manifest_path(name))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Read the installed manifest for a pack.
|
|
81
|
+
#
|
|
82
|
+
# @param name [String]
|
|
83
|
+
# @return [Hash, nil]
|
|
84
|
+
def read_manifest(name)
|
|
85
|
+
path = manifest_path(name)
|
|
86
|
+
return nil unless File.exist?(path)
|
|
87
|
+
|
|
88
|
+
JSON.parse(File.read(path))
|
|
89
|
+
rescue JSON::ParserError
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def install_single(name, update: false)
|
|
96
|
+
yield(:fetching, { name: name }) if block_given?
|
|
97
|
+
|
|
98
|
+
pack_meta = @client.fetch_pack(name)
|
|
99
|
+
files = pack_meta['files'] || []
|
|
100
|
+
|
|
101
|
+
existing = read_manifest(name)
|
|
102
|
+
if existing && !update
|
|
103
|
+
if existing['version'] == pack_meta['version']
|
|
104
|
+
yield(:up_to_date, { name: name, version: pack_meta['version'] }) if block_given?
|
|
105
|
+
return { name: name, status: :up_to_date, files: [] }
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
etags = load_etags(name)
|
|
110
|
+
downloaded = download_files(name, files, etags)
|
|
111
|
+
|
|
112
|
+
yield(:downloading, { name: name, total: files.size, downloaded: downloaded.size }) if block_given?
|
|
113
|
+
|
|
114
|
+
write_manifest(name, pack_meta)
|
|
115
|
+
save_etags(name, etags)
|
|
116
|
+
|
|
117
|
+
yield(:installed, { name: name, version: pack_meta['version'], files: downloaded }) if block_given?
|
|
118
|
+
|
|
119
|
+
{ name: name, status: :installed, files: downloaded }
|
|
120
|
+
rescue RegistryError => e
|
|
121
|
+
yield(:error, { name: name, message: e.message }) if block_given?
|
|
122
|
+
{ name: name, status: :error, message: e.message }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def download_files(pack_name, files, etags)
|
|
126
|
+
dir = pack_dir(pack_name)
|
|
127
|
+
FileUtils.mkdir_p(dir)
|
|
128
|
+
|
|
129
|
+
downloaded = []
|
|
130
|
+
|
|
131
|
+
files.each do |file_info|
|
|
132
|
+
path = file_info['path']
|
|
133
|
+
result = @client.fetch_file(pack_name, path, etag: etags[path])
|
|
134
|
+
|
|
135
|
+
if result[:not_modified]
|
|
136
|
+
next
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
File.write(File.join(dir, path), result[:content])
|
|
140
|
+
etags[path] = result[:etag] if result[:etag]
|
|
141
|
+
downloaded << path
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
downloaded
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def write_manifest(name, pack_meta)
|
|
148
|
+
manifest = {
|
|
149
|
+
'name' => pack_meta['name'],
|
|
150
|
+
'displayName' => pack_meta['displayName'],
|
|
151
|
+
'version' => pack_meta['version'],
|
|
152
|
+
'installedAt' => Time.now.utc.iso8601,
|
|
153
|
+
'skillCount' => (pack_meta['files'] || []).size,
|
|
154
|
+
'files' => (pack_meta['files'] || []).map { |f| f['path'] }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
File.write(manifest_path(name), JSON.pretty_generate(manifest))
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def load_etags(name)
|
|
161
|
+
path = etags_path(name)
|
|
162
|
+
return {} unless File.exist?(path)
|
|
163
|
+
|
|
164
|
+
JSON.parse(File.read(path))
|
|
165
|
+
rescue JSON::ParserError
|
|
166
|
+
{}
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def save_etags(name, etags)
|
|
170
|
+
File.write(etags_path(name), JSON.pretty_generate(etags))
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def pack_dir(name)
|
|
174
|
+
File.join(skills_base_dir, name)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def manifest_path(name)
|
|
178
|
+
File.join(pack_dir(name), MANIFEST_FILE)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def etags_path(name)
|
|
182
|
+
File.join(pack_dir(name), '.etags.json')
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def skills_base_dir
|
|
186
|
+
if @global
|
|
187
|
+
File.join(Config::Defaults::HOME_DIR, 'skills')
|
|
188
|
+
else
|
|
189
|
+
File.join(@project_root, '.rubyn-code', 'skills')
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -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
|