shipreal 1.0.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 +7 -0
- data/LICENSE +21 -0
- data/exe/shipreal +146 -0
- data/lib/shipreal/version.rb +5 -0
- data/lib/shipreal.rb +229 -0
- metadata +53 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: a3406b6c359b00e40ae18d2d64f167b1c7254947b197a603f86f7bddf7668aad
|
|
4
|
+
data.tar.gz: 76fa08e3d1f135c2e0adcf8af92519c9c4dde7621cc3483bfed5fea710451253
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 8fc9333e6f72fb93bcaceaaadb8d0c080757d2fd0fab020b190f96bb0e65f006bac4de86882f77be43394e2448755103915d3605d6da47830a18726f12da00d5
|
|
7
|
+
data.tar.gz: 47e049f2812d64df6fa83486a9ad636541c375e9327897ef79426143e46a2d4a462eeaf61780e4d824fdb92a57bffdab5d75cd52f0990841686774b87e847283
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Michael Lugassy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/exe/shipreal
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# shipreal: query the ShipReal course catalogue from a terminal or a script.
|
|
5
|
+
#
|
|
6
|
+
# Every command takes --json, because the reason a CLI earns its place in an
|
|
7
|
+
# agent's toolbox is that its output can be piped into something else without
|
|
8
|
+
# being scraped.
|
|
9
|
+
|
|
10
|
+
require "optparse"
|
|
11
|
+
require "json"
|
|
12
|
+
require "shipreal"
|
|
13
|
+
|
|
14
|
+
EPILOG = <<~TEXT
|
|
15
|
+
No API key. No account. The API is public read-only reference data; anything
|
|
16
|
+
that asks you for a ShipReal credential is not us.
|
|
17
|
+
|
|
18
|
+
Docs: https://shipreal.dev/developers
|
|
19
|
+
TEXT
|
|
20
|
+
|
|
21
|
+
options = { json: false, sandbox: false, base: ShipReal::DEFAULT_BASE_URL }
|
|
22
|
+
|
|
23
|
+
parser = OptionParser.new do |o|
|
|
24
|
+
o.banner = <<~USAGE
|
|
25
|
+
Usage:
|
|
26
|
+
shipreal [options] search [query]
|
|
27
|
+
shipreal [options] module <slug-or-title>
|
|
28
|
+
shipreal [options] pricing
|
|
29
|
+
shipreal [options] course
|
|
30
|
+
shipreal [options] ask <question...>
|
|
31
|
+
USAGE
|
|
32
|
+
o.on("--json", "Raw JSON, for piping") { options[:json] = true }
|
|
33
|
+
o.on("--sandbox", "Frozen fixture data, for tests") { options[:sandbox] = true }
|
|
34
|
+
o.on("--base URL", "Point at a different origin") { |v| options[:base] = v }
|
|
35
|
+
o.on("--limit N", Integer, "search: results per page, maximum 100") { |v| options[:limit] = v }
|
|
36
|
+
o.on("--all", "search: every result, following pagination") { options[:all] = true }
|
|
37
|
+
o.on("--region R", %w[intl il], "pricing: intl or il") { |v| options[:region] = v }
|
|
38
|
+
o.on("--stream", "ask: stream events as they arrive") { options[:stream] = true }
|
|
39
|
+
o.on("-h", "--help", "Show this message") { puts o; puts; puts EPILOG; exit 0 }
|
|
40
|
+
o.on("-v", "--version", "Show the version") { puts ShipReal::VERSION; exit 0 }
|
|
41
|
+
end
|
|
42
|
+
parser.parse!
|
|
43
|
+
|
|
44
|
+
command = ARGV.shift
|
|
45
|
+
unless command
|
|
46
|
+
warn parser
|
|
47
|
+
exit 1
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
client = ShipReal::Client.new(base_url: options[:base], sandbox: options[:sandbox])
|
|
51
|
+
|
|
52
|
+
def print_modules(mods)
|
|
53
|
+
if mods.empty?
|
|
54
|
+
puts "No modules matched. The course does not cover that topic under that name."
|
|
55
|
+
return
|
|
56
|
+
end
|
|
57
|
+
mods.each do |m|
|
|
58
|
+
puts "#{m['slug']} #{m['title']}"
|
|
59
|
+
puts " #{m['part']}"
|
|
60
|
+
puts " #{m['chapters']} chapters, #{m['minutes']} min"
|
|
61
|
+
puts
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
begin
|
|
66
|
+
case command
|
|
67
|
+
when "search"
|
|
68
|
+
query = ARGV.join(" ")
|
|
69
|
+
if options[:all]
|
|
70
|
+
mods = client.modules(query.empty? ? nil : query)
|
|
71
|
+
options[:json] ? puts(JSON.generate(mods)) : print_modules(mods)
|
|
72
|
+
else
|
|
73
|
+
page = client.search(query: query.empty? ? nil : query, limit: options[:limit])
|
|
74
|
+
options[:json] ? puts(JSON.generate(page)) : print_modules(page["data"] || [])
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
when "module"
|
|
78
|
+
abort "module needs a slug or title" if ARGV.empty?
|
|
79
|
+
m = client.module_by(ARGV.join(" "))
|
|
80
|
+
if options[:json]
|
|
81
|
+
puts JSON.generate(m)
|
|
82
|
+
else
|
|
83
|
+
puts "#{m['slug']} #{m['title']}"
|
|
84
|
+
puts " #{m['part']}"
|
|
85
|
+
puts " #{m['chapters']} chapters, #{m['minutes']} min"
|
|
86
|
+
puts " #{m['url']}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
when "pricing"
|
|
90
|
+
p = client.pricing(region: options[:region])
|
|
91
|
+
if options[:json]
|
|
92
|
+
puts JSON.generate(p)
|
|
93
|
+
elsif options[:region]
|
|
94
|
+
puts "Free #{p['free']['includes']}"
|
|
95
|
+
puts "Complete #{p['complete']['now']} (was #{p['complete']['list']})"
|
|
96
|
+
puts "Teams #{p['teams']['now']} per seat (was #{p['teams']['list']}), from #{p['teams']['minSeats']} seat"
|
|
97
|
+
else
|
|
98
|
+
# Both regions when none is named. They are not conversions of each
|
|
99
|
+
# other, so printing one unlabelled would misquote the other.
|
|
100
|
+
puts "Free #{p['free']['includes']}"
|
|
101
|
+
puts "Complete #{p['complete']['intl']['now']} international, #{p['complete']['il']['now']} Israel"
|
|
102
|
+
puts "Teams #{p['teams']['intl']['now']} / #{p['teams']['il']['now']} per seat, from #{p['teams']['minSeats']} seat"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
when "course"
|
|
106
|
+
c = client.course
|
|
107
|
+
if options[:json]
|
|
108
|
+
puts JSON.generate(c)
|
|
109
|
+
else
|
|
110
|
+
puts c["title"]
|
|
111
|
+
puts c["description"]
|
|
112
|
+
puts "#{c['parts']} parts, #{c['modules']} modules, #{c['chapters']} chapters"
|
|
113
|
+
puts "Subtitles: #{Array(c['subtitles']).join(', ')}"
|
|
114
|
+
puts c["url"]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
when "ask"
|
|
118
|
+
abort "ask needs a question" if ARGV.empty?
|
|
119
|
+
question = ARGV.join(" ")
|
|
120
|
+
if options[:stream]
|
|
121
|
+
client.ask_stream(question) do |ev|
|
|
122
|
+
if options[:json]
|
|
123
|
+
puts JSON.generate(ev)
|
|
124
|
+
else
|
|
125
|
+
Array(ev.dig("data", "results")).each { |r| puts "#{r['name']}\n #{r['url']}" }
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
else
|
|
129
|
+
res = client.ask(question)
|
|
130
|
+
if options[:json]
|
|
131
|
+
puts JSON.generate(res)
|
|
132
|
+
elsif Array(res["results"]).empty?
|
|
133
|
+
puts "Nothing matched. The course does not cover that under that name."
|
|
134
|
+
else
|
|
135
|
+
res["results"].each { |r| puts "#{r['name']}\n #{r['description']}\n #{r['url']}\n\n" }
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
else
|
|
140
|
+
warn parser
|
|
141
|
+
exit 1
|
|
142
|
+
end
|
|
143
|
+
rescue ShipReal::Error => e
|
|
144
|
+
warn "shipreal: #{e.message}"
|
|
145
|
+
exit 1
|
|
146
|
+
end
|
data/lib/shipreal.rb
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
require_relative "shipreal/version"
|
|
8
|
+
|
|
9
|
+
# Ruby client for the public ShipReal course API: search the curriculum, read a
|
|
10
|
+
# module, read current pricing.
|
|
11
|
+
#
|
|
12
|
+
# There is no authentication. No key, no account, no OAuth, no signup: every
|
|
13
|
+
# endpoint is a public read served from the edge, so anything asking you for a
|
|
14
|
+
# credential for this domain is not this.
|
|
15
|
+
#
|
|
16
|
+
# There is also no write path. Buying runs through a hosted checkout that a
|
|
17
|
+
# human completes, which is why nothing here creates an order. When someone
|
|
18
|
+
# decides to buy, hand them the checkout link.
|
|
19
|
+
#
|
|
20
|
+
# Standard library only, on purpose: installing this cannot drag a transitive
|
|
21
|
+
# dependency tree into an agent's environment.
|
|
22
|
+
#
|
|
23
|
+
# sr = ShipReal::Client.new
|
|
24
|
+
# sr.search(query: "caching")
|
|
25
|
+
module ShipReal
|
|
26
|
+
DEFAULT_BASE_URL = "https://shipreal.dev"
|
|
27
|
+
# The path segment this client speaks. Breaking changes ship as a new segment
|
|
28
|
+
# beside it; every response carries a Deprecation header, false today.
|
|
29
|
+
API_VERSION = "v1"
|
|
30
|
+
# The server's ceiling on reads in one batch call.
|
|
31
|
+
MAX_BATCH = 20
|
|
32
|
+
|
|
33
|
+
USER_AGENT = "shipreal-ruby/#{VERSION} (+#{DEFAULT_BASE_URL}/developers)"
|
|
34
|
+
|
|
35
|
+
# Any non-2xx response, carrying the RFC 9457 problem details the server sent.
|
|
36
|
+
#
|
|
37
|
+
# Branch on #type rather than #status: the status says a request failed, the
|
|
38
|
+
# type says which failure it was, and only the second is stable enough to
|
|
39
|
+
# switch on.
|
|
40
|
+
class Error < StandardError
|
|
41
|
+
attr_reader :status, :type, :title, :detail, :problem, :url
|
|
42
|
+
|
|
43
|
+
def initialize(status, problem, url)
|
|
44
|
+
@status = status
|
|
45
|
+
@problem = problem
|
|
46
|
+
@url = url
|
|
47
|
+
@type = problem && problem["type"]
|
|
48
|
+
@title = problem && problem["title"]
|
|
49
|
+
@detail = problem && problem["detail"]
|
|
50
|
+
super(@detail || @title || "Request failed with #{status}")
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Talks to the ShipReal API.
|
|
55
|
+
class Client
|
|
56
|
+
attr_reader :base_url, :sandbox, :timeout
|
|
57
|
+
|
|
58
|
+
# sandbox: routes reads at the frozen fixture data. Same code path and same
|
|
59
|
+
# shapes over contents that never change, so a test written against it stays
|
|
60
|
+
# green when the curriculum moves. Fixture prices are 1 unit and fixture
|
|
61
|
+
# links point at example.invalid, so sandbox data leaking into real output
|
|
62
|
+
# is obvious. See https://shipreal.dev/sandbox
|
|
63
|
+
def initialize(base_url: DEFAULT_BASE_URL, sandbox: false, timeout: 30)
|
|
64
|
+
@base_url = base_url.chomp("/")
|
|
65
|
+
@sandbox = sandbox
|
|
66
|
+
@timeout = timeout
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Search the curriculum. Without a query, every module in course order.
|
|
70
|
+
#
|
|
71
|
+
# Matching is a case-insensitive substring over title, description and part
|
|
72
|
+
# name, so an empty result means the course does not cover that topic under
|
|
73
|
+
# that name, rather than that the search was too clever.
|
|
74
|
+
def search(query: nil, page: nil, limit: nil, cursor: nil)
|
|
75
|
+
get("/modules", q: query, page: page, limit: limit, cursor: cursor)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Every matching module, following pagination for you.
|
|
79
|
+
def modules(query = nil)
|
|
80
|
+
out = []
|
|
81
|
+
cursor = nil
|
|
82
|
+
loop do
|
|
83
|
+
page = search(query: query, limit: 100, cursor: cursor)
|
|
84
|
+
out.concat(page["data"] || [])
|
|
85
|
+
cursor = page.dig("pagination", "nextCursor")
|
|
86
|
+
break if cursor.nil? || cursor.empty?
|
|
87
|
+
end
|
|
88
|
+
out
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# One module by slug, or by an exact or partial title match.
|
|
92
|
+
def module_by(slug_or_title)
|
|
93
|
+
raise ArgumentError, "module_by needs a slug or title" if slug_or_title.to_s.empty?
|
|
94
|
+
|
|
95
|
+
get("/modules/#{ERB_ESCAPE.call(slug_or_title.to_s)}")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Current plans and prices.
|
|
99
|
+
#
|
|
100
|
+
# Two regional prices are live at once, so quoting one without naming its
|
|
101
|
+
# region is misleading. Pass region ("intl" or "il") when you know which
|
|
102
|
+
# applies and the response comes back flattened to it.
|
|
103
|
+
def pricing(region: nil)
|
|
104
|
+
every = get("/pricing")
|
|
105
|
+
return every unless %w[intl il].include?(region)
|
|
106
|
+
|
|
107
|
+
complete = every["complete"][region].dup
|
|
108
|
+
complete["url"] = every["complete"]["url"]
|
|
109
|
+
teams = every["teams"][region].dup
|
|
110
|
+
teams["minSeats"] = every["teams"]["minSeats"]
|
|
111
|
+
teams["perSeat"] = true
|
|
112
|
+
{ "region" => region, "free" => every["free"], "complete" => complete, "teams" => teams }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Totals, language and the subtitle languages.
|
|
116
|
+
def course
|
|
117
|
+
get("/course")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Several reads in one round trip, up to MAX_BATCH.
|
|
121
|
+
#
|
|
122
|
+
# Each item comes back with its own status, so check per item rather than
|
|
123
|
+
# assuming the whole batch succeeded.
|
|
124
|
+
def batch(requests)
|
|
125
|
+
raise ArgumentError, "batch takes at most #{MAX_BATCH} requests" if requests.length > MAX_BATCH
|
|
126
|
+
|
|
127
|
+
request(:post, "#{@base_url}/api/#{API_VERSION}/batch", body: { requests: requests })
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Ask in natural language (NLWeb).
|
|
131
|
+
#
|
|
132
|
+
# There is no model behind this: it runs the same keyword search, which
|
|
133
|
+
# means it says so when nothing matches instead of inventing a module.
|
|
134
|
+
def ask(query)
|
|
135
|
+
raise ArgumentError, "ask needs a question" if query.to_s.empty?
|
|
136
|
+
|
|
137
|
+
request(:post, "#{@base_url}/ask", body: { query: query })
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# The same question, streamed. Yields NLWeb events as they arrive: "start",
|
|
141
|
+
# then one "result" per hit, then "complete".
|
|
142
|
+
def ask_stream(query)
|
|
143
|
+
raise ArgumentError, "ask_stream needs a question" if query.to_s.empty?
|
|
144
|
+
return enum_for(:ask_stream, query) unless block_given?
|
|
145
|
+
|
|
146
|
+
uri = URI("#{@base_url}/ask")
|
|
147
|
+
req = Net::HTTP::Post.new(uri)
|
|
148
|
+
req["accept"] = "text/event-stream"
|
|
149
|
+
req["content-type"] = "application/json"
|
|
150
|
+
req["user-agent"] = USER_AGENT
|
|
151
|
+
req.body = JSON.generate({ query: query })
|
|
152
|
+
|
|
153
|
+
http(uri).request(req) do |res|
|
|
154
|
+
raise Error.new(res.code.to_i, safe_json(res.body), uri.to_s) unless res.is_a?(Net::HTTPSuccess)
|
|
155
|
+
|
|
156
|
+
event = "message"
|
|
157
|
+
data = +""
|
|
158
|
+
res.read_body do |chunk|
|
|
159
|
+
chunk.each_line do |raw|
|
|
160
|
+
line = raw.chomp
|
|
161
|
+
if line.empty?
|
|
162
|
+
# A blank line closes an SSE frame. Anything short of one is a
|
|
163
|
+
# partial frame and waits for the next line.
|
|
164
|
+
unless data.empty?
|
|
165
|
+
parsed = safe_json(data)
|
|
166
|
+
yield({ "event" => event, "data" => parsed }) if parsed
|
|
167
|
+
end
|
|
168
|
+
event = "message"
|
|
169
|
+
data = +""
|
|
170
|
+
elsif line.start_with?("event:")
|
|
171
|
+
event = line[6..].strip
|
|
172
|
+
elsif line.start_with?("data:")
|
|
173
|
+
data << line[5..].strip
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
private
|
|
181
|
+
|
|
182
|
+
ERB_ESCAPE = ->(s) { URI.encode_www_form_component(s).gsub("+", "%20") }
|
|
183
|
+
|
|
184
|
+
def api_url(path, params = {})
|
|
185
|
+
prefix = @sandbox ? "/api/#{API_VERSION}/sandbox" : "/api/#{API_VERSION}"
|
|
186
|
+
uri = URI("#{@base_url}#{prefix}#{path}")
|
|
187
|
+
query = params.reject { |_, v| v.nil? || v == "" }
|
|
188
|
+
uri.query = URI.encode_www_form(query) unless query.empty?
|
|
189
|
+
uri.to_s
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def get(path, params = {})
|
|
193
|
+
request(:get, api_url(path, params))
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def http(uri)
|
|
197
|
+
client = Net::HTTP.new(uri.host, uri.port)
|
|
198
|
+
client.use_ssl = uri.scheme == "https"
|
|
199
|
+
client.open_timeout = @timeout
|
|
200
|
+
client.read_timeout = @timeout
|
|
201
|
+
client
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def request(method, url, body: nil)
|
|
205
|
+
uri = URI(url)
|
|
206
|
+
req = method == :post ? Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
|
|
207
|
+
req["accept"] = "application/json"
|
|
208
|
+
req["user-agent"] = USER_AGENT
|
|
209
|
+
if body
|
|
210
|
+
req["content-type"] = "application/json"
|
|
211
|
+
req.body = JSON.generate(body)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
res = http(uri).request(req)
|
|
215
|
+
# The error body is where the problem details live, so it gets parsed
|
|
216
|
+
# rather than discarded. A body that is not JSON is not itself worth
|
|
217
|
+
# raising over: the status still stands.
|
|
218
|
+
raise Error.new(res.code.to_i, safe_json(res.body), url) unless res.is_a?(Net::HTTPSuccess)
|
|
219
|
+
|
|
220
|
+
safe_json(res.body) || {}
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def safe_json(text)
|
|
224
|
+
JSON.parse(text)
|
|
225
|
+
rescue StandardError
|
|
226
|
+
nil
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: shipreal
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Michael Lugassy
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: 'Search the curriculum, read a module, read pricing. No authentication:
|
|
13
|
+
the API is public read-only reference data about one course. Standard library only,
|
|
14
|
+
no runtime dependencies.'
|
|
15
|
+
email:
|
|
16
|
+
- michael@shipreal.dev
|
|
17
|
+
executables:
|
|
18
|
+
- shipreal
|
|
19
|
+
extensions: []
|
|
20
|
+
extra_rdoc_files: []
|
|
21
|
+
files:
|
|
22
|
+
- LICENSE
|
|
23
|
+
- exe/shipreal
|
|
24
|
+
- lib/shipreal.rb
|
|
25
|
+
- lib/shipreal/version.rb
|
|
26
|
+
homepage: https://shipreal.dev/developers
|
|
27
|
+
licenses:
|
|
28
|
+
- MIT
|
|
29
|
+
metadata:
|
|
30
|
+
homepage_uri: https://shipreal.dev/developers
|
|
31
|
+
documentation_uri: https://shipreal.dev/developers
|
|
32
|
+
source_code_uri: https://github.com/mluggy/shipreal-dev
|
|
33
|
+
bug_tracker_uri: https://github.com/mluggy/shipreal-dev/issues
|
|
34
|
+
changelog_uri: https://github.com/mluggy/shipreal-dev/releases
|
|
35
|
+
rubygems_mfa_required: 'true'
|
|
36
|
+
rdoc_options: []
|
|
37
|
+
require_paths:
|
|
38
|
+
- lib
|
|
39
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
40
|
+
requirements:
|
|
41
|
+
- - ">="
|
|
42
|
+
- !ruby/object:Gem::Version
|
|
43
|
+
version: 2.6.0
|
|
44
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
45
|
+
requirements:
|
|
46
|
+
- - ">="
|
|
47
|
+
- !ruby/object:Gem::Version
|
|
48
|
+
version: '0'
|
|
49
|
+
requirements: []
|
|
50
|
+
rubygems_version: 3.7.2
|
|
51
|
+
specification_version: 4
|
|
52
|
+
summary: SDK and CLI for the public ShipReal course API
|
|
53
|
+
test_files: []
|