svg_icon 0.2.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0d79c62b35c624cbb3ed71ccbd125f4c1893bc5a1600c9a94eeccc45d08b5a24
4
- data.tar.gz: 1451e1bca013904510f3ea79da03cd3afadb8a4fb178eda53bc213986799ec9f
3
+ metadata.gz: b388bb80ccfff538510163078ff382d07908549abaa76da0382af3ee7bc1ed07
4
+ data.tar.gz: b646054bc7c5035c49f51dd4e6845154aba0390d0d37d5804462e0869ef74ebf
5
5
  SHA512:
6
- metadata.gz: ed3ba9b1cb0495cce3b891066e81e4df0f34eb3f0504371fe2de974cb8da20009af05c328891daf96a8848d41e1ca627103110d2515dcdc8f86de17f25e5874e
7
- data.tar.gz: 5552482587ba92295495066bde6a28e7e5fd476846cf826ee4374fee6840a80a8429ea621e9793cbf6105c4ff1484a3c611ef37b8fe6e51169a0b156b157b2aa
6
+ metadata.gz: 88cad87bd6dbad2faf9ac6458f9f0b30cb0e851df11eaac75034b0b85bd068a0cd820b4ea41d860f012ea697d923f3c6f10fdf44d6df5274cea4adaff46c5686
7
+ data.tar.gz: 527479eb7d5a90b3a10d4473298db0d166acefac938d4f227c40fc76e49872be8980f44f7c24cff4f9cc0017cc1fc67c53c149e574afcc84575dc3358cb0b4c1
data/Gemfile.lock CHANGED
@@ -1,21 +1,54 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- svg_icon (0.1.0)
4
+ svg_icon (0.4.0)
5
+ activesupport (>= 5.0)
5
6
  multi_json
6
7
 
7
8
  GEM
8
9
  remote: https://rubygems.org/
9
10
  specs:
11
+ activesupport (8.1.3.1)
12
+ base64
13
+ bigdecimal
14
+ concurrent-ruby (~> 1.0, >= 1.3.1)
15
+ connection_pool (>= 2.2.5)
16
+ drb
17
+ i18n (>= 1.6, < 2)
18
+ json
19
+ logger (>= 1.4.2)
20
+ minitest (>= 5.1)
21
+ securerandom (>= 0.3)
22
+ tzinfo (~> 2.0, >= 2.0.5)
23
+ uri (>= 0.13.1)
24
+ base64 (0.3.0)
25
+ bigdecimal (4.1.2)
26
+ concurrent-ruby (1.3.8)
27
+ connection_pool (3.0.2)
28
+ drb (2.2.3)
29
+ i18n (1.15.2)
30
+ concurrent-ruby (~> 1.0)
31
+ json (2.21.2)
32
+ logger (1.7.0)
33
+ minitest (6.0.6)
34
+ drb (~> 2.0)
35
+ prism (~> 1.5)
10
36
  multi_json (1.15.0)
37
+ prism (1.9.0)
11
38
  rake (13.0.6)
39
+ securerandom (0.4.1)
40
+ tzinfo (2.0.6)
41
+ concurrent-ruby (~> 1.0)
42
+ uri (1.1.1)
12
43
 
13
44
  PLATFORMS
45
+ arm64-darwin-24
14
46
  x86_64-darwin-18
15
47
 
16
48
  DEPENDENCIES
49
+ minitest
17
50
  rake (~> 13.0)
18
51
  svg_icon!
19
52
 
20
53
  BUNDLED WITH
21
- 2.2.3
54
+ 4.0.16
data/README.md CHANGED
@@ -22,11 +22,10 @@ Or install it yourself as:
22
22
 
23
23
  Add `svg_icon.rb` in initializers folder
24
24
 
25
- ```
26
- # frozen_string_literal: true
27
-
25
+ ```ruby
28
26
  SvgIcon.configure do |config|
29
- # config.icon = "bi" # Options are "bi" and "bx"
27
+ # config.icon = "lucide" # icon set name: "lucide", "bi", "bx", "heroicons", or any fetched set
28
+ config.icons_path = Rails.root.join("config", "svg_icons") # defaults to "config/svg_icons" under the project root
30
29
 
