uvx ruff check . works without installing anything. That is the whole appeal: one command, a throwaway environment, nothing left behind. It is also the problem. Every invocation re-resolves the package, the version you get today is not necessarily the version you got last week, and which ruff comes back empty because there is nothing on PATH.

uv tool install is the other half of the story. It builds a persistent, isolated environment for a Python CLI and links its executables into a directory on your PATH, so ruff becomes a real command with a version you chose and control. Homebrew’s pipx niche, in other words, handled by the tool you already have.

This article covers the full lifecycle: where uv puts things, how to pin and upgrade, how to add plugins to a tool’s environment, and how to install your own project as a tool.

What you will build

  • A working uv tool installation of ruff, dissected down to the symlink.
  • A pinned tool, plus the reason a pinned tool refuses to upgrade.
  • pytest with pytest-cov injected into its environment via --with.
  • whichtool, a small dependency-free CLI that reports which uv tool provides a command, with a pytest suite and a Makefile.
  • An editable tool install for a fast edit-and-run loop.

Prerequisites

  • macOS 13+ with Homebrew (brew.sh).
  • uv 0.5+brew install uv. Verify with uv --version. Everything below was validated on uv 0.11.26 on macOS 26.5.2 (Apple silicon).
  • Xcode Command Line Tools (xcode-select --install) for make.
  • No Python installation is required in advance. uv downloads an interpreter if it needs one.

If you have used uvx before, you already have everything. uvx is the alias for uv tool run, and it ships in the same binary.

Step 1: Find out where uv keeps tools

Two directories matter, and uv will tell you both.

uv tool dir
uv tool dir --bin

On a default macOS install:

/Users/you/.local/share/uv/tools
/Users/you/.local/bin

The first holds one virtual environment per tool. The second holds the executables that land on your PATH. They are deliberately separate: the environments can be large and are uv’s to manage, while the bin directory contains only symlinks and is the only part your shell needs to know about.

Confirm that the bin directory is actually on your PATH:

uv tool update-shell

If it is already there, the command says so and changes nothing:

Executable directory /Users/you/.local/bin is already in PATH

Otherwise it appends the directory to your shell profile, and you restart your shell. Skipping this step is the single most common way to install a tool successfully and then get command not found.

Check the current inventory, which on a fresh machine is empty:

uv tool list
No tools installed

Step 2: Install a tool and take it apart

ruff is a good first subject: one package, no dependencies, fast to install.

uv tool install ruff
Resolved 1 package in 338ms
Downloading ruff (10.1MiB)
 Downloaded ruff
Prepared 1 package in 305ms
Installed 1 package in 3ms
 + ruff==0.16.1
Installed 1 executable: ruff

Timings vary between runs, and the Downloading lines appear only when the package is not already in uv’s cache, so a reinstall is quieter than the output above. The last line is the one that matters. ruff is now a command:

which ruff
ruff --version
/Users/you/.local/bin/ruff
ruff 0.16.1

uv tool list reports the tool and the executables it published:

ruff v0.16.1
- ruff

What actually got created

Four flags make uv tool list show its work. They compose, so you can ask for all of them at once:

uv tool list --show-paths --show-version-specifiers --show-python
ruff v0.16.1 [CPython 3.12.9] (/Users/you/.local/share/uv/tools/ruff)
- ruff (/Users/you/.local/bin/ruff)

Follow the pieces on disk. The command on your PATH is a symlink:

ls -l ~/.local/bin/ruff
lrwxr-xr-x  1 you  staff  48 Aug  3 06:55 /Users/you/.local/bin/ruff -> /Users/you/.local/share/uv/tools/ruff/bin/ruff

The target lives inside an ordinary virtual environment:

ls -1 ~/.local/share/uv/tools/ruff/
bin
CACHEDIR.TAG
lib
pyvenv.cfg
uv-receipt.toml

pyvenv.cfg and lib/ confirm there is no magic here, just a venv. The interesting file is uv’s own bookkeeping:

cat ~/.local/share/uv/tools/ruff/uv-receipt.toml
[tool]
requirements = [{ name = "ruff" }]
entrypoints = [
    { name = "ruff", install-path = "/Users/you/.local/bin/ruff", from = "ruff" },
]

