Skip to content

How to distribute internal Python CLI tools with uv

uv

Your team built a CLI. Now you need one install path for teammate laptops and fresh CI runners, plus a coordinated way to push updates. uv installs the tool from a private index, a git tag, or a wheel, and uv tool install pkg@latest rolls out upgrades on command.

This guide picks up where How to create and distribute a Python CLI tool leaves off. That page covers publishing the package; this one covers installing it, pinning it, and keeping it current across a team.

Confirm the package is installable

Before distributing, confirm the project defines a [project.scripts] entry point and builds a wheel. In the tool’s repository:

$ uv build
Successfully built dist/mycli-0.1.0.tar.gz
Successfully built dist/mycli-0.1.0-py3-none-any.whl

The wheel is what every downstream install consumes, whether served from an index, a git clone, or a shared bucket.

Install from a private PyPI index

If your team already runs a private index (Artifactory, Nexus, CodeArtifact, Azure Artifacts, GitLab Packages, GitHub Packages), that’s the production path. uv tool install accepts --index using a name=url form so auth environment variables can bind to the name:

uv tool install mycli --index internal=https://pypi.example.internal/simple

uv resolves the package against the private index, creates a dedicated environment for the tool, and places the entry-point executable in ~/.local/bin on macOS and Linux (%USERPROFILE%\.local\bin on Windows). Run uv tool dir --bin to print the exact directory uv uses on the current platform.

Authentication uses the same UV_INDEX_<NAME>_USERNAME / UV_INDEX_<NAME>_PASSWORD pair as uv sync, where <NAME> is the uppercased index name. See How to use private package indexes with uv for the full matrix of authentication methods (named env vars, .netrc, keyring, cloud-provider tokens).

Teammates can persist the index in ~/.config/uv/uv.toml (Linux/macOS) or %APPDATA%\uv\uv.toml (Windows) so they don’t retype it:

~/.config/uv/uv.toml
[[index]]
name = "internal"
url = "https://pypi.example.internal/simple"

With that in place, uv tool install mycli works without any flags, and UV_INDEX_INTERNAL_USERNAME / UV_INDEX_INTERNAL_PASSWORD supply the credentials.

Install from a git repository

When the team doesn’t operate a private index, install directly from the git repository that hosts the tool. A tagged release pins to a specific commit:

uv tool install "git+ssh://[email protected]/acme/[email protected]"

uv clones the repo, builds the wheel locally, and installs the entry point. Subsequent installs reuse the cached build as long as the ref resolves to the same commit.

Replace @v1.2.0 with @main to follow the main branch, or @<sha> to pin to a specific commit. Branch refs drift over time; tags and SHAs don’t. For internal tools, prefer tags (@v1.2.0) during rollout and SHAs (@abc1234) for emergency pins.

HTTPS URLs work too, but require a credential helper or a personal access token embedded in the URL. SSH is usually easier on developer machines because the SSH agent is already configured for git clone.

Install from a pre-built wheel

If CI publishes wheels to an artifact bucket (S3, GCS, Azure Blob, internal file server), install straight from the artifact URL or a local path:

# From a URL
uv tool install https://artifacts.example.internal/mycli-1.2.0-py3-none-any.whl

# From a local file
uv tool install ./mycli-1.2.0-py3-none-any.whl

Use this path when your team does not want to run a package index. The wheel filename encodes the version, so rollback means pointing teammates at the previous artifact URL. No registry to run and no package-name collisions with PyPI.

Dependencies of the tool still resolve against PyPI unless --index or --default-index point elsewhere. If the tool depends on other internal packages, serve those from a private index too.

Pin and upgrade the team

Install with == to freeze every teammate to a specific release:

uv tool install 'mycli==1.2.0' --index internal=https://pypi.example.internal/simple

When pinned with ==, uv tool upgrade mycli becomes a no-op and prints a hint:

$ uv tool upgrade mycli
Nothing to upgrade

hint: `mycli` is pinned to `1.2.0` (installed with an exact version pin); reinstall with `uv tool install mycli@latest` to upgrade to a new version.

For a team tool, that’s the point: teammates cannot drift forward until the lead coordinates the rollout. When the new version ships, the whole team runs one command together:

uv tool install mycli@latest --index internal=https://pypi.example.internal/simple

@latest is the explicit opt-in to re-resolve past a pin. It reinstalls the tool and updates the executable on PATH.

For git installs, pin to a tag for the same effect:

uv tool install "git+ssh://[email protected]/acme/[email protected]"

A tagged git ref stays pinned until someone reinstalls from a different tag or SHA. There is no @latest shortcut for git URLs; you pass the new tag explicitly.

To upgrade every tool at once:

uv tool upgrade --all

This skips exactly-pinned tools by design, so mixing pinned and floating tools on the same machine is safe.

For fleet management, ship the upgrade command in a git-tracked bootstrap script (covered under Bootstrap new machines in one script) and have teammates re-run it. This avoids ad-hoc Slack messages and keeps the upgrade trail in version control.

Run one-off without installing

For a command that doesn’t warrant a permanent install (a one-time migration or an onboarding audit), uvx runs the tool from a throwaway environment:

# From a private index
uvx --index internal=https://pypi.example.internal/simple mycli --dry-run

# From a git repository
uvx --from "git+ssh://[email protected]/acme/[email protected]" mycli --dry-run

# From a wheel URL
uvx --from https://artifacts.example.internal/mycli-1.2.0-py3-none-any.whl mycli --dry-run

uvx caches the build and reuses it on subsequent runs, so the second invocation resolves in milliseconds. The tool never appears on PATH or in uv tool list. Use this form in CI jobs that run the tool once per pipeline.

See When to use uv run vs uvx if you’re deciding between the two for a specific workflow.

Bootstrap new machines in one script

Put the install command in a script that new hires and CI runners both execute:

scripts/bootstrap.sh
#!/usr/bin/env bash
set -euo pipefail

# Install uv if not already present
if ! command -v uv >/dev/null; then
    curl -LsSf https://astral.sh/uv/install.sh | sh
fi

# Install internal CLIs at pinned versions
export UV_INDEX_INTERNAL_USERNAME="${CI_REGISTRY_USER:-$USER}"
export UV_INDEX_INTERNAL_PASSWORD="${CI_REGISTRY_TOKEN:?set CI_REGISTRY_TOKEN}"

INDEX="internal=https://pypi.example.internal/simple"
uv tool install "mycli==1.2.0" --index "$INDEX"
uv tool install "deploy-tool==0.5.3" --index "$INDEX"

A new hire runs the script on their first day. CI runs the same script at the top of every job. When you release a new version of any tool, bump the pin, commit, and ask teammates to re-run. The upgrade happens in one place.

Tip

In GitHub Actions, astral-sh/setup-uv@v7 installs uv with dependency caching already configured. Add it as a step, then run the uv tool install commands directly, skipping the command -v uv check.

Learn More

Last updated on

Please submit corrections and feedback...