31
30
  ##
32
31
  # You can set a default class for icon
@@ -40,6 +39,19 @@ add ` include SvgIcon::Helper` to `ApplicationHelper`
40
39
  <%= svg_icon("search") %>
41
40
  ```
42
41
 
42
+ ## Fetching icon sets
43
+
44
+ The gem bundles a few icon sets (lucide, bi, bx, heroicons). To use any other
45
+ [iconify icon set](https://github.com/iconify/icon-sets/tree/master/json), download it into your project:
46
+
47
+ $ svg_icon fetch bi
48
+
49
+ This downloads `bi.json` into `config/svg_icons/`. Set `config.icon = "bi"` to use it —
50
+ the gem looks for `<icon>.json` in `config/svg_icons/` first, then falls back to bundled data.
51
+
52
+ Commit `config/svg_icons/` to your repository so deploys don't need to re-fetch.
53
+ Re-run `svg_icon fetch <name>` to update an existing set.
54
+
43
55
  ## Development
44
56
 
45
57
  After checking out the repo, run `bin/setup` to install dependencies. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
data/Rakefile CHANGED
@@ -1,4 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "bundler/gem_tasks"
4
- task default: %i[]
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "test" << "lib"
8
+ t.test_files = FileList["test/**/*_test.rb"]
9
+ t.verbose = true
10
+ end
11
+
12
+ task default: :test
@@ -0,0 +1,557 @@
1
+ # svg_icon fetch CLI Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Add a `svg_icon fetch <name>` CLI that downloads icon sets from iconify/icon-sets into the project, usable as independent icon sets via config.
6
+
7
+ **Architecture:** New `SvgIcon::Fetcher` class (net/http, injectable for tests) handles download/validate/atomic-write. New `SvgIcon::CLI` class + `exe/svg_icon` thin wrapper dispatch `fetch`. Configuration gains `icons_path`; `file_data` resolution order becomes external `icons_path/<icon>.json` → bundled `lib/data/<icon>.json` → raise.
8
+
9
+ **Tech Stack:** Ruby, minitest, net/http (stdlib), multi_json, OptionParser-free manual dispatch (tiny surface).
10
+
11
+ **Spec:** `docs/superpowers/specs/2026-08-18-svg-icon-fetch-design.md`
12
+
13
+ ---
14
+
15
+ ### Task 1: `icons_path` config + external-first data resolution
16
+
17
+ **Files:**
18
+ - Modify: `lib/svg_icon/configuration.rb`
19
+ - Modify: `lib/svg_icon.rb`
20
+ - Test: `test/svg_icon_test.rb`
21
+
22
+ - [ ] **Step 1: Write the failing tests**
23
+
24
+ Append to `test/svg_icon_test.rb`:
25
+
26
+ ```ruby
27
+ def test_default_icons_path_points_to_project_config_dir
28
+ assert_equal File.join(Dir.pwd, "config", "svg_icons"), SvgIcon.configuration.icons_path
29
+ end
30
+
31
+ def test_icons_loads_from_icons_path_when_present
32
+ Dir.mktmpdir do |dir|
33
+ File.write(File.join(dir, "custom.json"), %({"prefix":"custom","icons":{"x":{"body":"1"}}}))
34
+ SvgIcon.configure { |config| config.icon = "custom"; config.icons_path = dir }
35
+ assert_equal "custom", SvgIcon.icons["prefix"]
36
+ end
37
+ end
38
+
39
+ def test_icons_falls_back_to_bundled_data
40
+ Dir.mktmpdir do |dir|
41
+ SvgIcon.configure { |config| config.icon = "lucide"; config.icons_path = dir }
42
+ assert_equal "lucide", SvgIcon.icons["prefix"]
43
+ end
44
+ end
45
+
46
+ def test_icons_raises_when_file_missing_everywhere
47
+ Dir.mktmpdir do |dir|
48
+ SvgIcon.configure { |config| config.icon = "nonexistent"; config.icons_path = dir }
49
+ error = assert_raises(SvgIcon::Error) { SvgIcon.icons }
50
+ assert_match(/Icon data file not found/, error.message)
51
+ end
52
+ end
53
+ ```
54
+
55
+ - [ ] **Step 2: Run tests to verify they fail**
56
+
57
+ Run: `bundle exec ruby -Ilib -Itest test/svg_icon_test.rb`
58
+ Expected: `test_default_icons_path...` FAILS with `NoMethodError: undefined method 'icons_path'`; the other three fail with `Icon data file not found: .../lib/data/custom.json` (external dir never consulted).
59
+
60
+ - [ ] **Step 3: Add `icons_path` to Configuration**
61
+
62
+ In `lib/svg_icon/configuration.rb`, change the class to:
63
+
64
+ ```ruby
65
+ class Configuration
66
+ DEFAULT_ICON = "lucide"
67
+
68
+ attr_accessor :icon
69
+ attr_accessor :default_class
70
+ attr_accessor :extra_icons_path
71
+ attr_accessor :icons_path
72
+
73
+ def initialize
74
+ @icon = DEFAULT_ICON
75
+ @icons_path = File.join(Dir.pwd, "config", "svg_icons")
76
+ end
77
+ end
78
+ ```
79
+
80
+ - [ ] **Step 4: External-first resolution in `lib/svg_icon.rb`**
81
+
82
+ Replace the `data_path` and `file_data` methods (currently `data_path` builds `File.join(__dir__, "data", ...)` and `file_data` memoizes by path) with:
83
+
84
+ ```ruby
85
+ def file_data
86
+ @file_data ||= {}
87
+ @file_data[icon] ||= begin
88
+ path = resolve_icon_path
89
+ raise Error, "Icon data file not found: #{path}" unless File.exist?(path)
90
+
91
+ File.read(path)
92
+ end
93
+ end
94
+
95
+ def resolve_icon_path
96
+ external = File.join(configuration.icons_path, "#{icon}.json")
97
+ return external if File.exist?(external)
98
+
99
+ File.join(__dir__, "data", "#{icon}.json")
100
+ end
101
+ ```
102
+
103
+ Then in `merge_extra_icons` (same file), replace `#{data_path}` with `#{resolve_icon_path}` in the error message. Delete the now-unused `data_path` method.
104
+
105
+ - [ ] **Step 5: Run full test suite**
106
+
107
+ Run: `bundle exec rake test`
108
+ Expected: all 26 tests pass (22 existing + 4 new).
109
+
110
+ - [ ] **Step 6: Commit**
111
+
112
+ ```bash
113
+ git add lib/svg_icon.rb lib/svg_icon/configuration.rb test/svg_icon_test.rb
114
+ git commit -m "feat: support external icon sets via icons_path config"
115
+ ```
116
+
117
+ ---
118
+
119
+ ### Task 2: `SvgIcon::Fetcher`
120
+
121
+ **Files:**
122
+ - Create: `lib/svg_icon/fetcher.rb`
123
+ - Create: `test/fetcher_test.rb`
124
+ - Modify: `lib/svg_icon.rb` (require fetcher)
125
+
126
+ - [ ] **Step 1: Write the failing tests**
127
+
128
+ Create `test/fetcher_test.rb`:
129
+
130
+ ```ruby
131
+ # frozen_string_literal: true
132
+
133
+ require "test_helper"
134
+ require "tmpdir"
135
+
136
+ class FetcherTest < Minitest::Test
137
+ FakeResponse = Struct.new(:code, :body)
138
+
139
+ class FakeHttp
140
+ attr_reader :requests
141
+
142
+ def initialize(code, body)
143
+ @code = code
144
+ @body = body
145
+ @requests = []
146
+ end
147
+
148
+ def get_response(uri)
149
+ @requests << uri
150
+ FakeResponse.new(@code, @body)
151
+ end
152
+ end
153
+
154
+ def setup
155
+ @dir = Dir.mktmpdir
156
+ end
157
+
158
+ def teardown
159
+ FileUtils.remove_entry(@dir)
160
+ end
161
+
162
+ def test_fetch_downloads_and_writes_file
163
+ body = %({"prefix":"bi","icons":{"search":{"body":"<path/>"}}})
164
+ http = FakeHttp.new("200", body)
165
+ fetcher = SvgIcon::Fetcher.new(http: http)
166
+ destination = File.join(@dir, "bi.json")
167
+
168
+ assert fetcher.fetch("bi", destination)
169
+ assert_equal body, File.read(destination)
170
+ assert_equal "https://raw.githubusercontent.com/iconify/icon-sets/master/json/bi.json", http.requests.first.to_s
171
+ end
172
+
173
+ def test_fetch_creates_destination_directory
174
+ http = FakeHttp.new("200", %({"icons":{"x":{"body":"1"}}}))
175
+ destination = File.join(@dir, "nested", "bi.json")
176
+
177
+ SvgIcon::Fetcher.new(http: http).fetch("bi", destination)
178
+
179
+ assert_equal %({"icons":{"x":{"body":"1"}}}), File.read(destination)
180
+ end
181
+
182
+ def test_fetch_uses_custom_base_url
183
+ http = FakeHttp.new("200", %({"icons":{"x":{"body":"1"}}}))
184
+ SvgIcon::Fetcher.new(base_url: "https://example.com/sets", http: http).fetch("bi", File.join(@dir, "out.json"))
185
+
186
+ assert_equal "https://example.com/sets/bi.json", http.requests.first.to_s
187
+ end
188
+
189
+ def test_fetch_raises_on_http_error
190
+ http = FakeHttp.new("404", "Not Found")
191
+ error = assert_raises(SvgIcon::FetchError) do
192
+ SvgIcon::Fetcher.new(http: http).fetch("bi", File.join(@dir, "bi.json"))
193
+ end
194
+ assert_match(/HTTP 404/, error.message)
195
+ assert_match(/not found/, error.message)
196
+ end
197
+
198
+ def test_fetch_raises_on_invalid_json
199
+ http = FakeHttp.new("200", "not-json")
200
+ error = assert_raises(SvgIcon::FetchError) do
201
+ SvgIcon::Fetcher.new(http: http).fetch("bi", File.join(@dir, "bi.json"))
202
+ end
203
+ assert_match(/invalid JSON/, error.message)
204
+ end
205
+
206
+ def test_fetch_raises_when_icons_key_missing
207
+ http = FakeHttp.new("200", %({"prefix":"bi"}))
208
+ error = assert_raises(SvgIcon::FetchError) do
209
+ SvgIcon::Fetcher.new(http: http).fetch("bi", File.join(@dir, "bi.json"))
210
+ end
211
+ assert_match(/must contain an 'icons' object/, error.message)
212
+ end
213
+
214
+ def test_failed_fetch_leaves_no_files
215
+ http = FakeHttp.new("404", "Not Found")
216
+ destination = File.join(@dir, "bi.json")
217
+
218
+ assert_raises(SvgIcon::FetchError) { SvgIcon::Fetcher.new(http: http).fetch("bi", destination) }
219
+
220
+ refute File.exist?(destination)
221
+ assert_empty Dir.glob(File.join(@dir, "*.tmp*"))
222
+ end
223
+ end
224
+ ```
225
+
226
+ - [ ] **Step 2: Run tests to verify they fail**
227
+
228
+ Run: `bundle exec ruby -Ilib -Itest test/fetcher_test.rb`
229
+ Expected: all FAIL with `NameError: uninitialized constant SvgIcon::Fetcher`.
230
+
231
+ - [ ] **Step 3: Implement Fetcher**
232
+
233
+ Create `lib/svg_icon/fetcher.rb`:
234
+
235
+ ```ruby
236
+ # frozen_string_literal: true
237
+
238
+ require "net/http"
239
+ require "tempfile"
240
+
241
+ module SvgIcon
242
+ class FetchError < Error; end
243
+
244
+ class Fetcher
245
+ DEFAULT_BASE_URL = "https://raw.githubusercontent.com/iconify/icon-sets/master/json"
246
+
247
+ def initialize(base_url: DEFAULT_BASE_URL, http: Net::HTTP)
248
+ @base_url = base_url
249
+ @http = http
250
+ end
251
+
252
+ def fetch(name, destination)
253
+ response = @http.get_response(uri_for(name))
254
+ raise FetchError, "Failed to fetch #{name}: HTTP #{response.code}#{not_found_hint(response.code)}" unless response.code == "200"
255
+
256
+ parse(response.body, name)
257
+ write(response.body, destination)
258
+ true
259
+ end
260
+
261
+ private
262
+
263
+ def uri_for(name)
264
+ URI.join("#{@base_url}/", "#{name}.json")
265
+ end
266
+
267
+ def not_found_hint(code)
268
+ code == "404" ? " (icon set not found)" : ""
269
+ end
270
+
271
+ def parse(body, name)
272
+ data = MultiJson.load(body)
273
+ return if data.is_a?(Hash) && data["icons"].is_a?(Hash)
274
+
275
+ raise FetchError, "Invalid icon set '#{name}': JSON must contain an 'icons' object"
276
+ rescue MultiJson::ParseError => e
277
+ raise FetchError, "Invalid icon set '#{name}': invalid JSON (#{e.message})"
278
+ end
279
+
280
+ def write(body, destination)
281
+ dir = File.dirname(destination)
282
+ FileUtils.mkdir_p(dir)
283
+ temp = Tempfile.new([".#{File.basename(destination)}", ".tmp"], dir)
284
+ begin
285
+ temp.write(body)
286
+ temp.flush
287
+ File.rename(temp.path, destination)
288
+ ensure
289
+ temp.close!
290
+ end
291
+ end
292
+ end
293
+ end
294
+ ```
295
+
296
+ In `lib/svg_icon.rb`, add `require_relative "svg_icon/fetcher"` after the `require_relative "svg_icon/helper"` line.
297
+
298
+ - [ ] **Step 4: Run tests to verify they pass**
299
+
300
+ Run: `bundle exec ruby -Ilib -Itest test/fetcher_test.rb`
301
+ Expected: all 7 tests PASS.
302
+
303
+ - [ ] **Step 5: Commit**
304
+
305
+ ```bash
306
+ git add lib/svg_icon.rb lib/svg_icon/fetcher.rb test/fetcher_test.rb
307
+ git commit -m "feat: add Fetcher to download icon sets"
308
+ ```
309
+
310
+ ---
311
+
312
+ ### Task 3: `SvgIcon::CLI` + `exe/svg_icon`
313
+
314
+ **Files:**
315
+ - Create: `lib/svg_icon/cli.rb`
316
+ - Create: `exe/svg_icon`
317
+ - Create: `test/cli_test.rb`
318
+
319
+ - [ ] **Step 1: Write the failing tests**
320
+
321
+ Create `test/cli_test.rb`:
322
+
323
+ ```ruby
324
+ # frozen_string_literal: true
325
+
326
+ require "test_helper"
327
+
328
+ class CliTest < Minitest::Test
329
+ FakeFetcher = Struct.new(:name, :destination) do
330
+ def fetch(name, destination)
331
+ self.name = name
332
+ self.destination = destination
333
+ end
334
+ end
335
+
336
+ def test_fetch_downloads_into_icons_path
337
+ fake = FakeFetcher.new
338
+ SvgIcon::Fetcher.stub(:new, fake) do
339
+ out, = capture_io { SvgIcon::CLI.run(["fetch", "bi"]) }
340
+ assert_equal "bi", fake.name
341
+ assert_equal File.join(SvgIcon.configuration.icons_path, "bi.json"), fake.destination
342
+ assert_includes out, "Saved to"
343
+ end
344
+ end
345
+
346
+ def test_fetch_without_name_exits_with_usage
347
+ error = assert_raises(SystemExit) do
348
+ capture_io { SvgIcon::CLI.run(["fetch"]) }
349
+ end
350
+ assert_equal 1, error.status
351
+ end
352
+
353
+ def test_fetch_with_invalid_name_exits
354
+ error = assert_raises(SystemExit) do
355
+ capture_io { SvgIcon::CLI.run(["fetch", "../evil"]) }
356
+ end
357
+ assert_equal 1, error.status
358
+ end
359
+
360
+ def test_fetch_with_extra_arguments_exits
361
+ error = assert_raises(SystemExit) do
362
+ capture_io { SvgIcon::CLI.run(["fetch", "bi", "extra"]) }
363
+ end
364
+ assert_equal 1, error.status
365
+ end
366
+
367
+ def test_fetch_propagates_fetch_error
368
+ SvgIcon::Fetcher.stub(:new, proc { |_base_url, _http| raise SvgIcon::FetchError, "boom" }) do
369
+ _, err = capture_io do
370
+ error = assert_raises(SystemExit) { SvgIcon::CLI.run(["fetch", "bi"]) }
371
+ assert_equal 1, error.status
372
+ end
373
+ assert_includes err, "boom"
374
+ end
375
+ end
376
+
377
+ def test_unknown_command_exits
378
+ error = assert_raises(SystemExit) do
379
+ capture_io { SvgIcon::CLI.run(["frobnicate"]) }
380
+ end
381
+ assert_equal 1, error.status
382
+ end
383
+
384
+ def test_help_prints_usage_without_error
385
+ out, = capture_io { SvgIcon::CLI.run(["help"]) }
386
+ assert_includes out, "Usage:"
387
+ end
388
+ end
389
+ ```
390
+
391
+ Note: `SvgIcon::Fetcher.stub(:new, fake)` stubs `SvgIcon::Fetcher.new`; the CLI must call `SvgIcon::Fetcher.new` (with default args) so the stub intercepts it. The `proc` stub in `test_fetch_propagates_fetch_error` works because minitest calls the stub object's `call` when the stubbed method is invoked with matching args — the proc receives the same args `new` was called with.
392
+
393
+ - [ ] **Step 2: Run tests to verify they fail**
394
+
395
+ Run: `bundle exec ruby -Ilib -Itest test/cli_test.rb`
396
+ Expected: FAIL with `NameError: uninitialized constant SvgIcon::CLI`.
397
+
398
+ - [ ] **Step 3: Implement CLI**
399
+
400
+ Create `lib/svg_icon/cli.rb`:
401
+
402
+ ```ruby
403
+ # frozen_string_literal: true
404
+
405
+ module SvgIcon
406
+ class CLI
407
+ def self.run(argv)
408
+ new(argv).run
409
+ end
410
+
411
+ def initialize(argv)
412
+ @argv = argv
413
+ end
414
+
415
+ def run
416
+ command = @argv.shift
417
+ case command
418
+ when "fetch"
419
+ fetch
420
+ when nil, "help", "--help", "-h"
421
+ puts usage
422
+ else
423
+ warn "Unknown command: #{command}"
424
+ warn usage
425
+ exit 1
426
+ end
427
+ end
428
+
429
+ private
430
+
431
+ def fetch
432
+ name = @argv.shift
433
+ unless valid_name?(name) && @argv.empty?
434
+ warn "Usage: svg_icon fetch <icon_set_name>"
435
+ exit 1
436
+ end
437
+
438
+ destination = File.join(SvgIcon.configuration.icons_path, "#{name}.json")
439
+ puts "Fetching #{name} from iconify/icon-sets"
440
+ SvgIcon::Fetcher.new.fetch(name, destination)
441
+ puts "Saved to #{relative_path(destination)}"
442
+ rescue SvgIcon::FetchError => e
443
+ warn e.message
444
+ exit 1
445
+ rescue StandardError => e
446
+ warn "Error: #{e.message}"
447
+ exit 1
448
+ end
449
+
450
+ def valid_name?(name)
451
+ !name.nil? && !name.empty? && name.match?(/\A[a-z0-9\-_]+\z/)
452
+ end
453
+
454
+ def relative_path(path)
455
+ path.sub("#{Dir.pwd}/", "")
456
+ end
457
+
458
+ def usage
459
+ <<~TEXT
460
+ Usage: svg_icon COMMAND
461
+
462
+ Commands:
463
+ fetch NAME Download icon set NAME (e.g. bi) from iconify/icon-sets into #{SvgIcon.configuration.icons_path}
464
+ help Show this help
465
+ TEXT
466
+ end
467
+ end
468
+ end
469
+ ```
470
+
471
+ Create `exe/svg_icon` (make executable):
472
+
473
+ ```ruby
474
+ #!/usr/bin/env ruby
475
+ # frozen_string_literal: true
476
+
477
+ require "svg_icon"
478
+ require "svg_icon/cli"
479
+
480
+ exit(SvgIcon::CLI.run(ARGV) || 0)
481
+ ```
482
+
483
+ In `lib/svg_icon.rb`, add `require_relative "svg_icon/cli"` after `require_relative "svg_icon/fetcher"`.
484
+
485
+ - [ ] **Step 4: Run tests to verify they pass**
486
+
487
+ Run: `bundle exec ruby -Ilib -Itest test/cli_test.rb`
488
+ Expected: all 7 tests PASS.
489
+
490
+ - [ ] **Step 5: Verify the executable works end-to-end**
491
+
492
+ Run: `chmod +x exe/svg_icon && bundle exec ruby exe/svg_icon fetch nonexistent-set-xyz && echo "UNEXPECTED SUCCESS" || echo "expected failure"`
493
+ Expected: `expected failure` (network 404 or resolve failure → non-zero exit). Then run `bundle exec ruby exe/svg_icon help` — expect usage text.
494
+
495
+ - [ ] **Step 6: Commit**
496
+
497
+ ```bash
498
+ git add lib/svg_icon.rb lib/svg_icon/cli.rb exe/svg_icon test/cli_test.rb
499
+ git commit -m "feat: add svg_icon fetch CLI"
500
+ ```
501
+
502
+ ---
503
+
504
+ ### Task 4: README + full verification
505
+
506
+ **Files:**
507
+ - Modify: `README.md`
508
+ - Test: all existing tests
509
+
510
+ - [ ] **Step 1: Update README**
511
+
512
+ Replace the `## Usage` section's initializer example block (the `SvgIcon.configure` code block) with:
513
+
514
+ ```ruby
515
+ SvgIcon.configure do |config|
516
+ # config.icon = "lucide" # icon set name: "lucide", "bi", "bx", "heroicons", or any fetched set
517
+ config.icons_path = Rails.root.join("config", "svg_icons") # defaults to "config/svg_icons" under the project root
518
+
519
+ ##
520
+ # You can set a default class for icon
521
+ config.default_class = ""
522
+ end
523
+ ```
524
+
525
+ After the `<%= svg_icon("search") %>` usage example, add:
526
+
527
+ ```markdown
528
+ ## Fetching icon sets
529
+
530
+ The gem bundles a few icon sets (lucide, bi, bx, heroicons). To use any other
531
+ [iconify icon set](https://github.com/iconify/icon-sets/tree/master/json), download it into your project:
532
+
533
+ $ svg_icon fetch bi
534
+
535
+ This downloads `bi.json` into `config/svg_icons/`. Set `config.icon = "bi"` to use it —
536
+ the gem looks for `<icon>.json` in `config/svg_icons/` first, then falls back to bundled data.
537
+
538
+ Commit `config/svg_icons/` to your repository so deploys don't need to re-fetch.
539
+ Re-run `svg_icon fetch <name>` to update an existing set.
540
+ ```
541
+
542
+ - [ ] **Step 2: Run full test suite**
543
+
544
+ Run: `bundle exec rake test`
545
+ Expected: 40 runs, 0 failures, 0 errors (22 existing + 4 + 7 + 7 new).
546
+
547
+ - [ ] **Step 3: Verify gem packaging includes the executable**
548
+
549
+ Run: `gem build svg_icon.gemspec && tar -tf svg_icon-*.gem > /dev/null; rm svg_icon-*.gem`
550
+ Expected: build succeeds. The new `exe/svg_icon` is tracked by git (committed in Task 3), so `git ls-files` includes it and the gem ships it.
551
+
552
+ - [ ] **Step 4: Commit**
553
+
554
+ ```bash
555
+ git add README.md
556
+ git commit -m "docs: document svg_icon fetch CLI"
557
+ ```