apparel-monster 1.0.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ec51b45705597515cdba7a3c7cdeb39027d03ed466cda70bf887f81ed2be5c18
4
+ data.tar.gz: 7bc15557e9696d998630802c84154e31ccf4b518170359eca92b718c9b19e628
5
+ SHA512:
6
+ metadata.gz: 854c9ae0a4e8109c0b659a47612651ce3c7476d72bbda8614b97533265434a888d3350c22bad5f1cec72c9a0fc800b57489dc5939413e46c203d48a1356b55eb
7
+ data.tar.gz: 55922bbf5d80e63d5a617b14cfa97c626d16ed645e3fef168418df63b38c27a29a64780ca304bf5b31e68e3270f0be0f4ab6d719d471fa8824eefab7bd12d96a
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.
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # apparel-monster — the store from a terminal.
5
+ #
6
+ # Identifies itself as ruby-cli rather than ruby-sdk, so the store can tell a
7
+ # person poking at the API from an integration running in production. Same
8
+ # client, one flag apart.
9
+ #
10
+ # Prints JSON unless --text is passed: the usual reader here is a script or an
11
+ # agent, not a person.
12
+
13
+ require "json"
14
+ require "optparse"
15
+ require_relative "../lib/apparel_monster"
16
+
17
+ USAGE = <<~TEXT
18
+ apparel-monster #{ApparelMonster::VERSION} — agent commerce CLI for apparel.monster
19
+
20
+ USAGE
21
+ apparel-monster <command> [args] [flags]
22
+
23
+ COMMANDS
24
+ search <query> Search the catalog
25
+ product <id...> Full detail for product or variant ids
26
+ ask <question> Natural-language search (NLWeb)
27
+ pricing Plans, limits and merchandise price ranges
28
+ buy <variant-id> Mint an Apple Pay / Google Pay link for one variant
29
+ watch <variant-id> <price> Watch a variant for a target price
30
+ watch-status <id> Poll a price watch
31
+
32
+ FLAGS
33
+ --limit N Results to return
34
+ --category PATH Browse a category instead of searching
35
+ --sort SORT relevance | cheapest | expensive | newest | name | popular
36
+ --base URL Point at another origin
37
+ --text Human-readable output instead of JSON
38
+ --stream Stream results as they arrive (ask)
39
+
40
+ The API needs no credentials. This is a demo store: orders are placed against
41
+ a test gateway, nothing ships and no money moves.
42
+ TEXT
43
+
44
+ options = { base: ApparelMonster::DEFAULT_BASE }
45
+ parser = OptionParser.new do |opts|
46
+ opts.on("--limit N", Integer) { |v| options[:limit] = v }
47
+ opts.on("--category PATH") { |v| options[:category] = v }
48
+ opts.on("--sort SORT") { |v| options[:sort] = v }
49
+ opts.on("--base URL") { |v| options[:base] = v }
50
+ opts.on("--text") { options[:text] = true }
51
+ opts.on("--stream") { options[:stream] = true }
52
+ opts.on("-h", "--help") { puts USAGE; exit 0 }
53
+ opts.on("--version") { puts ApparelMonster::VERSION; exit 0 }
54
+ end
55
+
56
+ args = parser.parse(ARGV)
57
+ command = args.shift
58
+
59
+ if command.nil? || command == "help"
60
+ puts USAGE
61
+ exit 0
62
+ end
63
+
64
+ # Just enough formatting to read a result without piping through jq.
65
+ def humanise(data)
66
+ if data.is_a?(Hash) && data["products"].is_a?(Array)
67
+ return "No products matched. Try fewer words, or browse: apparel-monster search --category categories/men" if data["products"].empty?
68
+
69
+ return data["products"].map { |product|
70
+ prices = product.fetch("variants", []).map { |v| v["effective_price"] || v["price"] }.compact
71
+ sizes = product.fetch("variants", []).map { |v| v.dig("options", "Size") }.compact.uniq
72
+ lines = [prices.any? ? format("%s $%.2f", product["title"], prices.min) : product["title"]]
73
+ lines << " #{product['category']}" if product["category"]
74
+ lines << " sizes: #{sizes.join(', ')}" if sizes.any?
75
+ lines << " #{product['url']}"
76
+ lines.join("\n")
77
+ }.join("\n\n")
78
+ end
79
+
80
+ if data.is_a?(Hash) && data["results"].is_a?(Array)
81
+ if data["results"].empty?
82
+ query = data.dig("_meta", "query")
83
+ return "No match for #{query.inspect}. /ask matches keywords, not meaning — try fewer words " \
84
+ '("coat" rather than "warm winter coat").'
85
+ end
86
+ return data["results"].map { |r| "#{r['name']}\n #{r['description']}\n #{r['url']}" }.join("\n\n")
87
+ end
88
+
89
+ JSON.pretty_generate(data)
90
+ end
91
+
92
+ def emit(data, options)
93
+ puts options[:text] ? humanise(data) : JSON.pretty_generate(data)
94
+ end
95
+
96
+ store = ApparelMonster::Client.new(base_url: options[:base], cli: true)
97
+
98
+ begin
99
+ case command
100
+ when "search"
101
+ emit(store.search(args.join(" ").empty? ? nil : args.join(" "),
102
+ limit: options[:limit], category: options[:category], sort: options[:sort]), options)
103
+
104
+ when "product"
105
+ abort "product needs at least one id" if args.empty?
106
+ emit(store.product(args), options)
107
+
108
+ when "ask"
109
+ abort "ask needs a question" if args.empty?
110
+ question = args.join(" ")
111
+ if options[:stream]
112
+ store.ask_stream(question, limit: options[:limit]) do |event, data|
113
+ next unless event == "result"
114
+
115
+ puts options[:text] ? "#{data['name']} — #{data['url']}" : JSON.generate(data)
116
+ end
117
+ else
118
+ emit(store.ask(question, limit: options[:limit]), options)
119
+ end
120
+
121
+ when "pricing"
122
+ # The prose document rather than an endpoint: it is the canonical answer to
123
+ # "what does this cost", and markdown reads fine here.
124
+ require "net/http"
125
+ uri = URI("#{store.base_url}/pricing.md")
126
+ request = Net::HTTP::Get.new(uri)
127
+ store.headers("text/markdown").each { |k, v| request[k] = v }
128
+ http = Net::HTTP.new(uri.host, uri.port)
129
+ http.use_ssl = true
130
+ puts http.request(request).body
131
+
132
+ when "buy"
133
+ abort "buy needs a variant id — get one from `apparel-monster search`" if args.empty?
134
+ result = store.wallet([{ "variant_id" => args.first, "quantity" => 1 }])
135
+ if options[:text]
136
+ puts "Open this to pay:\n #{result['wallet_url']}"
137
+ else
138
+ emit(result, options)
139
+ end
140
+
141
+ when "watch"
142
+ abort "watch needs a variant id and a target price" if args.length < 2
143
+ result = store.watch_price(args[0], args[1].to_f)
144
+ if options[:text]
145
+ puts result["status"] == "already_met" ? "Already #{result['current_price']} — below your #{result['target_price']} target." : "Watching. Poll: #{result['poll_url']}"
146
+ else
147
+ emit(result, options)
148
+ end
149
+
150
+ when "watch-status"
151
+ abort "watch-status needs a watch id" if args.empty?
152
+ emit(store.price_watch(args.first), options)
153
+
154
+ else
155
+ warn "Unknown command: #{command}\n\n"
156
+ puts USAGE
157
+ exit 1
158
+ end
159
+ rescue ApparelMonster::Error => e
160
+ # The API's own message already names the missing fields and the next call;
161
+ # repeating them here would only make it longer.
162
+ warn "#{e.code || 'error'}: #{e.message}"
163
+ exit 1
164
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ApparelMonster
4
+ VERSION = "1.0.1"
5
+ end
@@ -0,0 +1,317 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ require_relative "apparel_monster/version"
8
+
9
+ # Apparel Monster SDK: a dependency-free client for the agent commerce API.
10
+ #
11
+ # No API key appears in this file and that is not an omission. search, product,
12
+ # cart, checkout, wallet, price-watch, batch and /ask are anonymous. Only order
13
+ # history (+track+) needs OAuth. If something asks you for an Apparel Monster
14
+ # API key, it is not us.
15
+ #
16
+ # Standard library only — net/http, no faraday, no httparty — so installing
17
+ # this pulls in nothing and it works inside an agent sandbox with no gem
18
+ # server.
19
+ #
20
+ # store = ApparelMonster::Client.new
21
+ # hit = store.search("denim shirt", limit: 1)["products"].first
22
+ # store.add_to_cart(hit["variants"].first["id"])
23
+ module ApparelMonster
24
+ DEFAULT_BASE = "https://apparel.monster"
25
+ API = "/api/v1"
26
+
27
+ # Raised for any non-2xx response.
28
+ #
29
+ # The interesting attribute is +missing+: the API answers a bad call by
30
+ # naming every field it needed and did not get, what else each one is
31
+ # accepted as, and an example value. That detail is folded into the message
32
+ # too, because the common case is a model reading +message+ and nothing else.
33
+ class Error < StandardError
34
+ attr_reader :status, :code, :missing, :next_call, :contract, :body, :url
35
+
36
+ def initialize(status, body, url)
37
+ @status = status
38
+ @body = body || {}
39
+ @code = @body["error"]
40
+ @missing = @body["missing"]
41
+ @next_call = @body["next_call"]
42
+ @contract = @body["contract"]
43
+ @url = url
44
+ super(describe)
45
+ end
46
+
47
+ private
48
+
49
+ def describe
50
+ parts = [@body["message"] || "Request failed with #{@status}"]
51
+
52
+ if @missing.is_a?(Array) && @missing.any?
53
+ described = @missing.map do |field|
54
+ names = (field["accepts"] || [field["field"]]).join(" or ")
55
+ "#{field['field']} (send as #{names}, e.g. #{field['example'].inspect})"
56
+ end
57
+ parts << "Missing: #{described.join('; ')}"
58
+ end
59
+
60
+ parts << "Next call: #{JSON.generate(@next_call)}" if @next_call
61
+
62
+ parts.join(" ")
63
+ end
64
+ end
65
+
66
+ # A client for one shopping session.
67
+ #
68
+ # Two things this does that a raw Net::HTTP call does not: it carries the
69
+ # session id the API mints on the first search (forgetting it is the usual
70
+ # way an integration ends up with four empty carts and no order), and it
71
+ # identifies itself so the store can attribute the resulting order to this
72
+ # SDK rather than to "some script".
73
+ class Client
74
+ attr_accessor :session_id, :access_token
75
+ attr_reader :base_url
76
+
77
+ # @param base_url [String] override the origin
78
+ # @param session_id [String, nil] resume an existing agent session
79
+ # @param access_token [String, nil] OAuth token, only needed by #track
80
+ # @param timeout [Numeric] seconds
81
+ # @param cli [Boolean] identify as the CLI rather than the library
82
+ def initialize(base_url: DEFAULT_BASE, session_id: nil, access_token: nil, timeout: 30, cli: false)
83
+ @base_url = base_url.to_s.sub(%r{/+\z}, "")
84
+ @session_id = session_id
85
+ @access_token = access_token
86
+ @timeout = timeout
87
+ @client = cli ? "ruby-cli" : "ruby-sdk"
88
+ end
89
+
90
+ # ---- catalog -------------------------------------------------------
91
+
92
+ # Search the catalog, or browse it when +query+ is nil.
93
+ #
94
+ # Mints the session id every later call needs, so this is almost always the
95
+ # first call you make.
96
+ def search(query = nil, category: nil, sort: nil, limit: nil, page: nil)
97
+ post("/search", "query" => query, "category" => category, "sort" => sort, "limit" => limit, "page" => page)
98
+ end
99
+
100
+ # Full detail for product ids or variant ids, mixed freely.
101
+ def product(ids)
102
+ post("/product", "ids" => Array(ids).map(&:to_s))
103
+ end
104
+
105
+ # ---- cart ----------------------------------------------------------
106
+
107
+ # Add a VARIANT — not a product. A product with five sizes has five
108
+ # variants and five prices, and adding a product id is the most common
109
+ # mistake made against this API.
110
+ def add_to_cart(variant_id, quantity = 1)
111
+ post("/cart", "action" => "add", "variant_id" => variant_id, "quantity" => quantity)
112
+ end
113
+
114
+ def update_cart_item(variant_id, quantity)
115
+ post("/cart", "action" => "update", "variant_id" => variant_id, "quantity" => quantity)
116
+ end
117
+
118
+ def remove_from_cart(variant_id)
119
+ post("/cart", "action" => "remove", "variant_id" => variant_id)
120
+ end
121
+
122
+ def view_cart
123
+ post("/cart", "action" => "view")
124
+ end
125
+
126
+ # Set the email and address; returns the cart with +shipping_options+
127
+ # priced against it.
128
+ #
129
+ # Required: email (once), line_one, city, postal_code. A missing one raises
130
+ # ApparelMonster::Error naming all of them at once, with the aliases each
131
+ # is accepted under.
132
+ def set_billing(address = {})
133
+ post("/cart", stringify(address).merge("action" => "billing"))
134
+ end
135
+
136
+ def set_shipping(shipping_id, address = {})
137
+ post("/cart", stringify(address).merge("action" => "shipping", "shipping_id" => shipping_id))
138
+ end
139
+
140
+ def apply_coupon(code)
141
+ post("/cart", "action" => "coupon", "coupon_code" => code)
142
+ end
143
+
144
+ # Attach a payment token. The field is +token+ — not +payment_token+, not
145
+ # +card_token+. Demo store on a test gateway: any non-empty string is
146
+ # accepted and nothing is ever charged.
147
+ def set_payment(token)
148
+ post("/cart", "action" => "payment", "token" => token)
149
+ end
150
+
151
+ # ---- checkout ------------------------------------------------------
152
+
153
+ # Place the order. Idempotent per session.
154
+ def checkout(callback_url: nil)
155
+ post("/checkout", "callback_url" => callback_url)
156
+ end
157
+
158
+ # A hosted Apple Pay / Google Pay link, for when a human can tap.
159
+ #
160
+ # The shortcut past the whole cart flow: hand it variant ids and it returns
161
+ # a URL that collects the shopper's email, address and card on their own
162
+ # device, so your agent never handles any of them.
163
+ def wallet(items)
164
+ normalised = Array(items).map do |item|
165
+ item.is_a?(Hash) ? stringify(item) : { "variant_id" => item.to_s, "quantity" => 1 }
166
+ end
167
+ post("/wallet", "items" => normalised)
168
+ end
169
+
170
+ # Order status and shipments. The only call that needs OAuth.
171
+ def track(params = {})
172
+ post("/track", stringify(params))
173
+ end
174
+
175
+ # ---- price watch ---------------------------------------------------
176
+
177
+ # Be told when a variant reaches a target price. Answers 202 with a
178
+ # +poll_url+ and a +job_id+; a target at or above today's price is not
179
+ # queued at all and comes back +already_met+.
180
+ def watch_price(variant_id, target_price, callback_url: nil)
181
+ post("/price-watch",
182
+ "variant_id" => variant_id, "target_price" => target_price, "callback_url" => callback_url)
183
+ end
184
+
185
+ def price_watch(watch_id)
186
+ request(Net::HTTP::Get, "#{@base_url}#{API}/price-watch/#{URI.encode_www_form_component(watch_id)}")
187
+ end
188
+
189
+ # ---- batch ---------------------------------------------------------
190
+
191
+ # Up to 10 operations in one round trip, executed in order. Each result
192
+ # carries its own status: the batch answers 200 whenever it ran, even if
193
+ # everything inside it failed.
194
+ def batch(operations, stop_on_error: false)
195
+ raise ArgumentError, "batch takes at most 10 operations" if operations.length > 10
196
+
197
+ post("/batch", "operations" => operations, "stop_on_error" => stop_on_error)
198
+ end
199
+
200
+ # ---- natural language ----------------------------------------------
201
+
202
+ # Ask in plain language (NLWeb); returns schema.org Product items. There is
203
+ # no model behind it — it runs the same catalog search — which is why it
204
+ # returns nothing rather than inventing a product.
205
+ def ask(query, limit: nil)
206
+ params = { "query" => query }
207
+ params["limit"] = limit if limit
208
+ request(Net::HTTP::Get, "#{@base_url}/ask?#{URI.encode_www_form(params)}")
209
+ end
210
+
211
+ # The same question, streamed: yields [event, data] as +start+, one
212
+ # +result+ per hit, then +complete+.
213
+ def ask_stream(query, limit: nil)
214
+ return enum_for(:ask_stream, query, limit: limit) unless block_given?
215
+
216
+ params = { "query" => query, "streaming" => "true" }
217
+ params["limit"] = limit if limit
218
+ uri = URI("#{@base_url}/ask?#{URI.encode_www_form(params)}")
219
+
220
+ http(uri).request(get_request(uri, "text/event-stream")) do |response|
221
+ event = "message"
222
+ data = +""
223
+ response.read_body do |chunk|
224
+ chunk.each_line do |raw|
225
+ line = raw.chomp
226
+ if line.start_with?("event:")
227
+ event = line[6..].strip
228
+ elsif line.start_with?("data:")
229
+ data << line[5..].strip
230
+ elsif line.empty?
231
+ # A blank line terminates an SSE frame; anything before one is a
232
+ # partial frame and must not be yielded.
233
+ unless data.empty?
234
+ begin
235
+ yield [event, JSON.parse(data)]
236
+ rescue JSON::ParserError
237
+ nil
238
+ end
239
+ end
240
+ event = "message"
241
+ data = +""
242
+ end
243
+ end
244
+ end
245
+ end
246
+ end
247
+
248
+ # ---- plumbing ------------------------------------------------------
249
+
250
+ def headers(accept = "application/json")
251
+ suffix = @client == "ruby-cli" ? "-cli" : ""
252
+ result = {
253
+ "Content-Type" => "application/json",
254
+ "Accept" => accept,
255
+ # Two spellings of one fact: the User-Agent is what the edge sees
256
+ # without the application being involved, X-Agent-Client is what
257
+ # survives an environment that rewrites User-Agent.
258
+ "User-Agent" => "apparel-monster-ruby#{suffix}/#{VERSION} (+https://github.com/mluggy/apparel-monster-dev)",
259
+ "X-Agent-Client" => "#{@client}/#{VERSION}"
260
+ }
261
+ result["Authorization"] = "Bearer #{@access_token}" if @access_token
262
+ result
263
+ end
264
+
265
+ private
266
+
267
+ def stringify(hash)
268
+ (hash || {}).each_with_object({}) { |(k, v), out| out[k.to_s] = v }
269
+ end
270
+
271
+ def with_session(body)
272
+ return body unless @session_id
273
+
274
+ { "session" => { "id" => @session_id } }.merge(body)
275
+ end
276
+
277
+ def post(path, body = {})
278
+ payload = with_session(body.reject { |_k, v| v.nil? })
279
+ request(Net::HTTP::Post, "#{@base_url}#{API}#{path}", JSON.generate(payload))
280
+ end
281
+
282
+ def http(uri)
283
+ client = Net::HTTP.new(uri.host, uri.port)
284
+ client.use_ssl = uri.scheme == "https"
285
+ client.open_timeout = @timeout
286
+ client.read_timeout = @timeout
287
+ client
288
+ end
289
+
290
+ def get_request(uri, accept)
291
+ Net::HTTP::Get.new(uri).tap { |r| headers(accept).each { |k, v| r[k] = v } }
292
+ end
293
+
294
+ def request(verb, url, body = nil)
295
+ uri = URI(url)
296
+ req = verb.new(uri)
297
+ headers.each { |k, v| req[k] = v }
298
+ req.body = body if body
299
+
300
+ response = http(uri).request(req)
301
+ parsed = begin
302
+ JSON.parse(response.body.to_s)
303
+ rescue JSON::ParserError
304
+ nil
305
+ end
306
+
307
+ raise Error.new(response.code.to_i, parsed, url) unless response.is_a?(Net::HTTPSuccess)
308
+
309
+ # The session arrives on the first search and is reused from then on.
310
+ if parsed.is_a?(Hash) && parsed.dig("session", "id")
311
+ @session_id = parsed["session"]["id"]
312
+ end
313
+
314
+ parsed
315
+ end
316
+ end
317
+ end
metadata ADDED
@@ -0,0 +1,57 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: apparel-monster
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Michael Lugassy
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-09-16 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: |
14
+ Search the catalog, build a cart, place an order, mint an Apple Pay / Google Pay
15
+ link, watch a price, or ask in natural language. No authentication: every call
16
+ except order history is anonymous. Standard library only, no runtime dependencies.
17
+ apparel.monster is a demo storefront — orders are placed against a test gateway
18
+ and nothing is ever fulfilled.
19
+ email:
20
+ executables:
21
+ - apparel-monster
22
+ extensions: []
23
+ extra_rdoc_files: []
24
+ files:
25
+ - LICENSE
26
+ - exe/apparel-monster
27
+ - lib/apparel_monster.rb
28
+ - lib/apparel_monster/version.rb
29
+ homepage: https://apparel.monster/developers
30
+ licenses:
31
+ - MIT
32
+ metadata:
33
+ homepage_uri: https://apparel.monster/developers
34
+ source_code_uri: https://github.com/mluggy/apparel-monster-dev
35
+ bug_tracker_uri: https://github.com/mluggy/apparel-monster-dev/issues
36
+ documentation_uri: https://apparel.monster/developers
37
+ rubygems_mfa_required: 'false'
38
+ post_install_message:
39
+ rdoc_options: []
40
+ require_paths:
41
+ - lib
42
+ required_ruby_version: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: 2.7.0
47
+ required_rubygems_version: !ruby/object:Gem::Requirement
48
+ requirements:
49
+ - - ">="
50
+ - !ruby/object:Gem::Version
51
+ version: '0'
52
+ requirements: []
53
+ rubygems_version: 3.0.3.1
54
+ signing_key:
55
+ specification_version: 4
56
+ summary: SDK and CLI for the Apparel Monster agent commerce API.
57
+ test_files: []