The receipt is how uv tool upgrade knows what you originally asked for and how uv tool uninstall knows which symlinks to remove. requirements records the request (ruff, unconstrained), not the resolved version. That distinction drives the next step.

Two consequences follow from the layout. Because each tool gets its own environment, two tools that need incompatible versions of the same library never collide. And because the only thing on your PATH is a symlink, removing a tool cannot leave a broken half-installed interpreter behind.

Step 3: Pin a version, then meet the upgrade trap

Pass a version specifier the same way you would to pip. Quote it so your shell does not interpret the = characters:

uv tool install 'ruff==0.14.0'

An existing install is replaced in place. No --force is needed, because the requested version differs from what is recorded in the receipt:

Resolved 1 package in 94ms
Downloading ruff (11.8MiB)
 Downloaded ruff
Prepared 1 package in 290ms
Uninstalled 1 package in 2ms
Installed 1 package in 1ms
 - ruff==0.16.1
 + ruff==0.14.0
Installed 1 executable: ruff

--show-version-specifiers now has something to report:

uv tool list --show-version-specifiers
ruff v0.14.0 [required: ==0.14.0]
- ruff

Ask which tools have newer releases available:

uv tool list --outdated
ruff v0.14.0 [latest: 0.16.1]
- ruff

A newer version exists, so the obvious next command is uv tool upgrade. It does nothing:

uv tool upgrade ruff
Nothing to upgrade

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

This is correct behavior rather than a bug, and it is worth internalizing. uv tool upgrade upgrades within the constraint stored in the receipt. An exact pin leaves no room, so the upgrade is a no-op. --outdated reports on the package index; upgrade reports on your constraint. The two answering differently is the expected result of pinning.

The hint spells out the escape. The @ syntax rewrites the requirement instead of resolving under it:

uv tool install ruff@latest
Resolved 1 package in 1ms
Prepared 1 package in 0.12ms
Uninstalled 1 package in 0.74ms
Installed 1 package in 1ms
 - ruff==0.14.0
 + ruff==0.16.1
Installed 1 executable: ruff

With the pin gone, repeat installs become idempotent and cheap:

uv tool install ruff
`ruff` is already installed

Use uv tool install --force ruff when you want to rebuild the environment anyway, for example after a Python upgrade breaks it.

Step 4: Add plugins to a tool with --with

A tool’s environment is isolated, which means a plugin installed anywhere else is invisible to it. pytest with no plugins is not much use, and --with is the answer: extra packages installed into the tool’s environment without publishing their executables.

uv tool install pytest --with pytest-cov
Resolved 7 packages in 291ms
Prepared 1 package in 117ms
Installed 7 packages in 22ms
 + coverage==7.15.3
 + iniconfig==2.3.0
 + packaging==26.2
 + pluggy==1.6.0
 + pygments==2.20.0
 + pytest==9.1.1
 + pytest-cov==7.1.0
Installed 2 executables: py.test, pytest

Seven packages went in, two executables came out. --show-with records the injection:

uv tool list --show-with
pytest v9.1.1 [with: pytest-cov]
- py.test
- pytest
ruff v0.16.1
- ruff

Verify that the plugin is loaded rather than merely present, by checking that it registered its command-line options:

pytest --help | grep -c -- --cov
11

Repeat --with for each addition (--with pytest-cov --with pytest-xdist), or point at a requirements file with --with-requirements. Because --with is part of the recorded request, the plugins are reinstalled with the tool on every upgrade.

A related flag. --with-executables-from <PACKAGE> also publishes the executables from an injected package, for the case where you want two related commands managed as one tool.

Step 5: Build a CLI worth installing

The rest of the article uses a tool of your own: whichtool, which answers “where did this command come from, and which uv tool owns it?” It has no runtime dependencies, so it installs in about a second.

Create the files

Create the .gitignore first so nothing untracked leaks into a commit. uv init leaves an existing .gitignore in place, so ordering the commands this way is safe:

mkdir -p whichtool
cd whichtool
touch .gitignore
uv init --package --name whichtool --no-workspace

Add the code: .gitignore

