Skip to content
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.

## [Unreleased]

### Deprecation Notice

- The `--json` flag used in `cloudsmith auth` command will be removed in upcoming releases. Please migrate to `--output-format json` instead.

### Fixed

- Fixed JSON output for all commands
- Informational messages, warnings, and interactive prompts are now routed to stderr when `--output-format json` is active.
- Error messages are now formatted as structured JSON on stdout when JSON output is requested.

## [1.10.1] - 2025-12-16

### Fixed
Expand Down
66 changes: 66 additions & 0 deletions cloudsmith_cli/cli/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,46 @@
from click_didyoumean import DYMGroup


def _is_json_output_requested(exception):
"""Determine if JSON output was requested, checking context and argv."""
# Check context if available
ctx = getattr(exception, "ctx", None)
if ctx and ctx.params:
fmt = ctx.params.get("output")
if fmt in ("json", "pretty_json"):
return True

# Fallback: check sys.argv for output format flags
import sys

argv = sys.argv

if "--output-format=json" in argv or "--output-format=pretty_json" in argv:
return True

for idx, arg in enumerate(argv):
if arg in ("-F", "--output-format") and idx + 1 < len(argv):
if argv[idx + 1] in ("json", "pretty_json"):
return True

return False


def _format_click_exception_as_json(exception):
"""Format a ClickException as a JSON error dict."""
return {
"detail": exception.format_message(),
"meta": {
"code": exception.exit_code,
"description": "Usage Error",
},
"help": {
"context": "Invalid usage",
"hint": "Check your command arguments/flags.",
},
}


class AliasGroup(DYMGroup):
"""A command group with DYM and alias support."""

Expand Down Expand Up @@ -92,3 +132,29 @@ def decorator(f):
def format_commands(self, ctx, formatter):
ctx.showing_help = True
return super().format_commands(ctx, formatter)

def main(self, *args, **kwargs):
"""Override main to intercept exceptions and format as JSON if requested."""
import sys

original_standalone_mode = kwargs.get("standalone_mode", True)
kwargs["standalone_mode"] = False

try:
return super().main(*args, **kwargs)
except click.exceptions.Abort:
if not original_standalone_mode:
raise
click.echo("Aborted!", err=True)
sys.exit(1)
except click.exceptions.ClickException as e:
if _is_json_output_requested(e):
import json

click.echo(json.dumps(_format_click_exception_as_json(e), indent=4))
sys.exit(e.exit_code)

if not original_standalone_mode:
raise
e.show()
sys.exit(e.exit_code)
37 changes: 25 additions & 12 deletions cloudsmith_cli/cli/commands/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import click

