# How to distribute internal Python CLI tools with 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](https://pydevtools.com/handbook/reference/uv.md) 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](https://pydevtools.com/handbook/how-to/how-to-create-and-distribute-a-python-cli-tool.md) 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:

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

The [wheel](https://pydevtools.com/handbook/reference/wheel.md) 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:

```bash
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](https://pydevtools.com/handbook/how-to/how-to-use-private-package-indexes-with-uv.md) 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:

```toml {filename="~/.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:

```bash
uv tool install "git+ssh://git@github.com/acme/mycli.git@v1.2.0"
```

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:

```bash
# 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:

```bash
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:

```console
$ 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:

```bash
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:

```bash
uv tool install "git+ssh://git@github.com/acme/mycli.git@v1.2.0"
```

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:

```bash
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](#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](https://pydevtools.com/handbook/reference/uvx.md) runs the tool from a throwaway environment:

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

# From a git repository
uvx --from "git+ssh://git@github.com/acme/mycli.git@v1.2.0" 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`](https://pydevtools.com/handbook/explanation/when-to-use-uv-run-vs-uvx.md) 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:

```bash {filename="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"
```
```powershell {filename="scripts/bootstrap.ps1"}
$ErrorActionPreference = "Stop"

# Install uv if not already present
if (-not (Get-Command uv -ErrorAction SilentlyContinue)) {
    powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
}

# Install internal CLIs at pinned versions
$env:UV_INDEX_INTERNAL_USERNAME = if ($env:CI_REGISTRY_USER) { $env:CI_REGISTRY_USER } else { $env:USERNAME }
if (-not $env:CI_REGISTRY_TOKEN) { throw "set CI_REGISTRY_TOKEN" }
$env:UV_INDEX_INTERNAL_PASSWORD = $env: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](https://pydevtools.com/handbook/tutorial/setting-up-github-actions-with-uv.md), `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

- [uv: A Complete Guide](https://pydevtools.com/handbook/explanation/uv-complete-guide.md) covers what uv does, how fast it is, the core workflows, and recent releases.
- [uvx reference](https://pydevtools.com/handbook/reference/uvx.md)
- [How to create and distribute a Python CLI tool](https://pydevtools.com/handbook/how-to/how-to-create-and-distribute-a-python-cli-tool.md)
- [How to use private package indexes with uv](https://pydevtools.com/handbook/how-to/how-to-use-private-package-indexes-with-uv.md)
- [When to use `uv run` vs `uvx`](https://pydevtools.com/handbook/explanation/when-to-use-uv-run-vs-uvx.md)
- [uv: tools documentation](https://docs.astral.sh/uv/concepts/tools/)
