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