rubyn-code 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +186 -2
  3. data/lib/rubyn_code/agent/conversation.rb +2 -1
  4. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +2 -1
  5. data/lib/rubyn_code/agent/llm_caller.rb +4 -2
  6. data/lib/rubyn_code/agent/loop.rb +7 -3
  7. data/lib/rubyn_code/agent/response_modes.rb +2 -1
  8. data/lib/rubyn_code/agent/system_prompt_builder.rb +39 -0
  9. data/lib/rubyn_code/agent/tool_processor.rb +4 -2
  10. data/lib/rubyn_code/cli/app.rb +85 -11
  11. data/lib/rubyn_code/cli/commands/install_skills.rb +44 -0
  12. data/lib/rubyn_code/cli/commands/list_skills.rb +149 -0
  13. data/lib/rubyn_code/cli/commands/provider.rb +2 -1
  14. data/lib/rubyn_code/cli/commands/remove_skills.rb +35 -0
  15. data/lib/rubyn_code/cli/commands/skill.rb +4 -2
  16. data/lib/rubyn_code/cli/commands/skills.rb +104 -0
  17. data/lib/rubyn_code/cli/repl.rb +11 -1
  18. data/lib/rubyn_code/cli/repl_commands.rb +2 -1
  19. data/lib/rubyn_code/cli/repl_setup.rb +38 -1
  20. data/lib/rubyn_code/config/defaults.rb +2 -0
  21. data/lib/rubyn_code/config/settings.rb +5 -2
  22. data/lib/rubyn_code/context/context_budget.rb +2 -1
  23. data/lib/rubyn_code/context/manager.rb +3 -3
  24. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +6 -3
  25. data/lib/rubyn_code/ide/handlers/review_handler.rb +19 -2
  26. data/lib/rubyn_code/ide/protocol.rb +2 -1
  27. data/lib/rubyn_code/index/codebase_index.rb +2 -1
  28. data/lib/rubyn_code/learning/extractor.rb +4 -2
  29. data/lib/rubyn_code/llm/model_router.rb +2 -1
  30. data/lib/rubyn_code/mcp/tool_bridge.rb +1 -1
  31. data/lib/rubyn_code/observability/usage_reporter.rb +4 -2
  32. data/lib/rubyn_code/output/diff_renderer.rb +3 -2
  33. data/lib/rubyn_code/self_test.rb +2 -1
  34. data/lib/rubyn_code/skills/auto_suggest.rb +131 -0
  35. data/lib/rubyn_code/skills/catalog.rb +10 -0
  36. data/lib/rubyn_code/skills/document.rb +8 -2
  37. data/lib/rubyn_code/skills/gemfile_parser.rb +40 -0
  38. data/lib/rubyn_code/skills/loader.rb +1 -1
  39. data/lib/rubyn_code/skills/matcher.rb +89 -0
  40. data/lib/rubyn_code/skills/pack_context.rb +163 -0
  41. data/lib/rubyn_code/skills/pack_installer.rb +194 -0
  42. data/lib/rubyn_code/skills/pack_manager.rb +230 -0
  43. data/lib/rubyn_code/skills/registry_autoload.rb +112 -0
  44. data/lib/rubyn_code/skills/registry_client.rb +241 -0
  45. data/lib/rubyn_code/tools/executor.rb +4 -2
  46. data/lib/rubyn_code/tools/grep.rb +2 -1
  47. data/lib/rubyn_code/tools/ide_diagnostics.rb +3 -1
  48. data/lib/rubyn_code/tools/ide_symbols.rb +3 -1
  49. data/lib/rubyn_code/tools/load_skill.rb +2 -1
  50. data/lib/rubyn_code/tools/output_compressor.rb +3 -6
  51. data/lib/rubyn_code/tools/review_pr.rb +15 -4
  52. data/lib/rubyn_code/tools/web_search.rb +2 -1
  53. data/lib/rubyn_code/version.rb +1 -1
  54. data/lib/rubyn_code.rb +12 -0
  55. data/skills/rubyn_self_test.md +75 -0
  56. metadata +13 -1
@@ -0,0 +1,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