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

### Added

- Added `--tag` option to `download` command for filtering packages by tags
- Enhanced tag filtering to support all metadata fields shown as tags in UI (format, architecture, distribution, component, etc.)
- Added download command documentation to README with comprehensive usage examples
- Added `CLOUDSMITH_NO_KEYRING` environment variable to disable keyring usage globally. Set `CLOUDSMITH_NO_KEYRING=1` to skip system keyring operations.
- Added `--request-api-key` flag to `cloudsmith auth` command for fully automated, non-interactive API token retrieval. Auto-creates a token if none exists, or auto-rotates (with warning) if one already exists. Compatible with `--save-config` and `CLOUDSMITH_NO_KEYRING`.

Expand Down
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ The CLI currently supports the following commands (and sub-commands):
- `delete`|`rm`: Delete a package from a repository.
- `dependencies`|`deps`: List direct (non-transitive) dependencies for a package.
- `docs`: Launch the help website in your browser.
- `download`: Download a package from a repository.
- `entitlements`|`ents`: Manage the entitlements for a repository.
- `create`|`new`: Create a new entitlement in a repository.
- `delete`|`rm`: Delete an entitlement from a repository.
Expand Down Expand Up @@ -249,6 +250,45 @@ cloudsmith push rpm --help
```


## Downloading Packages

You can download packages from repositories using the `cloudsmith download` command. The CLI supports various filtering options to help you find and download the exact package you need.

For example, to download a specific package:

```
cloudsmith download your-account/your-repo package-name
```

You can filter by various attributes like version, format, architecture, operating system, and tags:

```
# Download a specific version
cloudsmith download your-account/your-repo package-name --version 1.2.3

# Filter by format and architecture
cloudsmith download your-account/your-repo package-name --format deb --arch amd64

# Filter by package tag (e.g., latest, stable, beta)
cloudsmith download your-account/your-repo package-name --tag latest

# Combine tag with metadata filters
cloudsmith download your-account/your-repo package-name --tag stable --format deb --arch arm64

# Download all associated files (POM, sources, javadoc, etc.)
cloudsmith download your-account/your-repo package-name --all-files