# Python
__pycache__/
*.py[cod]
.venv/
.pytest_cache/
.ruff_cache/
.mypy_cache/

# Build artifacts
dist/
build/
wheels/
*.egg-info/

# OS / editor noise
.DS_Store
*.log

Detailed breakdown

  • uv init --package scaffolds a distributable project rather than a loose script: a src/whichtool/ package, a [project.scripts] entry, and the uv_build backend. uv tool install needs all three, because installing a tool means building the package and publishing its console scripts.
  • --no-workspace keeps the project standalone instead of enrolling it in a surrounding uv workspace.
  • dist/ and build/ are ignored because uv build regenerates them.
  • The scaffold also writes .python-version (containing 3.12), which pins the interpreter for local development. It does not constrain the tool environment; Step 10 covers that.

Create the file

touch src/whichtool/resolve.py

Add the code: src/whichtool/resolve.py

"""Resolve a command name to the environment that provides it."""

from __future__ import annotations

import shutil
import subprocess
from dataclasses import dataclass
from pathlib import Path


@dataclass(frozen=True)
class Resolution:
    """What a single command name resolves to."""

    name: str
    shim: Path | None
    target: Path | None
    tool: str | None

    @property
    def found(self) -> bool:
        return self.shim is not None

    @property
    def uv_managed(self) -> bool:
        return self.tool is not None


def uv_tool_dir(runner=subprocess.run) -> Path | None:
    """Ask uv where it keeps tool environments, or None if uv is unavailable."""
    uv = shutil.which("uv")
    if uv is None:
        return None
    try:
        result = runner(
            [uv, "tool", "dir"],
            capture_output=True,
            text=True,
            timeout=30,
            check=True,
        )
    except (OSError, subprocess.SubprocessError):
        return None
    output = result.stdout.strip()
    return Path(output) if output else None


def owning_tool(target: Path, tool_dir: Path) -> str | None:
    """Return the name of the uv tool that owns target, if any."""
    try:
        relative = target.resolve().relative_to(tool_dir.resolve())
    except (OSError, ValueError):
        return None
    parts = relative.parts
    return parts[0] if parts else None


def resolve(name: str, tool_dir: Path | None, path: str | None = None) -> Resolution:
    """Resolve name against PATH and attribute it to a uv tool when possible."""
    found = shutil.which(name, path=path)
    if found is None:
        return Resolution(name=name, shim=None, target=None, tool=None)
    shim = Path(found)
    target = shim.resolve()
    tool = owning_tool(target, tool_dir) if tool_dir is not None else None
    return Resolution(name=name, shim=shim, target=target, tool=tool)


def format_resolution(resolution: Resolution) -> str:
    """Render one resolution as a single output line."""
    if not resolution.found:
        return f"{resolution.name}: not found on PATH"
    if resolution.uv_managed:
        return f"{resolution.name}: {resolution.shim} -> uv tool '{resolution.tool}'"
    return f"{resolution.name}: {resolution.shim} (not a uv tool)"

Detailed breakdown

  • Resolution is a frozen dataclass holding the four facts about one lookup: the name asked for, the path PATH resolved to (shim), the path after following symlinks (target), and the owning tool name. Keeping shim and target separate is the whole point, since Step 2 showed they differ for every uv-installed tool.
  • uv_tool_dir shells out to uv tool dir rather than hardcoding ~/.local/share/uv/tools, because that path moves with UV_TOOL_DIR and XDG_DATA_HOME (Step 10). The runner parameter defaults to subprocess.run and exists so tests can substitute a fake without spawning a process. Every failure path returns None, so a machine without uv degrades to plain which behavior instead of crashing.
  • owning_tool does the attribution with Path.relative_to, which raises ValueError when the target is outside the tool root. Both sides are .resolve()d first so that /var versus /private/var on macOS cannot produce a false negative. The tool name is the first path component under the root, matching the <tool-dir>/<name>/bin/<exe> layout.
  • resolve accepts an explicit path argument, passed straight to shutil.which. In production it stays None and the real PATH is used; in tests it points at a fixture directory.
  • format_resolution covers exactly three states (missing, uv-managed, unmanaged) and returns a string instead of printing, which keeps it testable.

