cloudsmith-cli 1.11.1__py2.py3-none-any.whl → 1.12.1__py2.py3-none-any.whl

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.
@@ -12,6 +12,7 @@ from . import (
12
12
  help_,
13
13
  list_,
14
14
  login,
15
+ mcp,
15
16
  metrics,
16
17
  move,
17
18
  policy,
@@ -5,22 +5,31 @@ import click
5
5
  from ...core.api.version import get_version as get_api_version
6
6
  from ...core.utils import get_github_website, get_help_website
7
7
  from ...core.version import get_version as get_cli_version
8
- from .. import command, decorators
8
+ from .. import command, decorators, utils
9
9
 
10
10
  CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
11
11
 
12
12
 
13
- def print_version():
13
+ def print_version(opts):
14
14
  """Print the environment versions."""
15
- click.echo("Versions:")
16
- click.secho(
17
- "CLI Package Version: %(version)s"
18
- % {"version": click.style(get_cli_version(), bold=True)}
19
- )
20
- click.secho(
21
- "API Package Version: %(version)s"
22
- % {"version": click.style(get_api_version(), bold=True)}
23
- )
15
+ cli_version = get_cli_version()
16
+ api_version = get_api_version()
17
+
18
+ data = {
19
+ "cli_version": cli_version,
20
+ "api_version": api_version,
21
+ }
22
+
23
+ if not utils.maybe_print_as_json(opts, data):
24
+ click.echo("Versions:")
25
+ click.secho(
26
+ "CLI Package Version: %(version)s"
27
+ % {"version": click.style(cli_version, bold=True)}
28
+ )
29
+ click.secho(
30
+ "API Package Version: %(version)s"
31
+ % {"version": click.style(api_version, bold=True)}
32
+ )
24
33
 
25
34
 
26
35
  @click.group(
@@ -52,12 +61,13 @@ For issues/contributing: %(github_website)s
52
61
  is_eager=True,
53
62
  )
54
63
  @decorators.common_cli_config_options
64
+ @decorators.common_cli_output_options
55
65
  @click.pass_context
56
66
  def main(ctx, opts, version):
57
67
  """Handle entrypoint to CLI."""
58
68
  # pylint: disable=unused-argument
59
69
 
60
70
  if version:
61
- print_version()
71
+ print_version(opts)
62
72
  elif ctx.invoked_subcommand is None:
63
73
  click.echo(ctx.get_help())
@@ -0,0 +1,432 @@
1
+ """Main command/entrypoint."""
2
+
3
+ import json
4
+ import os
5
+ import shutil
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Dict, List
9
+
10
+ import click
11
+ import json5
12
+
13
+ from ...core.mcp import server
14
+ from ...core.mcp.data import OpenAPITool
15
+ from .. import command, decorators, utils
16
+ from .main import main
17
+
18
+ SUPPORTED_MCP_CLIENTS = {
19
+ "claude": "Claude Desktop",
20
+ "cursor": "Cursor IDE",
21
+ "vscode": "VS Code",
22
+ "gemini-cli": "Gemini CLI",
23
+ }
24
+
25
+
26
+ @main.group(cls=command.AliasGroup, name="mcp")
27
+ @decorators.common_cli_config_options
28
+ @decorators.common_cli_output_options
29
+ @decorators.common_api_auth_options
30
+ @decorators.initialise_api
31
+ @click.pass_context
32
+ def mcp_(ctx, opts): # pylint: disable=unused-argument
33
+ """
34
+ Start the Cloudsmith MCP Server
35
+
36
+ See the help for subcommands for more information on each.
37
+ """
38
+
39
+
40
+ @mcp_.command(name="start")
41
+ @decorators.initialise_api
42
+ @decorators.initialise_mcp
43
+ @click.pass_context
44
+ def start(ctx, opts, mcp_server: server.DynamicMCPServer):
45
+ """
46
+ Start the MCP Server
47
+ """
48
+ mcp_server.run()
49
+
50
+
51
+ @mcp_.command(name="list_tools")
52
+ @decorators.common_cli_config_options
53
+ @decorators.common_cli_output_options
54
+ @decorators.common_api_auth_options
55
+ @decorators.initialise_api
56
+ @decorators.initialise_mcp
57
+ @click.pass_context
58
+ def list_tools(ctx, opts, mcp_server: server.DynamicMCPServer):
59
+ """
60
+ List available tools that will be exposed to the MCP Client
61
+ """
62
+ use_stderr = utils.should_use_stderr(opts)
63
+
64
+ if not use_stderr:
65
+ click.echo("Getting list of tools ... ", nl=False, err=use_stderr)
66
+
67
+ with utils.maybe_spinner(opts):
68
+ tools = mcp_server.list_tools()
69
+
70
+ if not use_stderr:
71
+ click.secho("OK", fg="green", err=use_stderr)
72
+
73
+ tools_data = [
74
+ {"name": name, "description": spec.description} for name, spec in tools.items()
75
+ ]
76
+
77
+ if utils.maybe_print_as_json(opts, tools_data):
78
+ return
79
+
80
+ print_tools(tools)
81
+
82
+
83
+ @mcp_.command(name="list_groups")
84
+ @decorators.common_cli_config_options
85
+ @decorators.common_cli_output_options
86
+ @decorators.common_api_auth_options
87
+ @decorators.initialise_api
88
+ @decorators.initialise_mcp
89
+ @click.pass_context
90
+ def list_groups(ctx, opts, mcp_server: server.DynamicMCPServer):
91
+ """
92
+ List available tool groups and the tools they contain
93
+ """
94
+ use_stderr = utils.should_use_stderr(opts)
95
+
96
+ if not use_stderr:
97
+ click.echo("Getting list of tool groups ... ", nl=False, err=use_stderr)
98
+
99
+ with utils.maybe_spinner(opts):
100
+ groups = mcp_server.list_groups()
101
+
102
+ if not use_stderr:
103
+ click.secho("OK", fg="green", err=use_stderr)
104
+
105
+ groups_data = [{"name": name, "tools": tools} for name, tools in groups.items()]
106
+
107
+ if utils.maybe_print_as_json(opts, groups_data):
108
+ return
109
+
110
+ print_groups(groups)
111
+
112
+
113
+ def print_tools(tool_list: Dict[str, OpenAPITool]):
114
+ """Print tools as a table or output in another format."""
115
+
116
+ headers = [
117
+ "Name",
118
+ "Description",
119
+ ]
120
+
121
+ rows = []
122
+ for tool_name, tools_spec in tool_list.items():
123
+ rows.append(
124
+ [
125
+ click.style(tool_name, fg="cyan"),
126
+ click.style(tools_spec.description, fg="yellow"),
127
+ ]
128
+ )
129
+
130
+ if tool_list:
131
+ click.echo()
132
+ utils.pretty_print_table(headers, rows)
133
+
134
+ click.echo()
135
+
136
+ num_results = len(tool_list)
137
+ list_suffix = "tool%s visible" % ("s" if num_results != 1 else "")
138
+ utils.pretty_print_list_info(num_results=num_results, suffix=list_suffix)
139
+
140
+
141
+ def print_groups(group_list: Dict[str, List[str]]):
142
+ """Print tool groups as a table or output in another format."""
143
+
144
+ headers = [
145
+ "Group Name",
146
+ "Tool Count",
147
+ "Sample Tools",
148
+ ]
149
+
150
+ rows = []
151
+ for group_name, tools in group_list.items():
152
+ # Show first 3 tools as samples
153
+ sample_tools = ", ".join(tools[:3])
154
+ if len(tools) > 3:
155
+ sample_tools += f", ... (+{len(tools) - 3} more)"
156
+
157
+ rows.append(
158
+ [
159
+ click.style(group_name, fg="cyan"),
160
+ click.style(str(len(tools)), fg="yellow"),
161
+ click.style(sample_tools, fg="white"),
162
+ ]
163
+ )
164
+
165
+ if group_list:
166
+ click.echo()
167
+ utils.pretty_print_table(headers, rows)
168
+
169
+ click.echo()
170
+
171
+ num_results = len(group_list)
172
+ list_suffix = "group%s visible" % ("s" if num_results != 1 else "")
173
+ utils.pretty_print_list_info(num_results=num_results, suffix=list_suffix)
174
+
175
+
176
+ @mcp_.command(name="configure")
177
+ @decorators.common_cli_config_options
178
+ @decorators.common_cli_output_options
179
+ @decorators.common_api_auth_options
180
+ @click.option(
181
+ "--client",
182
+ type=click.Choice(list(SUPPORTED_MCP_CLIENTS.keys()), case_sensitive=False),
183
+ help=f"MCP client to configure ({', '.join(SUPPORTED_MCP_CLIENTS.keys())}). If not specified, will attempt to detect and configure all.",
184
+ )
185
+ @click.option(
186
+ "--global/--local",
187
+ "is_global",
188
+ default=True,
189
+ help="Configure globally (default) or in current project directory (local)",
190
+ )
191
+ @decorators.initialise_api
192
+ @click.pass_context
193
+ def configure(ctx, opts, client, is_global): # pylint: disable=unused-argument
194
+ """
195
+ Configure the Cloudsmith MCP server for supported clients.
196
+
197
+ This command automatically adds the Cloudsmith MCP server configuration
198
+ to the specified client's configuration file. Supported clients are:
199
+ - Claude Desktop
200
+ - Cursor IDE
201
+ - VS Code (GitHub Copilot)
202
+ - Gemini CLI
203
+
204
+ Examples:\n
205
+ cloudsmith mcp configure --client claude\n
206
+ cloudsmith mcp configure --client cursor --local\n
207
+ cloudsmith mcp configure --client gemini-cli\n
208
+ cloudsmith mcp configure # Auto-detect and configure all
209
+ """
210
+
211
+ use_stderr = utils.should_use_stderr(opts)
212
+
213
+ # Get the profile from context
214
+ profile = ctx.meta.get("profile")
215
+
216
+ # Determine the best command to run the MCP server
217
+ server_config = _get_server_config(profile)
218
+
219
+ clients_to_configure = []
220
+ if client:
221
+ clients_to_configure = [client.lower()]
222
+ else:
223
+ # Auto-detect available clients
224
+ clients_to_configure = detect_available_clients()
225
+
226
+ if not clients_to_configure:
227
+ if not use_stderr:
228
+ click.echo(click.style("No supported MCP clients detected.", fg="yellow"))
229
+ click.echo("\nSupported clients:")
230
+ for display_name in SUPPORTED_MCP_CLIENTS.values():
231
+ click.echo(f" - {display_name}")
232
+
233
+ utils.maybe_print_as_json(opts, [])
234
+ return
235
+
236
+ results = []
237
+ success_count = 0
238
+ for client_name in clients_to_configure:
239
+ try:
240
+ if configure_client(client_name, server_config, is_global, profile):
241
+ if not use_stderr:
242
+ click.echo(
243
+ click.style(f"✓ Configured {client_name.title()}", fg="green")
244
+ )
245
+ success_count += 1
246
+ results.append({"client": client_name, "success": True})
247
+ else:
248
+ if not use_stderr:
249
+ click.echo(
250
+ click.style(
251
+ f"✗ Failed to configure {client_name.title()}", fg="red"
252
+ )
253
+ )
254
+ results.append(
255
+ {
256
+ "client": client_name,
257
+ "success": False,
258
+ "error": "Configuration failed",
259
+ }
260
+ )
261
+ except (OSError, ValueError) as e:
262
+ if not use_stderr:
263
+ click.echo(
264
+ click.style(
265
+ f"✗ Error configuring {client_name.title()}: {str(e)}", fg="red"
266
+ )
267
+ )
268
+ results.append({"client": client_name, "success": False, "error": str(e)})
269
+
270
+ if utils.maybe_print_as_json(opts, results):
271
+ return
272
+
273
+ if success_count > 0:
274
+ click.echo(
275
+ click.style(
276
+ f"\n✓ Successfully configured {success_count} client(s)", fg="green"
277
+ )
278
+ )
279
+ click.echo(
280
+ "\nNote: You may need to restart the client application for changes to take effect."
281
+ )
282
+ else:
283
+ click.echo(click.style("\n✗ No clients were configured successfully", fg="red"))
284
+
285
+
286
+ def _get_server_config(profile=None):
287
+ """Determine the first available command configuration to run the MCP server."""
288
+ # Check if running in a virtual environment
289
+ in_venv = hasattr(sys, "real_prefix") or (
290
+ hasattr(sys, "base_prefix") and sys.base_prefix != sys.prefix
291
+ )
292
+
293
+ # Build the base args
294
+ base_args = []
295
+ if profile:
296
+ base_args.extend(["-P", profile])
297
+
298
+ # In a venv, always use python -m to ensure we use the venv's packages
299
+ if in_venv:
300
+ return {
301
+ "command": sys.executable,
302
+ "args": ["-m", "cloudsmith_cli"] + base_args + ["mcp", "start"],
303
+ }
304
+
305
+ # Otherwise, try to find cloudsmith in PATH, fall back to python -m
306
+ cloudsmith_cmd = shutil.which("cloudsmith")
307
+ if cloudsmith_cmd:
308
+ return {"command": cloudsmith_cmd, "args": base_args + ["mcp", "start"]}
309
+
310
+ return {
311
+ "command": sys.executable,
312
+ "args": ["-m", "cloudsmith_cli"] + base_args + ["mcp", "start"],
313
+ }
314
+
315
+
316
+ def detect_available_clients():
317
+ """Detect which MCP clients are available on the system."""
318
+ available = []
319
+
320
+ for client in SUPPORTED_MCP_CLIENTS:
321
+ config = get_config_path(client, is_global=True)
322
+ if config and config.parent.exists():
323
+ available.append(client)
324
+
325
+ return available
326
+
327
+
328
+ def get_config_path(client_name, is_global=True):
329
+ """Get the configuration file path for a given client."""
330
+ home = Path.home()
331
+ appdata = os.getenv("APPDATA", "")
332
+
333
+ # Configuration paths by client, platform, and scope
334
+ config_paths = {
335
+ "claude": {
336
+ "darwin": home
337
+ / "Library"
338
+ / "Application Support"
339
+ / "Claude"
340
+ / "claude_desktop_config.json",
341
+ "win32": (
342
+ Path(appdata) / "Claude" / "claude_desktop_config.json"
343
+ if appdata
344
+ else None
345
+ ),
346
+ "linux": home / ".config" / "Claude" / "claude_desktop_config.json",
347
+ },
348
+ "cursor": {
349
+ "global": home / ".cursor" / "mcp.json",
350
+ "local": Path.cwd() / ".cursor" / "mcp.json",
351
+ },
352
+ "vscode": {
353
+ "darwin": home
354
+ / "Library"
355
+ / "Application Support"
356
+ / "Code"
357
+ / "User"
358
+ / "settings.json",
359
+ "win32": (
360
+ Path(appdata) / "Code" / "User" / "settings.json" if appdata else None
361
+ ),
362
+ "linux": home / ".config" / "Code" / "User" / "settings.json",
363
+ "local": Path.cwd() / ".vscode" / "settings.json",
364
+ },
365
+ "gemini-cli": {
366
+ "global": home / ".gemini" / "settings.json",
367
+ "local": Path.cwd() / ".gemini" / "settings.json",
368
+ },
369
+ }
370
+
371
+ client_config = config_paths.get(client_name, {})
372
+
373
+ # For Cursor and Gemini CLI, use global/local scope instead of platform
374
+ if client_name in ("cursor", "gemini-cli"):
375
+ scope = "global" if is_global else "local"
376
+ return client_config.get(scope)
377
+
378
+ # For VS Code local config
379
+ if client_name == "vscode" and not is_global:
380
+ return client_config.get("local")
381
+
382
+ # For platform-specific configs (Claude and VS Code global)
383
+ platform = sys.platform if sys.platform in ("darwin", "win32") else "linux"
384
+ return client_config.get(platform)
385
+
386
+
387
+ def configure_client(client_name, server_config, is_global=True, profile=None):
388
+ """Configure a specific MCP client with the Cloudsmith server."""
389
+ config_path = get_config_path(client_name, is_global)
390
+
391
+ if not config_path:
392
+ return False
393
+
394
+ # Ensure parent directory exists
395
+ config_path.parent.mkdir(parents=True, exist_ok=True)
396
+
397
+ # Read existing config or create new one
398
+ config = {}
399
+ if config_path.exists():
400
+ with open(config_path) as f:
401
+ content = f.read()
402
+ try:
403
+ # Use json5 first as it handles both standard JSON and JSONC
404
+ # (comments, trailing commas) which VS Code settings.json may contain
405
+ config = json5.loads(content)
406
+ except (json.JSONDecodeError, ValueError) as e:
407
+ raise ValueError(
408
+ f"Cannot parse config file '{config_path}': {e}. "
409
+ f"Please fix the JSON syntax or remove the file to create a new one."
410
+ ) from e
411
+
412
+ # Determine server name based on profile
413
+ server_name = f"cloudsmith-{profile}" if profile else "cloudsmith"
414
+
415
+ # Add Cloudsmith MCP server based on client format
416
+ if client_name in {"claude", "cursor", "gemini-cli"}:
417
+ if "mcpServers" not in config:
418
+ config["mcpServers"] = {}
419
+ config["mcpServers"][server_name] = server_config
420
+
421
+ elif client_name == "vscode":
422
+ # VS Code uses a different format in settings.json
423
+ if "chat.mcp.servers" not in config:
424
+ config["chat.mcp.servers"] = {}
425
+ config["chat.mcp.servers"][server_name] = server_config
426
+
427
+ # Write updated config
428
+ with open(config_path, "w") as f:
429
+ # As we are using json5 to read, we write standard JSON back, which removes existing user JSON comments in the files (currently only present in VS Code settings.json).
430
+ json.dump(config, f, indent=2)
431
+
432
+ return True
@@ -75,7 +75,7 @@ def _print_metrics_table(opts, data):
75
75
  type=str,
76
76
  required=False,
77
77
  help=(
78
- "A comma seperated list of entitlement token identifiers (i.e. slug_perm) or "
78
+ "A comma separated list of entitlement token identifiers (i.e. slug_perm) or "
79
79
  "token secrets. If a list is not specified then all entitlement tokens will "
80
80
  "be included for a given namespace or repository."
81
81
  ),
@@ -74,7 +74,7 @@ def _print_metrics_table(opts, data):
74
74
  type=str,
75
75
  required=False,
76
76
  help=(
77
- "A comma seperated list of package identifiers (i.e. slug_perm). "
77
+ "A comma separated list of package identifiers (i.e. slug_perm). "
78
78
  "If a list is not specified then all package will be included for "
79
79
  "a given repository."
80
80
  ),
@@ -24,6 +24,7 @@ UPSTREAM_FORMATS = [
24
24
  "dart",
25
25
  "deb",
26
26
  "docker",
27
+ "generic",
27
28
  "go",
28
29
  "helm",
29
30
  "hex",
@@ -64,6 +64,8 @@ class ConfigSchema:
64
64
  api_proxy = ConfigParam(name="api_proxy", type=str)
65
65
  api_ssl_verify = ConfigParam(name="api_ssl_verify", type=bool, default=True)
66
66
  api_user_agent = ConfigParam(name="api_user_agent", type=str)
67
+ mcp_allowed_tools = ConfigParam(name="mcp_allowed_tools", type=str)
68
+ mcp_allowed_tool_groups = ConfigParam(name="mcp_allowed_tool_groups", type=str)
67
69
 
68
70
  @matches_section("profile:*")
69
71
  class Profile(Default):
@@ -95,7 +97,7 @@ class ConfigReader(ConfigFileReader):
95
97
 
96
98
  @classmethod
97
99
  def get_default_filepath(cls):
98
- """Get the default filepath for the configuratin file."""
100
+ """Get the default filepath for the configuration file."""
99
101
  if not cls.config_files:
100
102
  return None
101
103
  if not cls.config_searchpath:
@@ -348,6 +350,34 @@ class Options:
348
350
  """Set value for debug flag."""
349
351
  self._set_option("debug", bool(value))
350
352
 
353
+ @property
354
+ def mcp_allowed_tools(self):
355
+ """Get value for Allowed MCP Tools."""
356
+ return self._get_option("mcp_allowed_tools")
357
+
358
+ @mcp_allowed_tools.setter
359
+ def mcp_allowed_tools(self, value):
360
+ """Set value for Allowed MCP Tools."""
361
+ if not value:
362
+ return
363
+ tools = [tool.strip() for tool in value.split(",")]
364
+
365
+ self._set_option("mcp_allowed_tools", tools)
366
+
367
+ @property
368
+ def mcp_allowed_tool_groups(self):
369
+ """Get value for Allowed MCP Tool Groups."""
370
+ return self._get_option("mcp_allowed_tool_groups")
371
+
372
+ @mcp_allowed_tool_groups.setter
373
+ def mcp_allowed_tool_groups(self, value):
374
+ """Set value for Allowed MCP Tool Groups."""
375
+ if not value:
376
+ return
377
+ tool_groups = [group.strip() for group in value.split(",")]
378
+
379
+ self._set_option("mcp_allowed_tool_groups", tool_groups)
380
+
351
381
  @property
352
382
  def output(self):
353
383
  """Get value for output format."""
@@ -7,6 +7,7 @@ import click
7
7
  from cloudsmith_cli.cli import validators
8
8
 
9
9
  from ..core.api.init import initialise_api as _initialise_api
10
+ from ..core.mcp import server
10
11
  from . import config, utils
11
12
 
12
13
 
@@ -93,9 +94,15 @@ def common_cli_config_options(f):
93
94
  def wrapper(ctx, *args, **kwargs):
94
95
  # pylint: disable=missing-docstring
95
96
  opts = config.get_or_create_options(ctx)
96
- profile = kwargs.pop("profile")
97
- config_file = kwargs.pop("config_file")
98
- creds_file = kwargs.pop("credentials_file")
97
+ profile = kwargs.pop("profile") or ctx.meta.get("profile")
98
+ config_file = kwargs.pop("config_file") or ctx.meta.get("config_file")
99
+ creds_file = kwargs.pop("credentials_file") or ctx.meta.get("creds_file")
100
+
101
+ # Store in context for subcommands to inherit
102
+ ctx.meta["profile"] = profile
103
+ ctx.meta["config_file"] = config_file
104
+ ctx.meta["creds_file"] = creds_file
105
+
99
106
  opts.load_config_file(path=config_file, profile=profile)
100
107
  opts.load_creds_file(path=creds_file, profile=profile)
101
108
  kwargs["opts"] = opts
@@ -330,3 +337,40 @@ def initialise_api(f):
330
337
  return ctx.invoke(f, *args, **kwargs)
331
338
 
332
339
  return wrapper
340
+
341
+
342
+ def initialise_mcp(f):
343
+ @click.option(
344
+ "-a",
345
+ "--all-tools",
346
+ default=False,
347
+ is_flag=True,
348
+ help="Show all tools",
349
+ )
350
+ @click.option(
351
+ "-D",
352
+ "--allow-destructive-tools",
353
+ default=False,
354
+ is_flag=True,
355
+ help="Allow destructive tools to be used",
356
+ )
357
+ @click.pass_context
358
+ @functools.wraps(f)
359
+ def wrapper(ctx, *args, **kwargs):
360
+ opts = kwargs.get("opts")
361
+
362
+ all_tools = kwargs.pop("all_tools")
363
+ allow_destructive_tools = kwargs.pop("allow_destructive_tools")
364
+
365
+ mcp_server = server.DynamicMCPServer(
366
+ api_config=opts.api_config,
367
+ debug_mode=opts.debug,
368
+ allow_destructive_tools=allow_destructive_tools,
369
+ allowed_tool_groups=opts.mcp_allowed_tool_groups,
370
+ allowed_tools=opts.mcp_allowed_tools,
371
+ force_all_tools=all_tools,
372
+ )
373
+ kwargs["mcp_server"] = mcp_server
374
+ return ctx.invoke(f, *args, **kwargs)
375
+
376
+ return wrapper
@@ -1,3 +1,5 @@
1
+ import json
2
+
1
3
  import pytest
2
4
 
3
5
  from ....core.api.version import get_version as get_api_version
@@ -17,6 +19,32 @@ class TestMainCommand:
17
19
  "API Package Version: " + get_api_version() + "\n"
18
20
  )
19
21
 
22
+ @pytest.mark.parametrize("option", ["-V", "--version"])
23
+ @pytest.mark.parametrize(
24
+ "format_option,format_value",
25
+ [("-F", "json"), ("--output-format", "json")],
26
+ )
27
+ def test_main_version_json(self, runner, option, format_option, format_value):
28
+ """Test the JSON output of `cloudsmith --version --output-format json`."""
29
+ result = runner.invoke(main, [option, format_option, format_value])
30
+ assert result.exit_code == 0
31
+ output = json.loads(result.output)
32
+ assert "data" in output
33
+ assert output["data"]["cli_version"] == get_version()
34
+ assert output["data"]["api_version"] == get_api_version()
35
+
36
+ @pytest.mark.parametrize("option", ["-V", "--version"])
37
+ def test_main_version_pretty_json(self, runner, option):
38
+ """Test the pretty JSON output of `cloudsmith --version --output-format pretty_json`."""
39
+ result = runner.invoke(main, [option, "--output-format", "pretty_json"])
40
+ assert result.exit_code == 0
41
+ output = json.loads(result.output)
42
+ assert "data" in output
43
+ assert output["data"]["cli_version"] == get_version()
44
+ assert output["data"]["api_version"] == get_api_version()
45
+ # Verify it's formatted with indentation
46
+ assert " " in result.output
47
+
20
48
  @pytest.mark.parametrize("option", ["-h", "--help"])
21
49
  def test_main_help(self, runner, option):
22
50
  """Test the output of `cloudsmith --help`."""