rage-rb 1.26.1 → 1.28.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.
@@ -1,291 +1,293 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class CLISkills < Thor
4
- SKILLS_DIR = "rage-framework"
5
- VERSION_FILE = ".version"
6
-
7
- desc "install", "Install skills for coding agents"
8
- option :verbose, desc: "Debug output"
9
- def install
10
- installation_path = choose_installation_path
11
- return unless installation_path
12
-
13
- skills_version = fetch_skills_version
14
- say "Downloading skills..."
15
- install_skills(installation_path, skills_version)
16
-
17
- say "#{set_color("✓", :green)} Installed Rage skills #{set_color(skills_version, :bold)} to #{set_color(installation_path, :cyan)}."
18
- say "#{set_color("✓", :green)} Skills are now available to your coding agent."
19
- rescue => e
20
- say_error(e)
21
- end
22
-
23
- desc "update", "Update installed skills"
24
- option :verbose, desc: "Debug output"
25
- option :json, type: :boolean, desc: "Output JSON for programmatic use"
26
- def update
27
- skills_destinations = Dir.glob(".*/skills/#{SKILLS_DIR}")
28
- debug { "Existing skills installations found: #{skills_destinations}" }
29
-
30
- if skills_destinations.empty?
31
- log "No existing installation found. Running fresh install...\n\n"
32
- return install
3
+ module Rage::CLI
4
+ class Skills < Base
5
+ SKILLS_DIR = "rage-framework"
6
+ VERSION_FILE = ".version"
7
+
8
+ desc "install", "Install skills for coding agents"
9
+ option :verbose, desc: "Debug output"
10
+ def install
11
+ installation_path = choose_installation_path
12
+ return unless installation_path
13
+
14
+ skills_version = fetch_skills_version
15
+ say "Downloading skills..."
16
+ install_skills(installation_path, skills_version)
17
+
18
+ say "#{set_color("✓", :green)} Installed Rage skills #{set_color(skills_version, :bold)} to #{set_color(installation_path, :cyan)}."
19
+ say "#{set_color("✓", :green)} Skills are now available to your coding agent."
20
+ rescue => e
21
+ say_error(e)
33
22
  end
34
23
 
35
- skills_version = fetch_skills_version
36
- updated_paths = []
24
+ desc "update", "Update installed skills"
25
+ option :verbose, desc: "Debug output"
26
+ option :json, type: :boolean, desc: "Output JSON for programmatic use"
27
+ def update
28
+ skills_destinations = Dir.glob(".*/skills/#{SKILLS_DIR}")
29
+ debug { "Existing skills installations found: #{skills_destinations}" }
37
30
 
38
- skills_destinations.each do |destination|
39
- version_file = File.join(destination, VERSION_FILE)
40
- current_version = File.exist?(version_file) ? File.read(version_file).strip : nil
41
-
42
- if current_version == skills_version
43
- log "#{set_color(destination, :cyan)}: already up to date."
44
- next
31
+ if skills_destinations.empty?
32
+ log "No existing installation found. Running fresh install...\n\n"
33
+ return install
45
34
  end
46
35
 
47
- log "Updating #{set_color(destination, :cyan)}..."
48
- install_skills(destination, skills_version)
49
- updated_paths << destination
50
- end
36
+ skills_version = fetch_skills_version
37
+ updated_paths = []
51
38
 
52
- if updated_paths.any?
53
- log "#{set_color("✓", :green)} Updated #{updated_paths.size} installation#{"s" if updated_paths.size > 1} to #{set_color(skills_version, :bold)}."
54
- end
39
+ skills_destinations.each do |destination|
40
+ version_file = File.join(destination, VERSION_FILE)
41
+ current_version = File.exist?(version_file) ? File.read(version_file).strip : nil
55
42
 
56
- json_output(
57
- status: updated_paths.any? ? "updated" : "up_to_date",
58
- version: skills_version,
59
- paths: skills_destinations
60
- )
61
- rescue => e
62
- if options[:json]
63
- json_output(status: "error", message: e.message)
64
- exit 1
65
- else
66
- say_error(e)
67
- end
68
- end
43
+ if current_version == skills_version
44
+ log "#{set_color(destination, :cyan)}: already up to date."
45
+ next
46
+ end
69
47
 
