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.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +68 -0
- data/.rubocop.yml +70 -0
- data/CHANGELOG.md +65 -0
- data/LICENSE.txt +21 -0
- data/README.md +387 -0
- data/Rakefile +17 -0
- data/exe/smily +17 -0
- data/lib/smily_cli/base_command.rb +134 -0
- data/lib/smily_cli/cli.rb +205 -0
- data/lib/smily_cli/cli_options.rb +70 -0
- data/lib/smily_cli/client.rb +291 -0
- data/lib/smily_cli/color.rb +53 -0
- data/lib/smily_cli/commands/auth_command.rb +171 -0
- data/lib/smily_cli/commands/config_command.rb +159 -0
- data/lib/smily_cli/commands/mcp_command.rb +195 -0
- data/lib/smily_cli/completion.rb +85 -0
- data/lib/smily_cli/config.rb +192 -0
- data/lib/smily_cli/context.rb +278 -0
- data/lib/smily_cli/errors.rb +143 -0
- data/lib/smily_cli/formatters.rb +222 -0
- data/lib/smily_cli/mcp/client.rb +200 -0
- data/lib/smily_cli/oauth.rb +141 -0
- data/lib/smily_cli/query_options.rb +164 -0
- data/lib/smily_cli/redaction.rb +74 -0
- data/lib/smily_cli/registry.rb +175 -0
- data/lib/smily_cli/resource.rb +42 -0
- data/lib/smily_cli/resource_command.rb +140 -0
- data/lib/smily_cli/result.rb +149 -0
- data/lib/smily_cli/version.rb +5 -0
- data/lib/smily_cli.rb +37 -0
- data/sig/smily_cli.rbs +21 -0
- metadata +157 -0
|
@@ -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
|