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,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require "json"
5
+
6
+ module SmilyCli
7
+ # Common behavior shared by every Thor command class in the CLI: building the
8
+ # {Context}/{Client} from resolved options, rendering {Result}s in the chosen
9
+ # format, reading `--data` payloads, and printing status/errors on the right
10
+ # stream (data on stdout, everything else on stderr).
11
+ class BaseCommand < Thor
12
+ # Make Thor exit non-zero on its own argument errors.
13
+ def self.exit_on_failure?
14
+ true
15
+ end
16
+
17
+ no_commands do
18
+ # @return [Context] settings resolved from flags/env/profile
19
+ def context
20
+ @context ||= Context.from(options)
21
+ end
22
+
23
+ # @return [Client] API client for the current context
24
+ def client
25
+ @client ||= context.client
26
+ end
27
+
28
+ # Render a {Result} to stdout in the effective (or overridden) format.
29
+ #
30
+ # @param result [Result]
31
+ # @param format [String, nil] override the context format
32
+ # @param fields [Array<String>, nil] override the context fields
33
+ # @return [void]
34
+ def render_result(result, format: nil, fields: nil)
35
+ fmt = format || context.output_format
36
+ output = Formatters.render(result, format: fmt, fields: fields || context.fields)
37
+ emit(output)
38
+ warn_rate_limit(result)
39
+ end
40
+
41
+ # Print data to stdout (kept free of decoration so it stays pipeable).
42
+ #
43
+ # @param string [String]
44
+ # @return [void]
45
+ def emit(string)
46
+ return if string.nil? || string.empty?
47
+
48
+ $stdout.puts(string)
49
+ end
50
+
51
+ # Print an informational line to stderr unless `--quiet`.
52
+ #
53
+ # @param message [String]
54
+ # @return [void]
55
+ def notice(message)
56
+ warn(message) unless context.quiet?
57
+ end
58
+
59
+ # Print a green success line to stderr.
60
+ #
61
+ # @param message [String]
62
+ # @return [void]
63
+ def success(message)
64
+ notice(Color.green(message))
65
+ end
66
+
67
+ # Read and JSON-parse the `--data` option, which may be inline JSON,
68
+ # `@path` to read a file, or `-` to read stdin. Raises when absent.
69
+ #
70
+ # @return [Hash, Array]
71
+ def require_data
72
+ raw = options["data"]
73
+ if raw.nil? || raw.to_s.empty?
74
+ raise UsageError,
75
+ "This command needs a JSON body: pass --data '<json>', --data @file.json, or --data - for stdin."
76
+ end
77
+
78
+ parse_data(raw)
79
+ end
80
+
81
+ # @param raw [String]
82
+ # @return [Object] parsed JSON
83
+ def parse_data(raw)
84
+ content =
85
+ if raw == "-"
86
+ $stdin.read
87
+ elsif raw.start_with?("@")
88
+ read_data_file(raw[1..])
89
+ else
90
+ raw
91
+ end
92
+ JSON.parse(content)
93
+ rescue JSON::ParserError => e
94
+ raise UsageError, "Invalid JSON in --data: #{e.message}"
95
+ end
96
+
97
+ # @param path [String]
98
+ # @return [String]
99
+ def read_data_file(path)
100
+ File.read(File.expand_path(path))
101
+ rescue SystemCallError => e
102
+ raise UsageError, "Could not read --data file #{path.inspect}: #{e.message}"
103
+ end
104
+
105
+ # Guard a destructive action behind confirmation unless `--yes` is set.
106
+ # In a non-interactive context, refuses rather than blocking.
107
+ #
108
+ # @param message [String]
109
+ # @return [void]
110
+ def confirm!(message)
111
+ return if options["yes"]
112
+
113
+ unless $stdin.respond_to?(:tty?) && $stdin.tty?
114
+ raise UsageError, "#{message} Refusing without confirmation; pass --yes to proceed."
115
+ end
116
+
117
+ $stderr.print("#{message} [y/N] ")
118
+ answer = $stdin.gets&.strip&.downcase
119
+ raise Error, "Aborted." unless %w[y yes].include?(answer)
120
+ end
121
+
122
+ # In verbose mode, surface the remaining rate-limit budget.
123
+ #
124
+ # @param result [Result]
125
+ # @return [void]
126
+ def warn_rate_limit(result)
127
+ return unless context.verbose?
128
+
129
+ remaining = result.rate_limit_remaining
130
+ notice(Color.dim("rate limit remaining: #{remaining}")) if remaining
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require "json"
5
+ require "bookingsync/api" # for the `version` command's BookingSync::API::VERSION
6
+
7
+ module SmilyCli
8
+ # The top-level `smily` command. It wires the reusable resource CRUD commands
9
+ # (one registered subcommand per {Registry} entry), the `auth`/`config`
10
+ # subcommand groups, and a handful of core commands (`api`, `resources`,
11
+ # `whoami`, `completion`, `version`). Business logic lives in the library
12
+ # classes; this class only parses input and dispatches.
13
+ class CLI < BaseCommand
14
+ CLIOptions.connection(self)
15
+ CLIOptions.output(self)
16
+
17
+ package_name "smily"
18
+
19
+ # Single-value global flags that are safe to move past the subcommand.
20
+ HOISTABLE_VALUE_FLAGS = %w[
21
+ --profile --token --base-url --account-id --max-pages --retry
22
+ -o --output --mcp-token --mcp-url
23
+ ].freeze
24
+ # Value-less global flags.
25
+ HOISTABLE_BOOL_FLAGS = %w[-v --verbose -q --quiet --no-color].freeze
26
+
27
+ # Every top-level command word, so normalization can tell a real flag value
28
+ # from a subcommand the user meant to run.
29
+ #
30
+ # @return [Array<String>]
31
+ def self.command_names
32
+ @command_names ||= (
33
+ Registry.commands + %w[api auth config mcp resources whoami completion version help]
34
+ ).freeze
35
+ end
36
+
37
+ # Thor's registered subcommands can't see class options that appear *before*
38
+ # the subcommand word, so `smily -o json rentals list` would drop `-o`. We
39
+ # normalize by moving any leading global flags to the end of the args, where
40
+ # every command re-declares them. (Array-valued flags like `--fields` are
41
+ # left alone and must follow the subcommand.)
42
+ #
43
+ # A leading value flag with no attached `=value` whose next token is missing
44
+ # or is itself a command word is a forgotten value (`smily --token rentals
45
+ # list`). We fail with a clear {UsageError} rather than swallow the
46
+ # subcommand into the flag — which otherwise yields a baffling "command not
47
+ # found" or a request fired with a bogus value. Use `--flag=value` when a
48
+ # value legitimately collides with a command name (e.g. a profile named like
49
+ # a resource).
50
+ #
51
+ # @param argv [Array<String>]
52
+ # @return [Array<String>]
53
+ # @raise [UsageError] when a leading value flag is missing its value
54
+ def self.normalize_argv(argv)
55
+ leading = []
56
+ rest = argv.dup
57
+ until rest.empty?
58
+ token = rest.first
59
+ break if token == "--"
60
+
61
+ name = token.split("=", 2).first
62
+ if HOISTABLE_VALUE_FLAGS.include?(name)
63
+ leading << rest.shift
64
+ next if token.include?("=") # value already attached
65
+
66
+ value = rest.first
67
+ unless takes_value?(value)
68
+ raise UsageError,
69
+ "Missing value for #{name}. Pass it as `#{name} <VALUE>`, or as " \
70
+ "`#{name}=<VALUE>` if the value is also a command name."
71
+ end
72
+ leading << rest.shift
73
+ elsif HOISTABLE_BOOL_FLAGS.include?(token)
74
+ leading << rest.shift
75
+ else
76
+ break
77
+ end
78
+ end
79
+ leading.empty? || rest.empty? ? argv : rest + leading
80
+ end
81
+
82
+ # A token is a flag's value only when it's present and not a command word
83
+ # the user more likely meant to run (a forgotten flag value).
84
+ #
85
+ # @param token [String, nil]
86
+ # @return [Boolean]
87
+ def self.takes_value?(token)
88
+ !token.nil? && !command_names.include?(token)
89
+ end
90
+
91
+ def self.start(given_args = ARGV, config = {})
92
+ super(normalize_argv(Array(given_args)), config)
93
+ end
94
+
95
+ # Register every known resource as a subcommand with list/get/create/... .
96
+ Registry.all.each do |resource|
97
+ verbs = resource.writable? ? "list|get|create|update|delete" : "list|get"
98
+ register(
99
+ ResourceCommand.bind(resource),
100
+ resource.command,
101
+ "#{resource.command} <#{verbs}>",
102
+ resource.description
103
+ )
104
+ end
105
+
106
+ register(Commands::AuthCommand, "auth", "auth <SUBCOMMAND>",
107
+ "Obtain, refresh and inspect OAuth tokens")
108
+ register(Commands::ConfigCommand, "config", "config <SUBCOMMAND>",
109
+ "Manage config profiles and settings")
110
+ register(Commands::McpCommand, "mcp", "mcp <SUBCOMMAND>",
111
+ "Talk to a BookingSync MCP server (Model Context Protocol)")
112
+
113
+ desc "api METHOD PATH", "Make a raw request to any API v3 endpoint"
114
+ long_desc <<~DESC
115
+ Escape hatch for endpoints (or verbs) the resource commands don't cover.
116
+ The body is sent verbatim — no envelope wrapping.
117
+
118
+ smily api get rentals --query per_page=5
119
+ smily api get bookings/123
120
+ smily api post rentals/42/bookings --data @booking.json
121
+ smily api get rentals --paginate -o ndjson
122
+
123
+ By default the raw JSON response is printed. With --paginate, every page
124
+ is fetched and the combined records are printed as a JSON array.
125
+ DESC
126
+ method_option :query, type: :array, banner: "k=v", desc: "Query params (key=value)"
127
+ method_option :data, type: :string, aliases: "-d", banner: "JSON|@file|-", desc: "Request body"
128
+ method_option :paginate, type: :boolean, desc: "Follow pagination and combine records"
129
+ def api(method, path)
130
+ verb = method.to_s.downcase
131
+ unless %w[get post put patch delete head].include?(verb)
132
+ raise UsageError, "Unknown HTTP method #{method.inspect}. Use get, post, put, patch or delete."
133
+ end
134
+
135
+ query = QueryOptions.build(fields: context.fields, query: options["query"] || [])
136
+
137
+ if options["paginate"]
138
+ result = client.list(path, query: query, all: true)
139
+ render_api(result, records: true)
140
+ else
141
+ body = options["data"] ? parse_data(options["data"]) : nil
142
+ result = client.request(verb, path, query: query, body: body)
143
+ render_api(result, records: false)
144
+ end
145
+ end
146
+
147
+ desc "resources [FILTER]", "List the API v3 resources this CLI knows about"
148
+ def resources(filter = nil)
149
+ selected = Registry.all.select { |r| filter.nil? || r.command.include?(filter) }
150
+
151
+ if context.output_format == "table"
152
+ render_resources_table(selected)
153
+ else
154
+ payload = selected.map do |r|
155
+ { "command" => r.command, "path" => r.path, "group" => r.group,
156
+ "description" => r.description, "readonly" => r.readonly }
157
+ end
158
+ render_result(Result.new(records: payload, single: false))
159
+ end
160
+ end
161
+
162
+ desc "whoami", "Show the current application and account (/me)"
163
+ def whoami
164
+ render_result(client.me)
165
+ end
166
+
167
+ desc "completion SHELL", "Print a completion script (bash, zsh or fish)"
168
+ def completion(shell)
169
+ emit(Completion.script(shell))
170
+ end
171
+
172
+ desc "version", "Show version information"
173
+ def version
174
+ emit("smily #{SmilyCli::VERSION} (ruby #{RUBY_VERSION}, bookingsync-api #{BookingSync::API::VERSION})")
175
+ end
176
+ map %w[--version] => :version
177
+
178
+ no_commands do
179
+ # Render an `api` result: honor an explicit non-json format, otherwise
180
+ # print raw JSON (records array when paginating, else the full envelope).
181
+ def render_api(result, records:)
182
+ fmt = context.requested_output_format
183
+ # render_result already surfaces the rate-limit note, so return early to
184
+ # avoid warning about it twice.
185
+ return render_result(result, format: fmt) if fmt && fmt != "json"
186
+
187
+ emit(JSON.pretty_generate(records ? result.records : (result.envelope || {})))
188
+ warn_rate_limit(result)
189
+ end
190
+
191
+ # The grouped, human-readable resource listing (table output).
192
+ def render_resources_table(selected)
193
+ selected.group_by(&:group).each do |group, list|
194
+ emit(Color.bold(group))
195
+ width = list.map { |r| r.command.length }.max
196
+ list.each do |r|
197
+ flag = r.readonly ? Color.dim(" (read-only)") : ""
198
+ emit(" #{r.command.ljust(width)} #{Color.dim(r.description)}#{flag}")
199
+ end
200
+ emit("")
201
+ end
202
+ end
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmilyCli
4
+ # Declares the reusable Thor option groups so every command exposes the same
5
+ # connection and output flags with identical wording. Because Thor does not
6
+ # forward a parent command's `class_option`s to `register`ed subcommands, each
7
+ # command class opts in by calling these helpers in its body.
8
+ module CLIOptions
9
+ module_function
10
+
11
+ # Connection / global behavior flags (token, profile, base URL, verbosity).
12
+ #
13
+ # @param thor [Class<Thor>]
14
+ # @return [void]
15
+ def connection(thor)
16
+ thor.class_option :profile, type: :string, banner: "NAME",
17
+ desc: "Config profile to use (env: SMILY_PROFILE)"
18
+ thor.class_option :token, type: :string, banner: "TOKEN",
19
+ desc: "OAuth access token (env: SMILY_TOKEN)"
20
+ thor.class_option :base_url, type: :string, banner: "URL",
21
+ desc: "API base URL (default: https://www.bookingsync.com)"
22
+ thor.class_option :account_id, type: :string, banner: "ID",
23
+ desc: "Scope requests to an account (for client-credentials tokens)"
24
+ thor.class_option :max_pages, type: :numeric, banner: "N",
25
+ desc: "Cap auto-pagination at N pages (default 1000)"
26
+ thor.class_option :retry, type: :numeric, banner: "N",
27
+ desc: "Retry 429s up to N times, honoring rate-limit reset"
28
+ thor.class_option :verbose, type: :boolean, aliases: "-v",
29
+ desc: "Log HTTP requests/responses to stderr"
30
+ thor.class_option :quiet, type: :boolean, aliases: "-q",
31
+ desc: "Suppress status messages"
32
+ thor.class_option :no_color, type: :boolean,
33
+ desc: "Disable ANSI color"
34
+ end
35
+
36
+ # MCP-mode connection flags. Shares profile/base-url/account/verbosity with
37
+ # {connection} but swaps the REST token for the dedicated MCP credential.
38
+ #
39
+ # @param thor [Class<Thor>]
40
+ # @return [void]
41
+ def mcp(thor)
42
+ thor.class_option :profile, type: :string, banner: "NAME",
43
+ desc: "Config profile to use (env: SMILY_PROFILE)"
44
+ thor.class_option :mcp_token, type: :string, banner: "TOKEN",
45
+ desc: "MCP access token (env: SMILY_MCP_TOKEN)"
46
+ thor.class_option :mcp_url, type: :string, banner: "URL",
47
+ desc: "MCP server endpoint (default: <base-url>/mcp)"
48
+ thor.class_option :base_url, type: :string, banner: "URL",
49
+ desc: "Base URL used to derive the default MCP endpoint"
50
+ thor.class_option :account_id, type: :string, banner: "ID",
51
+ desc: "Scope tool calls to an account"
52
+ thor.class_option :verbose, type: :boolean, aliases: "-v",
53
+ desc: "Log MCP traffic to stderr"
54
+ thor.class_option :quiet, type: :boolean, aliases: "-q",
55
+ desc: "Suppress status messages"
56
+ thor.class_option :no_color, type: :boolean, desc: "Disable ANSI color"
57
+ end
58
+
59
+ # Output-shaping flags (format and field selection).
60
+ #
61
+ # @param thor [Class<Thor>]
62
+ # @return [void]
63
+ def output(thor)
64
+ thor.class_option :output, type: :string, aliases: "-o", banner: "FORMAT",
65
+ desc: "Output: table, json, yaml, csv, ndjson (default: table on a TTY, else json)"
66
+ thor.class_option :fields, type: :array, banner: "a b c",
67
+ desc: "Limit fields/columns (sparse fieldset)"
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,291 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+ require "bookingsync/api"
5
+
6
+ module SmilyCli
7
+ # A thin, generic wrapper over the official {BookingSync::API::Client}. It
8
+ # reuses that client's connection handling, JSON:API serialization, `Link`
9
+ # header pagination and typed errors, and adds exactly what a CLI needs:
10
+ # path-based (rather than method-per-resource) access, cross-page collection,
11
+ # write-body wrapping, and translation of gem errors into {ApiError}.
12
+ #
13
+ # Everything is driven by a resource *path*, so any v3 endpoint is reachable —
14
+ # including ones the {Registry} doesn't list.
15
+ class Client
16
+ # @param token [String] OAuth access token
17
+ # @param base_url [String] site base URL (the client appends `/api/v3`)
18
+ # @param account_id [String, Integer, nil] scope requests to one account.
19
+ # Sent as the `account_id` query parameter on every request, which is how
20
+ # a Client-Credentials-flow token (spanning many accounts) is pinned to a
21
+ # single account. Harmless for single-account (Authorization Code) tokens.
22
+ # @param max_pages [Integer] hard backstop on auto-pagination: stop after
23
+ # this many pages even if the server keeps advertising a `next` link.
24
+ # @param retries [Integer] how many times to retry a `429 Too Many Requests`,
25
+ # waiting for the rate-limit window before each retry.
26
+ # @param max_retry_wait [Integer] cap (seconds) on any single retry wait.
27
+ # @param sleeper [#call] injectable sleep, for tests. Defaults to Kernel#sleep.
28
+ # @param verbose [Boolean] log HTTP traffic to stderr
29
+ def initialize(token:, base_url: Context::DEFAULT_BASE_URL, account_id: nil,
30
+ max_pages: Context::DEFAULT_MAX_PAGES, retries: 0,
31
+ max_retry_wait: 60, sleeper: nil, verbose: false)
32
+ @account_id = account_id
33
+ @max_pages = max_pages
34
+ @retries = retries
35
+ @max_retry_wait = max_retry_wait
36
+ @sleeper = sleeper || ->(seconds) { sleep(seconds) }
37
+ @api = BookingSync::API.new(token, base_url: base_url, logger: build_logger(verbose))
38
+ end
39
+
40
+ # List a collection, transparently following pagination as requested.
41
+ #
42
+ # @param path [String] collection path, e.g. "rentals"
43
+ # @param query [Hash] query parameters (filters, include, fields, ...)
44
+ # @param limit [Integer, nil] stop after this many records (follows pages)
45
+ # @param page [Integer, nil] fetch one specific page (disables following)
46
+ # @param per_page [Integer, nil] page size hint
47
+ # @param all [Boolean] fetch every page
48
+ # @return [Result]
49
+ def list(path, query: {}, limit: nil, page: nil, per_page: nil, all: false)
50
+ q = stringify(query)
51
+ q["per_page"] = per_page if per_page
52
+ # With a limit but no explicit page size, request exactly what we need
53
+ # (capped at the API max of 100) instead of paging at the default 25.
54
+ q["per_page"] = [limit, 100].min if per_page.nil? && limit && page.nil?
55
+ q["page"] = page if page
56
+
57
+ response = call(:get, path, query: q)
58
+ first = Result.from_response(response)
59
+ records = first.records.dup
60
+ last = first
61
+
62
+ last = follow_pages(response, records, limit: limit) if all || (limit && page.nil?)
63
+
64
+ records = records.first(limit) if limit
65
+ Result.new(
66
+ records: records, single: false,
67
+ envelope: last.envelope, key: last.key,
68
+ status: last.status, headers: last.headers
69
+ )
70
+ end
71
+
72
+ # Fetch a single member.
73
+ #
74
+ # @param path [String] collection path
75
+ # @param id [String, Integer]
76
+ # @param query [Hash]
77
+ # @return [Result]
78
+ def get(path, id, query: {})
79
+ response = call(:get, member_path(path, id), query: stringify(query))
80
+ Result.from_response(response, single: true)
81
+ end
82
+
83
+ # Create a member. Wraps attributes into the `{ key: [attrs] }` envelope v3
84
+ # expects, unless the caller already supplied that envelope.
85
+ #
86
+ # @param path [String] collection path
87
+ # @param resource_key [String] JSON:API key (usually the last path segment)
88
+ # @param attributes [Hash, Array] attributes or a ready envelope
89
+ # @param query [Hash]
90
+ # @return [Result]
91
+ def create(path, resource_key, attributes, query: {})
92
+ response = call(:post, path, query: stringify(query), body: wrap_body(resource_key, attributes))
93
+ Result.from_response(response, single: true)
94
+ end
95
+
96
+ # Update a member (HTTP PUT, matching the v3 reference).
97
+ #
98
+ # @return [Result]
99
+ def update(path, resource_key, id, attributes, query: {})
100
+ response = call(:put, member_path(path, id), query: stringify(query), body: wrap_body(resource_key, attributes))
101
+ Result.from_response(response, single: true)
102
+ end
103
+
104
+ # Delete/cancel a member. Returns an empty {Result} on 204.
105
+ #
106
+ # @return [Result]
107
+ def delete(path, id, body: nil, query: {})
108
+ response = call(:delete, member_path(path, id), query: stringify(query), body: body)
109
+ Result.from_response(response, single: true)
110
+ end
111
+
112
+ # Escape hatch: issue an arbitrary request against any path. The body is
113
+ # sent verbatim (no envelope wrapping) so callers stay in full control.
114
+ #
115
+ # @param method [Symbol, String] :get/:post/:put/:patch/:delete
116
+ # @param path [String] path or full URL (base + `/api/v3` are stripped)
117
+ # @param query [Hash]
118
+ # @param body [Hash, String, nil]
119
+ # @return [Result]
120
+ def request(method, path, query: {}, body: nil)
121
+ response = call(method.to_sym, path, query: stringify(query), body: body)
122
+ Result.from_response(response)
123
+ end
124
+
125
+ # Fetch `/me` (current application + account).
126
+ #
127
+ # @return [Result]
128
+ def me
129
+ Result.from_response(call(:get, "me", query: {}), single: true)
130
+ end
131
+
132
+ private
133
+
134
+ # Perform one HTTP call through the underlying gem, mapping its typed errors
135
+ # into {ApiError}. Returns the raw {BookingSync::API::Response} (or nil on
136
+ # 204) so pagination can read `Link` relations.
137
+ def call(method, path, query: {}, body: nil)
138
+ with_error_handling do
139
+ m = method.to_sym
140
+ normalized = normalize_path(path)
141
+ q = with_account_scope(query)
142
+ if %i[get head].include?(m)
143
+ @api.call(m, normalized, query: q)
144
+ else
145
+ @api.call(m, normalized, body, query: q)
146
+ end
147
+ end
148
+ end
149
+
150
+ # Fetch the next page from a `Link`-header relation, through the same error
151
+ # handling / retry as a normal call (the relation otherwise bypasses it).
152
+ def fetch_next(relation)
153
+ with_error_handling { relation.call({}, method: :get) }
154
+ end
155
+
156
+ # Run a request block, mapping gem errors to {ApiError} and retrying on 429
157
+ # (up to @retries times) after waiting for the rate-limit window.
158
+ def with_error_handling
159
+ attempt = 0
160
+ begin
161
+ yield
162
+ rescue BookingSync::API::RateLimitExceeded => e
163
+ raise ApiError.from_api(e) unless attempt < @retries
164
+
165
+ attempt += 1
166
+ @sleeper.call(retry_delay(e.headers))
167
+ retry
168
+ rescue BookingSync::API::Error => e
169
+ raise ApiError.from_api(e)
170
+ rescue Faraday::Error => e
171
+ raise ApiError, "Network error talking to the API: #{e.message}"
172
+ end
173
+ end
174
+
175
+ # Seconds to wait before a retry: prefer `Retry-After`, then the time until
176
+ # `X-RateLimit-Reset`, clamped to [1, @max_retry_wait].
177
+ def retry_delay(headers)
178
+ retry_after = header(headers, "Retry-After").to_i
179
+ return clamp_delay(retry_after) if retry_after.positive?
180
+
181
+ reset = header(headers, "X-RateLimit-Reset").to_i
182
+ return clamp_delay(reset - Time.now.to_i) if reset.positive?
183
+
184
+ 1
185
+ end
186
+
187
+ def clamp_delay(seconds)
188
+ seconds.clamp(1, @max_retry_wait)
189
+ end
190
+
191
+ def header(headers, name)
192
+ return nil unless headers
193
+
194
+ headers[name] || headers[name.downcase]
195
+ end
196
+
197
+ # Walk `next` links, appending records to `records` in place, until the
198
+ # limit is reached, there is no next page, a page comes back empty, or the
199
+ # max-pages backstop trips. Returns the {Result} of the last page fetched.
200
+ def follow_pages(response, records, limit:)
201
+ current = response
202
+ last = Result.from_response(response)
203
+ pages = 1
204
+
205
+ loop do
206
+ break if limit && records.length >= limit
207
+
208
+ nxt = current&.relations&.[](:next)
209
+ break unless nxt
210
+
211
+ # We have another page to fetch but have hit the backstop — warn only
212
+ # here (i.e. when data is genuinely being left behind), never when the
213
+ # page count merely equals @max_pages on a fully-fetched collection.
214
+ if pages >= @max_pages
215
+ warn_truncated(pages)
216
+ break
217
+ end
218
+
219
+ current = fetch_next(nxt)
220
+ pages += 1
221
+ page_result = Result.from_response(current)
222
+ break if page_result.records.empty?
223
+
224
+ records.concat(page_result.records)
225
+ last = page_result
226
+ end
227
+
228
+ last
229
+ end
230
+
231
+ def member_path(path, id)
232
+ "#{path}/#{id}"
233
+ end
234
+
235
+ def warn_truncated(pages)
236
+ warn("warning: stopped after #{pages} pages (max-pages #{@max_pages}); " \
237
+ "results may be truncated — raise --max-pages to fetch more")
238
+ end
239
+
240
+ # Add the configured account_id to a query unless the caller set one.
241
+ def with_account_scope(query)
242
+ return query if @account_id.nil? || @account_id.to_s.empty?
243
+ return query if query.key?("account_id") || query.key?(:account_id)
244
+
245
+ query.merge("account_id" => @account_id)
246
+ end
247
+
248
+ def wrap_body(resource_key, attributes)
249
+ case attributes
250
+ when Array
251
+ { resource_key => attributes }
252
+ when Hash
253
+ return attributes if attributes.key?(resource_key) || attributes.key?(resource_key.to_sym)
254
+
255
+ { resource_key => [attributes] }
256
+ else
257
+ raise UsageError, "Request body must be a JSON object or array of objects."
258
+ end
259
+ end
260
+
261
+ def stringify(query)
262
+ (query || {}).each_with_object({}) do |(k, v), acc|
263
+ next if v.nil?
264
+
265
+ acc[k.to_s] = v
266
+ end
267
+ end
268
+
269
+ # Accept full URLs, `/api/v3/...`, or bare paths and return a bare path.
270
+ def normalize_path(path)
271
+ p = path.to_s.strip
272
+ p = p.sub(%r{\Ahttps?://[^/]+}, "")
273
+ p = p.sub(%r{\A/}, "")
274
+ p.sub(%r{\Aapi/v3/}, "")
275
+ end
276
+
277
+ def build_logger(verbose)
278
+ return nil unless verbose
279
+
280
+ logger = Logger.new($stderr)
281
+ logger.level = Logger::DEBUG
282
+ # The API gem's logger middleware dumps request/response headers verbatim,
283
+ # which includes `Authorization: Bearer <token>` — scrub before writing.
284
+ logger.formatter = lambda do |severity, _time, prog, msg|
285
+ label = prog ? "#{prog}: " : ""
286
+ "[smily #{severity.downcase}] #{label}#{Redaction.scrub(msg)}\n"
287
+ end
288
+ logger
289
+ end
290
+ end
291
+ end