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.
Files changed (103) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +263 -21
  3. data/db/migrations/013_add_failed_status_to_tasks.rb +51 -0
  4. data/lib/rubyn_code/agent/conversation.rb +34 -4
  5. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +57 -3
  6. data/lib/rubyn_code/agent/llm_caller.rb +11 -1
  7. data/lib/rubyn_code/agent/loop.rb +14 -3
  8. data/lib/rubyn_code/agent/response_modes.rb +2 -1
  9. data/lib/rubyn_code/agent/system_prompt_builder.rb +49 -4
  10. data/lib/rubyn_code/agent/tool_processor.rb +25 -3
  11. data/lib/rubyn_code/auth/key_encryption.rb +118 -0
  12. data/lib/rubyn_code/auth/token_store.rb +50 -9
  13. data/lib/rubyn_code/autonomous/daemon.rb +117 -14
  14. data/lib/rubyn_code/autonomous/idle_poller.rb +0 -20
  15. data/lib/rubyn_code/autonomous/task_claimer.rb +17 -11
  16. data/lib/rubyn_code/cli/app.rb +116 -11
  17. data/lib/rubyn_code/cli/commands/doctor.rb +73 -0
  18. data/lib/rubyn_code/cli/commands/install_skills.rb +44 -0
  19. data/lib/rubyn_code/cli/commands/list_skills.rb +149 -0
  20. data/lib/rubyn_code/cli/commands/mcp.rb +77 -0
  21. data/lib/rubyn_code/cli/commands/model.rb +32 -2
  22. data/lib/rubyn_code/cli/commands/provider.rb +124 -0
  23. data/lib/rubyn_code/cli/commands/remove_skills.rb +35 -0
  24. data/lib/rubyn_code/cli/commands/skill.rb +54 -3
  25. data/lib/rubyn_code/cli/commands/skills.rb +104 -0
  26. data/lib/rubyn_code/cli/daemon_runner.rb +36 -0
  27. data/lib/rubyn_code/cli/first_run.rb +159 -0
  28. data/lib/rubyn_code/cli/repl.rb +15 -0
  29. data/lib/rubyn_code/cli/repl_commands.rb +3 -1
  30. data/lib/rubyn_code/cli/repl_lifecycle.rb +1 -0
  31. data/lib/rubyn_code/cli/repl_setup.rb +74 -1
  32. data/lib/rubyn_code/config/defaults.rb +3 -0
  33. data/lib/rubyn_code/config/schema.json +49 -0
  34. data/lib/rubyn_code/config/settings.rb +12 -6
  35. data/lib/rubyn_code/config/validator.rb +63 -0
  36. data/lib/rubyn_code/context/context_budget.rb +18 -2
  37. data/lib/rubyn_code/context/context_collapse.rb +34 -4
  38. data/lib/rubyn_code/context/manager.rb +37 -3
  39. data/lib/rubyn_code/context/manual_compact.rb +1 -1
  40. data/lib/rubyn_code/hooks/registry.rb +4 -0
  41. data/lib/rubyn_code/ide/adapters/tool_output.rb +330 -0
  42. data/lib/rubyn_code/ide/client.rb +110 -0
  43. data/lib/rubyn_code/ide/handlers/accept_edit_handler.rb +35 -0
  44. data/lib/rubyn_code/ide/handlers/approve_tool_use_handler.rb +34 -0
  45. data/lib/rubyn_code/ide/handlers/cancel_handler.rb +41 -0
  46. data/lib/rubyn_code/ide/handlers/config_get_handler.rb +63 -0
  47. data/lib/rubyn_code/ide/handlers/config_set_handler.rb +86 -0
  48. data/lib/rubyn_code/ide/handlers/initialize_handler.rb +79 -0
  49. data/lib/rubyn_code/ide/handlers/models_list_handler.rb +39 -0
  50. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +218 -0
  51. data/lib/rubyn_code/ide/handlers/review_handler.rb +127 -0
  52. data/lib/rubyn_code/ide/handlers/session_fork_handler.rb +49 -0
  53. data/lib/rubyn_code/ide/handlers/session_list_handler.rb +41 -0
  54. data/lib/rubyn_code/ide/handlers/session_reset_handler.rb +31 -0
  55. data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +42 -0
  56. data/lib/rubyn_code/ide/handlers/shutdown_handler.rb +37 -0
  57. data/lib/rubyn_code/ide/handlers.rb +76 -0
  58. data/lib/rubyn_code/ide/protocol.rb +112 -0
  59. data/lib/rubyn_code/ide/server.rb +186 -0
  60. data/lib/rubyn_code/index/codebase_index.rb +69 -2
  61. data/lib/rubyn_code/learning/extractor.rb +4 -2
  62. data/lib/rubyn_code/llm/adapters/anthropic.rb +6 -2
  63. data/lib/rubyn_code/llm/adapters/anthropic_compatible.rb +60 -0
  64. data/lib/rubyn_code/llm/adapters/openai_compatible.rb +6 -2
  65. data/lib/rubyn_code/llm/client.rb +29 -4
  66. data/lib/rubyn_code/llm/model_router.rb +2 -1
  67. data/lib/rubyn_code/mcp/config.rb +2 -1
  68. data/lib/rubyn_code/mcp/tool_bridge.rb +1 -1
  69. data/lib/rubyn_code/memory/search.rb +1 -0
  70. data/lib/rubyn_code/observability/usage_reporter.rb +4 -2
  71. data/lib/rubyn_code/output/diff_renderer.rb +3 -2
  72. data/lib/rubyn_code/self_test.rb +316 -0
  73. data/lib/rubyn_code/skills/auto_suggest.rb +131 -0
  74. data/lib/rubyn_code/skills/catalog.rb +76 -0
  75. data/lib/rubyn_code/skills/document.rb +8 -2
  76. data/lib/rubyn_code/skills/gemfile_parser.rb +40 -0
  77. data/lib/rubyn_code/skills/loader.rb +43 -0
  78. data/lib/rubyn_code/skills/matcher.rb +89 -0
  79. data/lib/rubyn_code/skills/pack_context.rb +163 -0
  80. data/lib/rubyn_code/skills/pack_installer.rb +194 -0
  81. data/lib/rubyn_code/skills/pack_manager.rb +230 -0
  82. data/lib/rubyn_code/skills/registry_autoload.rb +112 -0
  83. data/lib/rubyn_code/skills/registry_client.rb +241 -0
  84. data/lib/rubyn_code/tasks/models.rb +1 -0
  85. data/lib/rubyn_code/tools/base.rb +13 -0
  86. data/lib/rubyn_code/tools/bash.rb +5 -0
  87. data/lib/rubyn_code/tools/edit_file.rb +62 -5
  88. data/lib/rubyn_code/tools/executor.rb +65 -8
  89. data/lib/rubyn_code/tools/glob.rb +6 -0
  90. data/lib/rubyn_code/tools/grep.rb +7 -0
  91. data/lib/rubyn_code/tools/ide_diagnostics.rb +53 -0
  92. data/lib/rubyn_code/tools/ide_symbols.rb +55 -0
  93. data/lib/rubyn_code/tools/load_skill.rb +2 -1
  94. data/lib/rubyn_code/tools/output_compressor.rb +9 -7
  95. data/lib/rubyn_code/tools/read_file.rb +6 -0
  96. data/lib/rubyn_code/tools/registry.rb +11 -0
  97. data/lib/rubyn_code/tools/review_pr.rb +15 -4
  98. data/lib/rubyn_code/tools/web_search.rb +2 -1
  99. data/lib/rubyn_code/tools/write_file.rb +17 -0
  100. data/lib/rubyn_code/version.rb +1 -1
  101. data/lib/rubyn_code.rb +34 -0
  102. data/skills/rubyn_self_test.md +88 -1
  103. 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
@@ -10,6 +10,7 @@ module RubynCode
10
10
  def in_progress? = status == 'in_progress'
11
11
  def completed? = status == 'completed'
12
12
  def blocked? = status == 'blocked'
13
+ def failed? = status == 'failed'
13
14
 
14
15
  def to_h
15
16
  {
@@ -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