from .. import decorators, validators
from .. import decorators, utils, validators
from ..exceptions import handle_api_exceptions
from ..saml import create_configured_session, get_idp_url
from ..webserver import AuthenticationWebRequestHandler, AuthenticationWebServer
Expand All @@ -22,14 +22,15 @@ def _perform_saml_authentication(opts, owner, enable_token_creation=False, json=
api_host = opts.api_config.host

idp_url = get_idp_url(api_host, owner, session=session)
if not json:
click.echo(
f"Opening your organization's SAML IDP URL in your browser: {click.style(idp_url, bold=True)}"
)
click.echo()

click.echo(
f"Opening your organization's SAML IDP URL in your browser: {click.style(idp_url, bold=True)}",
err=json,
)
click.echo(err=json)
webbrowser.open(idp_url)
if not json:
click.echo("Starting webserver to begin authentication ... ")

click.echo("Starting webserver to begin authentication ... ", err=json)

auth_server = AuthenticationWebServer(
(AUTH_SERVER_HOST, AUTH_SERVER_PORT),
Expand Down Expand Up @@ -86,13 +87,25 @@ def _perform_saml_authentication(opts, owner, enable_token_creation=False, json=
@click.pass_context
def authenticate(ctx, opts, owner, token, force, save_config, json):
"""Authenticate to Cloudsmith using the org's SAML setup."""
owner = owner[0].strip("'[]'")
json = json or utils.should_use_stderr(opts)
# If using json output, we redirect info messages to stderr
use_stderr = json

if not json:
click.echo(
f"Beginning authentication for the {click.style(owner, bold=True)} org ... "
if json and not utils.should_use_stderr(opts):
click.secho(
"DEPRECATION WARNING: The `--json` flag is deprecated and will be removed in a future release. "
"Please use `--output-format json` instead.",
fg="yellow",
err=True,
)

owner = owner[0].strip("'[]'")

click.echo(
f"Beginning authentication for the {click.style(owner, bold=True)} org ... ",
err=use_stderr,
)

context_message = "Failed to authenticate via SSO!"
with handle_api_exceptions(ctx, opts=opts, context_msg=context_message):
_perform_saml_authentication(
Expand Down
22 changes: 18 additions & 4 deletions cloudsmith_cli/cli/commands/check.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,18 @@ def check(ctx, opts): # pylint: disable=unused-argument
@click.pass_context
def rates(ctx, opts):
"""Check current API rate limits."""
click.echo("Retrieving rate limits ... ", nl=False)
use_stderr = utils.should_use_stderr(opts)
click.echo("Retrieving rate limits ... ", nl=False, err=use_stderr)

context_msg = "Failed to retrieve status!"
with handle_api_exceptions(ctx, opts=opts, context_msg=context_msg):
with maybe_spinner(opts):
resources_limits = get_rate_limits()

click.secho("OK", fg="green")
click.secho("OK", fg="green", err=use_stderr)

if utils.maybe_print_as_json(opts, resources_limits):
return

headers = ["Resource", "Throttled", "Remaining", "Interval (Seconds)", "Reset"]

Expand Down Expand Up @@ -77,17 +81,27 @@ def rates(ctx, opts):
@click.pass_context
def service(ctx, opts):
"""Check the status of the Cloudsmith service."""
click.echo("Retrieving service status ... ", nl=False)
use_stderr = utils.should_use_stderr(opts)
click.echo("Retrieving service status ... ", nl=False, err=use_stderr)

context_msg = "Failed to retrieve status!"
with handle_api_exceptions(ctx, opts=opts, context_msg=context_msg):
with maybe_spinner(opts):
status, version = get_status(with_version=True)

click.secho("OK", fg="green")
click.secho("OK", fg="green", err=use_stderr)

config = cloudsmith_api.Configuration()

data = {
"endpoint": config.host,
"status": status,
"version": version,
}

if utils.maybe_print_as_json(opts, data):
return

click.echo()
click.echo(f"The service endpoint is: {click.style(config.host, bold=True)}")
click.echo(f"The service status is: {click.style(status, bold=True)}")
Expand Down
10 changes: 8 additions & 2 deletions cloudsmith_cli/cli/commands/copy.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import click

from ...core.api.packages import copy_package
from .. import decorators, validators
from .. import decorators, utils, validators
from ..exceptions import handle_api_exceptions
from ..utils import maybe_spinner
from .main import main
Expand Down Expand Up @@ -56,6 +56,8 @@ def copy(
"""
owner, source, slug = owner_repo_package

use_stderr = utils.should_use_stderr(opts)

click.echo(
"Copying %(slug)s package from %(source)s to %(dest)s ... "
% {
Expand All @@ -64,6 +66,7 @@ def copy(
"dest": click.style(destination, bold=True),
},
nl=False,
err=use_stderr,
)

context_msg = "Failed to copy package!"
Expand All @@ -75,9 +78,10 @@ def copy(
owner=owner, repo=source, identifier=slug, destination=destination
)

click.secho("OK", fg="green")
click.secho("OK", fg="green", err=use_stderr)

if no_wait_for_sync:
utils.maybe_print_status_json(opts, {"slug": new_slug, "status": "OK"})
return

wait_for_package_sync(
Expand All @@ -90,3 +94,5 @@ def copy(
skip_errors=skip_errors,
attempts=sync_attempts,
)

utils.maybe_print_status_json(opts, {"slug": new_slug, "status": "OK"})
12 changes: 9 additions & 3 deletions cloudsmith_cli/cli/commands/delete.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,17 +45,23 @@ def delete(ctx, opts, owner_repo_package, yes):
"package": click.style(slug, bold=True),
}

use_stderr = utils.should_use_stderr(opts)

prompt = "delete the %(package)s from %(owner)s/%(repo)s" % delete_args
if not utils.confirm_operation(prompt, assume_yes=yes):
if not utils.confirm_operation(prompt, assume_yes=yes, err=use_stderr):
return

click.echo(
"Deleting %(package)s from %(owner)s/%(repo)s ... " % delete_args, nl=False
"Deleting %(package)s from %(owner)s/%(repo)s ... " % delete_args,
nl=False,
err=use_stderr,
)

context_msg = "Failed to delete the package!"
with handle_api_exceptions(ctx, opts=opts, context_msg=context_msg):
with maybe_spinner(opts):
delete_package(owner=owner, repo=repo, identifier=slug)

click.secho("OK", fg="green")
click.secho("OK", fg="green", err=use_stderr)

utils.maybe_print_status_json(opts, {"slug": slug, "status": "OK"})
2 changes: 1 addition & 1 deletion cloudsmith_cli/cli/commands/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ def list_dependencies(ctx, opts, owner_repo_package):
owner, repo, identifier = owner_repo_package

# Use stderr for messages if the output is something else (e.g. # JSON)
use_stderr = opts.output != "pretty"
use_stderr = utils.should_use_stderr(opts)

click.echo(
"Getting direct (non-transitive) dependencies of %(package)s in "
Expand Down
Loading