70
- no_commands do
71
- def debug
72
- puts("* #{yield}") if options[:verbose]
73
- end
48
+ log "Updating #{set_color(destination, :cyan)}..."
49
+ install_skills(destination, skills_version)
50
+ updated_paths << destination
51
+ end
52
+
53
+ if updated_paths.any?
54
+ log "#{set_color("✓", :green)} Updated #{updated_paths.size} installation#{"s" if updated_paths.size > 1} to #{set_color(skills_version, :bold)}."
55
+ end
74
56
 
75
- def log(message)
57
+ json_output(
58
+ status: updated_paths.any? ? "updated" : "up_to_date",
59
+ version: skills_version,
60
+ paths: skills_destinations
61
+ )
62
+ rescue => e
76
63
  if options[:json]
77
- warn(message)
64
+ json_output(status: "error", message: e.message)
65
+ exit 1
78
66
  else
79
- say(message)
67
+ say_error(e)
80
68
  end
81
69
  end
82
70
 
83
- def json_output(data)
84
- return unless options[:json]
85
- require "json"
86
- puts JSON.generate(data)
87
- end
88
-
89
- def say_error(error)
90
- say "#{set_color("𐄂 Error:", :red, :bold)} #{error.message}"
91
- debug { "#{error.class}: #{error.message}\n#{error.backtrace.join("\n")}" }
92
- end
71
+ no_commands do
72
+ def debug
73
+ puts("* #{yield}") if options[:verbose]
74
+ end
93
75
 
94
- def choose_installation_path
95
- agent_options = [
96
- ["1", "Claude Code", ".claude/skills"],
97
- ["2", "GitHub Copilot", ".github/skills"],
98
- ["3", "Cursor", ".cursor/skills"],
99
- ["4", "Amp/Codex", ".agents/skills"],
100
- ["5", "Antigravity", ".agent/skills"],
101
- ["6", "Gemini CLI", ".gemini/skills"],
102
- ["7", "Windsurf", ".windsurf/skills"],
103
- ["8", "OpenCode", ".opencode/skills"],
104
- ["9", "Other", ".claude/skills"]
105
- ]
106
-
107
- display_options = ([["Option", "Coding Agent", "Installation Path"]] + agent_options).map.with_index do |(option, agent, path), index|
108
- if index == 0
109
- [set_color(option, :bold), set_color(agent, :bold), set_color(path, :bold)]
76
+ def log(message)
77
+ if options[:json]
78
+ warn(message)
110
79
  else
111
- [set_color(option, :bold), agent, set_color(path, :cyan)]
80
+ say(message)
112
81
  end
113
82
  end
114
83
 
115
- print_ansi_table(display_options)
116
- agent_choice = ask(set_color("Select your coding agent (1-#{agent_options[-1][0]}):", :bold), default: "1")
84
+ def json_output(data)
85
+ return unless options[:json]
86
+ require "json"
87
+ puts JSON.generate(data)
88
+ end
117
89
 
118
- installation_path = ".claude/skills"
90
+ def say_error(error)
91
+ say "#{set_color("𐄂 Error:", :red, :bold)} #{error.message}"
92
+ debug { "#{error.class}: #{error.message}\n#{error.backtrace.join("\n")}" }
93
+ end
119
94
 
120
- agent_options.each do |option, agent, path|
121
- if agent_choice == option || agent.downcase.include?(agent_choice.downcase)
122
- installation_path = path
123
- break
95
+ def choose_installation_path
96
+ agent_options = [
97
+ ["1", "Claude Code", ".claude/skills"],
98
+ ["2", "GitHub Copilot", ".github/skills"],
99
+ ["3", "Cursor", ".cursor/skills"],
100
+ ["4", "Amp/Codex", ".agents/skills"],
101
+ ["5", "Antigravity", ".agent/skills"],
102
+ ["6", "Gemini CLI", ".gemini/skills"],
103
+ ["7", "Windsurf", ".windsurf/skills"],
104
+ ["8", "OpenCode", ".opencode/skills"],
105
+ ["9", "Other", ".claude/skills"]
106
+ ]
107
+
108
+ display_options = ([["Option", "Coding Agent", "Installation Path"]] + agent_options).map.with_index do |(option, agent, path), index|
109
+ if index == 0
110
+ [set_color(option, :bold), set_color(agent, :bold), set_color(path, :bold)]
111
+ else
112
+ [set_color(option, :bold), agent, set_color(path, :cyan)]
113
+ end
124
114
  end