Create the file

touch src/whichtool/cli.py

Add the code: src/whichtool/cli.py

"""Command-line interface for whichtool."""

from __future__ import annotations

import argparse
from importlib.metadata import version

from whichtool.resolve import format_resolution, resolve, uv_tool_dir


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="whichtool",
        description="Show which uv tool environment provides a command.",
    )
    parser.add_argument(
        "names",
        nargs="+",
        metavar="COMMAND",
        help="one or more command names to look up",
    )
    parser.add_argument(
        "--managed-only",
        action="store_true",
        help="print only commands provided by a uv tool",
    )
    parser.add_argument(
        "--version",
        action="version",
        version=f"whichtool {version('whichtool')}",
    )
    return parser


def main(argv: list[str] | None = None) -> int:
    args = build_parser().parse_args(argv)
    tool_dir = uv_tool_dir()
    missing = 0
    for name in args.names:
        resolution = resolve(name, tool_dir)
        if not resolution.found:
            missing += 1
        if args.managed_only and not resolution.uv_managed:
            continue
        print(format_resolution(resolution))
    return 1 if missing else 0

Detailed breakdown

  • build_parser is a separate function so tests can inspect the parser without running the program.
  • version('whichtool') reads the installed package metadata instead of duplicating the version string in the source. It works because the tool is always installed into an environment before it runs, whether that is uv run, a wheel, or uv tool install.
  • main(argv=None) returns an int rather than calling sys.exit, so tests can assert on the exit code directly. argv=None makes argparse fall back to sys.argv[1:] in real use.
  • uv_tool_dir() is called once, not once per name, which keeps a lookup of ten commands to a single subprocess.
  • The missing counter is incremented before the --managed-only filter, so a typo still produces exit code 1 even when its output line is suppressed. A filter that could silently mask a failure would make the tool useless in a script.

Create the file

touch src/whichtool/__init__.py

Add the code: src/whichtool/__init__.py

"""whichtool: report which uv tool environment provides a command."""

from whichtool.cli import main

__all__ = ["main"]

Detailed breakdown

The scaffold wrote a placeholder main here that prints a greeting; replace it entirely. pyproject.toml declares whichtool = "whichtool:main", so the console script imports main from the package root. Re-exporting it from cli.py keeps that entry point short while the implementation lives in a module of its own.

Step 6: Test it

Create the files

mkdir -p tests
touch tests/conftest.py tests/test_resolve.py tests/test_cli.py
uv add --dev pytest

Add the code: tests/conftest.py

"""Shared fixtures: a miniature stand-in for the uv tool layout."""

from __future__ import annotations

import pytest


@pytest.fixture
def fake_layout(tmp_path):
    """Build a tool root and a shim directory that mirror uv's real layout.

    Returns (tool_dir, bin_dir). `ruff` is a symlink into the tool root, the way
    uv installs it; `systemtool` is an ordinary executable that is not.
    """
    tool_dir = tmp_path / "tools"
    bin_dir = tmp_path / "bin"
    (tool_dir / "ruff" / "bin").mkdir(parents=True)
    bin_dir.mkdir()

    real = tool_dir / "ruff" / "bin" / "ruff"
    real.write_text("#!/bin/sh\nexit 0\n")
    real.chmod(0o755)
    (bin_dir / "ruff").symlink_to(real)

    unmanaged = bin_dir / "systemtool"
    unmanaged.write_text("#!/bin/sh\nexit 0\n")
    unmanaged.chmod(0o755)

    return tool_dir, bin_dir

Detailed breakdown

The fixture rebuilds the structure from Step 2 in a temporary directory: a tool root containing ruff/bin/ruff, and a bin directory whose ruff is a symlink into it. Tests then run against a layout they fully control, so the suite passes on a machine with no tools installed and cannot be broken by whatever you have installed for real. chmod(0o755) matters because shutil.which only returns files with the execute bit set. systemtool is the control case, an executable that lives outside the tool root.

Add the code: tests/test_resolve.py

"""Tests for the resolution logic."""

from __future__ import annotations

import subprocess
from pathlib import Path

from whichtool.resolve import (
    Resolution,
    format_resolution,
    owning_tool,
    resolve,
    uv_tool_dir,
)


