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 toolinstallation ofruff, dissected down to the symlink. - A pinned tool, plus the reason a pinned tool refuses to upgrade.
pytestwithpytest-covinjected into its environment via--with.whichtool, a small dependency-free CLI that reports which uv tool provides a command, with apytestsuite and aMakefile.- 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 withuv --version. Everything below was validated on uv 0.11.26 on macOS 26.5.2 (Apple silicon). - Xcode Command Line Tools (
xcode-select --install) formake. - 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 --packagescaffolds a distributable project rather than a loose script: asrc/whichtool/package, a[project.scripts]entry, and theuv_buildbackend.uv tool installneeds all three, because installing a tool means building the package and publishing its console scripts.--no-workspacekeeps the project standalone instead of enrolling it in a surrounding uv workspace.dist/andbuild/are ignored becauseuv buildregenerates them.- The scaffold also writes
.python-version(containing3.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
Resolutionis a frozen dataclass holding the four facts about one lookup: the name asked for, the pathPATHresolved to (shim), the path after following symlinks (target), and the owning tool name. Keepingshimandtargetseparate is the whole point, since Step 2 showed they differ for every uv-installed tool.uv_tool_dirshells out touv tool dirrather than hardcoding~/.local/share/uv/tools, because that path moves withUV_TOOL_DIRandXDG_DATA_HOME(Step 10). Therunnerparameter defaults tosubprocess.runand exists so tests can substitute a fake without spawning a process. Every failure path returnsNone, so a machine without uv degrades to plainwhichbehavior instead of crashing.owning_tooldoes the attribution withPath.relative_to, which raisesValueErrorwhen the target is outside the tool root. Both sides are.resolve()d first so that/varversus/private/varon 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.resolveaccepts an explicitpathargument, passed straight toshutil.which. In production it staysNoneand the realPATHis used; in tests it points at a fixture directory.format_resolutioncovers 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_parseris 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 isuv run, a wheel, oruv tool install.main(argv=None)returns anintrather than callingsys.exit, so tests can assert on the exit code directly.argv=Nonemakesargparsefall back tosys.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
missingcounter is incremented before the--managed-onlyfilter, 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_rootpins theValueErrorbranch ofrelative_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_dirtests replaceshutil.whichinside the module under test, then pass a substituterunner. 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_statesconstructsResolutionobjects 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_envbuilds onfake_layoutand closes the two holes that would otherwise make CLI tests depend on the host: it rewritesPATHto the fixture directory withmonkeypatch.setenv, and replacesuv_tool_diras imported intocli.pyso no subprocess runs. Patching the name inwhichtool.clirather thanwhichtool.resolveis what makes the substitution take effect, sincecli.pyimported the function directly.test_managed_only_still_fails_on_a_missing_commandis the guard for the ordering decision inmain. Move the counter after the filter and this test fails while every other test still passes.test_no_arguments_is_a_usage_errordocumentsargparse’s exit code 2 for a missing required argument, which reaches the caller asSystemExitrather 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 := helpmakes a baremakeprint the help screen instead of running the first target.- The
helptarget parses the Makefile itself. Every target ending in## descriptionis picked up by thegrep/awkpair, so a new target documents itself the moment you add the comment. The doubled$$escapes the shell’s$frommake. checkdepends ontestand then runs the installed shim rather thanuv run, which verifies the packaging rather than the source tree.runtakesARGSbecause the CLI requires at least one positional argument;make runwith noARGSexits 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:
| Situation | Command |
|---|---|
| A command you type most days | uv tool install |
| A version you need to hold steady across a team or a machine | uv tool install 'pkg==x.y.z' |
| One-off, or a tool you use twice a year | uvx pkg |
| CI, where the runner is discarded anyway | uvx pkg |
| Trying a tool before committing to it | uvx 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 diranduv tool dir --binname the two directories.uv tool update-shellputs the second on yourPATH.- An installed tool is a virtual environment plus a symlink, described by
uv-receipt.toml. That receipt is whatupgradeanduninstallread. - A pin recorded in the receipt makes
uv tool upgradea no-op.@latestrewrites the requirement;--outdatedreports the index, not your constraint. --withinjects 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;--editablemakes source edits take effect without reinstalling.uvxisuv tool run, ephemeral by design, and never touches installed tools.UV_TOOL_DIRandUV_TOOL_BIN_DIRrelocate the tool root, which is the safe way to experiment.UV_PYTHONpicks the interpreter, which a project’s.python-versionwill not do for you.
Next improvements
- Add
--jsonoutput towhichtoolso it composes withjqin scripts. - Have
whichtoolreaduv tool list --show-pathsto report the tool version alongside the path. - Keep a
uv tool listsnapshot in version control as a manifest of the tools a machine is supposed to have.