smily_cli 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,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmilyCli
4
+ # Turns user-facing flags (`--filter status=booked`, `--query per_page=100`,
5
+ # `--fields id,name`, `--include availability`) into the flat query hash the
6
+ # API v3 expects. v3 filtering is plain query parameters, so `--filter` and
7
+ # `--query` share one mechanism; `--filter` simply reads as intent.
8
+ module QueryOptions
9
+ # Comparison operators the API v3 filter DSL accepts (mirrors the operators
10
+ # reported per attribute by the `resource_schema` / `api_v3_resource_schema`
11
+ # tool). Used to recognize the `attribute[op]=value` filter syntax.
12
+ OPERATORS = %w[eq not_eq gt gteq lt lteq in matches does_not_match].freeze
13
+
14
+ module_function
15
+
16
+ # Parse `key=value` strings into a hash. A repeated key accumulates into an
17
+ # array (so `--filter status=booked --filter status=tentative` sends both).
18
+ #
19
+ # @param pairs [Array<String>]
20
+ # @return [Hash{String=>Object}]
21
+ # @raise [UsageError] when an entry has no `=`
22
+ def parse_pairs(pairs)
23
+ Array(pairs).each_with_object({}) do |pair, acc|
24
+ key, value = split_pair(pair)
25
+ acc[key] = if acc.key?(key)
26
+ Array(acc[key]) << value
27
+ else
28
+ value
29
+ end
30
+ end
31
+ end
32
+
33
+ # Parse `key=value` / `key[op]=value` filter pairs into the *structured*
34
+ # filter object the API v3 list tool accepts (via MCP `api_v3_list`):
35
+ #
36
+ # * a bare (coerced) value for plain equality,
37
+ # * an `{ "op", "value" }` object for a single operator, and
38
+ # * an array of such objects when one attribute carries more than one
39
+ # condition (AND) — e.g. a numeric or date range.
40
+ #
41
+ # Examples:
42
+ # ["currency=EUR"] => { "currency" => "EUR" }
43
+ # ["adults[gteq]=2"] => { "adults" => { "op" => "gteq", "value" => 2 } }
44
+ # ["final_price[gteq]=600",
45
+ # "final_price[lt]=800"] => { "final_price" =>
46
+ # [ { "op" => "gteq", "value" => 600 },
47
+ # { "op" => "lt", "value" => 800 } ] }
48
+ # ["reference[in]=A,B"] => { "reference" => { "op" => "in", "value" => %w[A B] } }
49
+ #
50
+ # Values are coerced (integer/float/boolean) so numeric attributes compare
51
+ # as numbers rather than strings; ISO 8601 timestamps stay strings.
52
+ #
53
+ # @param pairs [Array<String>]
54
+ # @return [Hash{String=>Object}]
55
+ # @raise [UsageError] on a missing `=` or an unknown operator
56
+ def parse_filter(pairs)
57
+ Array(pairs).each_with_object({}) do |pair, acc|
58
+ key, value = split_pair(pair)
59
+ attribute, operator = parse_operator(key)
60
+ merge_condition(acc, attribute, condition_for(operator, value))
61
+ end
62
+ end
63
+
64
+ # Assemble the final query hash for a request from the standard flags.
65
+ #
66
+ # @param fields [Array<String>, nil] sparse fieldset
67
+ # @param include [Array<String>, String, nil] associations to sideload
68
+ # @param filter [Array<String>] `key=value` filter pairs
69
+ # @param query [Array<String>] `key=value` passthrough params
70
+ # @return [Hash{String=>Object}]
71
+ def build(fields: nil, include: nil, filter: [], query: [])
72
+ result = {}
73
+ result.merge!(parse_pairs(query))
74
+ result.merge!(parse_pairs(filter))
75
+ if (inc = normalize_list(include))
76
+ result["include"] = inc.join(",")
77
+ end
78
+ result["fields"] = fields.join(",") if fields && !fields.empty?
79
+ result
80
+ end
81
+
82
+ # @api private
83
+ def split_pair(pair)
84
+ str = pair.to_s
85
+ key, sep, value = str.partition("=")
86
+ raise UsageError, "Invalid key=value pair #{pair.inspect} (expected e.g. status=booked)" if sep.empty?
87
+
88
+ [key.strip, value]
89
+ end
90
+
91
+ # A single filter condition: an `{ op, value }` object when an operator was
92
+ # given, else the bare coerced equality value.
93
+ # @api private
94
+ def condition_for(operator, value)
95
+ return coerce_scalar(value) unless operator
96
+
97
+ { "op" => operator, "value" => coerce_filter_value(operator, value) }
98
+ end
99
+
100
+ # Split an `attribute[op]` key into `[attribute, op]`, validating the
101
+ # operator; a plain `attribute` returns `[attribute, nil]` (equality).
102
+ # @api private
103
+ def parse_operator(key)
104
+ match = key.match(/\A(?<attribute>.+?)\[(?<op>[a-z_]+)\]\z/)
105
+ return [key, nil] unless match
106
+
107
+ operator = match[:op]
108
+ unless OPERATORS.include?(operator)
109
+ raise UsageError,
110
+ "Unknown filter operator #{operator.inspect} in #{key.inspect}. Valid: #{OPERATORS.join(', ')}."
111
+ end
112
+ [match[:attribute], operator]
113
+ end
114
+
115
+ # Add one condition to the filter, arraying (AND) when the attribute already
116
+ # has one. A bare equality value is promoted to an `{ op: "eq" }` object when
117
+ # it must share an array with operator conditions, keeping the array uniform.
118
+ # @api private
119
+ def merge_condition(filter, attribute, condition)
120
+ if filter.key?(attribute)
121
+ existing = filter[attribute]
122
+ list = existing.is_a?(Array) ? existing : [as_operator(existing)]
123
+ filter[attribute] = list << as_operator(condition)
124
+ else
125
+ filter[attribute] = condition
126
+ end
127
+ end
128
+
129
+ # @api private
130
+ def as_operator(condition)
131
+ condition.is_a?(Hash) ? condition : { "op" => "eq", "value" => condition }
132
+ end
133
+
134
+ # `in` takes a comma-separated set; every other operator a single value.
135
+ # @api private
136
+ def coerce_filter_value(operator, value)
137
+ return value.split(",").map { |v| coerce_scalar(v.strip) } if operator == "in"
138
+
139
+ coerce_scalar(value)
140
+ end
141
+
142
+ # Coerce a raw string into the natural JSON scalar so numeric/boolean
143
+ # attributes compare as themselves; anything else (incl. ISO 8601) stays a
144
+ # string.
145
+ # @api private
146
+ def coerce_scalar(value)
147
+ case value
148
+ when /\A-?\d+\z/ then value.to_i
149
+ when /\A-?\d+\.\d+\z/ then Float(value)
150
+ when "true" then true
151
+ when "false" then false
152
+ else value
153
+ end
154
+ end
155
+
156
+ # @api private
157
+ def normalize_list(value)
158
+ return nil if value.nil?
159
+
160
+ list = Array(value).flat_map { |v| v.to_s.split(",") }.map(&:strip).reject(&:empty?)
161
+ list.empty? ? nil : list
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmilyCli
4
+ # Scrubs secrets out of any text before it is written to a log or debug
5
+ # stream. `--verbose` wires the HTTP stacks' debug output through here so that
6
+ # access tokens, client secrets and refresh tokens never reach stderr in the
7
+ # clear (verbose output routinely ends up pasted into tickets/chat).
8
+ module Redaction
9
+ PLACEHOLDER = "[REDACTED]"
10
+
11
+ # Each pattern captures a leading label ($1) that is kept, and a secret
12
+ # value ($2) that is replaced.
13
+ PATTERNS = [
14
+ /(authorization:\s*bearer\s+)(\S+)/i,
15
+ /(bearer\s+)([A-Za-z0-9._-]{6,})/i,
16
+ /(access[_-]?token["'=:\s]+)([^&"'\s]+)/i,
17
+ /(refresh[_-]?token["'=:\s]+)([^&"'\s]+)/i,
18
+ /(client[_-]?secret["'=:\s]+)([^&"'\s]+)/i,
19
+ /(mcp[_-]?token["'=:\s]+)([^&"'\s]+)/i
20
+ ].freeze
21
+
22
+ module_function
23
+
24
+ # Return `text` with any recognized secret replaced by {PLACEHOLDER}.
25
+ #
26
+ # @param text [Object] coerced to String
27
+ # @return [String]
28
+ def scrub(text)
29
+ PATTERNS.reduce(text.to_s) do |acc, pattern|
30
+ acc.gsub(pattern) { "#{Regexp.last_match(1)}#{PLACEHOLDER}" }
31
+ end
32
+ end
33
+
34
+ # Wrap an IO so everything written to it is scrubbed first. Suitable for
35
+ # `Net::HTTP#set_debug_output`, which appends wire data via `<<`/`write`.
36
+ #
37
+ # @param io [IO]
38
+ # @return [ScrubbedIO]
39
+ def stream(io)
40
+ ScrubbedIO.new(io)
41
+ end
42
+
43
+ # An IO-ish wrapper that scrubs every chunk before forwarding it.
44
+ class ScrubbedIO
45
+ # @param io [IO]
46
+ def initialize(io)
47
+ @io = io
48
+ end
49
+
50
+ def write(string)
51
+ @io.write(Redaction.scrub(string))
52
+ end
53
+
54
+ def <<(string)
55
+ write(string)
56
+ self
57
+ end
58
+
59
+ def print(*strings)
60
+ strings.each { |string| write(string) }
61
+ nil
62
+ end
63
+
64
+ def puts(*strings)
65
+ strings.each { |string| @io.puts(Redaction.scrub(string.to_s)) }
66
+ nil
67
+ end
68
+
69
+ def flush
70
+ @io.flush if @io.respond_to?(:flush)
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmilyCli
4
+ # The catalog of BookingSync API v3 resources the CLI exposes as first-class
5
+ # commands. Sourced from the public API reference
6
+ # (https://developers.bookingsync.com/reference). Resources absent here can
7
+ # still be reached with `smily api <method> <path>`.
8
+ module Registry
9
+ # @api private
10
+ # Compact DSL row: [command, group, description, opts]
11
+ # `path` defaults to the command; `readonly` and `singular` are optional.
12
+ ROWS = [
13
+ # --- Bookings ---------------------------------------------------------
14
+ ["bookings", "Bookings", "Reservations and unavailable periods."],
15
+ ["bookings_fees", "Bookings", "Per-booking fees (cleaning, tax, ...)."],
16
+ ["bookings_payments", "Bookings", "Per-booking payment line items."],
17
+ ["bookings_taxes", "Bookings", "Per-booking tax line items."],
18
+ ["bookings_tags", "Bookings", "Booking tags (account taxonomy)."],
19
+ ["booking_comments", "Bookings", "Internal comments on bookings."],
20
+ ["booking_discount_codes", "Bookings", "Booking discount codes configured by the account."],
21
+ ["booking_discount_code_usages", "Bookings", "Records of discount code applications on bookings."],
22
+
23
+ # --- Rentals ----------------------------------------------------------
24
+ ["rentals", "Rentals", "Listings / properties."],
25
+ ["rentals_amenities", "Rentals", "Per-rental amenity availability."],
26
+ ["rentals_fees", "Rentals", "Per-rental fee configuration."],
27
+ ["rentals_taxes", "Rentals", "Per-rental tax configuration."],
28
+ ["rentals_tags", "Rentals", "Account tags applied to rentals."],
29
+ ["rentals_contents_overrides", "Rentals", "Per-application content overrides."],
30
+ ["rental_agreements", "Rentals", "Localized rental agreement contracts."],
31
+ ["rental_cancelation_policies", "Rentals", "Cancelation policies on rentals."],
32
+ ["rental_cancelation_policy_items", "Rentals", "Refund tiers of cancelation policies."],
33
+ ["rental_contacts", "Rentals", "Contacts linked to rentals."],
34
+ ["rental_urls", "Rentals", "External URLs for rentals."],
35
+ ["rental_link_groups", "Rentals", "Groups of linked rentals."],
36
+ ["rental_links", "Rentals", "Rental-to-link-group memberships."],
37
+ ["bathrooms", "Rentals", "Bathroom layout per rental."],
38
+ ["bedrooms", "Rentals", "Bedroom layout per rental."],
39
+ ["living_rooms", "Rentals", "Living room layout per rental."],
40
+ ["photos", "Rentals", "Photos attached to rentals."],
41
+ ["change_overs", "Rentals", "Change-over (turnover) days."],
42
+ ["availabilities", "Rentals", "Availability windows per rental."],
43
+ ["special_offers", "Rentals", "Promotional offers on rentals."],
44
+
45
+ # --- Rates & pricing --------------------------------------------------
46
+ ["rates", "Rates", "Nightly rates per rental/season."],
47
+ ["rates_rules", "Rates", "Pricing rules (min-stay, discounts...)."],
48
+ ["rates_tables", "Rates", "Named pricing strategies."],
49
+ ["seasons", "Rates", "Pricing seasons."],
50
+ ["periods", "Rates", "Date ranges belonging to seasons."],
51
+ ["los_records", "Rates", "Length-of-stay precomputed rates.", { readonly: true }],
52
+ ["nightly_rate_maps", "Rates", "Nightly rate maps per rental."],
53
+ ["mid_term_rate_maps", "Rates", "Mid-term (monthly) rate maps."],
54
+
55
+ # --- Clients & people -------------------------------------------------
56
+ ["clients", "People", "Guests / customers at account level."],
57
+ ["hosts", "People", "Hosts (greeters, on-site staff)."],
58
+ ["contacts", "People", "Address book (cleaners, owners...)."],
59
+ ["inquiries", "People", "Pre-booking inquiries."],
60
+ ["reviews", "People", "Guest reviews of rentals."],
61
+
62
+ # --- Inbox ------------------------------------------------------------
63
+ ["inbox_conversations", "Inbox", "Conversation threads."],
64
+ ["inbox_messages", "Inbox", "Messages within conversations."],
65
+ ["inbox_attachments", "Inbox", "Attachments on inbox messages."],
66
+ ["inbox_participants", "Inbox", "Participants in conversations."],
67
+
68
+ # --- Billing & payments ----------------------------------------------
69
+ ["payments", "Billing", "Top-level payment transactions."],
70
+ ["payment_gateways", "Billing", "Configured payment gateways."],
71
+ ["fees", "Billing", "Account-level fee catalog."],
72
+ ["fees_taxes", "Billing", "Joins between fees and taxes."],
73
+ ["taxes", "Billing", "Account-level tax catalog."],
74
+ ["tax_rules", "Billing", "Rules controlling when taxes apply."],
75
+
76
+ # --- Catalog & account ------------------------------------------------
77
+ ["accounts", "Account", "Accounts installed for the app.", { readonly: true }],
78
+ ["applications", "Account", "Applications registered on the account.", { readonly: true }],
79
+ ["amenities", "Account", "Global amenity catalog.", { readonly: true }],
80
+ ["destinations", "Account", "Destinations for the account's rentals."],
81
+ ["sources", "Account", "Booking sources / channels."],
82
+ ["hosts_reviews", "Account", "Reviews written by hosts about guests.", { path: "host_reviews" }],
83
+ ["webhooks", "Account", "Configured outbound webhooks."]
84
+ ].freeze
85
+ private_constant :ROWS
86
+
87
+ # @return [Array<Resource>] every registered resource, ordered as declared
88
+ def self.all
89
+ @all ||= ROWS.map do |command, group, description, opts|
90
+ opts ||= {}
91
+ Resource.new(
92
+ command: command,
93
+ path: opts[:path] || command,
94
+ singular: opts[:singular] || singularize(command),
95
+ group: group,
96
+ description: description,
97
+ readonly: opts.fetch(:readonly, false)
98
+ )
99
+ end.freeze
100
+ end
101
+
102
+ # @return [Array<String>] every command word, sorted
103
+ def self.commands
104
+ @commands ||= all.map(&:command).sort.freeze
105
+ end
106
+
107
+ # Look up a resource by its command word.
108
+ #
109
+ # @param command [String]
110
+ # @return [Resource, nil]
111
+ def self.find(command)
112
+ index[command.to_s]
113
+ end
114
+
115
+ # Like {find} but raises a helpful {UsageError} when unknown.
116
+ #
117
+ # @param command [String]
118
+ # @return [Resource]
119
+ def self.fetch(command)
120
+ find(command) || raise(UsageError, unknown_message(command))
121
+ end
122
+
123
+ # Registered resources grouped by their {Resource#group}, preserving the
124
+ # declaration order of both groups and members.
125
+ #
126
+ # @return [Hash{String => Array<Resource>}]
127
+ def self.grouped
128
+ all.each_with_object({}) do |resource, groups|
129
+ (groups[resource.group] ||= []) << resource
130
+ end
131
+ end
132
+
133
+ # @api private
134
+ def self.index
135
+ @index ||= all.to_h { |r| [r.command, r] }.freeze
136
+ end
137
+
138
+ # @api private
139
+ def self.singularize(command)
140
+ # Good-enough English singularization for the labels we actually use.
141
+ case command
142
+ when /ies\z/ then command.sub(/ies\z/, "y")
143
+ when /ses\z/ then command.sub(/es\z/, "")
144
+ when /s\z/ then command.sub(/s\z/, "")
145
+ else command
146
+ end
147
+ end
148
+
149
+ # @api private
150
+ def self.unknown_message(command)
151
+ suggestion = commands.min_by { |c| levenshtein(command.to_s, c) }
152
+ base = "Unknown resource #{command.inspect}."
153
+ hint = suggestion ? " Did you mean #{suggestion.inspect}?" : ""
154
+ "#{base}#{hint} Run `smily resources` to list them, or use `smily api`."
155
+ end
156
+ private_class_method :unknown_message
157
+
158
+ # @api private
159
+ def self.levenshtein(from, to)
160
+ row = (0..to.length).to_a
161
+ (1..from.length).each do |i|
162
+ prev = row[0]
163
+ row[0] = i
164
+ (1..to.length).each do |j|
165
+ current = row[j]
166
+ cost = from[i - 1] == to[j - 1] ? 0 : 1
167
+ row[j] = [row[j] + 1, row[j - 1] + 1, prev + cost].min
168
+ prev = current
169
+ end
170
+ end
171
+ row[to.length]
172
+ end
173
+ private_class_method :levenshtein
174
+ end
175
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmilyCli
4
+ # A single API v3 resource the CLI knows about by name: the command word the
5
+ # user types (`rentals`), the HTTP path it maps to (`rentals`, or
6
+ # `booking/discount_codes`), the singular label for help text, and grouping
7
+ # metadata used by `smily resources`.
8
+ #
9
+ # Anything *not* in the {Registry} is still reachable through `smily api`,
10
+ # so the registry is about ergonomics and discoverability, not a hard gate.
11
+ Resource = Struct.new(
12
+ :command, :path, :singular, :group, :description, :readonly,
13
+ keyword_init: true
14
+ ) do
15
+ # The JSON:API top-level key used in request/response bodies for this
16
+ # resource. For v3 this is the pluralized resource name, i.e. the last
17
+ # path segment (`booking/discount_codes` -> `discount_codes`).
18
+ #
19
+ # @return [String]
20
+ def resource_key
21
+ path.split("/").last
22
+ end
23
+
24
+ # @return [Boolean] whether write commands (create/update/delete) apply
25
+ def writable?
26
+ !readonly
27
+ end
28
+
29
+ # Path to the collection endpoint (e.g. "rentals").
30
+ # @return [String]
31
+ def collection_path
32
+ path
33
+ end
34
+
35
+ # Path to a member endpoint (e.g. "rentals/42").
36
+ # @param id [String, Integer]
37
+ # @return [String]
38
+ def member_path(id)
39
+ "#{path}/#{id}"
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmilyCli
4
+ # The set of subcommands every registered resource gets: `list`, `get`, and
5
+ # (unless the resource is read-only) `create`, `update`, `delete`. One bound
6
+ # subclass is generated per {Resource} via {.bind}; the bound class carries
7
+ # the resource so the shared command bodies know which path/key to use.
8
+ class ResourceCommand < BaseCommand
9
+ CLIOptions.connection(self)
10
+ CLIOptions.output(self)
11
+
12
+ # Generated subclasses live here so they have real constant names (Thor
13
+ # derives a command namespace from the class name).
14
+ module Bound; end
15
+
16
+ class << self
17
+ # @return [Resource, nil] the resource this class is bound to
18
+ attr_reader :resource
19
+
20
+ # Build a Thor subcommand class bound to `resource`.
21
+ #
22
+ # @param resource [Resource]
23
+ # @return [Class<ResourceCommand>]
24
+ def bind(resource)
25
+ klass = Class.new(self)
26
+ klass.instance_variable_set(:@resource, resource)
27
+ # Make Thor render help/usage as `smily <command> ...` rather than
28
+ # deriving a namespace from the generated constant name.
29
+ klass.namespace(resource.command)
30
+ klass.remove_command("create", "update", "delete") if resource.readonly
31
+ klass.remove_command("tree")
32
+ Bound.const_set("#{camelize(resource.command)}Command", klass)
33
+ klass
34
+ end
35
+
36
+ # @api private
37
+ def camelize(command)
38
+ command.split(%r{[_/]}).map(&:capitalize).join
39
+ end
40
+ end
41
+
42
+ desc "list", "List records (supports --filter, --fields, pagination)"
43
+ long_desc <<~DESC
44
+ List records for this resource.
45
+
46
+ Filtering uses plain query parameters, e.g.:
47
+ smily bookings list --filter status=booked from=2026-01-01
48
+ smily rentals list --fields id name --limit 10
49
+ smily bookings list --all -o ndjson
50
+
51
+ Use --all to fetch every page, or --limit N to stop after N records.
52
+ DESC
53
+ method_option :filter, type: :array, banner: "k=v", desc: "Filter by query params (key=value)"
54
+ method_option :query, type: :array, banner: "k=v", desc: "Extra raw query params (key=value)"
55
+ method_option :include, type: :array, banner: "assoc", desc: "Sideload associations"
56
+ method_option :limit, type: :numeric, aliases: "-n", desc: "Stop after N records (follows pages)"
57
+ method_option :page, type: :numeric, desc: "Fetch a specific page"
58
+ method_option :per_page, type: :numeric, desc: "Page size"
59
+ method_option :all, type: :boolean, desc: "Fetch every page"
60
+ def list
61
+ query = QueryOptions.build(
62
+ fields: context.fields,
63
+ include: options["include"],
64
+ filter: options["filter"] || [],
65
+ query: options["query"] || []
66
+ )
67
+ result = client.list(
68
+ resource.collection_path,
69
+ query: query,
70
+ limit: options["limit"],
71
+ page: options["page"],
72
+ per_page: options["per_page"],
73
+ all: options["all"]
74
+ )
75
+ render_result(result)
76
+ end
77
+
78
+ desc "get ID", "Get a single record by ID"
79
+ method_option :include, type: :array, banner: "assoc", desc: "Sideload associations"
80
+ def get(id)
81
+ query = QueryOptions.build(fields: context.fields, include: options["include"])
82
+ render_result(client.get(resource.collection_path, id, query: query))
83
+ end
84
+
85
+ desc "create", "Create a record from --data JSON"
86
+ long_desc <<~DESC
87
+ Create a record. Provide the attributes as a JSON object with --data;
88
+ the resource envelope is added for you:
89
+
90
+ smily rentals create --data '{"name":"Villa"}'
91
+ smily clients create --data @client.json
92
+
93
+ For nested creates, point --path at the parent collection (the resource
94
+ envelope key is unchanged):
95
+
96
+ smily bookings create --path rentals/42/bookings --data '{"start_at":"..."}'
97
+ DESC
98
+ method_option :data, type: :string, aliases: "-d", banner: "JSON|@file|-",
99
+ desc: "Attributes as JSON, @file, or - for stdin"
100
+ method_option :path, type: :string, banner: "PATH",
101
+ desc: "Override the endpoint path (for nested creates)"
102
+ def create
103
+ path = options["path"] || resource.collection_path
104
+ render_result(client.create(path, resource.resource_key, require_data))
105
+ end
106
+
107
+ desc "update ID", "Update a record from --data JSON"
108
+ method_option :data, type: :string, aliases: "-d", banner: "JSON|@file|-",
109
+ desc: "Attributes as JSON, @file, or - for stdin"
110
+ def update(id)
111
+ render_result(client.update(resource.collection_path, resource.resource_key, id, require_data))
112
+ end
113
+
114
+ desc "delete ID", "Delete (or cancel) a record by ID"
115
+ method_option :yes, type: :boolean, aliases: "-y", desc: "Skip the confirmation prompt"
116
+ method_option :data, type: :string, aliases: "-d", banner: "JSON|@file|-",
117
+ desc: "Optional JSON body (e.g. cancelation_reason)"
118
+ def delete(id)
119
+ confirm!("Delete #{resource.singular} #{id}?")
120
+ body = options["data"] ? wrap_delete_body(parse_data(options["data"])) : nil
121
+ client.delete(resource.collection_path, id, body: body)
122
+ success("Deleted #{resource.singular} #{id}.")
123
+ end
124
+
125
+ no_commands do
126
+ # @return [Resource] the resource bound to this command class
127
+ def resource
128
+ self.class.resource
129
+ end
130
+
131
+ # Wrap a delete/cancel body in the resource envelope when needed.
132
+ # @api private
133
+ def wrap_delete_body(attrs)
134
+ return attrs if attrs.is_a?(Hash) && attrs.key?(resource.resource_key)
135
+
136
+ { resource.resource_key => [attrs] }
137
+ end
138
+ end
139
+ end
140
+ end