def test_resolve_attributes_a_shim_to_its_tool(fake_layout):
    tool_dir, bin_dir = fake_layout
    result = resolve("ruff", tool_dir, path=str(bin_dir))
    assert result.found
    assert result.uv_managed
    assert result.tool == "ruff"
    assert result.shim == bin_dir / "ruff"
    assert result.target == (tool_dir / "ruff" / "bin" / "ruff").resolve()


def test_resolve_leaves_unmanaged_commands_unattributed(fake_layout):
    tool_dir, bin_dir = fake_layout
    result = resolve("systemtool", tool_dir, path=str(bin_dir))
    assert result.found
    assert not result.uv_managed
    assert result.tool is None


def test_resolve_reports_a_missing_command(fake_layout):
    tool_dir, bin_dir = fake_layout
    result = resolve("nosuchcommand", tool_dir, path=str(bin_dir))
    assert not result.found
    assert result.shim is None
    assert result.target is None


def test_resolve_without_a_tool_dir_skips_attribution(fake_layout):
    _, bin_dir = fake_layout
    result = resolve("ruff", None, path=str(bin_dir))
    assert result.found
    assert result.tool is None


def test_owning_tool_rejects_paths_outside_the_tool_root(tmp_path):
    outside = tmp_path / "elsewhere" / "bin" / "ruff"
    outside.parent.mkdir(parents=True)
    outside.touch()
    assert owning_tool(outside, tmp_path / "tools") is None


def test_uv_tool_dir_parses_the_command_output(monkeypatch):
    monkeypatch.setattr("whichtool.resolve.shutil.which", lambda name: "/usr/bin/uv")

    def fake_runner(cmd, **kwargs):
        assert cmd[1:] == ["tool", "dir"]
        return subprocess.CompletedProcess(cmd, 0, stdout="/opt/uv/tools\n", stderr="")

    assert uv_tool_dir(runner=fake_runner) == Path("/opt/uv/tools")


def test_uv_tool_dir_is_none_without_uv(monkeypatch):
    monkeypatch.setattr("whichtool.resolve.shutil.which", lambda name: None)
    assert uv_tool_dir() is None


def test_uv_tool_dir_is_none_when_uv_fails(monkeypatch):
    monkeypatch.setattr("whichtool.resolve.shutil.which", lambda name: "/usr/bin/uv")

    def failing_runner(cmd, **kwargs):
        raise subprocess.CalledProcessError(2, cmd)

    assert uv_tool_dir(runner=failing_runner) is None


def test_format_resolution_covers_all_three_states():
    managed = Resolution("ruff", Path("/u/bin/ruff"), Path("/u/tools/ruff/bin/ruff"), "ruff")
    unmanaged = Resolution("git", Path("/usr/bin/git"), Path("/usr/bin/git"), None)
    missing = Resolution("nope", None, None, None)

    assert format_resolution(managed) == "ruff: /u/bin/ruff -> uv tool 'ruff'"
    assert format_resolution(unmanaged) == "git: /usr/bin/git (not a uv tool)"
    assert format_resolution(missing) == "nope: not found on PATH"

Detailed breakdown

  • The first four tests exercise the three output states plus the no-uv-available case, all against the fixture layout.
  • test_owning_tool_rejects_paths_outside_the_tool_root pins the ValueError branch of relative_to. Without it, a bug that attributed every command on the system to some tool would pass the rest of the suite.
  • The three uv_tool_dir tests replace shutil.which inside the module under test, then pass a substitute runner. The successful case also asserts the command that would have been run (["tool", "dir"]), so a future edit that changes the arguments fails loudly. No real subprocess is spawned, which is what keeps the suite fast and hermetic.
  • test_format_resolution_covers_all_three_states constructs Resolution objects directly with fixed paths, making the assertions exact string comparisons rather than substring checks.

Add the code: tests/test_cli.py

"""Tests for the command-line entry point."""

from __future__ import annotations

import pytest

from whichtool.cli import main


