Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,30 @@ Or you can get the latest pre-release version from Cloudsmith:
pip install --upgrade cloudsmith-cli --extra-index-url=https://dl.cloudsmith.io/public/cloudsmith/cli/python/index/
```

### Optional Dependencies

The CLI supports optional extras for additional functionality:

#### AWS OIDC Support

For AWS environments (ECS, EKS, EC2), install with `aws` extra to enable automatic credential discovery:

```
pip install cloudsmith-cli[aws]
```

This installs `boto3[crt]` for AWS credential chain support, STS token generation, and AWS SSO compatibility.

#### All Optional Features

To install all optional dependencies:

```
pip install cloudsmith-cli[all]
```

**Note:** If you don't install the AWS extra, the AWS OIDC detector will gracefully skip itself with no errors. All other CI/CD platforms (GitHub Actions, GitLab CI, CircleCI, Azure DevOps, Bitbucket Pipelines, Jenkins) work without any extras.

## Configuration

There are two configuration files used by the CLI:
Expand Down
1 change: 1 addition & 0 deletions cloudsmith_cli/cli/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
metrics,
move,
policy,
print_token,
push,
quarantine,
quota,
Expand Down
6 changes: 6 additions & 0 deletions cloudsmith_cli/cli/commands/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,12 @@ def login(ctx, opts, login, password): # pylint: disable=redefined-outer-name
"Your API key/token is: %(token)s"
% {"token": click.style(api_key, fg="magenta")}
)
click.echo()
click.echo(
"💡 Tip: Use "
+ click.style("cloudsmith print-token", fg="cyan")
+ " to retrieve this token later"
)

create, has_errors = create_config_files(ctx, opts, api_key=api_key)
new_config_messaging(has_errors, opts, create, api_key=api_key)
104 changes: 104 additions & 0 deletions cloudsmith_cli/cli/commands/print_token.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""CLI/Commands - Print the active authentication token."""

import click
Comment on lines +1 to +3
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New source files in this repo appear to include a copyright header comment near the top (e.g., cloudsmith_cli/cli/commands/logout.py:1). Consider adding # Copyright 2026 Cloudsmith Ltd above the module docstring here to match that convention.

Copilot uses AI. Check for mistakes.

from .. import decorators, utils
from .main import main


def _extract_token(api_config):
"""Extract the active token from the API configuration.

Returns (token, token_type) where token_type is 'bearer' or 'api_key'.
"""
headers = getattr(api_config, "headers", {}) or {}
auth_header = headers.get("Authorization", "")

if auth_header.startswith("Bearer "):
return auth_header[len("Bearer ") :], "bearer"

api_keys = getattr(api_config, "api_key", {}) or {}
api_key = api_keys.get("X-Api-Key")
if api_key:
return api_key, "api_key"

return None, None


@main.command(name="print-token")
@decorators.common_cli_config_options
@decorators.common_cli_output_options
@decorators.common_api_auth_options
@decorators.initialise_api
@click.pass_context
def token(ctx, opts):
"""Print the active authentication token.

Outputs the token currently used to authenticate with the Cloudsmith API.
This is useful for passing to other tools like curl, docker, pip, etc.

Note: This prints your CURRENT/ACTIVE token. To LOGIN and get a NEW token
interactively, use 'cloudsmith login' (or its alias 'cloudsmith token').

⚠️ WARNING: This command prints sensitive credentials to stdout.
Avoid running this command in logged/recorded terminal sessions.
The token will appear in your shell history if stored in a variable.

For safer usage, pipe directly to another command without storing
in variables.

\b
Examples:
# Use with curl (pipe directly)
cloudsmith print-token | xargs -I{} curl -H "X-Api-Key: {}" https://api.cloudsmith.io/v1/user/self/

# Use with docker login (pipe to stdin)
cloudsmith print-token | docker login docker.cloudsmith.io -u token --password-stdin

# Avoid: storing in shell variable (appears in history)
# export CS_TOKEN=$(cloudsmith print-token) # NOT RECOMMENDED

\b
See also:
cloudsmith login Interactive login to get a NEW token
cloudsmith whoami Show current authentication status
"""
active_token, token_type = _extract_token(opts.api_config)

if not active_token:
click.secho("Error: No authentication token available", fg="red", err=True)
click.echo(err=True)
click.echo("Try one of these commands to authenticate:", err=True)
click.echo(
" "
+ click.style("cloudsmith login", fg="cyan")
+ " # Interactive login",
err=True,
)
click.echo(
" "
+ click.style("cloudsmith authenticate", fg="cyan")
+ " # SAML/SSO login",
err=True,
)
click.echo(
" "
+ click.style("export CLOUDSMITH_API_KEY=...", fg="cyan")
+ " # Set via environment variable",
err=True,
)
click.echo(err=True)
click.echo(
"For OIDC auto-discovery, set CLOUDSMITH_ORG and CLOUDSMITH_SERVICE_SLUG",
err=True,
)
ctx.exit(1)

if utils.maybe_print_as_json(
opts,
{"token": active_token, "type": token_type},
):
return

# Print bare token to stdout (stderr used for any messages)
click.echo(active_token)
70 changes: 63 additions & 7 deletions cloudsmith_cli/cli/commands/whoami.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,27 +27,74 @@ def _get_api_key_source(opts):
"""Determine where the API key was loaded from.

Checks in priority order matching actual resolution:
CLI --api-key flag > CLOUDSMITH_API_KEY env var > credentials.ini.
CLI --api-key flag > CLOUDSMITH_API_KEY env var > credentials.ini > OIDC.
"""
if not opts.api_key:
# Check if ANY API key is configured (from any source)
api_key_configured = opts.api_key or (
hasattr(opts, "api_config") and opts.api_config and opts.api_config.api_key
)

if not api_key_configured:
return {"configured": False, "source": None, "source_key": None}

env_key = os.environ.get("CLOUDSMITH_API_KEY")

# If env var is set but differs from the resolved key, CLI flag won
if env_key and opts.api_key != env_key:
source, key = "CLI --api-key flag", "cli_flag"
# If CLI --api-key flag was explicitly passed
if opts.api_key:
# If env var is set but differs from the CLI flag, CLI flag won
if env_key and opts.api_key != env_key:
source, key = "CLI --api-key flag", "cli_flag"
# If env var is set and matches, it's actually from env var
elif env_key and opts.api_key == env_key:
suffix = env_key[-4:]
source, key = (
f"CLOUDSMITH_API_KEY env var (ends with ...{suffix})",
"env_var",
)
# CLI flag was set explicitly (not from env)
else:
source, key = "CLI --api-key flag", "cli_flag"
# No CLI flag, check other sources
Comment on lines +32 to +57
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_get_api_key_source() treats any truthy opts.api_key as “CLI --api-key flag was explicitly passed”. However opts.api_key can also be populated from credentials.ini via common_cli_config_options (and Options._set_option prevents later None from clearing it), so credentials-file auth will be misreported as CLI. A more reliable approach is to record Click’s parameter source for --api-key in common_api_auth_options (commandline/envvar/default) or store a separate flag like opts.api_key_from_cli/env instead of inferring from opts.api_key alone.

Copilot uses AI. Check for mistakes.
elif env_key:
suffix = env_key[-4:]
source, key = f"CLOUDSMITH_API_KEY env var (ends with ...{suffix})", "env_var"
elif creds := CredentialsReader.find_existing_files():
source, key = f"credentials.ini ({creds[0]})", "credentials_file"
elif _is_oidc_configured():
org = os.environ.get("CLOUDSMITH_ORG", "")
detector_name = _get_oidc_detector_name()
if detector_name:
source = f"OIDC auto-discovery: {detector_name} (org: {org})"
else:
source = f"OIDC auto-discovery (org: {org})"
key = "oidc"
else:
source, key = "CLI --api-key flag", "cli_flag"
source, key = "Unknown source", "unknown"

return {"configured": True, "source": source, "source_key": key}


def _get_oidc_detector_name():
"""Get the name of the OIDC detector that would be used."""
try:
from cloudsmith_cli.core.credentials.oidc.detectors import detect_environment

detector = detect_environment(debug=False)
if detector:
return detector.name
except Exception: # pylint: disable=broad-exception-caught
# Gracefully handle any detection failures - this is for display only
pass
return None


def _is_oidc_configured():
"""Check if OIDC environment variables are set."""
return bool(
os.environ.get("CLOUDSMITH_ORG") and os.environ.get("CLOUDSMITH_SERVICE_SLUG")
)


def _get_sso_status(api_host):
"""Return SSO token status from the system keyring."""
enabled = keyring.should_use_keyring()
Expand Down Expand Up @@ -120,14 +167,23 @@ def _print_verbose_text(data):
click.echo(f" Source: {ak['source']}")
click.echo(" Note: SSO token is being used instead")
elif active == "api_key":
click.secho("Authentication Method: API Key", fg="cyan", bold=True)
if ak.get("source_key") == "oidc":
click.secho(
"Authentication Method: OIDC Auto-Discovery", fg="cyan", bold=True
)
else:
click.secho("Authentication Method: API Key", fg="cyan", bold=True)
for label, field in [
("Source", "source"),
("Token Slug", "slug"),
("Created", "created"),
]:
if ak.get(field):
click.echo(f" {label}: {ak[field]}")
click.echo()
click.echo(
"💡 Export this token: " + click.style("cloudsmith print-token", fg="cyan")
)
else:
click.secho("Authentication Method: None (anonymous)", fg="yellow", bold=True)

Expand Down
35 changes: 35 additions & 0 deletions cloudsmith_cli/core/api/init.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Cloudsmith API - Initialisation."""

import base64
import logging
from typing import Type, TypeVar

import click
Expand All @@ -11,6 +12,37 @@
from ..rest import RestClient
from .exceptions import ApiException

logger = logging.getLogger(__name__)


def _try_oidc_credential(config):
"""Attempt OIDC auto-discovery as a last-resort credential provider.

Only activates when CLOUDSMITH_ORG and CLOUDSMITH_SERVICE_SLUG are set.
"""
from ..credentials import CredentialContext
from ..credentials.providers import OidcProvider

context = CredentialContext(
api_host=config.host,
debug=config.debug,
proxy=config.proxy,
ssl_verify=config.verify_ssl,
user_agent=config.user_agent,
headers=config.headers.copy() if config.headers else None,
)

provider = OidcProvider()
result = provider.resolve(context)

if result is not None:
config.api_key["X-Api-Key"] = result.api_key

if config.debug:
click.echo(f"OIDC credential resolved: {result.source_detail}")
elif config.debug:
logger.debug("OIDC auto-discovery did not resolve credentials")
Comment on lines 18 to 44
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OIDC auto-discovery currently doesn’t appear to respect the CLI’s API networking configuration (proxy, SSL verify, custom headers/user-agent). _try_oidc_credential() only passes api_host into OidcProvider, and exchange_oidc_token() uses requests.post(...) with default settings, so users relying on CLOUDSMITH_API_PROXY / CLOUDSMITH_WITHOUT_API_SSL_VERIFY may see OIDC fail even though normal API calls work. Consider threading proxy/verify (or a configured requests.Session) through CredentialContext into the detector/exchange code.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good AI. Let me see what can be done here.



def initialise_api(
debug=False,
Expand Down Expand Up @@ -104,6 +136,9 @@ def initialise_api(

if config.debug:
click.echo("User API key config value set")
else:
# No access token and no API key provided — try OIDC auto-discovery
_try_oidc_credential(config)

auth_header = headers and config.headers.get("Authorization")
if auth_header and " " in auth_header:
Expand Down
Loading