rate-card 0.1.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.
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RateCard
4
+ module TUI
5
+ module Fields
6
+ # Pick exactly one. Bubbles::List would do this, but it brings a title
7
+ # bar, pagination and a filter this wizard has no use for, and it cannot
8
+ # be shared with MultiSelect below — so both are built on one small
9
+ # cursor-and-window core instead.
10
+ class Select
11
+ WINDOW = 8
12
+
13
+ attr_reader :label
14
+
15
+ # choices: Array<[display String, value]>
16
+ # selected: index the cursor starts on, so a field re-entered by going
17
+ # back opens on the answer it already has rather than on the first row.
18
+ def initialize(label:, choices:, selected: 0)
19
+ @label = label
20
+ @choices = choices
21
+ @cursor = selected
22
+ @value = nil
23
+ @chosen = false
24
+ end
25
+
26
+ def done? = @chosen
27
+ def value = @value
28
+
29
+ def update(message)
30
+ return nil unless message.is_a?(Bubbletea::KeyMessage)
31
+
32
+ if message.up? || message.to_s == 'k'
33
+ move(-1)
34
+ elsif message.down? || message.to_s == 'j'
35
+ move(1)
36
+ elsif message.enter?
37
+ @value = @choices[@cursor].last
38
+ @chosen = true
39
+ end
40
+ nil
41
+ end
42
+
43
+ def view
44
+ lines = ["#{Theme.accent(Theme::CURSOR)} #{Theme.bold(@label)}"]
45
+ window.each do |index|
46
+ display = @choices[index].first
47
+ lines << if index == @cursor
48
+ " #{Theme.accent(Theme::CURSOR)} #{Theme.accent(display)}"
49
+ else
50
+ " #{display}"
51
+ end
52
+ end
53
+ lines << " #{Theme.muted(hint)}"
54
+ lines.join("\n")
55
+ end
56
+
57
+ private
58
+
59
+ def move(delta)
60
+ @cursor = (@cursor + delta) % @choices.length
61
+ end
62
+
63
+ # Keeps the cursor inside a fixed-height window so a long service list
64
+ # does not push the banner and earlier answers off the screen.
65
+ def window
66
+ return (0...@choices.length) if @choices.length <= WINDOW
67
+
68
+ start = [[@cursor - (WINDOW / 2), 0].max, @choices.length - WINDOW].min
69
+ (start...(start + WINDOW))
70
+ end
71
+
72
+ def hint
73
+ parts = ['↑↓ move', 'enter confirm', 'esc back']
74
+ parts << "#{@cursor + 1}/#{@choices.length}" if @choices.length > WINDOW
75
+ parts.join(' · ')
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bubbles'
4
+
5
+ module RateCard
6
+ module TUI
7
+ module Fields
8
+ # A single-line answer, optionally masked and optionally validated.
9
+ #
10
+ # Validation runs on Enter and keeps the field open on failure, so a
11
+ # mistyped zone list or a mangled paste of a JWT costs a keystroke rather
12
+ # than the run — the same guarantee the tty-prompt wizard made.
13
+ class Text
14
+ attr_reader :label, :error
15
+
16
+ # parse: String -> value, raising ArgumentError with a message to show.
17
+ def initialize(label:, parse:, default: nil, mask: false, hint: nil)
18
+ @label = label
19
+ @parse = parse
20
+ @default = default
21
+ @hint = hint
22
+ @error = nil
23
+ @value = nil
24
+
25
+ @input = Bubbles::TextInput.new
26
+ @input.prompt = ''
27
+ @input.placeholder = default.to_s if default
28
+ if mask
29
+ @input.echo_mode = :password
30
+ @input.echo_character = '•'
31
+ end
32
+ @input.focus
33
+ end
34
+
35
+ def done? = !@value.nil?
36
+ def value = @value
37
+
38
+ def update(message)
39
+ if message.is_a?(Bubbletea::KeyMessage) && message.enter?
40
+ submit
41
+ return nil
42
+ end
43
+
44
+ @input, command = @input.update(message)
45
+ command
46
+ end
47
+
48
+ def view
49
+ lines = ["#{Theme.accent(Theme::CURSOR)} #{Theme.bold(@label)} #{@input.view}"]
50
+ lines << " #{Theme.muted("#{@hint} · esc back")}" if @hint
51
+ lines << " #{Theme.danger(Theme::CROSS)} #{Theme.danger(@error)}" if @error
52
+ lines.join("\n")
53
+ end
54
+
55
+ private
56
+
57
+ # An empty answer means "take the default"; without this the placeholder
58
+ # would be shown but never actually used.
59
+ def submit
60
+ raw = @input.value.to_s.strip
61
+ raw = @default.to_s if raw.empty? && @default
62
+
63
+ @value = @parse.call(raw)
64
+ @error = nil
65
+ rescue ArgumentError => e
66
+ @error = e.message
67
+ @value = nil
68
+ @input.reset
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bubbletea'
4
+
5
+ module RateCard
6
+ module TUI
7
+ # Results of work done off the event loop. Bubbletea runs a Proc command in
8
+ # a Thread and feeds whatever it returns back through #update, and the fetch
9
+ # pushes ProgressAdvanced from its worker threads via Runner#send.
10
+ class ServicesLoaded < Bubbletea::Message
11
+ attr_reader :services
12
+
13
+ def initialize(services)
14
+ super()
15
+ @services = services
16
+ end
17
+ end
18
+
19
+ class LoadFailed < Bubbletea::Message
20
+ attr_reader :error
21
+
22
+ def initialize(error)
23
+ super()
24
+ @error = error
25
+ end
26
+ end
27
+
28
+ # Carries the running failure count so the bar can show trouble as it
29
+ # happens: on a doomed run the user should not have to wait out all 128
30
+ # calls to find out.
31
+ class ProgressAdvanced < Bubbletea::Message
32
+ attr_reader :completed, :failed
33
+
34
+ def initialize(completed:, failed:)
35
+ super()
36
+ @completed = completed
37
+ @failed = failed
38
+ end
39
+ end
40
+
41
+ class FetchFinished < Bubbletea::Message
42
+ attr_reader :grid
43
+
44
+ def initialize(grid)
45
+ super()
46
+ @grid = grid
47
+ end
48
+ end
49
+
50
+ class FetchFailed < Bubbletea::Message
51
+ attr_reader :error
52
+
53
+ def initialize(error)
54
+ super()
55
+ @error = error
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'lipgloss'
4
+
5
+ module RateCard
6
+ module TUI
7
+ # Every colour and glyph the TUI uses. Lipgloss strips colour by itself when
8
+ # stdout is not a terminal, so nothing here needs a tty? guard — unlike the
9
+ # Pastel/TTY pairing this replaced, where only Pastel was gated and the
10
+ # spinner and bar leaked escapes into a pipe.
11
+ module Theme
12
+ ACCENT = Lipgloss::AdaptiveColor.new(dark: '#00D3FF', light: '#0072B5')
13
+ OK = Lipgloss::AdaptiveColor.new(dark: '#2ECC71', light: '#1E8449')
14
+ WARNING = Lipgloss::AdaptiveColor.new(dark: '#F5A623', light: '#B7791F')
15
+ DANGER = Lipgloss::AdaptiveColor.new(dark: '#FF5C5C', light: '#C0392B')
16
+ MUTED = Lipgloss::AdaptiveColor.new(dark: '#8A8A8A', light: '#6B6B6B')
17
+
18
+ CURSOR = '❯'
19
+ CHECKED = '◉'
20
+ UNCHECKED = '○'
21
+ TICK = '✓'
22
+ CROSS = '✗'
23
+ BULLET = '▸'
24
+ ALERT = '⚠'
25
+
26
+ module_function
27
+
28
+ def style = Lipgloss::Style.new
29
+
30
+ def accent(text) = style.foreground(ACCENT).render(text)
31
+ def ok(text) = style.foreground(OK).render(text)
32
+ def warning(text) = style.foreground(WARNING).render(text)
33
+ def danger(text) = style.foreground(DANGER).render(text)
34
+ def muted(text) = style.foreground(MUTED).render(text)
35
+ def bold(text) = style.bold(true).render(text)
36
+
37
+ def title(text)
38
+ style.bold(true).foreground(ACCENT).render(text)
39
+ end
40
+
41
+ # The run targets production and cannot be pointed elsewhere, so the
42
+ # banner says so in the one colour the eye catches first.
43
+ def banner
44
+ style.border(Lipgloss::ROUNDED_BORDER)
45
+ .border_foreground(ACCENT)
46
+ .padding(0, 1)
47
+ .render("#{bold('eHub Rate Card Builder')}\n#{danger('●')} production · api.ehub.com")
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'tui/theme'
4
+
5
+ module RateCard
6
+ # Plain, non-interactive output: everything printed before the TUI starts or
7
+ # after it exits — errors, the rate tables, the failure and warning reports,
8
+ # and where the files went.
9
+ #
10
+ # The interactive half (banner, prompts, spinner, progress) moved to TUI::App
11
+ # when this switched to Bubbletea. What stays here is deliberately dumb line
12
+ # printing, so tables and reports survive being piped to a file and specs can
13
+ # still assert against a StringIO.
14
+ class UI
15
+ MAX_LISTED_FAILURES = 10
16
+ MAX_LISTED_WARNINGS = 10
17
+
18
+ def initialize(io: $stdout)
19
+ @io = io
20
+ end
21
+
22
+ # The one thing printed before the TUI starts, so the run says what it
23
+ # targets before a production token is pasted into it.
24
+ def banner
25
+ @io.puts
26
+ @io.puts TUI::Theme.banner
27
+ @io.puts
28
+ end
29
+
30
+ # No newline: the answer is typed on the same line.
31
+ def prompt(message)
32
+ @io.print "#{TUI::Theme.accent(TUI::Theme::CURSOR)} #{TUI::Theme.bold(message)}"
33
+ @io.flush
34
+ end
35
+
36
+ def info(message)
37
+ @io.puts "#{TUI::Theme.accent(TUI::Theme::BULLET)} #{message}"
38
+ end
39
+
40
+ def warn(message)
41
+ @io.puts " #{TUI::Theme.warning(TUI::Theme::ALERT)} #{message}"
42
+ end
43
+
44
+ def error(message)
45
+ @io.puts " #{TUI::Theme.danger(TUI::Theme::CROSS)} #{message}"
46
+ end
47
+
48
+ def success(message)
49
+ @io.puts " #{TUI::Theme.ok(TUI::Theme::TICK)} #{message}"
50
+ end
51
+
52
+ def blank
53
+ @io.puts
54
+ end
55
+
56
+ def print_tables(tables)
57
+ tables.each do |title, rendered|
58
+ @io.puts
59
+ @io.puts " #{TUI::Theme.bold(title)}"
60
+ @io.puts rendered
61
+ end
62
+ end
63
+
64
+ def failure_report(failures, spec:)
65
+ return if failures.empty?
66
+
67
+ @io.puts
68
+ warn "#{failures.length} #{failures.length == 1 ? 'cell' : 'cells'} failed"
69
+ failures.first(MAX_LISTED_FAILURES).each do |failure|
70
+ @io.puts " #{row_prefix(spec)}#{spec.row_label(failure.weight)} Z#{failure.zone} → #{failure.message}"
71
+ end
72
+ remaining = failures.length - MAX_LISTED_FAILURES
73
+ @io.puts " … and #{remaining} more" if remaining.positive?
74
+ end
75
+
76
+ # What the API itself reported on calls that succeeded. Separate from
77
+ # #failure_report: those cells have no answer, these have an answer that
78
+ # says why there is no rate.
79
+ def warning_report(warnings)
80
+ return if warnings.empty?
81
+
82
+ @io.puts
83
+ noun = warnings.length == 1 ? 'warning' : 'warnings'
84
+ warn "the API reported #{warnings.length} #{noun}"
85
+
86
+ listed = warnings.first(MAX_LISTED_WARNINGS)
87
+ account, service = listed.partition(&:account_wide?)
88
+ warning_group("for this card's services", service)
89
+ warning_group('for every service on this token, including ones not in this card', account)
90
+
91
+ remaining = warnings.length - MAX_LISTED_WARNINGS
92
+ @io.puts " … and #{remaining} more" if remaining.positive?
93
+ end
94
+
95
+ # Headed rather than filtered. An account-wide message can still be about
96
+ # this card — 'Unable to verify address' names the destination we sent — so
97
+ # dropping the ones that do not name a selected service would delete the
98
+ # most actionable warning in the run. The heading says what it can be
99
+ # blamed on and leaves the reading to the user.
100
+ def warning_group(heading, warnings)
101
+ return if warnings.empty?
102
+
103
+ @io.puts " #{heading}"
104
+ warnings.each do |warning|
105
+ repeat = warning.count > 1 ? " (×#{warning.count})" : ''
106
+ @io.puts " #{warning.message}#{repeat}"
107
+ end
108
+ end
109
+
110
+ def saved(paths)
111
+ return if paths.empty?
112
+
113
+ @io.puts
114
+ noun = paths.length == 1 ? 'file' : 'files'
115
+ success "Saved #{paths.length} #{noun} to #{paths.first.dirname}"
116
+ paths.each { |path| @io.puts " #{path.basename}" }
117
+ end
118
+
119
+ # "wt " in weight mode, so failure lines read exactly as before; nothing
120
+ # extra in cubic mode, since row_label is already self-describing
121
+ # ("Tier 3", not just "3").
122
+ def row_prefix(spec)
123
+ spec.rate_mode == :cubic ? '' : 'wt '
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RateCard
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RateCard
4
+ # One distinct thing the API told us went wrong, with how many calls said it.
5
+ #
6
+ # Deliberately not keyed by (weight, zone) the way Failure is: a carrier
7
+ # outage repeats the same message on all 128 calls, and the user needs the
8
+ # message once, not a wall of it.
9
+ #
10
+ # `scope` is what the message can be blamed on. :service means it came from
11
+ # the `errors` field of a service in this card, so it explains that service's
12
+ # blank cells. :account means it came from the response-level `warnings`
13
+ # array, which covers every service enabled on the token — one call rates all
14
+ # of them — so it may be about a service nobody asked for. It is still
15
+ # reported, not filtered: 'Unable to verify address' arrives at response level
16
+ # and is about this card's own destination address.
17
+ class Warning < Struct.new(:message, :count, :scope, keyword_init: true)
18
+ SERVICE = :service
19
+ ACCOUNT = :account
20
+
21
+ # Service-scoped is the safe default: it says 'this is about what you
22
+ # selected', which is the claim a caller that did not set a scope is making.
23
+ def scope
24
+ self[:scope] || SERVICE
25
+ end
26
+
27
+ def account_wide?
28
+ scope == ACCOUNT
29
+ end
30
+ end
31
+ end
data/lib/rate_card.rb ADDED
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'rate_card/version'
4
+
5
+ module RateCard
6
+ # Base for everything this tool raises on purpose.
7
+ class Error < StandardError; end
8
+
9
+ # The production API rejected the token. Retrying will not help.
10
+ class Unauthorized < Error; end
11
+
12
+ # A rate call failed in a way we chose not to retry (or ran out of retries).
13
+ class RequestFailed < Error; end
14
+
15
+ # The probe call came back with no services at all — nothing to build a card from.
16
+ class NoServices < Error; end
17
+
18
+ # The destination directory cannot be written to.
19
+ class OutputNotWritable < Error; end
20
+
21
+ # A carrier was asked for that has no hand-curated zone chart. Raised rather
22
+ # than defaulted: a zone number only means something against a real carrier's
23
+ # own chart, so substituting another carrier's addresses would produce a card
24
+ # that looks right and is mislabeled.
25
+ class UnsupportedCarrier < Error; end
26
+ end
27
+
28
+ require_relative 'rate_card/token'
29
+ require_relative 'rate_card/token_prompt'
30
+ require_relative 'rate_card/service'
31
+ require_relative 'rate_card/failure'
32
+ require_relative 'rate_card/constants/carriers'
33
+ require_relative 'rate_card/constants/addresses'
34
+ require_relative 'rate_card/constants/cubic_tiers'
35
+ require_relative 'rate_card/client'
36
+ require_relative 'rate_card/service_catalog'
37
+ require_relative 'rate_card/input'
38
+ require_relative 'rate_card/run_spec'
39
+ require_relative 'rate_card/shipment'
40
+ require_relative 'rate_card/warning'
41
+ require_relative 'rate_card/grid'
42
+ require_relative 'rate_card/csv_writer'
43
+ require_relative 'rate_card/table_renderer'
44
+ require_relative 'rate_card/tui/theme'
45
+ require_relative 'rate_card/tui/messages'
46
+ require_relative 'rate_card/tui/fields/text'
47
+ require_relative 'rate_card/tui/fields/select'
48
+ require_relative 'rate_card/tui/fields/multi_select'
49
+ require_relative 'rate_card/tui/app'
50
+ require_relative 'rate_card/ui'
51
+ require_relative 'rate_card/runner'
metadata ADDED
@@ -0,0 +1,189 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rate-card
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - eHub
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-09-02 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: base64
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.2'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.2'
27
+ - !ruby/object:Gem::Dependency
28
+ name: csv
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '3.3'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '3.3'
41
+ - !ruby/object:Gem::Dependency
42
+ name: faraday
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '2.13'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '2.13'
55
+ - !ruby/object:Gem::Dependency
56
+ name: parallel
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.26'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.26'
69
+ - !ruby/object:Gem::Dependency
70
+ name: bubbles
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '0.1'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '0.1'
83
+ - !ruby/object:Gem::Dependency
84
+ name: bubbletea
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '0.1'
90
+ type: :runtime
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '0.1'
97
+ - !ruby/object:Gem::Dependency
98
+ name: lipgloss
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '0.2'
104
+ type: :runtime
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '0.2'
111
+ - !ruby/object:Gem::Dependency
112
+ name: ntcharts
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '0.1'
118
+ type: :runtime
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '0.1'
125
+ description: |
126
+ rate-card walks you through a carrier, its services, zones, a weight range and a
127
+ package type, fetches every weight x zone cell from the eHub API, prints the rate
128
+ card to your terminal and saves it as CSV.
129
+ email:
130
+ executables:
131
+ - rate-card
132
+ extensions: []
133
+ extra_rdoc_files: []
134
+ files:
135
+ - LICENSE.txt
136
+ - README.md
137
+ - exe/rate-card
138
+ - lib/rate_card.rb
139
+ - lib/rate_card/client.rb
140
+ - lib/rate_card/constants/addresses.rb
141
+ - lib/rate_card/constants/carriers.rb
142
+ - lib/rate_card/constants/cubic_tiers.rb
143
+ - lib/rate_card/csv_writer.rb
144
+ - lib/rate_card/failure.rb
145
+ - lib/rate_card/grid.rb
146
+ - lib/rate_card/input.rb
147
+ - lib/rate_card/run_spec.rb
148
+ - lib/rate_card/runner.rb
149
+ - lib/rate_card/service.rb
150
+ - lib/rate_card/service_catalog.rb
151
+ - lib/rate_card/shipment.rb
152
+ - lib/rate_card/table_renderer.rb
153
+ - lib/rate_card/token.rb
154
+ - lib/rate_card/token_prompt.rb
155
+ - lib/rate_card/tui/app.rb
156
+ - lib/rate_card/tui/fields/multi_select.rb
157
+ - lib/rate_card/tui/fields/select.rb
158
+ - lib/rate_card/tui/fields/text.rb
159
+ - lib/rate_card/tui/messages.rb
160
+ - lib/rate_card/tui/theme.rb
161
+ - lib/rate_card/ui.rb
162
+ - lib/rate_card/version.rb
163
+ - lib/rate_card/warning.rb
164
+ homepage: https://github.com/jxsoren/rate-card-cli
165
+ licenses:
166
+ - MIT
167
+ metadata:
168
+ source_code_uri: https://github.com/jxsoren/rate-card-cli
169
+ rubygems_mfa_required: 'true'
170
+ post_install_message:
171
+ rdoc_options: []
172
+ require_paths:
173
+ - lib
174
+ required_ruby_version: !ruby/object:Gem::Requirement
175
+ requirements:
176
+ - - ">="
177
+ - !ruby/object:Gem::Version
178
+ version: '3.2'
179
+ required_rubygems_version: !ruby/object:Gem::Requirement
180
+ requirements:
181
+ - - ">="
182
+ - !ruby/object:Gem::Version
183
+ version: '0'
184
+ requirements: []
185
+ rubygems_version: 3.5.11
186
+ signing_key:
187
+ specification_version: 4
188
+ summary: Interactive CLI that builds a shipping rate card from the eHub API.
189
+ test_files: []