@pytest.fixture
def cli_env(fake_layout, monkeypatch):
    """Point PATH at the fake shim directory and uv at the fake tool root."""
    tool_dir, bin_dir = fake_layout
    monkeypatch.setenv("PATH", str(bin_dir))
    monkeypatch.setattr("whichtool.cli.uv_tool_dir", lambda: tool_dir)
    return tool_dir, bin_dir


def test_main_reports_a_managed_tool(cli_env, capsys):
    exit_code = main(["ruff"])
    assert exit_code == 0
    assert "uv tool 'ruff'" in capsys.readouterr().out


def test_main_exits_nonzero_when_a_command_is_missing(cli_env, capsys):
    exit_code = main(["ruff", "nosuchcommand"])
    assert exit_code == 1
    output = capsys.readouterr().out
    assert "nosuchcommand: not found on PATH" in output


def test_managed_only_filters_out_everything_else(cli_env, capsys):
    exit_code = main(["--managed-only", "ruff", "systemtool"])
    assert exit_code == 0
    output = capsys.readouterr().out
    assert "ruff" in output
    assert "systemtool" not in output


def test_managed_only_still_fails_on_a_missing_command(cli_env):
    assert main(["--managed-only", "nosuchcommand"]) == 1


def test_no_arguments_is_a_usage_error():
    with pytest.raises(SystemExit) as excinfo:
        main([])
    assert excinfo.value.code == 2

Detailed breakdown

  • cli_env builds on fake_layout and closes the two holes that would otherwise make CLI tests depend on the host: it rewrites PATH to the fixture directory with monkeypatch.setenv, and replaces uv_tool_dir as imported into cli.py so no subprocess runs. Patching the name in whichtool.cli rather than whichtool.resolve is what makes the substitution take effect, since cli.py imported the function directly.
  • test_managed_only_still_fails_on_a_missing_command is the guard for the ordering decision in main. Move the counter after the filter and this test fails while every other test still passes.
  • test_no_arguments_is_a_usage_error documents argparse’s exit code 2 for a missing required argument, which reaches the caller as SystemExit rather than a return value.

Run the suite:

uv run pytest -q
..............                                                           [100%]
14 passed in 0.02s

Step 7: Add a Makefile

Create the file

touch Makefile

Add the code: Makefile

.DEFAULT_GOAL := help

.PHONY: help sync test run build install-tool install-editable list uninstall-tool check clean

help:  ## Show this help screen
	@echo "whichtool - available targets"
	@echo
	@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
		| awk 'BEGIN {FS = ":.*?## "}; {printf "  \033[36m%-18s\033[0m %s\n", $$1, $$2}'
	@echo

sync:  ## Install project and dev dependencies
	uv sync

test:  ## Run the test suite
	uv run pytest -q

run:  ## Run the CLI from the project (make run ARGS="ruff pytest")
	uv run whichtool $(ARGS)

build:  ## Build the wheel and sdist into dist/
	uv build

install-tool:  ## Install this project as a uv tool
	uv tool install --force .

install-editable:  ## Install as an editable uv tool for a fast edit loop
	uv tool install --force --editable .

list:  ## Show installed uv tools with their paths
	uv tool list --show-paths

uninstall-tool:  ## Remove the installed uv tool
	uv tool uninstall whichtool

check: test  ## Run tests, then exercise the installed shim
	whichtool ruff whichtool

clean:  ## Remove build artifacts and caches
	rm -rf dist build .pytest_cache
	find . -name __pycache__ -type d -prune -exec rm -rf {} +

Detailed breakdown

  • .DEFAULT_GOAL := help makes a bare make print the help screen instead of running the first target.
  • The help target parses the Makefile itself. Every target ending in ## description is picked up by the grep/awk pair, so a new target documents itself the moment you add the comment. The doubled $$ escapes the shell’s $ from make.
  • check depends on test and then runs the installed shim rather than uv run, which verifies the packaging rather than the source tree.
  • run takes ARGS because the CLI requires at least one positional argument; make run with no ARGS exits 2, matching the CLI’s own contract.
  • Recipe lines must begin with a real tab character.

Verify the default target:

