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,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "yaml"
5
+ require "csv"
6
+
7
+ module SmilyCli
8
+ # Rendering of {Result} records into the output formats the CLI supports.
9
+ # Formatters operate on plain record hashes (string keys) and return a String;
10
+ # the command layer decides where to print it. Table output is human-first
11
+ # (aligned columns, optional color, vertical layout for single records); the
12
+ # rest are pipe-friendly and lossless.
13
+ module Formatters
14
+ # Maximum width of a single table column before truncation.
15
+ MAX_COL_WIDTH = 48
16
+ # Default cap on the number of columns shown in a multi-row table.
17
+ MAX_TABLE_COLUMNS = 8
18
+
19
+ module_function
20
+
21
+ # Render a result in the requested format.
22
+ #
23
+ # @param result [Result]
24
+ # @param format [String] one of {Context::OUTPUT_FORMATS}
25
+ # @param fields [Array<String>, nil] explicit column/field selection
26
+ # @return [String]
27
+ def render(result, format:, fields: nil)
28
+ records = result.records
29
+ payload = result.single? ? records.first : records
30
+
31
+ case format
32
+ when "json" then json(payload)
33
+ when "yaml" then yaml(payload)
34
+ when "ndjson", "jsonl" then ndjson(records)
35
+ when "csv" then csv(records, fields: fields)
36
+ when "table" then table(result, fields: fields)
37
+ else
38
+ raise UsageError, "Unknown output format #{format.inspect}."
39
+ end
40
+ end
41
+
42
+ # @return [String] pretty JSON (object for single, array for collections)
43
+ def json(payload)
44
+ JSON.pretty_generate(payload.nil? ? [] : payload)
45
+ end
46
+
47
+ # @return [String]
48
+ def yaml(payload)
49
+ YAML.dump(deep_stringify(payload.nil? ? [] : payload))
50
+ end
51
+
52
+ # @return [String] one compact JSON object per line
53
+ def ndjson(records)
54
+ records.map { |r| JSON.generate(r) }.join("\n")
55
+ end
56
+
57
+ # @return [String] CSV with a header row; nested values become compact JSON
58
+ def csv(records, fields: nil)
59
+ return "" if records.empty?
60
+
61
+ rows = rowify(records)
62
+ columns = fields || union_columns(rows)
63
+ CSV.generate do |out|
64
+ out << columns
65
+ rows.each do |record|
66
+ out << columns.map { |c| scalarize(record[c]) }
67
+ end
68
+ end
69
+ end
70
+
71
+ # Render a table: a vertical key/value layout for a single record, or an
72
+ # aligned grid for a collection.
73
+ #
74
+ # @param result [Result]
75
+ # @param fields [Array<String>, nil]
76
+ # @return [String]
77
+ def table(result, fields: nil)
78
+ records = rowify(result.records)
79
+ return Color.dim("No records found.") if records.empty?
80
+
81
+ if result.single?
82
+ vertical_table(records.first, fields: fields)
83
+ else
84
+ grid_table(records, fields: fields, meta: table_footer(result, records))
85
+ end
86
+ end
87
+
88
+ # ---- table internals ---------------------------------------------------
89
+
90
+ # Coerce records so table/CSV rendering never assumes a Hash: a non-hash
91
+ # record (a scalar or array an arbitrary MCP tool might return) becomes a
92
+ # single `value` column.
93
+ #
94
+ # @api private
95
+ def rowify(records)
96
+ records.map { |record| record.is_a?(Hash) ? record : { "value" => record } }
97
+ end
98
+
99
+ # @api private
100
+ def vertical_table(record, fields: nil)
101
+ columns = fields || record.keys
102
+ width = columns.map { |c| c.to_s.length }.max || 0
103
+ lines = columns.map do |col|
104
+ label = Color.bold(col.to_s.ljust(width))
105
+ "#{label} #{scalarize(record[col], limit: 200)}"
106
+ end
107
+ lines.join("\n")
108
+ end
109
+
110
+ # @api private
111
+ def grid_table(records, fields:, meta: nil)
112
+ columns = fields || default_columns(records)
113
+ widths = column_widths(records, columns)
114
+ numeric = numeric_columns(records, columns)
115
+
116
+ header = columns.map.with_index do |col, i|
117
+ Color.bold(pad(col.to_s, widths[i], right: numeric[col]))
118
+ end.join(" ")
119
+ rule = Color.dim(columns.map.with_index { |_, i| "-" * widths[i] }.join(" "))
120
+
121
+ rows = records.map do |record|
122
+ columns.map.with_index do |col, i|
123
+ pad(scalarize(record[col]), widths[i], right: numeric[col])
124
+ end.join(" ")
125
+ end
126
+
127
+ [header, rule, *rows, meta].compact.join("\n")
128
+ end
129
+
130
+ # @api private
131
+ def table_footer(result, records)
132
+ count = records.length
133
+ parts = []
134
+ parts << "#{count} #{count == 1 ? 'record' : 'records'}"
135
+ parts << "#{result.total_pages} pages total" if result.total_pages
136
+ hidden = hidden_field_note(records)
137
+ parts << hidden if hidden
138
+ Color.dim("(#{parts.join(', ')})")
139
+ end
140
+
141
+ # @api private
142
+ def hidden_field_note(records)
143
+ return nil if records.empty?
144
+
145
+ total = union_columns(records).length
146
+ shown = default_columns(records).length
147
+ return nil if total <= shown
148
+
149
+ "#{total - shown} more fields — use --fields or -o json"
150
+ end
151
+
152
+ # @api private
153
+ def default_columns(records)
154
+ cols = union_columns(records)
155
+ preferred = %w[id]
156
+ ordered = preferred.select { |c| cols.include?(c) } + (cols - preferred)
157
+ ordered.first(MAX_TABLE_COLUMNS)
158
+ end
159
+
160
+ # @api private
161
+ def union_columns(records)
162
+ records.each_with_object([]) do |record, acc|
163
+ record.each_key { |k| acc << k.to_s unless acc.include?(k.to_s) }
164
+ end
165
+ end
166
+
167
+ # @api private
168
+ def column_widths(records, columns)
169
+ columns.map do |col|
170
+ cells = records.map { |r| scalarize(r[col]).length }
171
+ widest = [col.to_s.length, *cells].max
172
+ [widest, MAX_COL_WIDTH].min
173
+ end
174
+ end
175
+
176
+ # @api private
177
+ def numeric_columns(records, columns)
178
+ columns.each_with_object({}) do |col, acc|
179
+ values = records.map { |r| r[col] }.compact
180
+ acc[col] = !values.empty? && values.all?(Numeric)
181
+ end
182
+ end
183
+
184
+ # @api private
185
+ def pad(text, width, right: false)
186
+ s = truncate(text.to_s, width)
187
+ right ? s.rjust(width) : s.ljust(width)
188
+ end
189
+
190
+ # @api private
191
+ def truncate(text, width)
192
+ return text if text.length <= width
193
+ return text[0, width] if width <= 1
194
+
195
+ "#{text[0, width - 1]}…"
196
+ end
197
+
198
+ # Render a value as a single-line string suitable for a cell/CSV field.
199
+ # Scalars pass through; Hash/Array become compact JSON.
200
+ #
201
+ # @api private
202
+ def scalarize(value, limit: nil)
203
+ s = case value
204
+ when nil then ""
205
+ when String then value
206
+ when Hash, Array then JSON.generate(value)
207
+ else value.to_s
208
+ end
209
+ s = s.gsub(/\s+/, " ").strip
210
+ limit ? truncate(s, limit) : s
211
+ end
212
+
213
+ # @api private
214
+ def deep_stringify(value)
215
+ case value
216
+ when Hash then value.each_with_object({}) { |(k, v), h| h[k.to_s] = deep_stringify(v) }
217
+ when Array then value.map { |v| deep_stringify(v) }
218
+ else value
219
+ end
220
+ end
221
+ end
222
+ end
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module SmilyCli
8
+ module Mcp
9
+ # A minimal MCP client for BookingSync's Streamable-HTTP transport.
10
+ #
11
+ # It speaks JSON-RPC 2.0 over `POST <url>`: it performs the `initialize`
12
+ # handshake (capturing the `Mcp-Session-Id` and negotiated protocol version),
13
+ # then issues `tools/list` / `tools/call` (or any method) within that session.
14
+ # Responses are accepted as either `application/json` or a single SSE
15
+ # `message` frame, so it interoperates with both response modes the server
16
+ # may pick.
17
+ #
18
+ # The session is established lazily on first use and reused for the life of
19
+ # the client (i.e. for a single CLI invocation).
20
+ class Client
21
+ PROTOCOL_VERSION = "2025-06-18"
22
+ SESSION_HEADER = "Mcp-Session-Id"
23
+
24
+ # @param token [String] MCP access token (sent as a Bearer token)
25
+ # @param url [String] MCP server endpoint, e.g. https://www.bookingsync.com/mcp
26
+ # @param verbose [Boolean] log traffic to stderr
27
+ def initialize(token:, url:, verbose: false)
28
+ @token = token
29
+ @uri = URI(url)
30
+ @verbose = verbose
31
+ @id = 0
32
+ @session_id = nil
33
+ @protocol_version = nil
34
+ @server_info = nil
35
+ end
36
+
37
+ # Perform (once) the initialize handshake and return the server's info:
38
+ # { "protocolVersion" =>, "capabilities" =>, "serverInfo" => }.
39
+ #
40
+ # @return [Hash]
41
+ def connect
42
+ return @server_info if @server_info
43
+
44
+ result = rpc("initialize", {
45
+ "protocolVersion" => PROTOCOL_VERSION,
46
+ "capabilities" => {},
47
+ "clientInfo" => { "name" => "smily-cli", "version" => SmilyCli::VERSION }
48
+ }, expect_session: true)
49
+
50
+ @protocol_version = result["protocolVersion"] || PROTOCOL_VERSION
51
+ # Best-effort per MCP spec; the server accepts it as a 202 notification.
52
+ notify("notifications/initialized")
53
+ @server_info = result
54
+ end
55
+
56
+ # @return [Array<Hash>] tool definitions ({ "name", "description", "inputSchema" })
57
+ def list_tools
58
+ connect
59
+ collect_paginated("tools/list", "tools")
60
+ end
61
+
62
+ # Call a tool by name.
63
+ #
64
+ # @param name [String]
65
+ # @param arguments [Hash]
66
+ # @return [Hash] the tool result ({ "content" =>, "structuredContent" =>, "isError" => })
67
+ def call_tool(name, arguments = {})
68
+ connect
69
+ rpc("tools/call", { "name" => name, "arguments" => arguments })
70
+ end
71
+
72
+ # @return [Array<Hash>] MCP resource descriptors, if the server exposes any
73
+ def list_resources
74
+ connect
75
+ collect_paginated("resources/list", "resources")
76
+ end
77
+
78
+ # End the session (best-effort; ignores failures).
79
+ #
80
+ # @return [void]
81
+ def close
82
+ return unless @session_id
83
+
84
+ http_request(Net::HTTP::Delete.new(@uri))
85
+ rescue StandardError
86
+ nil
87
+ ensure
88
+ @session_id = nil
89
+ end
90
+
91
+ private
92
+
93
+ # Issue a JSON-RPC request and return its `result`, raising {McpError} on a
94
+ # JSON-RPC error envelope.
95
+ def rpc(method, params = {}, expect_session: false)
96
+ response = post({ "jsonrpc" => "2.0", "id" => next_id, "method" => method, "params" => params })
97
+ capture_session(response) if expect_session
98
+ envelope = parse_body(response)
99
+
100
+ if (error = envelope["error"])
101
+ raise McpError.new(error["message"] || "MCP error", code: error["code"])
102
+ end
103
+
104
+ envelope["result"] || {}
105
+ end
106
+
107
+ # Fire a notification (no id, no result expected).
108
+ def notify(method, params = {})
109
+ post({ "jsonrpc" => "2.0", "method" => method, "params" => params })
110
+ rescue McpError
111
+ nil # notifications never carry a meaningful error for us
112
+ end
113
+
114
+ # Follow `nextCursor` pagination, concatenating `field` across pages.
115
+ def collect_paginated(method, field)
116
+ items = []
117
+ cursor = nil
118
+ loop do
119
+ params = cursor ? { "cursor" => cursor } : {}
120
+ result = rpc(method, params)
121
+ items.concat(Array(result[field]))
122
+ cursor = result["nextCursor"]
123
+ break if cursor.nil? || cursor.to_s.empty?
124
+ end
125
+ items
126
+ end
127
+
128
+ def post(payload)
129
+ request = Net::HTTP::Post.new(@uri)
130
+ request["Content-Type"] = "application/json"
131
+ request["Accept"] = "application/json, text/event-stream"
132
+ request.body = JSON.generate(payload)
133
+ http_request(request)
134
+ end
135
+
136
+ def http_request(request)
137
+ request["Authorization"] = "Bearer #{@token}"
138
+ request[SESSION_HEADER] = @session_id if @session_id
139
+ request["MCP-Protocol-Version"] = @protocol_version if @protocol_version
140
+
141
+ http = Net::HTTP.new(@uri.host, @uri.port)
142
+ http.use_ssl = @uri.scheme == "https"
143
+ # Scrub the wire dump so the bearer token never hits stderr in clear.
144
+ http.set_debug_output(Redaction.stream($stderr)) if @verbose
145
+
146
+ response = http.request(request)
147
+ check_transport!(response)
148
+ response
149
+ rescue SocketError, Errno::ECONNREFUSED, Timeout::Error => e
150
+ raise McpError, "Could not reach the MCP server at #{@uri}: #{e.message}"
151
+ end
152
+
153
+ def check_transport!(response)
154
+ return if response.is_a?(Net::HTTPSuccess)
155
+
156
+ # Non-2xx: try to surface a JSON-RPC error message, else a generic one.
157
+ detail = begin
158
+ JSON.parse(response.body.to_s).dig("error", "message")
159
+ rescue StandardError
160
+ nil
161
+ end
162
+ code = response.code.to_i
163
+ raise McpError.new(detail || "MCP request failed (HTTP #{response.code})",
164
+ code: code == 401 ? -32_000 : nil)
165
+ end
166
+
167
+ def capture_session(response)
168
+ session = response[SESSION_HEADER] || response["mcp-session-id"]
169
+ @session_id = session if session && !session.empty?
170
+ end
171
+
172
+ # Parse a response body that may be plain JSON or a single SSE frame
173
+ # (`event: message\ndata: {json}\n\n`).
174
+ def parse_body(response)
175
+ body = response.body.to_s
176
+ return {} if body.empty? || response.code.to_i == 202
177
+
178
+ json = sse?(response, body) ? extract_sse_data(body) : body
179
+ JSON.parse(json)
180
+ rescue JSON::ParserError => e
181
+ raise McpError, "Malformed MCP response: #{e.message}"
182
+ end
183
+
184
+ def sse?(response, body)
185
+ content_type = response["content-type"].to_s
186
+ content_type.include?("text/event-stream") || body.lstrip.start_with?("event:", "data:")
187
+ end
188
+
189
+ def extract_sse_data(body)
190
+ body.each_line.filter_map do |line|
191
+ line.start_with?("data:") ? line.sub(/\Adata:\s?/, "").chomp : nil
192
+ end.join
193
+ end
194
+
195
+ def next_id
196
+ @id += 1
197
+ end
198
+ end
199
+ end
200
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module SmilyCli
8
+ # OAuth 2.0 helper for obtaining and refreshing access tokens against the
9
+ # BookingSync token endpoint (`<base_url>/oauth/token`). Supports the flows
10
+ # documented in the API reference: client credentials, refresh token, and the
11
+ # authorization-code exchange (plus building the authorize URL for the
12
+ # interactive step of that flow).
13
+ #
14
+ # Kept deliberately on Net::HTTP so token acquisition has no dependency on the
15
+ # API client's connection stack.
16
+ class OAuth
17
+ # Structured token response.
18
+ Token = Struct.new(
19
+ :access_token, :token_type, :expires_in, :refresh_token, :scope,
20
+ :created_at, :raw, keyword_init: true
21
+ ) do
22
+ # @return [Time, nil] absolute expiry, when derivable
23
+ def expires_at
24
+ return nil unless created_at && expires_in
25
+
26
+ Time.at(created_at.to_i + expires_in.to_i)
27
+ end
28
+ end
29
+
30
+ # @param base_url [String] site base URL
31
+ # @param verbose [Boolean]
32
+ def initialize(base_url: Context::DEFAULT_BASE_URL, verbose: false)
33
+ @base_url = base_url
34
+ @verbose = verbose
35
+ end
36
+
37
+ # Client Credentials flow — an app-level token limited to the `public`
38
+ # scope, able to read across every account that authorized the app.
39
+ #
40
+ # @return [Token]
41
+ def client_credentials(client_id:, client_secret:, scope: nil)
42
+ request_token(
43
+ grant_type: "client_credentials",
44
+ client_id: client_id,
45
+ client_secret: client_secret,
46
+ scope: scope
47
+ )
48
+ end
49
+
50
+ # Refresh an access token using a refresh token.
51
+ #
52
+ # @return [Token]
53
+ def refresh(client_id:, client_secret:, refresh_token:, redirect_uri: nil)
54
+ request_token(
55
+ grant_type: "refresh_token",
56
+ client_id: client_id,
57
+ client_secret: client_secret,
58
+ refresh_token: refresh_token,
59
+ redirect_uri: redirect_uri
60
+ )
61
+ end
62
+
63
+ # Exchange an authorization code (from the redirect) for tokens.
64
+ #
65
+ # @return [Token]
66
+ def authorization_code(client_id:, client_secret:, code:, redirect_uri:)
67
+ request_token(
68
+ grant_type: "authorization_code",
69
+ client_id: client_id,
70
+ client_secret: client_secret,
71
+ code: code,
72
+ redirect_uri: redirect_uri
73
+ )
74
+ end
75
+
76
+ # Build the URL a user visits to authorize the app (step 1 of the
77
+ # authorization-code / implicit flows).
78
+ #
79
+ # @param response_type [String] "code" (server-side) or "token" (implicit)
80
+ # @return [String]
81
+ def authorize_url(client_id:, redirect_uri:, scope: nil, state: nil, account_id: nil, response_type: "code")
82
+ params = {
83
+ client_id: client_id,
84
+ redirect_uri: redirect_uri,
85
+ response_type: response_type,
86
+ scope: scope,
87
+ state: state,
88
+ account_id: account_id
89
+ }.reject { |_, v| v.nil? || v.to_s.empty? }
90
+
91
+ uri = URI.join(@base_url, "/oauth/authorize")
92
+ uri.query = URI.encode_www_form(params)
93
+ uri.to_s
94
+ end
95
+
96
+ private
97
+
98
+ def request_token(params)
99
+ body = params.reject { |_, v| v.nil? || v.to_s.empty? }
100
+ uri = URI.join(@base_url, "/oauth/token")
101
+
102
+ http = Net::HTTP.new(uri.host, uri.port)
103
+ http.use_ssl = uri.scheme == "https"
104
+ # Scrub the wire dump so client_secret / tokens never hit stderr in clear.
105
+ http.set_debug_output(Redaction.stream($stderr)) if @verbose
106
+
107
+ req = Net::HTTP::Post.new(uri)
108
+ req["Accept"] = "application/json"
109
+ req.set_form_data(body)
110
+
111
+ response = http.request(req)
112
+ parse_token(response)
113
+ rescue SocketError, Errno::ECONNREFUSED, Timeout::Error => e
114
+ raise ApiError.new("Could not reach the OAuth endpoint at #{uri}: #{e.message}")
115
+ end
116
+
117
+ def parse_token(response)
118
+ data = begin
119
+ JSON.parse(response.body.to_s)
120
+ rescue JSON::ParserError
121
+ {}
122
+ end
123
+
124
+ unless response.is_a?(Net::HTTPSuccess)
125
+ message = data["error_description"] || data["error"] || response.message
126
+ raise ApiError.new("OAuth request failed (HTTP #{response.code}): #{message}",
127
+ status: response.code.to_i, body: data)
128
+ end
129
+
130
+ Token.new(
131
+ access_token: data["access_token"],
132
+ token_type: data["token_type"],
133
+ expires_in: data["expires_in"],
134
+ refresh_token: data["refresh_token"],
135
+ scope: data["scope"],
136
+ created_at: data["created_at"],
137
+ raw: data
138
+ )
139
+ end
140
+ end
141
+ end