# Preview what would be downloaded without actually downloading
cloudsmith download your-account/your-repo package-name --dry-run
```

For more advanced usage and all available options:

```
cloudsmith download --help
```


## Contributing

Yes! Please do contribute, this is why we love open source. Please see [CONTRIBUTING](https://github.com/cloudsmith-io/cloudsmith-cli/blob/master/CONTRIBUTING.md) for contribution guidelines when making code changes or raising issues for bug reports, ideas, discussions and/or questions (i.e. help required).
Expand Down
13 changes: 12 additions & 1 deletion cloudsmith_cli/cli/commands/download.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@
@click.option(
"--arch", "arch_filter", help="Architecture filter (e.g., 'amd64', 'arm64')."
)
@click.option(
"--tag",
"tag_filter",
help="Filter by package tag (e.g., 'latest', 'stable'). Use --format, --arch, --os for metadata filters.",
)
@click.option(
"--outfile",
type=click.Path(),
Expand Down Expand Up @@ -78,6 +83,7 @@ def download( # noqa: C901
format_filter,
os_filter,
arch_filter,
tag_filter,
outfile,
overwrite,
all_files,
Expand All @@ -88,7 +94,7 @@ def download( # noqa: C901
Download a package from a Cloudsmith repository.

This command downloads a package binary from a Cloudsmith repository. You can
filter packages by version, format, operating system, and architecture.
filter packages by version, format, operating system, architecture, and tags.

Examples:

Expand All @@ -104,6 +110,10 @@ def download( # noqa: C901
# Download with filters and custom output name
cloudsmith download myorg/myrepo mypackage --format deb --arch amd64 --outfile my-package.deb

\b
# Download a package with a specific tag
cloudsmith download myorg/myrepo mypackage --tag latest

\b
# Download all associated files (POM, sources, javadoc, etc.) for a Maven/NuGet package
cloudsmith download myorg/myrepo mypackage --all-files
Expand Down Expand Up @@ -150,6 +160,7 @@ def download( # noqa: C901
format_filter=format_filter,
os_filter=os_filter,
arch_filter=arch_filter,
tag_filter=tag_filter,
yes=yes,
)

Expand Down
54 changes: 53 additions & 1 deletion cloudsmith_cli/cli/tests/commands/test_download.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,59 @@


class TestDownloadCommand(unittest.TestCase):
"""Test the download CLI command."""
@patch("cloudsmith_cli.core.download.list_packages")
@patch("cloudsmith_cli.cli.commands.download.resolve_auth")
def test_download_with_tag_filter_integration(
self, mock_resolve_auth, mock_list_packages
):
"""Integration test: download command with --tag filter (end-to-end)."""
mock_session = Mock()
mock_resolve_auth.return_value = (mock_session, {}, "none")

# Simulate two packages, only one matches the tag
mock_packages = [
{
"name": "test-package",
"version": "1.0.0",
"format": "deb",
"tags": {"info": ["latest", "beta"]},
"filename": "test-package_1.0.0.deb",
"cdn_url": "https://example.com/test-package_1.0.0.deb",
"size": 1024,
},
{
"name": "test-package",
"version": "0.9.0",
"format": "deb",
"tags": {"info": ["beta"]},
"filename": "test-package_0.9.0.deb",
"cdn_url": "https://example.com/test-package_0.9.0.deb",
"size": 512,
},
]
mock_page_info = Mock()
mock_page_info.is_valid = True
mock_page_info.page = 1
mock_page_info.page_total = 1
mock_list_packages.return_value = (mock_packages, mock_page_info)

runner = CliRunner()
result = runner.invoke(
download,
[
"--config-file",
"/dev/null",
"testorg/testrepo",
"test-package",
"--tag",
"latest",
"--dry-run",
],
)

self.assertEqual(result.exit_code, 0)
self.assertIn("test-package v1.0.0", result.output)
self.assertNotIn("test-package v0.9.0", result.output)

def setUp(self):
self.runner = CliRunner()
Expand Down
28 changes: 28 additions & 0 deletions cloudsmith_cli/core/download.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,29 @@ def resolve_auth(
return session, headers, auth_source


def _matches_tag_filter(pkg: Dict, tag_filter: str) -> bool:
"""
Check if a package matches the tag filter.

Only matches against actual package tags (the 'tags' field),
not metadata fields like format, architecture, or distro.
Use --format, --arch, and --os for filtering by those fields.

Args:
pkg: Package dictionary
tag_filter: Tag to match against

Returns:
True if package matches the tag filter
"""
pkg_tags = pkg.get("tags", {})
for tag_category in pkg_tags.values():
if isinstance(tag_category, list) and tag_filter in tag_category:
return True

return False


def resolve_package(
owner: str,
repo: str,
Expand All @@ -62,6 +85,7 @@ def resolve_package(
format_filter: Optional[str] = None,
os_filter: Optional[str] = None,
arch_filter: Optional[str] = None,
tag_filter: Optional[str] = None,
yes: bool = False,
) -> Dict:
"""
Expand All @@ -75,6 +99,7 @@ def resolve_package(
format_filter: Optional format filter
os_filter: Optional OS filter
arch_filter: Optional architecture filter
tag_filter: Optional tag filter
yes: If True, automatically select best match when multiple found

Returns:
Expand Down Expand Up @@ -125,6 +150,9 @@ def resolve_package(
# Apply architecture filter
if arch_filter and pkg.get("architecture") != arch_filter:
continue
# Apply tag filter
if tag_filter and not _matches_tag_filter(pkg, tag_filter):
continue
filtered_packages.append(pkg)
packages = filtered_packages

Expand Down
90 changes: 90 additions & 0 deletions cloudsmith_cli/core/tests/test_download.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,96 @@ def test_resolve_package_with_filters(self, mock_list_packages):
page_size=100,
)

@patch("cloudsmith_cli.core.download.list_packages")
def test_resolve_package_with_tag_filter(self, mock_list_packages):
"""Test package resolution with tag filter."""
mock_packages = [
{
"name": "test-package",
"version": "1.0.0",
"format": "deb",
"tags": {"info": ["latest"], "version": ["stable"]},
},
{
"name": "test-package",
"version": "0.9.0",
"format": "rpm",
"tags": {"info": ["beta"], "version": ["unstable"]},
},
]
mock_page_info = Mock()
mock_page_info.is_valid = True
mock_page_info.page = 1
mock_page_info.page_total = 1
mock_list_packages.return_value = (mock_packages, mock_page_info)

# Test actual tag filtering - should return v1.0.0 (has "latest" tag)
result = download.resolve_package(
"owner", "repo", "test-package", tag_filter="latest"
)
self.assertEqual(result["version"], "1.0.0")

# Test tag from version category - should return v0.9.0 (has "unstable")
result = download.resolve_package(
"owner", "repo", "test-package", tag_filter="unstable"
)
self.assertEqual(result["version"], "0.9.0")

# Tag filter should NOT match metadata fields like format
with self.assertRaises(click.ClickException):
download.resolve_package("owner", "repo", "test-package", tag_filter="deb")

# Tag filter should NOT match metadata fields like architecture
with self.assertRaises(click.ClickException):
download.resolve_package(
"owner", "repo", "test-package", tag_filter="amd64"
)

def test_matches_tag_filter_edge_cases(self):
"""Test _matches_tag_filter function with edge cases."""

# Test package without tags field
pkg_no_tags = {"name": "test", "format": "deb"}
self.assertFalse(download._matches_tag_filter(pkg_no_tags, "latest"))

# Test package with empty tags
pkg_empty_tags = {"tags": {}, "format": "rpm"}
self.assertFalse(download._matches_tag_filter(pkg_empty_tags, "latest"))

# Test matching actual tags
pkg_with_tags = {"tags": {"info": ["test", "upstream"]}}
self.assertTrue(download._matches_tag_filter(pkg_with_tags, "test"))
self.assertTrue(download._matches_tag_filter(pkg_with_tags, "upstream"))
self.assertFalse(download._matches_tag_filter(pkg_with_tags, "nonexistent"))

# Test case-sensitive matching for actual tags
pkg_case_tags = {"tags": {"info": ["Latest", "Beta"]}}
self.assertTrue(download._matches_tag_filter(pkg_case_tags, "Latest"))
self.assertFalse(
download._matches_tag_filter(pkg_case_tags, "latest")
) # case mismatch

# Test multiple tag categories
pkg_multi_cats = {"tags": {"info": ["upstream"], "version": ["latest"]}}
self.assertTrue(download._matches_tag_filter(pkg_multi_cats, "upstream"))
self.assertTrue(download._matches_tag_filter(pkg_multi_cats, "latest"))

# Tag filter should NOT match metadata fields
pkg_metadata = {
"format": "deb",
"architectures": [{"name": "arm64"}],
"distro": {"name": "Ubuntu"},
"distro_version": {"name": "noble"},
"identifiers": {"deb_component": "main"},
"tags": {},
}
self.assertFalse(download._matches_tag_filter(pkg_metadata, "deb"))
self.assertFalse(download._matches_tag_filter(pkg_metadata, "arm64"))
self.assertFalse(download._matches_tag_filter(pkg_metadata, "Ubuntu"))
self.assertFalse(download._matches_tag_filter(pkg_metadata, "noble"))
self.assertFalse(download._matches_tag_filter(pkg_metadata, "main"))
self.assertFalse(download._matches_tag_filter(pkg_metadata, "Ubuntu/noble"))

@patch("cloudsmith_cli.core.download.list_packages")
def test_resolve_package_exact_name_match(self, mock_list_packages):
"""Test that only exact name matches are returned (not partial)."""
Expand Down