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,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmilyCli
4
+ # Minimal, dependency-free ANSI coloring. Coloring is a process-wide setting
5
+ # (driven by `--no-color`, the `NO_COLOR`/`SMILY_NO_COLOR` env vars, and
6
+ # whether stdout is a TTY) so formatters can call {Color.bold} etc. without
7
+ # threading a flag everywhere.
8
+ module Color
9
+ CODES = {
10
+ reset: 0, bold: 1, dim: 2, red: 31, green: 32, yellow: 33,
11
+ blue: 34, magenta: 35, cyan: 36, gray: 90
12
+ }.freeze
13
+
14
+ class << self
15
+ # @return [Boolean] whether ANSI codes are currently emitted
16
+ attr_accessor :enabled
17
+ end
18
+
19
+ # Decide the default enabled state from the environment. Honors the
20
+ # informal `NO_COLOR` standard and requires an interactive stdout.
21
+ #
22
+ # @param stream [IO] stream that output is written to (defaults to $stdout)
23
+ # @return [Boolean]
24
+ def self.default_enabled?(stream: $stdout)
25
+ return false if ENV["NO_COLOR"] && !ENV["NO_COLOR"].empty?
26
+ return false if ENV["SMILY_NO_COLOR"] && !ENV["SMILY_NO_COLOR"].empty?
27
+
28
+ stream.respond_to?(:tty?) && stream.tty?
29
+ end
30
+
31
+ # Wrap `text` in the given ANSI styles when coloring is enabled.
32
+ #
33
+ # @param text [String]
34
+ # @param styles [Array<Symbol>] any of {CODES}' keys
35
+ # @return [String]
36
+ def self.paint(text, *styles)
37
+ return text.to_s unless enabled
38
+
39
+ codes = styles.flatten.filter_map { |s| CODES[s] }
40
+ return text.to_s if codes.empty?
41
+
42
+ "\e[#{codes.join(';')}m#{text}\e[0m"
43
+ end
44
+
45
+ CODES.each_key do |name|
46
+ next if name == :reset
47
+
48
+ define_singleton_method(name) { |text| paint(text, name) }
49
+ end
50
+ end
51
+
52
+ Color.enabled = Color.default_enabled?
53
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmilyCli
4
+ module Commands
5
+ # OAuth helpers: obtain, refresh, inspect and (optionally) persist access
6
+ # tokens. Client id/secret and refresh token resolve from flags, then env,
7
+ # then the active profile, so once a profile holds the app credentials the
8
+ # flags become optional.
9
+ class AuthCommand < BaseCommand
10
+ CLIOptions.connection(self)
11
+ namespace "auth"
12
+ remove_command "tree"
13
+
14
+ desc "client-credentials", "Get an app token via the Client Credentials flow"
15
+ long_desc <<~DESC
16
+ Request an application-level access token (public scope, valid ~2h)
17
+ that can read across every account which authorized the app.
18
+
19
+ smily auth client-credentials --client-id ID --client-secret SECRET --save
20
+ DESC
21
+ method_option :client_id, type: :string, desc: "Application Client ID (env: SMILY_CLIENT_ID)"
22
+ method_option :client_secret, type: :string, desc: "Application Client Secret (env: SMILY_CLIENT_SECRET)"
23
+ method_option :scope, type: :string, desc: "Requested scope(s), space separated"
24
+ method_option :save, type: :boolean, desc: "Save the token to the active profile"
25
+ def client_credentials
26
+ token = context.oauth.client_credentials(
27
+ client_id: require_option(:client_id, context.client_id, "client id"),
28
+ client_secret: require_option(:client_secret, context.client_secret, "client secret"),
29
+ scope: options["scope"]
30
+ )
31
+ render_token(token, save_client_id: options["client_id"] || context.client_id,
32
+ save_client_secret: options["client_secret"] || context.client_secret)
33
+ end
34
+ map "cc" => "client_credentials"
35
+
36
+ desc "refresh", "Refresh an access token using a refresh token"
37
+ method_option :client_id, type: :string, desc: "Application Client ID"
38
+ method_option :client_secret, type: :string, desc: "Application Client Secret"
39
+ method_option :refresh_token, type: :string, desc: "Refresh token (env: SMILY_REFRESH_TOKEN)"
40
+ method_option :redirect_uri, type: :string, desc: "Original redirect URI (if required)"
41
+ method_option :save, type: :boolean, desc: "Save the new tokens to the active profile"
42
+ def refresh
43
+ token = context.oauth.refresh(
44
+ client_id: require_option(:client_id, context.client_id, "client id"),
45
+ client_secret: require_option(:client_secret, context.client_secret, "client secret"),
46
+ refresh_token: require_option(:refresh_token, context.refresh_token, "refresh token"),
47
+ redirect_uri: options["redirect_uri"]
48
+ )
49
+ render_token(token, save_client_id: options["client_id"] || context.client_id,
50
+ save_client_secret: options["client_secret"] || context.client_secret)
51
+ end
52
+
53
+ desc "authorize-url", "Print the URL to start the Authorization Code flow"
54
+ method_option :client_id, type: :string, required: true, desc: "Application Client ID"
55
+ method_option :redirect_uri, type: :string, required: true, desc: "Registered redirect URI"
56
+ method_option :scope, type: :string, desc: "Requested scope(s), space separated"
57
+ method_option :state, type: :string, desc: "Opaque state value"
58
+ method_option :account_id, type: :string, desc: "Pre-select an account to authorize"
59
+ method_option :response_type, type: :string, default: "code", desc: "code (server) or token (implicit)"
60
+ def authorize_url
61
+ url = context.oauth.authorize_url(
62
+ client_id: options["client_id"],
63
+ redirect_uri: options["redirect_uri"],
64
+ scope: options["scope"],
65
+ state: options["state"],
66
+ account_id: options["account_id"],
67
+ response_type: options["response_type"]
68
+ )
69
+ notice("Open this URL, approve the app, then use `smily auth exchange` with the returned code:")
70
+ emit(url)
71
+ end
72
+
73
+ desc "exchange", "Exchange an authorization code for tokens"
74
+ method_option :client_id, type: :string, desc: "Application Client ID"
75
+ method_option :client_secret, type: :string, desc: "Application Client Secret"
76
+ method_option :code, type: :string, required: true, desc: "Authorization code from the redirect"
77
+ method_option :redirect_uri, type: :string, required: true, desc: "Same redirect URI as the authorize step"
78
+ method_option :save, type: :boolean, desc: "Save the tokens to the active profile"
79
+ def exchange
80
+ token = context.oauth.authorization_code(
81
+ client_id: require_option(:client_id, context.client_id, "client id"),
82
+ client_secret: require_option(:client_secret, context.client_secret, "client secret"),
83
+ code: options["code"],
84
+ redirect_uri: options["redirect_uri"]
85
+ )
86
+ render_token(token, save_client_id: options["client_id"] || context.client_id,
87
+ save_client_secret: options["client_secret"] || context.client_secret)
88
+ end
89
+
90
+ desc "status", "Show whether a token is configured (and optionally validate it)"
91
+ method_option :check, type: :boolean, desc: "Validate the token against /me"
92
+ def status
93
+ token = context.token
94
+ if token.nil?
95
+ notice(Color.yellow("No token configured for profile #{context.profile_name.inspect}."))
96
+ notice("Obtain one with `smily auth client-credentials` or configure it via `smily config set token ...`.")
97
+ return
98
+ end
99
+
100
+ notice("Profile : #{context.profile_name}")
101
+ notice("Base URL: #{context.base_url}")
102
+ notice("Token : #{mask(token)}")
103
+ return unless options["check"]
104
+
105
+ me = client.me.record || {}
106
+ account = me["account"] || me["id"]
107
+ success("Token is valid. Account: #{account}.")
108
+ end
109
+
110
+ desc "token", "Print the resolved access token"
111
+ def token
112
+ value = context.token!
113
+ emit(value)
114
+ end
115
+
116
+ no_commands do
117
+ # Print a token result, respecting -o json, and optionally persist it.
118
+ #
119
+ # Gate the full-payload dump on an *explicitly* requested json format,
120
+ # not the resolved one: non-TTY output defaults to json, so keying off
121
+ # {Context#output_format} would spill the whole payload — including the
122
+ # refresh_token — whenever the token is piped or captured in a command
123
+ # substitution, defeating token-only scripting. Default stays access
124
+ # token only; the full payload is opt-in via `-o json` / SMILY_OUTPUT.
125
+ def render_token(token, save_client_id: nil, save_client_secret: nil)
126
+ if context.requested_output_format == "json"
127
+ emit(JSON.pretty_generate(token.raw))
128
+ else
129
+ emit(token.access_token)
130
+ notice(token_summary(token))
131
+ end
132
+ save_token(token, save_client_id, save_client_secret) if options["save"]
133
+ end
134
+
135
+ def token_summary(token)
136
+ parts = []
137
+ parts << "type=#{token.token_type}" if token.token_type
138
+ parts << "scope=#{token.scope}" if token.scope
139
+ parts << "expires_in=#{token.expires_in}s" if token.expires_in
140
+ parts << "refresh_token=#{mask(token.refresh_token)}" if token.refresh_token
141
+ Color.dim(parts.join(" "))
142
+ end
143
+
144
+ def save_token(token, client_id, client_secret)
145
+ cfg = context.config
146
+ cfg.set("token", token.access_token)
147
+ cfg.set("base_url", context.base_url)
148
+ cfg.set("refresh_token", token.refresh_token) if token.refresh_token
149
+ cfg.set("client_id", client_id) if client_id
150
+ cfg.set("client_secret", client_secret) if client_secret
151
+ cfg.save
152
+ success("Saved token to profile #{cfg.current_profile_name.inspect} (#{cfg.path}).")
153
+ end
154
+
155
+ def require_option(flag, resolved, label)
156
+ value = options[flag.to_s] || resolved
157
+ return value if value && !value.to_s.empty?
158
+
159
+ raise UsageError, "Missing #{label}: pass --#{flag.to_s.tr('_', '-')} or set it in your profile."
160
+ end
161
+
162
+ def mask(secret)
163
+ return "" if secret.nil? || secret.empty?
164
+ return "****" if secret.length <= 4
165
+
166
+ "#{'*' * (secret.length - 4)}#{secret[-4, 4]}"
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmilyCli
4
+ module Commands
5
+ # Manage the on-disk config file and its profiles. Secrets are masked in any
6
+ # human-readable listing; the raw file (mode 0600) remains the source of
7
+ # truth. All subcommands accept `--profile` to target a specific profile.
8
+ class ConfigCommand < BaseCommand
9
+ SECRET_KEYS = %w[token refresh_token client_secret mcp_token].freeze
10
+
11
+ namespace "config"
12
+ remove_command "tree"
13
+ class_option :profile, type: :string, banner: "NAME", desc: "Profile to target"
14
+ class_option :quiet, type: :boolean, aliases: "-q"
15
+ class_option :no_color, type: :boolean
16
+
17
+ desc "path", "Print the config file path"
18
+ def path
19
+ emit(config.path)
20
+ end
21
+
22
+ desc "init", "Create/append a profile interactively"
23
+ long_desc <<~DESC
24
+ Initialize a profile by storing a token (and base URL). Values can be
25
+ passed as flags to run non-interactively:
26
+
27
+ smily config init --token TOKEN
28
+ smily config init --profile staging --base-url https://staging.example.com --token TOKEN
29
+ DESC
30
+ method_option :token, type: :string, desc: "Access token to store"
31
+ method_option :base_url, type: :string, desc: "API base URL to store"
32
+ def init
33
+ name = target_profile
34
+ token = options["token"] || prompt("Access token")
35
+ base_url = options["base_url"] || prompt("Base URL [https://www.bookingsync.com]", default: Context::DEFAULT_BASE_URL)
36
+
37
+ config.set("token", token, profile: name) unless token.to_s.empty?
38
+ config.set("base_url", base_url, profile: name) unless base_url.to_s.empty?
39
+ config.current_profile_name = name unless config.profile_names.length > 1 && options["profile"].nil?
40
+ config.save
41
+ success("Wrote profile #{name.inspect} to #{config.path}.")
42
+ end
43
+
44
+ desc "list", "List profiles and their settings (secrets masked)"
45
+ def list
46
+ if config.profile_names.empty?
47
+ notice("No profiles configured yet. Create one with `smily config init` or `smily config set`.")
48
+ return
49
+ end
50
+
51
+ if context_output_json?
52
+ emit(JSON.pretty_generate(masked_profiles))
53
+ return
54
+ end
55
+
56
+ lines = config.profile_names.map do |name|
57
+ marker = name == config.current_profile_name ? Color.green("* ") : " "
58
+ settings = masked_profile(name).map { |k, v| "#{k}=#{v}" }.join(" ")
59
+ "#{marker}#{Color.bold(name)} #{Color.dim(settings)}"
60
+ end
61
+ emit(lines.join("\n"))
62
+ end
63
+
64
+ desc "get KEY", "Print a single setting from the target profile"
65
+ def get(key)
66
+ value = config.profile(target_profile)[key]
67
+ emit(value.nil? ? "" : value.to_s)
68
+ end
69
+
70
+ desc "set KEY VALUE", "Set a setting on the target profile"
71
+ def set(key, value)
72
+ warn_unknown_key(key)
73
+ config.set(key, coerce(value), profile: target_profile)
74
+ config.save
75
+ success("Set #{key} on profile #{target_profile.inspect}.")
76
+ end
77
+
78
+ desc "unset KEY", "Remove a setting from the target profile"
79
+ def unset(key)
80
+ config.unset(key, profile: target_profile)
81
+ config.save
82
+ success("Unset #{key} on profile #{target_profile.inspect}.")
83
+ end
84
+
85
+ desc "use NAME", "Set the default profile"
86
+ def use(name)
87
+ unless config.profile?(name)
88
+ notice(Color.yellow("Profile #{name.inspect} has no settings yet; selecting it anyway."))
89
+ end
90
+ config.current_profile_name = name
91
+ config.save
92
+ success("Default profile is now #{name.inspect}.")
93
+ end
94
+
95
+ desc "remove NAME", "Delete a profile"
96
+ method_option :yes, type: :boolean, aliases: "-y"
97
+ def remove(name)
98
+ confirm!("Delete profile #{name.inspect}?")
99
+ config.delete_profile(name)
100
+ config.save
101
+ success("Removed profile #{name.inspect}.")
102
+ end
103
+
104
+ no_commands do
105
+ def config
106
+ @config ||= Config.load
107
+ end
108
+
109
+ def target_profile
110
+ options["profile"] || config.current_profile_name
111
+ end
112
+
113
+ def masked_profiles
114
+ config.profile_names.to_h { |n| [n, masked_profile(n)] }
115
+ end
116
+
117
+ def masked_profile(name)
118
+ config.profile(name).each_with_object({}) do |(k, v), h|
119
+ h[k] = SECRET_KEYS.include?(k) ? mask(v.to_s) : v
120
+ end
121
+ end
122
+
123
+ def context_output_json?
124
+ fmt = options["output"] || ENV.fetch("SMILY_OUTPUT", nil)
125
+ fmt.to_s == "json"
126
+ end
127
+
128
+ def warn_unknown_key(key)
129
+ return if Config::KNOWN_KEYS.include?(key)
130
+
131
+ notice(Color.yellow("Note: #{key.inspect} isn't a standard setting (#{Config::KNOWN_KEYS.join(', ')})."))
132
+ end
133
+
134
+ def coerce(value)
135
+ case value
136
+ when /\A-?\d+\z/ then value.to_i
137
+ when "true" then true
138
+ when "false" then false
139
+ else value
140
+ end
141
+ end
142
+
143
+ def prompt(label, default: nil)
144
+ $stderr.print("#{label}: ")
145
+ input = $stdin.gets&.strip
146
+ input = default if input.nil? || input.empty?
147
+ input
148
+ end
149
+
150
+ def mask(secret)
151
+ return "" if secret.nil? || secret.empty?
152
+ return "****" if secret.length <= 4
153
+
154
+ "#{'*' * [secret.length - 4, 4].max}#{secret[-4, 4]}"
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmilyCli
4
+ module Commands
5
+ # `smily mcp` — talk to a BookingSync MCP server (Model Context Protocol)
6
+ # instead of the REST API. This is a separate mode with its own credential
7
+ # (`mcp_token`) and endpoint (`mcp_url`, defaulting to `<base-url>/mcp`).
8
+ #
9
+ # Two layers:
10
+ # * low-level — `tools` (discover) and `call` (invoke) work against any
11
+ # MCP server, whatever tools it exposes;
12
+ # * convenience — `list` / `get` / `schema` / `resources` mirror the REST
13
+ # resource commands, routed through the server's generic dispatcher
14
+ # tools, so the mental model carries over.
15
+ class McpCommand < BaseCommand
16
+ namespace "mcp"
17
+ remove_command "tree"
18
+ CLIOptions.mcp(self)
19
+ CLIOptions.output(self)
20
+
21
+ desc "info", "Handshake with the MCP server and show its capabilities"
22
+ def info
23
+ render_payload(mcp_client.connect)
24
+ end
25
+
26
+ desc "tools", "List the tools the MCP server exposes"
27
+ def tools
28
+ list = mcp_client.list_tools
29
+ if context.output_format == "table"
30
+ rows = list.map { |t| { "name" => t["name"], "description" => t["description"] } }
31
+ render_result(Result.new(records: rows, single: false))
32
+ else
33
+ render_payload(list)
34
+ end
35
+ end
36
+
37
+ desc "call TOOL", "Call an MCP tool by name with --args JSON"
38
+ long_desc <<~DESC
39
+ Invoke any tool the server exposes. Arguments are a JSON object:
40
+
41
+ smily mcp call list --args '{"resource":"rentals","limit":5}'
42
+ smily mcp call resource_schema --args '{"resource":"bookings"}'
43
+ smily mcp call get --args @args.json
44
+ DESC
45
+ method_option :args, type: :string, aliases: "-a", banner: "JSON|@file|-",
46
+ desc: "Tool arguments as JSON, @file, or - for stdin"
47
+ def call(tool)
48
+ args = options["args"] ? parse_data(options["args"]) : {}
49
+ render_tool_result(mcp_client.call_tool(tool, args))
50
+ end
51
+
52
+ desc "resources", "List the resources exposed via MCP (generic `resources` tool)"
53
+ def resources
54
+ render_tool_result(mcp_client.call_tool(tool_name("resources"), {}))
55
+ end
56
+
57
+ desc "list RESOURCE", "List a resource's records via the MCP `list` tool"
58
+ long_desc <<~DESC
59
+ Mirror of `smily <resource> list`, routed through the MCP server:
60
+
61
+ smily mcp list rentals --limit 5
62
+ smily mcp list bookings --filter status=booked --account-id 42
63
+
64
+ Filters support the API v3 operator DSL via `attribute[op]=value`
65
+ (operators: #{SmilyCli::QueryOptions::OPERATORS.join(', ')}). Repeat an
66
+ attribute to combine conditions (AND), e.g. a range:
67
+
68
+ smily mcp list bookings --filter 'final_price[gteq]=600' --filter 'final_price[lt]=800'
69
+ smily mcp list bookings --filter 'start_at[gt]=2026-01-01T00:00:00Z'
70
+ smily mcp list bookings --filter 'reference[in]=A123,B456'
71
+ DESC
72
+ method_option :filter, type: :array, banner: "k[op]=v", desc: "Attribute filters (supports operators + ranges)"
73
+ method_option :fields, type: :array, banner: "a b c", desc: "Sparse fieldset"
74
+ method_option :limit, type: :numeric, aliases: "-n", desc: "Page size"
75
+ method_option :offset, type: :numeric, desc: "Pagination offset"
76
+ method_option :ids, type: :string, desc: "Comma-separated ids to fetch"
77
+ method_option :updated_since, type: :string, desc: "ISO8601 timestamp filter"
78
+ def list(resource)
79
+ args = { "resource" => resource }
80
+ args["filter"] = QueryOptions.parse_filter(options["filter"]) if options["filter"]
81
+ args["fields"] = context.fields if context.fields
82
+ args["limit"] = options["limit"] if options["limit"]
83
+ args["offset"] = options["offset"] if options["offset"]
84
+ args["ids"] = options["ids"] if options["ids"]
85
+ args["updated_since"] = options["updated_since"] if options["updated_since"]
86
+ args.merge!(account_arg)
87
+ render_tool_result(mcp_client.call_tool(tool_name("list"), args))
88
+ end
89
+
90
+ desc "get RESOURCE ID", "Fetch one record via the MCP `get` tool"
91
+ method_option :fields, type: :array, banner: "a b c", desc: "Sparse fieldset"
92
+ def get(resource, id)
93
+ args = { "resource" => resource, "id" => coerce_id(id) }
94
+ args["fields"] = context.fields if context.fields
95
+ args.merge!(account_arg)
96
+ render_tool_result(mcp_client.call_tool(tool_name("get"), args), single: true)
97
+ end
98
+
99
+ desc "schema RESOURCE", "Show a resource's schema via the MCP `resource_schema` tool"
100
+ def schema(resource)
101
+ render_tool_result(mcp_client.call_tool(tool_name("resource_schema"), { "resource" => resource }), single: true)
102
+ end
103
+
104
+ desc "login [TOKEN]", "Save MCP credentials (token, and optional URL) to the active profile"
105
+ method_option :url, type: :string, desc: "MCP server endpoint to store"
106
+ def login(token = nil)
107
+ mcp_token = token || options["mcp_token"]
108
+ if mcp_token.to_s.empty?
109
+ raise UsageError, "Provide the MCP token: smily mcp login <TOKEN> (or --mcp-token TOKEN)."
110
+ end
111
+
112
+ cfg = context.config
113
+ cfg.set("mcp_token", mcp_token)
114
+ cfg.set("mcp_url", options["url"] || options["mcp_url"]) if options["url"] || options["mcp_url"]
115
+ cfg.save
116
+ success("Saved MCP credentials to profile #{cfg.current_profile_name.inspect} (#{cfg.path}).")
117
+ info_line("Try it with: smily mcp info")
118
+ end
119
+
120
+ no_commands do
121
+ def mcp_client
122
+ @mcp_client ||= context.mcp_client
123
+ end
124
+
125
+ # Resolve a convenience verb to an actual tool name, tolerating servers
126
+ # that prefix their generic tools (e.g. `api_v3_list` vs `list`).
127
+ def tool_name(base)
128
+ @tool_names ||= mcp_client.list_tools.map { |t| t["name"] }
129
+ @tool_names.find { |n| n == base } ||
130
+ @tool_names.find { |n| n.end_with?("_#{base}", ":#{base}") } ||
131
+ base
132
+ end
133
+
134
+ def account_arg
135
+ id = context.account_id
136
+ id.nil? || id.to_s.empty? ? {} : { "account_id" => coerce_id(id) }
137
+ end
138
+
139
+ def coerce_id(value)
140
+ value.to_s.match?(/\A\d+\z/) ? value.to_i : value
141
+ end
142
+
143
+ # Render an MCP tool result: raise on `isError`, else render the
144
+ # structured output (or parsed text content) like any other payload.
145
+ # `single:` renders a member (e.g. `get`/`schema`) rather than a list.
146
+ def render_tool_result(result, single: false)
147
+ raise McpError, tool_text(result) if result["isError"]
148
+
149
+ payload = result.key?("structuredContent") ? result["structuredContent"] : parse_text_content(result)
150
+ render_payload(payload, single: single)
151
+ end
152
+
153
+ def tool_text(result)
154
+ Array(result["content"]).filter_map { |item| item["text"] }.join("\n")
155
+ end
156
+
157
+ def parse_text_content(result)
158
+ text = tool_text(result)
159
+ return text if text.strip.empty?
160
+
161
+ JSON.parse(text)
162
+ rescue JSON::ParserError
163
+ text
164
+ end
165
+
166
+ # Render an arbitrary payload: a JSON:API envelope becomes a record
167
+ # table/list; a bare object renders as a single record; a string prints
168
+ # as-is. `single:` forces member (object) rendering for wrappers like
169
+ # `get`/`schema`.
170
+ def render_payload(payload, single: false)
171
+ case payload
172
+ when nil then nil
173
+ when String then emit(payload)
174
+ when Array then render_result(Result.new(records: payload, single: single))
175
+ when Hash then render_hash_payload(payload, single: single)
176
+ else emit(JSON.pretty_generate(payload))
177
+ end
178
+ end
179
+
180
+ def render_hash_payload(payload, single: false)
181
+ key = Result.resource_key(payload)
182
+ if key && payload[key].is_a?(Array)
183
+ render_result(Result.from_body(payload, single: single))
184
+ else
185
+ render_result(Result.new(records: [payload], single: true))
186
+ end
187
+ end
188
+
189
+ def info_line(message)
190
+ notice(Color.dim(message))
191
+ end
192
+ end
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmilyCli
4
+ # Generates shell completion scripts. The scripts are static (they embed the
5
+ # known resource and command names at generation time) which keeps completion
6
+ # instant — no subshell to the CLI on every <Tab>.
7
+ module Completion
8
+ SHELLS = %w[bash zsh fish].freeze
9
+ # Subcommands shared by every resource.
10
+ RESOURCE_ACTIONS = %w[list get create update delete].freeze
11
+
12
+ module_function
13
+
14
+ # @param shell [String] one of {SHELLS}
15
+ # @return [String] the completion script
16
+ # @raise [UsageError] for an unsupported shell
17
+ def script(shell)
18
+ case shell.to_s
19
+ when "bash" then bash
20
+ when "zsh" then zsh
21
+ when "fish" then fish
22
+ else
23
+ raise UsageError, "Unsupported shell #{shell.inspect}. Supported: #{SHELLS.join(', ')}."
24
+ end
25
+ end
26
+
27
+ # @return [Array<String>] every top-level command word
28
+ def top_level
29
+ (Registry.commands + %w[api auth config resources whoami completion version help]).uniq.sort
30
+ end
31
+
32
+ def bash
33
+ <<~BASH
34
+ # smily bash completion. Install with:
35
+ # smily completion bash > /usr/local/etc/bash_completion.d/smily
36
+ _smily() {
37
+ local cur prev words cword
38
+ _init_completion 2>/dev/null || { cur="${COMP_WORDS[COMP_CWORD]}"; prev="${COMP_WORDS[COMP_CWORD-1]}"; }
39
+ local top="#{top_level.join(' ')}"
40
+ local actions="#{RESOURCE_ACTIONS.join(' ')}"
41
+ if [ "$COMP_CWORD" -eq 1 ]; then
42
+ COMPREPLY=( $(compgen -W "$top" -- "$cur") )
43
+ elif [ "$COMP_CWORD" -eq 2 ]; then
44
+ COMPREPLY=( $(compgen -W "$actions" -- "$cur") )
45
+ fi
46
+ return 0
47
+ }
48
+ complete -F _smily smily
49
+ BASH
50
+ end
51
+
52
+ def zsh
53
+ <<~ZSH
54
+ #compdef smily
55
+ # smily zsh completion. Install by placing on your $fpath as _smily, e.g.:
56
+ # smily completion zsh > "${fpath[1]}/_smily"
57
+ _smily() {
58
+ local -a top actions
59
+ top=(#{top_level.map { |c| "'#{c}'" }.join(' ')})
60
+ actions=(#{RESOURCE_ACTIONS.map { |c| "'#{c}'" }.join(' ')})
61
+ if (( CURRENT == 2 )); then
62
+ compadd -- $top
63
+ elif (( CURRENT == 3 )); then
64
+ compadd -- $actions
65
+ fi
66
+ }
67
+ compdef _smily smily
68
+ ZSH
69
+ end
70
+
71
+ def fish
72
+ lines = []
73
+ lines << "# smily fish completion. Install with:"
74
+ lines << "# smily completion fish > ~/.config/fish/completions/smily.fish"
75
+ lines << "complete -c smily -f"
76
+ top_level.each do |cmd|
77
+ lines << "complete -c smily -n '__fish_use_subcommand' -a '#{cmd}'"
78
+ end
79
+ RESOURCE_ACTIONS.each do |action|
80
+ lines << "complete -c smily -n '__fish_seen_subcommand_from #{Registry.commands.join(' ')}' -a '#{action}'"
81
+ end
82
+ "#{lines.join("\n")}\n"
83
+ end
84
+ end
85
+ end