make
whichtool - available targets

  help               Show this help screen
  sync               Install project and dev dependencies
  test               Run the test suite
  run                Run the CLI from the project (make run ARGS="ruff pytest")
  build              Build the wheel and sdist into dist/
  install-tool       Install this project as a uv tool
  install-editable   Install as an editable uv tool for a fast edit loop
  list               Show installed uv tools with their paths
  uninstall-tool     Remove the installed uv tool
  check              Run tests, then exercise the installed shim
  clean              Remove build artifacts and caches

Step 8: Install your own project as a tool

uv tool install accepts a directory, so a local project installs like any published package. From the project root:

uv tool install .
Resolved 1 package in 0.54ms
   Building whichtool @ file:///Users/you/whichtool
      Built whichtool @ file:///Users/you/whichtool
Prepared 1 package in 2ms
Installed 1 package in 1ms
 + whichtool==0.1.0 (from file:///Users/you/whichtool)
Installed 1 executable: whichtool

The tool can now describe its own installation, alongside everything else installed so far:

whichtool ruff pytest whichtool git nosuchcommand
ruff: /Users/you/.local/bin/ruff -> uv tool 'ruff'
pytest: /Users/you/.local/bin/pytest -> uv tool 'pytest'
whichtool: /Users/you/.local/bin/whichtool -> uv tool 'whichtool'
git: /opt/homebrew/bin/git (not a uv tool)
nosuchcommand: not found on PATH

The exit code is 1 because of the last line, so whichtool <name> works as a guard in a shell script.

The editable loop

A plain uv tool install . copies a built wheel into the tool environment. Edit the source afterwards and the installed command does not change. Change the (not a uv tool) string in resolve.py and run the shim again:

whichtool git
git: /opt/homebrew/bin/git (not a uv tool)

The old string, because the installed copy is a snapshot. Reinstall in editable mode:

uv tool install --force --editable .
Installed 1 package in 1ms
 + whichtool==0.1.0 (from file:///Users/you/whichtool)
Installed 1 executable: whichtool
whichtool git
git: /opt/homebrew/bin/git [system]

The edit is now live, and every subsequent edit takes effect without reinstalling:

whichtool git
git: /opt/homebrew/bin/git (managed elsewhere)

Restore the original string in src/whichtool/resolve.py before continuing, so the source matches Step 5 again. Under an editable install the shim reflects the revert immediately, with no reinstall:

whichtool git
git: /opt/homebrew/bin/git (not a uv tool)

--force is required here because whichtool is already installed and the requirement string is otherwise unchanged. Editable mode is the right default while developing a tool you use daily; switch back with uv tool install --force . when you want a frozen copy.

Step 9: uv tool run versus uv tool install

uvx is the alias for uv tool run, and the two have nothing to do with your installed tools. The @ syntax makes the difference visible. With ruff 0.16.1 installed, run a different version ephemerally:

uvx [email protected] --version
Installed 1 package in 1ms
ruff 0.14.0

Nothing about the persistent install changed:

uv tool list
ruff --version
pytest v9.1.1
- py.test
- pytest
ruff v0.16.1
- ruff
whichtool v0.1.0
- whichtool
ruff 0.16.1

Pick between them on lifetime, not preference:

SituationCommand
A command you type most daysuv tool install
A version you need to hold steady across a team or a machineuv tool install 'pkg==x.y.z'
One-off, or a tool you use twice a yearuvx pkg
CI, where the runner is discarded anywayuvx pkg
Trying a tool before committing to ituvx pkg, then install if it sticks

For packaging and publishing a tool so that others can uvx it, see Package and Distribute a FastMCP Server as a uvx Tool on macOS and Build a Python CLI Tool and Distribute It on GitHub with uvx.

Step 10: Environment variables worth knowing

Every uv tool flag has an environment variable behind it, which uv tool install --help prints as the [env: ...] binding on each flag. Two are worth knowing for day-to-day use.

Relocating the tool root

UV_TOOL_DIR and UV_TOOL_BIN_DIR move the two directories from Step 1. Neither appears in --help, but both are honored:

UV_TOOL_DIR=/tmp/scratch-tools uv tool dir
UV_TOOL_BIN_DIR=/tmp/scratch-bin uv tool dir --bin
/tmp/scratch-tools
/tmp/scratch-bin

They partition state completely, so a uv tool list under a different root reports its own inventory rather than your usual one:

UV_TOOL_DIR=/tmp/scratch-tools UV_TOOL_BIN_DIR=/tmp/scratch-bin uv tool list
No tools installed

That makes them the safe way to try something without disturbing your real installation. uv respects XDG_DATA_HOME too, which relocates the default without naming uv explicitly:

XDG_DATA_HOME=/tmp/xdg uv tool dir
/tmp/xdg/uv/tools

Installing into a directory that is not on PATH produces a warning rather than a silent failure:

warning: `/tmp/scratch-bin` is not on your PATH. To use installed tools, run `export PATH="/tmp/scratch-bin:$PATH"` or `uv tool update-shell`.

Choosing the interpreter

UV_PYTHON sets the interpreter for tool environments, matching the --python flag. The choice shows up in uv tool list:

uv tool install --force --python 3.13 ruff
uv tool list --show-python
ruff v0.16.1 [CPython 3.13.14]
- ruff

A tool’s interpreter is fixed at install time and does not follow a project’s .python-version. Running uv tool install ruff from a directory whose .python-version reads 3.13 still produced a CPython 3.12.9 environment, so reach for --python or UV_PYTHON whenever the version matters. When you remove a Python version that a tool was built against, rebuild the affected tools with uv tool install --force.

Step 11: Upgrade, uninstall, and clean up

Upgrade one tool, several by name, or everything:

uv tool upgrade ruff
uv tool upgrade ruff pytest
uv tool upgrade --all
Nothing to upgrade

Upgrades honor the recorded requirement, including any --with packages, so a tool that was installed with plugins keeps them. A tool installed from a local directory is upgraded by reinstalling from that directory, not by upgrade.

Removal takes one or more names, and --all clears everything:

uv tool uninstall whichtool
Uninstalled 1 executable: whichtool

Uninstalling removes both the environment and the symlinks recorded in the receipt. To restore the machine to its starting state:

uv tool uninstall --all
uv tool list
No tools installed

The download cache survives uninstalls, which is why reinstalling a tool you removed a minute ago is nearly instant. Clear it with uv cache clean when you need the space.

Troubleshooting

command not found right after a successful install. The bin directory is not on your PATH. Run uv tool update-shell, then restart your shell. Confirm with uv tool dir --bin and compare against echo $PATH.

uv tool upgrade says “Nothing to upgrade” but --outdated disagrees. The tool was installed with an exact pin. Use uv tool install <name>@latest, as the hint in the output suggests.

A tool broke after a Python upgrade. The environment references an interpreter that is gone. Rebuild it with uv tool install --force <name>.

Two tools need conflicting versions of the same library. They do not interact. Each tool has its own environment, and this is the failure mode uv tool exists to prevent.

Wrong ruff running. Another copy earlier on PATH is winning, commonly one from Homebrew or a project virtual environment. whichtool ruff shows which directory the resolved command came from and whether uv owns it.

Changes to a local tool’s source are not showing up. A non-editable install is a snapshot. Reinstall with uv tool install --force --editable ..

Recap

  • uv tool dir and uv tool dir --bin name the two directories. uv tool update-shell puts the second on your PATH.
  • An installed tool is a virtual environment plus a symlink, described by uv-receipt.toml. That receipt is what upgrade and uninstall read.
  • A pin recorded in the receipt makes uv tool upgrade a no-op. @latest rewrites the requirement; --outdated reports the index, not your constraint.
  • --with injects packages into a tool’s environment without publishing their executables, which is how plugins reach an isolated tool.
  • uv tool install . installs a local project; --editable makes source edits take effect without reinstalling.
  • uvx is uv tool run, ephemeral by design, and never touches installed tools.
  • UV_TOOL_DIR and UV_TOOL_BIN_DIR relocate the tool root, which is the safe way to experiment. UV_PYTHON picks the interpreter, which a project’s .python-version will not do for you.

Next improvements

  • Add --json output to whichtool so it composes with jq in scripts.
  • Have whichtool read uv tool list --show-paths to report the tool version alongside the path.
  • Keep a uv tool list snapshot in version control as a manifest of the tools a machine is supposed to have.