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,192 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module SmilyCli
|
|
7
|
+
# On-disk configuration: a YAML file holding one or more named profiles, each
|
|
8
|
+
# a bag of credentials/settings (token, base_url, refresh_token, client_id,
|
|
9
|
+
# client_secret, ...). Precedence between file, environment and flags lives in
|
|
10
|
+
# {Context}; this class only owns reading and writing the file.
|
|
11
|
+
#
|
|
12
|
+
# File shape:
|
|
13
|
+
# current_profile: default
|
|
14
|
+
# profiles:
|
|
15
|
+
# default:
|
|
16
|
+
# token: "..."
|
|
17
|
+
# base_url: "https://www.bookingsync.com"
|
|
18
|
+
class Config
|
|
19
|
+
# Keys that are meaningful inside a profile. Unknown keys are preserved on
|
|
20
|
+
# save but this list drives validation and `config set` completion.
|
|
21
|
+
KNOWN_KEYS = %w[
|
|
22
|
+
token base_url refresh_token client_id client_secret redirect_uri
|
|
23
|
+
scope account_id mcp_token mcp_url
|
|
24
|
+
].freeze
|
|
25
|
+
|
|
26
|
+
DEFAULT_PROFILE = "default"
|
|
27
|
+
|
|
28
|
+
# @return [String] absolute path to the config file backing this instance
|
|
29
|
+
attr_reader :path
|
|
30
|
+
|
|
31
|
+
# Resolve the default config path, honoring `SMILY_CONFIG` (full path),
|
|
32
|
+
# then `XDG_CONFIG_HOME`, then `~/.config`.
|
|
33
|
+
#
|
|
34
|
+
# @return [String]
|
|
35
|
+
def self.default_path
|
|
36
|
+
return ENV["SMILY_CONFIG"] if ENV["SMILY_CONFIG"] && !ENV["SMILY_CONFIG"].empty?
|
|
37
|
+
|
|
38
|
+
base = ENV.fetch("XDG_CONFIG_HOME", nil)
|
|
39
|
+
base = File.join(Dir.home, ".config") if base.nil? || base.empty?
|
|
40
|
+
File.join(base, "smily", "config.yml")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Load configuration from `path` (missing file yields an empty config).
|
|
44
|
+
#
|
|
45
|
+
# @param path [String]
|
|
46
|
+
# @return [Config]
|
|
47
|
+
def self.load(path = default_path)
|
|
48
|
+
new(path)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @param path [String]
|
|
52
|
+
def initialize(path = self.class.default_path)
|
|
53
|
+
@path = path
|
|
54
|
+
@data = read
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @return [Boolean] whether the backing file exists on disk
|
|
58
|
+
def exist?
|
|
59
|
+
File.exist?(path)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Name of the active profile: explicit `current_profile` or DEFAULT_PROFILE.
|
|
63
|
+
# @return [String]
|
|
64
|
+
def current_profile_name
|
|
65
|
+
@data["current_profile"] || DEFAULT_PROFILE
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @param name [String]
|
|
69
|
+
def current_profile_name=(name)
|
|
70
|
+
@data["current_profile"] = name.to_s
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @return [Array<String>] sorted profile names present in the file
|
|
74
|
+
def profile_names
|
|
75
|
+
profiles.keys.sort
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Fetch a profile's settings hash (never nil).
|
|
79
|
+
#
|
|
80
|
+
# @param name [String, nil] defaults to the current profile
|
|
81
|
+
# @return [Hash{String=>Object}]
|
|
82
|
+
def profile(name = current_profile_name)
|
|
83
|
+
profiles[name.to_s] || {}
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# @param name [String, nil]
|
|
87
|
+
# @return [Boolean]
|
|
88
|
+
def profile?(name = current_profile_name)
|
|
89
|
+
profiles.key?(name.to_s)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Set a single key on a profile (creating the profile if needed).
|
|
93
|
+
#
|
|
94
|
+
# @param key [String, Symbol]
|
|
95
|
+
# @param value [Object]
|
|
96
|
+
# @param profile [String]
|
|
97
|
+
# @return [void]
|
|
98
|
+
def set(key, value, profile: current_profile_name)
|
|
99
|
+
profiles[profile.to_s] ||= {}
|
|
100
|
+
profiles[profile.to_s][key.to_s] = value
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Remove a key from a profile.
|
|
104
|
+
#
|
|
105
|
+
# @return [Object, nil] the removed value
|
|
106
|
+
def unset(key, profile: current_profile_name)
|
|
107
|
+
(profiles[profile.to_s] || {}).delete(key.to_s)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Delete an entire profile.
|
|
111
|
+
#
|
|
112
|
+
# @param name [String]
|
|
113
|
+
# @return [Hash, nil] the removed profile
|
|
114
|
+
def delete_profile(name)
|
|
115
|
+
profiles.delete(name.to_s)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Persist the config to {#path}, creating the directory and locking down
|
|
119
|
+
# permissions (the file holds secrets).
|
|
120
|
+
#
|
|
121
|
+
# @return [String] the path written
|
|
122
|
+
def save
|
|
123
|
+
dir = File.dirname(path)
|
|
124
|
+
created_dir = !File.directory?(dir)
|
|
125
|
+
FileUtils.mkdir_p(dir)
|
|
126
|
+
# Only tighten a directory we created, so we never clobber the perms of a
|
|
127
|
+
# pre-existing (possibly shared) parent.
|
|
128
|
+
File.chmod(0o700, dir) if created_dir
|
|
129
|
+
# Create with 0600 from the start so there is no world-readable window;
|
|
130
|
+
# re-chmod covers a pre-existing file whose perms may have drifted.
|
|
131
|
+
File.open(path, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |file|
|
|
132
|
+
file.write(YAML.dump(prune(@data)))
|
|
133
|
+
end
|
|
134
|
+
File.chmod(0o600, path)
|
|
135
|
+
path
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# @return [Hash{String=>Hash}] the profiles map (mutable)
|
|
139
|
+
def profiles
|
|
140
|
+
@data["profiles"] ||= {}
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private
|
|
144
|
+
|
|
145
|
+
def read
|
|
146
|
+
return {} unless File.exist?(path)
|
|
147
|
+
|
|
148
|
+
parsed = YAML.safe_load_file(path, permitted_classes: [], aliases: false)
|
|
149
|
+
data = parsed.is_a?(Hash) ? parsed : {}
|
|
150
|
+
validate_shape!(data)
|
|
151
|
+
data
|
|
152
|
+
rescue Psych::SyntaxError => e
|
|
153
|
+
raise ConfigurationError, "Config file #{path} is not valid YAML: #{e.message}"
|
|
154
|
+
rescue Psych::Exception => e
|
|
155
|
+
# A structurally valid YAML file can still carry a value safe_load refuses
|
|
156
|
+
# (a bare date/time -> DisallowedClass, an alias -> AliasesNotEnabled).
|
|
157
|
+
# Surface it as a clean CLI error instead of an uncaught Psych backtrace.
|
|
158
|
+
raise ConfigurationError,
|
|
159
|
+
"Config file #{path} has an unsupported value (#{e.message}). " \
|
|
160
|
+
"Quote any value that looks like a date, time, or symbol."
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Guard against a well-formed-YAML-but-wrong-shape config (e.g. a hand edit
|
|
164
|
+
# leaving `profiles: []`), which would otherwise blow up with a raw
|
|
165
|
+
# NoMethodError deep in a later command instead of a clean CLI error.
|
|
166
|
+
def validate_shape!(data)
|
|
167
|
+
profiles = data["profiles"]
|
|
168
|
+
return if profiles.nil?
|
|
169
|
+
|
|
170
|
+
unless profiles.is_a?(Hash)
|
|
171
|
+
raise ConfigurationError,
|
|
172
|
+
"Config file #{path}: `profiles` must be a mapping of name => settings, got #{profiles.class}."
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
profiles.each do |name, settings|
|
|
176
|
+
next if settings.nil? || settings.is_a?(Hash)
|
|
177
|
+
|
|
178
|
+
raise ConfigurationError,
|
|
179
|
+
"Config file #{path}: profile #{name.inspect} must be a mapping of settings, got #{settings.class}."
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Drop empty profiles/containers so we never write noise.
|
|
184
|
+
def prune(data)
|
|
185
|
+
copy = data.dup
|
|
186
|
+
if copy["profiles"].is_a?(Hash)
|
|
187
|
+
copy["profiles"] = copy["profiles"].reject { |_, v| v.nil? || (v.respond_to?(:empty?) && v.empty?) }
|
|
188
|
+
end
|
|
189
|
+
copy
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module SmilyCli
|
|
6
|
+
# Resolves effective settings for a command from three layers, highest wins:
|
|
7
|
+
#
|
|
8
|
+
# 1. explicit flags (the Thor `options` hash)
|
|
9
|
+
# 2. environment variables (`SMILY_*`, with `BOOKINGSYNC_*` fallbacks)
|
|
10
|
+
# 3. the selected config profile
|
|
11
|
+
#
|
|
12
|
+
# A Context is the single object a command needs: it exposes the resolved
|
|
13
|
+
# token/base_url/output settings and can build a {Client}. It never performs
|
|
14
|
+
# I/O beyond reading the config file.
|
|
15
|
+
class Context
|
|
16
|
+
DEFAULT_BASE_URL = "https://www.bookingsync.com"
|
|
17
|
+
OUTPUT_FORMATS = %w[table json yaml csv ndjson jsonl].freeze
|
|
18
|
+
DEFAULT_MAX_PAGES = 1000
|
|
19
|
+
|
|
20
|
+
# @return [Config]
|
|
21
|
+
attr_reader :config
|
|
22
|
+
# @return [Hash] the raw flags this context was built from
|
|
23
|
+
attr_reader :options
|
|
24
|
+
|
|
25
|
+
# Build a Context from a Thor options hash.
|
|
26
|
+
#
|
|
27
|
+
# @param options [Hash] parsed CLI options (string or symbol keys)
|
|
28
|
+
# @param config [Config] loaded configuration
|
|
29
|
+
# @return [Context]
|
|
30
|
+
def self.from(options, config: nil)
|
|
31
|
+
opts = options.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
|
|
32
|
+
cfg = config || Config.load
|
|
33
|
+
if (profile = opts["profile"] || ENV.fetch("SMILY_PROFILE", nil))
|
|
34
|
+
cfg.current_profile_name = profile
|
|
35
|
+
end
|
|
36
|
+
new(opts, cfg)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @param options [Hash{String=>Object}]
|
|
40
|
+
# @param config [Config]
|
|
41
|
+
def initialize(options, config)
|
|
42
|
+
@options = options
|
|
43
|
+
@config = config
|
|
44
|
+
apply_color_setting
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [String] the profile these settings resolve against
|
|
48
|
+
def profile_name
|
|
49
|
+
config.current_profile_name
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The OAuth access token. Resolution order applies.
|
|
53
|
+
#
|
|
54
|
+
# @return [String, nil]
|
|
55
|
+
def token
|
|
56
|
+
resolve("token", env: %w[SMILY_TOKEN BOOKINGSYNC_TOKEN SMILY_ACCESS_TOKEN])
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Like {#token} but raises when nothing is configured.
|
|
60
|
+
#
|
|
61
|
+
# @return [String]
|
|
62
|
+
def token!
|
|
63
|
+
token || raise(ConfigurationError, <<~MSG.strip)
|
|
64
|
+
No API token configured. Provide one with --token, set SMILY_TOKEN, or run:
|
|
65
|
+
smily config set token <ACCESS_TOKEN>
|
|
66
|
+
You can obtain a token with `smily auth client-credentials` or `smily auth exchange`.
|
|
67
|
+
MSG
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# API base URL (no trailing `/api/v3`; the client appends that).
|
|
71
|
+
#
|
|
72
|
+
# @return [String]
|
|
73
|
+
def base_url
|
|
74
|
+
resolve("base_url", env: %w[SMILY_BASE_URL BOOKINGSYNC_URL]) || DEFAULT_BASE_URL
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Account to scope requests to. Primarily for Client-Credentials-flow tokens
|
|
78
|
+
# that span multiple accounts; sent as the `account_id` query parameter (REST)
|
|
79
|
+
# or `account_id` tool argument (MCP).
|
|
80
|
+
#
|
|
81
|
+
# @return [String, nil]
|
|
82
|
+
def account_id
|
|
83
|
+
resolve("account_id", env: %w[SMILY_ACCOUNT_ID BOOKINGSYNC_ACCOUNT_ID])
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The MCP-mode access token (distinct from the REST API token).
|
|
87
|
+
#
|
|
88
|
+
# @return [String, nil]
|
|
89
|
+
def mcp_token
|
|
90
|
+
resolve("mcp_token", env: %w[SMILY_MCP_TOKEN])
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# @return [String]
|
|
94
|
+
def mcp_token!
|
|
95
|
+
mcp_token || raise(ConfigurationError, <<~MSG.strip)
|
|
96
|
+
No MCP token configured. Provide one with --mcp-token, set SMILY_MCP_TOKEN, or run:
|
|
97
|
+
smily mcp login --token <MCP_TOKEN>
|
|
98
|
+
MSG
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# The MCP server endpoint. Defaults to `<base_url>/mcp`, which is where a
|
|
102
|
+
# BookingSync app mounts its MCP server; override for other servers.
|
|
103
|
+
#
|
|
104
|
+
# @return [String]
|
|
105
|
+
def mcp_url
|
|
106
|
+
explicit = resolve("mcp_url", env: %w[SMILY_MCP_URL])
|
|
107
|
+
return explicit if explicit && !explicit.empty?
|
|
108
|
+
|
|
109
|
+
URI.join("#{base_url.chomp('/')}/", "mcp").to_s
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The output format explicitly requested via `--output` or `SMILY_OUTPUT`,
|
|
113
|
+
# validated, or nil when none was given. Commands whose default isn't the
|
|
114
|
+
# usual table/json (e.g. `api`, which defaults to the raw envelope) use this
|
|
115
|
+
# so they still honor an explicit flag/env choice.
|
|
116
|
+
#
|
|
117
|
+
# @return [String, nil]
|
|
118
|
+
def requested_output_format
|
|
119
|
+
raw = (options["output"] || ENV.fetch("SMILY_OUTPUT", nil))&.to_s
|
|
120
|
+
return nil if raw.nil? || raw.empty?
|
|
121
|
+
|
|
122
|
+
validate_output_format!(raw)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Chosen output format. Defaults to `table` on a TTY, `json` otherwise, so
|
|
126
|
+
# piping produces machine-readable output without a flag.
|
|
127
|
+
#
|
|
128
|
+
# @return [String]
|
|
129
|
+
def output_format
|
|
130
|
+
requested_output_format || default_output_format
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Field selection shared between sparse fieldsets (sent to the API) and
|
|
134
|
+
# column selection (used by table/csv). Accepts repeated flags and/or
|
|
135
|
+
# comma-separated values.
|
|
136
|
+
#
|
|
137
|
+
# @return [Array<String>, nil]
|
|
138
|
+
def fields
|
|
139
|
+
raw = options["fields"]
|
|
140
|
+
return nil if raw.nil?
|
|
141
|
+
|
|
142
|
+
list = Array(raw).flat_map { |v| v.to_s.split(",") }.map(&:strip).reject(&:empty?)
|
|
143
|
+
list.empty? ? nil : list
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# @return [Boolean]
|
|
147
|
+
def verbose?
|
|
148
|
+
truthy(options["verbose"]) || truthy(ENV.fetch("SMILY_VERBOSE", nil))
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# @return [Boolean]
|
|
152
|
+
def quiet?
|
|
153
|
+
truthy(options["quiet"])
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# @return [Boolean] whether ANSI color is active for this run
|
|
157
|
+
def color?
|
|
158
|
+
Color.enabled
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Values used to seed OAuth commands, pulled from flags then profile.
|
|
162
|
+
# @return [String, nil]
|
|
163
|
+
def client_id = resolve("client_id", env: %w[SMILY_CLIENT_ID])
|
|
164
|
+
# @return [String, nil]
|
|
165
|
+
def client_secret = resolve("client_secret", env: %w[SMILY_CLIENT_SECRET])
|
|
166
|
+
# @return [String, nil]
|
|
167
|
+
def refresh_token = resolve("refresh_token", env: %w[SMILY_REFRESH_TOKEN])
|
|
168
|
+
|
|
169
|
+
# Build an API client from these settings.
|
|
170
|
+
#
|
|
171
|
+
# Hard backstop for auto-pagination (`--all` / `--limit`), so a server that
|
|
172
|
+
# keeps advertising a `next` link can't loop forever.
|
|
173
|
+
#
|
|
174
|
+
# @return [Integer]
|
|
175
|
+
def max_pages
|
|
176
|
+
raw = resolve("max_pages", env: %w[SMILY_MAX_PAGES])
|
|
177
|
+
value = raw.to_i
|
|
178
|
+
value.positive? ? value : DEFAULT_MAX_PAGES
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Number of times to retry a `429 Too Many Requests` (waiting for the
|
|
182
|
+
# rate-limit window each time). Off by default, like the GitHub CLI.
|
|
183
|
+
#
|
|
184
|
+
# @return [Integer]
|
|
185
|
+
def retries
|
|
186
|
+
resolve("retry", env: %w[SMILY_RETRY]).to_i.clamp(0, 10)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# @return [Client]
|
|
190
|
+
def client
|
|
191
|
+
warn_insecure_transport(base_url)
|
|
192
|
+
Client.new(
|
|
193
|
+
token: token!,
|
|
194
|
+
base_url: base_url,
|
|
195
|
+
account_id: account_id,
|
|
196
|
+
max_pages: max_pages,
|
|
197
|
+
retries: retries,
|
|
198
|
+
verbose: verbose?
|
|
199
|
+
)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# An OAuth helper bound to this context's base URL.
|
|
203
|
+
#
|
|
204
|
+
# @return [OAuth]
|
|
205
|
+
def oauth
|
|
206
|
+
warn_insecure_transport(base_url)
|
|
207
|
+
OAuth.new(base_url: base_url, verbose: verbose?)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# An MCP client for the configured MCP server and token.
|
|
211
|
+
#
|
|
212
|
+
# @return [Mcp::Client]
|
|
213
|
+
def mcp_client
|
|
214
|
+
warn_insecure_transport(mcp_url)
|
|
215
|
+
Mcp::Client.new(token: mcp_token!, url: mcp_url, verbose: verbose?)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
private
|
|
219
|
+
|
|
220
|
+
# flag ? env ? profile
|
|
221
|
+
def resolve(key, env: [])
|
|
222
|
+
flag = options[key]
|
|
223
|
+
return flag if present?(flag)
|
|
224
|
+
|
|
225
|
+
env.each do |name|
|
|
226
|
+
value = ENV.fetch(name, nil)
|
|
227
|
+
return value if present?(value)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
profile_value = config.profile[key]
|
|
231
|
+
present?(profile_value) ? profile_value : nil
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def validate_output_format!(fmt)
|
|
235
|
+
return fmt if OUTPUT_FORMATS.include?(fmt)
|
|
236
|
+
|
|
237
|
+
raise UsageError, "Unknown output format #{fmt.inspect}. Valid: #{OUTPUT_FORMATS.join(', ')}."
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def default_output_format
|
|
241
|
+
$stdout.respond_to?(:tty?) && $stdout.tty? ? "table" : "json"
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def apply_color_setting
|
|
245
|
+
if truthy(options["no_color"]) || (ENV.fetch("NO_COLOR", nil) && !ENV["NO_COLOR"].empty?)
|
|
246
|
+
Color.enabled = false
|
|
247
|
+
elsif options.key?("color") && truthy(options["color"])
|
|
248
|
+
Color.enabled = true
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Warn (once, to stderr) when credentials would travel over a non-HTTPS
|
|
253
|
+
# endpoint. Like the GitHub CLI, we warn rather than hard-refuse so local
|
|
254
|
+
# test servers still work.
|
|
255
|
+
def warn_insecure_transport(url)
|
|
256
|
+
return if quiet?
|
|
257
|
+
|
|
258
|
+
scheme = URI(url).scheme
|
|
259
|
+
return if scheme == "https"
|
|
260
|
+
|
|
261
|
+
label = scheme.nil? || scheme.empty? ? "an insecure" : scheme.upcase
|
|
262
|
+
warn(Color.yellow("warning: sending credentials over #{label} to #{url} — prefer HTTPS"))
|
|
263
|
+
rescue URI::InvalidURIError
|
|
264
|
+
nil
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def present?(value)
|
|
268
|
+
!value.nil? && !(value.respond_to?(:empty?) && value.empty?)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def truthy(value)
|
|
272
|
+
return false if value.nil?
|
|
273
|
+
return value if [true, false].include?(value)
|
|
274
|
+
|
|
275
|
+
%w[1 true yes on].include?(value.to_s.strip.downcase)
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module SmilyCli
|
|
6
|
+
# Base class for every error the CLI raises deliberately. Command handlers
|
|
7
|
+
# rescue this and print a clean, single-line message instead of a backtrace.
|
|
8
|
+
class Error < StandardError
|
|
9
|
+
# Process exit status to use when this error reaches the top level.
|
|
10
|
+
# @return [Integer]
|
|
11
|
+
def exit_status
|
|
12
|
+
1
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Raised when configuration/credentials are missing or malformed, e.g. no
|
|
17
|
+
# token could be resolved from flags, environment, or the config file.
|
|
18
|
+
class ConfigurationError < Error; end
|
|
19
|
+
|
|
20
|
+
# Raised when the user asks for something the CLI cannot satisfy: an unknown
|
|
21
|
+
# resource, a bad flag combination, unparseable `--data`, etc.
|
|
22
|
+
class UsageError < Error
|
|
23
|
+
def exit_status
|
|
24
|
+
2
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Raised when an MCP interaction fails: a JSON-RPC error envelope, a tool that
|
|
29
|
+
# returns `isError: true`, a missing session, or a transport failure.
|
|
30
|
+
class McpError < Error
|
|
31
|
+
# @return [Integer, nil] JSON-RPC error code, when present
|
|
32
|
+
attr_reader :code
|
|
33
|
+
|
|
34
|
+
# @param message [String]
|
|
35
|
+
# @param code [Integer, nil]
|
|
36
|
+
def initialize(message, code: nil)
|
|
37
|
+
super(message)
|
|
38
|
+
@code = code
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def exit_status
|
|
42
|
+
# -32000 "Unauthorized", -32001 "Session not found" map to auth failures.
|
|
43
|
+
[-32_000, -32_001].include?(code) ? 3 : 1
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Raised when the API returns a non-success HTTP status. Carries the parsed
|
|
48
|
+
# status/body so callers can render a helpful message.
|
|
49
|
+
class ApiError < Error
|
|
50
|
+
# @return [Integer, nil] HTTP status code
|
|
51
|
+
attr_reader :status
|
|
52
|
+
# @return [Object, nil] parsed response body (Hash/Array) when JSON, else the raw string
|
|
53
|
+
attr_reader :body
|
|
54
|
+
# @return [Hash] response headers
|
|
55
|
+
attr_reader :headers
|
|
56
|
+
|
|
57
|
+
# Build an {ApiError} from a {BookingSync::API::Error}.
|
|
58
|
+
#
|
|
59
|
+
# @param error [BookingSync::API::Error]
|
|
60
|
+
# @return [ApiError]
|
|
61
|
+
def self.from_api(error)
|
|
62
|
+
status = error.respond_to?(:status) ? error.status : nil
|
|
63
|
+
headers = error.respond_to?(:headers) ? error.headers : {}
|
|
64
|
+
body = error.respond_to?(:body) ? error.body : nil
|
|
65
|
+
new(summary(status, body), status: status, body: parse_body(body), headers: headers || {})
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @param message [String]
|
|
69
|
+
# @param status [Integer, nil]
|
|
70
|
+
# @param body [Object, nil]
|
|
71
|
+
# @param headers [Hash]
|
|
72
|
+
def initialize(message, status: nil, body: nil, headers: {})
|
|
73
|
+
super(message)
|
|
74
|
+
@status = status
|
|
75
|
+
@body = body
|
|
76
|
+
@headers = headers || {}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def exit_status
|
|
80
|
+
case status
|
|
81
|
+
when 401, 403 then 3
|
|
82
|
+
when 404 then 4
|
|
83
|
+
when 429 then 5
|
|
84
|
+
else 1
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Best-effort extraction of API error detail into a one-line summary.
|
|
89
|
+
#
|
|
90
|
+
# @param status [Integer, nil]
|
|
91
|
+
# @param raw_body [String, nil]
|
|
92
|
+
# @return [String]
|
|
93
|
+
def self.summary(status, raw_body)
|
|
94
|
+
parsed = parse_body(raw_body)
|
|
95
|
+
detail = extract_detail(parsed)
|
|
96
|
+
label = status ? "API request failed (HTTP #{status})" : "API request failed"
|
|
97
|
+
detail ? "#{label}: #{detail}" : label
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# @api private
|
|
101
|
+
def self.parse_body(raw_body)
|
|
102
|
+
return raw_body unless raw_body.is_a?(String)
|
|
103
|
+
return nil if raw_body.empty?
|
|
104
|
+
|
|
105
|
+
JSON.parse(raw_body)
|
|
106
|
+
rescue JSON::ParserError
|
|
107
|
+
raw_body
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Pull a human message out of the many shapes BookingSync error bodies take:
|
|
111
|
+
# { "errors": [ { "title": ..., "detail": ... } ] }
|
|
112
|
+
# { "errors": { "field": ["msg"] } }
|
|
113
|
+
# { "error": "..." } / { "error_description": "..." } (OAuth)
|
|
114
|
+
# @api private
|
|
115
|
+
def self.extract_detail(parsed)
|
|
116
|
+
return parsed if parsed.is_a?(String)
|
|
117
|
+
return nil unless parsed.is_a?(Hash)
|
|
118
|
+
|
|
119
|
+
if (oauth = parsed["error_description"] || parsed["error"])
|
|
120
|
+
return oauth.to_s
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
errors = parsed["errors"] || parsed["error"]
|
|
124
|
+
case errors
|
|
125
|
+
when Array
|
|
126
|
+
errors.map { |e| error_line(e) }.compact.join("; ")
|
|
127
|
+
when Hash
|
|
128
|
+
errors.map { |field, msgs| "#{field}: #{Array(msgs).join(', ')}" }.join("; ")
|
|
129
|
+
when String
|
|
130
|
+
errors
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# @api private
|
|
135
|
+
def self.error_line(entry)
|
|
136
|
+
return entry if entry.is_a?(String)
|
|
137
|
+
return nil unless entry.is_a?(Hash)
|
|
138
|
+
|
|
139
|
+
[entry["title"], entry["detail"]].compact.uniq.join(" - ")
|
|
140
|
+
end
|
|
141
|
+
private_class_method :error_line
|
|
142
|
+
end
|
|
143
|
+
end
|