125
- end
126
115
 
127
- say "\n#{set_color("Source:", :bold)} #{set_color("https://github.com/rage-rb/skills", :cyan)}"
128
- say "#{set_color("Destination:", :bold)} #{set_color(installation_path, :cyan)}"
116
+ print_ansi_table(display_options)
117
+ agent_choice = ask(set_color("Select your coding agent (1-#{agent_options[-1][0]}):", :bold), default: "1")
129
118
 
130
- answer = ask(set_color("? ", :green, :bold) + set_color("Proceed with installation? [Y/n]", :bold), default: "y")
131
- unless answer.downcase.start_with?("y")
132
- say "Installation cancelled."
133
- return nil
134
- end
119
+ installation_path = ".claude/skills"
135
120
 
136
- File.join(installation_path, SKILLS_DIR)
137
- end
121
+ agent_options.each do |option, agent, path|
122
+ if agent_choice == option || agent.downcase.include?(agent_choice.downcase)
123
+ installation_path = path
124
+ break
125
+ end
126
+ end
138
127
 
139
- def fetch_skills_version
140
- require "json"
128
+ say "\n#{set_color("Source:", :bold)} #{set_color("https://github.com/rage-rb/skills", :cyan)}"
129
+ say "#{set_color("Destination:", :bold)} #{set_color(installation_path, :cyan)}"
141
130
 
142
- manifest = begin
143
- JSON.parse(fetch("https://rage-rb.github.io/skills/manifest.json"))
144
- rescue => e
145
- debug { "#{e.class} (#{e.message}):\n#{e.backtrace.join("\n")}" }
146
- raise "Could not download skills manifest. Please check your network connection."
131
+ answer = ask(set_color("? ", :green, :bold) + set_color("Proceed with installation? [Y/n]", :bold), default: "y")
132
+ unless answer.downcase.start_with?("y")
133
+ say "Installation cancelled."
134
+ return nil
135
+ end
136
+
137
+ File.join(installation_path, SKILLS_DIR)
147
138
  end
148
139
 
149
- major, minor, _ = Rage::VERSION.split(".").map(&:to_i)
140
+ def fetch_skills_version
141
+ require "json"
150
142
 
151
- debug { "Rage::VERSION: #{Rage::VERSION}; Manifest: #{manifest["versions"]}" }
143
+ manifest = begin
144
+ JSON.parse(fetch("https://rage-rb.github.io/skills/manifest.json"))
145
+ rescue => e
146
+ debug { "#{e.class} (#{e.message}):\n#{e.backtrace.join("\n")}" }
147
+ raise "Could not download skills manifest. Please check your network connection."
148
+ end
152
149
 
153
- # Find the closest matching version: same major, highest minor <= current
154
- matched_major, matched_minor = manifest["versions"].
155
- keys.
156
- map { |v| v.split(".").map(&:to_i) }.
157
- select { |_major, _| _major == major }.
158
- select { |_, _minor| _minor <= minor }.
159
- max_by { |_, _minor| _minor }
150
+ major, minor, _ = Rage::VERSION.split(".").map(&:to_i)
160
151
 
161
- if matched_major && matched_minor
162
- manifest["versions"]["#{matched_major}.#{matched_minor}"]
163
- else
164
- raise "No skills available for Rage #{major}.x."
165
- end
166
- end
152
+ debug { "Rage::VERSION: #{Rage::VERSION}; Manifest: #{manifest["versions"]}" }
167
153
 
168
- def install_skills(installation_path, version)
169
- require "zlib"
170
- require "rubygems/package"
171
- require "fileutils"
172
- require "stringio"
173
- require "digest"
154
+ # Find the closest matching version: same major, highest minor <= current
155
+ matched_major, matched_minor = manifest["versions"].
156
+ keys.
157
+ map { |v| v.split(".").map(&:to_i) }.
158
+ select { |_major, _| _major == major }.
159
+ select { |_, _minor| _minor <= minor }.
160
+ max_by { |_, _minor| _minor }
174
161
 
175
- Thread.report_on_exception = false
162
+ if matched_major && matched_minor
163
+ manifest["versions"]["#{matched_major}.#{matched_minor}"]
164
+ else
165
+ raise "No skills available for Rage #{major}.x."
166
+ end
167
+ end
176
168
 
