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,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