177
- artifact_request = Thread.new { fetch("https://github.com/rage-rb/skills/releases/download/#{version}/skills.tar.gz") }
178
- checksum_request = Thread.new { fetch("https://github.com/rage-rb/skills/releases/download/#{version}/checksums.txt") }
169
+ def install_skills(installation_path, version)
170
+ require "zlib"
171
+ require "rubygems/package"
172
+ require "fileutils"
173
+ require "stringio"
174
+ require "digest"
179
175
 
180
- artifact, checksum = artifact_request.value, checksum_request.value
176
+ Thread.report_on_exception = false
181
177
 
182
- if artifact.nil? || checksum.nil?
183
- raise "Could not download the skills package. Please check your network connection and try again."
184
- end
178
+ artifact_request = Thread.new { fetch("https://github.com/rage-rb/skills/releases/download/#{version}/skills.tar.gz") }
179
+ checksum_request = Thread.new { fetch("https://github.com/rage-rb/skills/releases/download/#{version}/checksums.txt") }
185
180
 
186
- sha, _ = checksum.split
187
- if Digest::SHA256.hexdigest(artifact) != sha
188
- raise "Download verification failed. Please try again."
189
- end
181
+ artifact, checksum = artifact_request.value, checksum_request.value
190
182
 
191
- destination = File.expand_path(installation_path)
192
- FileUtils.mkdir_p(destination)
183
+ if artifact.nil? || checksum.nil?
184
+ raise "Could not download the skills package. Please check your network connection and try again."
185
+ end
193
186
 
194
- # Clear existing contents but keep the directory intact
195
- Dir.children(destination).each do |child|
196
- debug { "Removing #{child}" }
197
- FileUtils.rm_rf(File.join(destination, child))
198
- end
187
+ sha, _ = checksum.split
188
+ if Digest::SHA256.hexdigest(artifact) != sha
189
+ raise "Download verification failed. Please try again."
190
+ end
199
191
 
200
- Zlib::GzipReader.wrap(StringIO.new(artifact)) do |gz|
201
- Gem::Package::TarReader.new(gz) do |tar|
202
- tar.each do |entry|
203
- path = File.join(destination, entry.full_name)
192
+ destination = File.expand_path(installation_path)
193
+ FileUtils.mkdir_p(destination)
204
194
 
205
- unless File.expand_path(path).start_with?("#{destination}/")
206
- raise "Invalid archive: contains files outside the destination directory."
207
- end
195
+ # Clear existing contents but keep the directory intact
196
+ Dir.children(destination).each do |child|
197
+ debug { "Removing #{child}" }
198
+ FileUtils.rm_rf(File.join(destination, child))
199
+ end
208
200
 
209
- if entry.directory?
210
- debug { "Created directory #{entry.full_name}" }
211
- FileUtils.mkdir_p(path)
212
- elsif entry.file?
213
- debug { "Written file #{entry.full_name}" }
214
- FileUtils.mkdir_p(File.dirname(path))
215
- File.binwrite(path, entry.read)
201
+ Zlib::GzipReader.wrap(StringIO.new(artifact)) do |gz|
202
+ Gem::Package::TarReader.new(gz) do |tar|
203
+ tar.each do |entry|
204
+ path = File.join(destination, entry.full_name)
205
+
206
+ unless File.expand_path(path).start_with?("#{destination}/")
207
+ raise "Invalid archive: contains files outside the destination directory."
208
+ end
209
+
210
+ if entry.directory?
211
+ debug { "Created directory #{entry.full_name}" }
212
+ FileUtils.mkdir_p(path)
213
+ elsif entry.file?
214
+ debug { "Written file #{entry.full_name}" }
215
+ FileUtils.mkdir_p(File.dirname(path))
216
+ File.binwrite(path, entry.read)
217
+ end
216
218
  end
217
219
  end
218
220
  end
219
- end
220
221
 
221
- File.write(File.join(destination, VERSION_FILE), version)
222
- end
222
+ File.write(File.join(destination, VERSION_FILE), version)
223
+ end
223
224
 
224
- def fetch(uri, retries = 2)
225
- response = request(uri)
226
- return response if response
225
+ def fetch(uri, retries = 2)
226
+ response = request(uri)
227
+ return response if response
227
228
 
228
- retries > 0 ? fetch(uri, retries - 1) : nil
229
- end
229
+ retries > 0 ? fetch(uri, retries - 1) : nil
230
+ end
230
231
 
231
- def request(uri, limit = 3)
232
- require "net/http"
232
+ def request(uri, limit = 3)
233
+ require "net/http"
233
234
 
234
- raise "Too many HTTP redirects" if limit == 0
235
+ raise "Too many HTTP redirects" if limit == 0
235
236
 
236
- debug { "Fetching #{uri[0..100]}" }
237
+ debug { "Fetching #{uri[0..100]}" }
237
238
 
238
- parsed_uri = URI(uri)
239
- http = Net::HTTP.new(parsed_uri.host, parsed_uri.port)
240
- http.open_timeout = 5
241
- http.read_timeout = 5
239
+ parsed_uri = URI(uri)
240
+ http = Net::HTTP.new(parsed_uri.host, parsed_uri.port)
241
+ http.open_timeout = 5
242
+ http.read_timeout = 5
242
243
 
243
- if parsed_uri.scheme == "https"
244
- http.use_ssl = true
245
- http.verify_mode = OpenSSL::SSL::VERIFY_PEER
246
- http.cert_store = OpenSSL::X509::Store.new.tap(&:set_default_paths)
247
- end
244
+ if parsed_uri.scheme == "https"
245
+ http.use_ssl = true
246
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
247
+ http.cert_store = OpenSSL::X509::Store.new.tap(&:set_default_paths)
248
+ end
248
249
 
249
- response = http.request(Net::HTTP::Get.new(parsed_uri))
250
+ response = http.request(Net::HTTP::Get.new(parsed_uri))
250
251
 
251
- case response
252
- when Net::HTTPSuccess
253
- response.body.force_encoding("ASCII-8BIT")
254
- when Net::HTTPRedirection
255
- debug { "Redirected to #{response["Location"][0..100]}" }
256
- request(response["Location"], limit - 1)
252
+ case response
253
+ when Net::HTTPSuccess
254
+ response.body.force_encoding("ASCII-8BIT")
255
+ when Net::HTTPRedirection
256
+ debug { "Redirected to #{response["Location"][0..100]}" }
257
+ request(response["Location"], limit - 1)
258
+ end
257
259
  end
258
- end
259
260
 
260
- # print a table stripping ANSI codes to ensure paddings are based on visible length
261
- def print_ansi_table(rows)
262
- return if rows.empty?
261
+ # print a table stripping ANSI codes to ensure paddings are based on visible length
262
+ def print_ansi_table(rows)
263
+ return if rows.empty?
263
264
 
264
- widths = rows.each_with_object([]) do |row, maxima|
265
- row.each_with_index do |column, index|
266
- visible_width = strip_ansi(column.to_s).size
267
- maxima[index] = [maxima[index] || 0, visible_width].max
265
+ widths = rows.each_with_object([]) do |row, maxima|
266
+ row.each_with_index do |column, index|
267
+ visible_width = strip_ansi(column.to_s).size
268
+ maxima[index] = [maxima[index] || 0, visible_width].max
269
+ end
268
270
  end
269
- end
270
271
 
271
- border = "+" + widths.map { |width| "-" * (width + 2) }.join("+") + "+"
272
- say(border)
272
+ border = "+" + widths.map { |width| "-" * (width + 2) }.join("+") + "+"
273
+ say(border)
274
+
275
+ rows.each do |row|
276
+ formatted_cells = widths.each_index.map do |index|
277
+ cell = row[index].to_s
278
+ cell_padding = widths[index] - strip_ansi(cell).size
279
+ " #{cell}#{" " * cell_padding} "
280
+ end
273
281
 
274
- rows.each do |row|
275
- formatted_cells = widths.each_index.map do |index|
276
- cell = row[index].to_s
277
- cell_padding = widths[index] - strip_ansi(cell).size
278
- " #{cell}#{" " * cell_padding} "
282
+ say("|#{formatted_cells.join("|")}|")
279
283
  end
280
284
 
281
- say("|#{formatted_cells.join("|")}|")
285
+ say(border)
282
286
  end
283
287
 
284
- say(border)
285
- end
286
-
287
- def strip_ansi(text)
288
- text.gsub(/\e\[[0-9;]*m/, "")
288
+ def strip_ansi(text)
289
+ text.gsub(/\e\[[0-9;]*m/, "")
290
+ end
289
291
  end
290
292
  end
291
293
  end