A sibling article, Publish a FastMCP Server to PyPI and Run It Anywhere with
uvx, ships a package to the public PyPI index. This one skips the index
entirely. If your code is a Git repository, uvx can install and run its console
script straight from the repo:
uvx --from git+https://github.com/your-username/textkit textkit --help
That command clones the repo into a cached, throwaway environment, builds the
package, and runs its console script — no git clone, no virtualenv, no
pip install on the user’s side. For a personal utility, an internal tool, or
anything you are not ready to name on PyPI, a GitHub repo is the whole
distribution channel.
On the push step. Pushing to GitHub is the one action this article cannot perform for you, and it is outward-facing, so it stays a deliberate manual step. Everything else is verified here against a local
git+file://clone: building the wheel and running it throughuvxby the exact same Git mechanismgit+https://…uses, which exercises the identical clone-build-run path minus the network.
What you will build
- A packaged Python CLI,
textkit, with two subcommands and no runtime dependencies. - A local test-and-run loop with
pytestanduv run. - A wheel verified through
uvxbefore it leaves your machine. - The
git+httpsinstall path — run frommain, or pinned to a released tag.
Prerequisites
- macOS 13+ with Homebrew (brew.sh).
- uv 0.5+ —
brew install uv;uvxships with it. Verify withuv --version. - Git and a GitHub account for the distribution step.
- Xcode Command Line Tools (
xcode-select --install) formake.
Step 1: Scaffold a packaged project
Distribution over uvx needs a real package: a src/ layout, a build backend,
and a [project.scripts] entry. uv init --package produces all three. Create
the .gitignore first so build artifacts and caches never reach a commit.
Create the files
mkdir -p textkit
cd textkit
touch .gitignore
uv init --package --name textkit --no-workspace
Add the code: .gitignore
# Python
__pycache__/
*.py[cod]
.venv/
.uv/
.pytest_cache/
.ruff_cache/
.mypy_cache/
# Build artifacts
dist/
build/
*.egg-info/
# OS / editor noise
.DS_Store
*.log
Detailed breakdown
uv init --package(with--package, not a plainuv init) scaffolds a distributable project: asrc/textkit/package, a[project.scripts]entry, and theuv_buildbackend. That backend is whatuvxinvokes to build the package after it clones your repo.--no-workspacekeeps this a standalone project rather than a member of a surroundinguvworkspace.dist/andbuild/are ignored becauseuv buildregenerates them; committing built wheels is an anti-pattern.uvxbuilds from your source, so there is nothing to commit there.
Step 2: Configure the package
The scaffold’s pyproject.toml needs one edit (a real description) and one dev
dependency (pytest). The runtime dependencies list stays empty on purpose.
Create the files
uv add --dev pytest
Add the code: pyproject.toml
[project]
name = "textkit"
version = "0.1.0"
description = "A small text-toolkit CLI distributed on GitHub and run with uvx"
readme = "README.md"
authors = [
{ name = "Your Name", email = "you@example.com" }
]
requires-python = ">=3.12"
dependencies = []
[project.scripts]
textkit = "textkit:main"
[build-system]
requires = ["uv_build>=0.11.26,<0.12.0"]
build-backend = "uv_build"
[dependency-groups]
dev = [
"pytest>=9.1.1",
]
Detailed breakdown
[project.scripts] textkit = "textkit:main"is the entire distribution contract: it declares a console script namedtextkitthat calls the package’smain. That script is whatuvx --from git+https://…/textkit textkitruns. The firsttextkitafteruvx --from <source>is the command name from this line; when the command and repo names match, the invocation reads cleanly.dependencies = []— the CLI is built entirely on the standard library. A dependency-free package is the fastest thinguvxcan resolve from a Git URL, and it removes any chance of a version conflict on the user’s machine. Add third-party libraries here later if you need them;uvxresolves them the same way it does for a PyPI install.version = "0.1.0"is read back at runtime by the--versionflag (Step 3) and is what a Git tag will pin to in Step 7.
Step 3: Write the CLI
Two subcommands: count (lines, words, characters) and slug (a URL-safe
string). Plain argparse, no dependencies.
Create the file
touch src/textkit/cli.py
Add the code: src/textkit/cli.py
"""Command-line interface for textkit.
The CLI is plain `argparse` with two subcommands, `count` and `slug`, so the
package has no runtime dependencies. A dependency-free wheel is what makes
`uvx --from git+https://github.com/<you>/textkit textkit` resolve with nothing
to download but the package itself.
"""
from __future__ import annotations
import argparse
import re
import sys
from importlib.metadata import version
_SLUG_STRIP = re.compile(r"[^a-z0-9]+")
def _read_text(path: str) -> str:
"""Return the contents of path, or stdin when path is "-"."""
if path == "-":
return sys.stdin.read()
with open(path, encoding="utf-8") as handle:
return handle.read()
def count(text: str) -> tuple[int, int, int]:
"""Return (lines, words, chars) for text.
Lines counts newline characters, so a file with a trailing newline and a
file without one that hold the same visible rows differ by one — the same
rule `wc -l` uses.
"""
lines = text.count("\n")
words = len(text.split())
chars = len(text)
return lines, words, chars
def slugify(text: str) -> str:
"""Lowercase text and reduce it to a URL-safe slug.
Runs of non-alphanumeric characters collapse to a single hyphen, and
leading and trailing hyphens are trimmed.
"""
return _SLUG_STRIP.sub("-", text.lower()).strip("-")
def _cmd_count(args: argparse.Namespace) -> int:
lines, words, chars = count(_read_text(args.file))
print(f"lines: {lines} words: {words} chars: {chars}")
return 0
def _cmd_slug(args: argparse.Namespace) -> int:
print(slugify(" ".join(args.text)))
return 0
def build_parser() -> argparse.ArgumentParser:
"""Construct the top-level parser and its subcommands."""
parser = argparse.ArgumentParser(
prog="textkit",
description="A small text toolkit distributed on GitHub and run with uvx.",
)
parser.add_argument(
"--version",
action="version",
version=f"%(prog)s {version('textkit')}",
)
sub = parser.add_subparsers(dest="command", required=True)
p_count = sub.add_parser("count", help="Count lines, words, and characters.")
p_count.add_argument(
"file",
nargs="?",
default="-",
help="File to read, or - for stdin (the default).",
)
p_count.set_defaults(func=_cmd_count)
p_slug = sub.add_parser("slug", help="Slugify text into a URL-safe string.")
p_slug.add_argument("text", nargs="+", help="Words to slugify.")
p_slug.set_defaults(func=_cmd_slug)
return parser
def main(argv: list[str] | None = None) -> int:
"""Entry point for the `textkit` console script."""
args = build_parser().parse_args(argv)
return args.func(args)
Detailed breakdown
countandslugifyare pure functions that take and return values with no I/O. That is what makes them directly unit-testable in Step 5, separate from the argument parsing and printing.slugifydrops non-ASCII rather than transliterating it.[^a-z0-9]+matches any run that is not a lowercase ASCII letter or digit, so"Café del Mar"becomescaf-del-mar, notcafe-del-mar. That is fine for a slug; note it if your inputs are heavily accented.main(argv=None)takes an optional argument list.argparsedefaults tosys.argv[1:]when you passNone, so the console script works with no argument, and tests can pass an explicit list like["slug", "hi"].mainreturns anint. The console-script wrapperuv_buildgenerates runssys.exit(main()), so returning0gives a success exit code. Subcommand handlers return their own codes.version("textkit")reads the installed package version from its metadata, so--versionalways reports whatever is inpyproject.tomlwithout repeating the number in code. It resolves becauseuvx(anduv) install the package before running it.
Step 4: Wire the console-script entry point
uv init --package left a placeholder main in __init__.py. Replace it so the
textkit:main target from pyproject.toml resolves to the real CLI.
Create/replace the file
# already exists from `uv init --package`; replace its contents
touch src/textkit/__init__.py
Add the code: src/textkit/__init__.py
"""textkit: a small, installable text toolkit.
`main` is the target of the `textkit` console script declared in
pyproject.toml, so `uvx --from git+https://github.com/<you>/textkit textkit`
runs it. It is re-exported here so the script target stays the short
`textkit:main`.
"""
from .cli import main
__all__ = ["main"]
Detailed breakdown
[project.scripts] textkit = "textkit:main"points attextkit.main, which is the package’s top-level__init__.py. Re-exportingmainfromclithere keeps the entry-point string short instead oftextkit.cli:main. Either form works; this one reads better.- Keeping the logic in
cli.pyand only re-exporting here means importing the package is cheap and side-effect-free — no argument parsing happens untilmainis actually called.
Step 5: Test and run it locally
Add a pytest config and a test module, then drive both the pure functions and
main.
Create the files
mkdir -p tests
touch tests/__init__.py tests/test_cli.py pytest.ini
Add the code: pytest.ini
[pytest]
testpaths = tests
Add the code: tests/test_cli.py
"""Unit tests for the textkit CLI, driven through the public functions and main()."""
import pytest
from textkit.cli import count, main, slugify
def test_count_basic():
# Two newlines -> 2 lines; split() gives 5 words; len counts every char.
text = "one two three\nfour five\n"
assert count(text) == (2, 5, 24)
def test_count_no_trailing_newline():
# No trailing newline: same words, one fewer counted line than wc -l shows.
assert count("alpha beta") == (0, 2, 10)
@pytest.mark.parametrize(
"raw, expected",
[
("Hello, World!", "hello-world"),
(" Leading and trailing ", "leading-and-trailing"),
("Café del Mar & symbols #1", "caf-del-mar-symbols-1"),
("---already--slugged---", "already-slugged"),
],
)
def test_slugify(raw, expected):
assert slugify(raw) == expected
def test_main_count_from_file(tmp_path, capsys):
target = tmp_path / "sample.txt"
target.write_text("one two three\nfour five\n", encoding="utf-8")
rc = main(["count", str(target)])
assert rc == 0
assert capsys.readouterr().out == "lines: 2 words: 5 chars: 24\n"
def test_main_slug(capsys):
rc = main(["slug", "Release", "Notes", "v2"])
assert rc == 0
assert capsys.readouterr().out == "release-notes-v2\n"
def test_main_requires_subcommand():
# argparse exits non-zero when the required subcommand is missing.
with pytest.raises(SystemExit) as exc:
main([])
assert exc.value.code == 2
Run the tests, then run the CLI itself through uv run:
uv run pytest -q
uv run textkit --version
uv run textkit slug "Release Notes v2"
printf 'one two three\nfour five\n' | uv run textkit count
9 passed in 0.01s
textkit 0.1.0
release-notes-v2
lines: 2 words: 5 chars: 24
Detailed breakdown
test_count_basicpins the arithmetic:"one two three\nfour five\n"has two\n(2 lines), five whitespace-separated tokens (5 words), and 24 characters counting both newlines.test_count_no_trailing_newlinedocuments the off-by-one thatwc -lalso has — no final newline means zero counted lines.test_main_count_from_fileusespytest’stmp_pathandcapsysfixtures to exercise the file path and assert the exact printed line, so the output shown above is checked, not just described.test_main_requires_subcommandconfirmsargparseexits with code2(its usage-error code) when no subcommand is given, because the subparser was created withrequired=True.uv runexecutes against the project’s synced environment withtextkitinstalled, soimport textkitand thetextkitscript both resolve without activating a virtualenv by hand.
Step 6: Verify the built wheel with uvx
Before distributing anything, build the wheel and run it through uvx from the
local directory. This is the same build-and-run uvx performs after cloning your
repo, so a green result here means the packaging is correct.
uv build
uvx --from ./ textkit slug hello from the wheel
Successfully built dist/textkit-0.1.0.tar.gz
Successfully built dist/textkit-0.1.0-py3-none-any.whl
hello-from-the-wheel
Detailed breakdown
uv buildproduces a wheel (.whl) and a source distribution (.tar.gz) indist/. You do not commit these; they only confirm the package builds.uvx --from ./ textkitinstalls the project from the current directory into a throwaway environment and runs thetextkitconsole script — the same isolationuvxuses for a Git or PyPI source. If the[project.scripts]entry or package layout were wrong, this is where it would fail, before any push.
Step 7: Push to GitHub and run it with uvx
Turn the project into a Git repository, push it to GitHub, and it is distributable. First commit and tag locally:
git init
git add .
git commit -m "textkit 0.1.0"
git tag v0.1.0
Create an empty repository named textkit on GitHub (no README, so the push is
clean), then push both the branch and the tag:
git remote add origin https://github.com/your-username/textkit.git
git push -u origin main
git push origin v0.1.0
Anyone with uv now runs the tool with no clone and no install:
# Latest commit on the default branch:
uvx --from git+https://github.com/your-username/textkit textkit slug "Hello from GitHub"
# Pinned to the released tag — reproducible:
uvx --from git+https://github.com/your-username/textkit@v0.1.0 textkit --version
hello-from-github
textkit 0.1.0
Rehearse the Git path locally first
You do not have to push to prove the Git install works. uvx accepts a
git+file:// URL against any local repository, which runs the identical
clone-build-run path against your local commits. Point it at the repo you just
created:
uvx --from "git+file://$(pwd)" textkit --version
uvx --from "git+file://$(pwd)@v0.1.0" textkit slug "Hello from GitHub"
textkit 0.1.0
hello-from-github
Detailed breakdown
git+https://github.com/<user>/<repo>tellsuvxto clone that repo, build the package with its declared backend, and run the named console script. The repo needs a validpyproject.tomlat its root; nothing else about hosting matters.@v0.1.0pins to a Git ref (a tag, branch, or commit SHA). Without it,uvxresolves the default branch’s current tip, so a pinned tag is what makes an install reproducible. This is the GitHub equivalent of pinning a PyPI version.git+file://$(pwd)is the offline rehearsal.uvxclones your local.gitexactly as it would a remote, so the--versionandslugoutput above is produced by the same mechanism agit+httpsinstall uses — the only difference is where the clone comes from. Because it reads committed refs, run it aftergit commit, and re-commit before re-testing a change.- A private repo works the same way;
uvxuses your Git credentials (an SSH URL likegit+ssh://git@github.com/<user>/<repo>or a token) to clone it.
Step 8: The Makefile
Wrap the lifecycle so plain make prints help.
Create the file
touch Makefile
Add the code: Makefile
.DEFAULT_GOAL := help
# Repo slug users install from: git+https://github.com/$(GH_REPO)
GH_REPO ?= your-username/textkit
.PHONY: help install test run build check check-git clean
help: ## Show this help screen
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}'
install: ## Sync runtime and dev dependencies into .venv
uv sync
test: ## Run the test suite
uv run pytest -q
run: ## Run the CLI locally (pass args with ARGS=...)
uv run textkit $(ARGS)
build: ## Build the wheel and sdist into dist/
uv build
check: ## Run the built wheel through uvx in an ephemeral env
uv build
uvx --from ./ textkit slug hello from the wheel
check-git: ## Verify the exact git-install path against the local commit
uvx --from git+file://$(CURDIR) textkit --version
clean: ## Remove build artifacts and caches
rm -rf dist build *.egg-info .pytest_cache __pycache__ src/textkit/__pycache__ tests/__pycache__
Detailed breakdown
- Plain
makeprints the help screen listing every target, because.DEFAULT_GOAL := helpand each target carries a##description that thehelprecipe extracts. make checkbuilds the wheel and runs it throughuvx --from ./— the pre-distribution gate from Step 6.make check-gitruns thegit+file://rehearsal from Step 7 against the current repo. It needs at least one commit, sinceuvxclones committed refs.make run ARGS="slug hello world"passes arguments through to the CLI for quick local iteration.
Troubleshooting
uvxruns an old version after you push a new commit.uvxcaches the built environment per source. Force a re-clone withuvx --refresh --from git+https://github.com/your-username/textkit textkit.git operation failed/does not appear to be a Python project.uvxclones the repo and looks forpyproject.tomlat its root. Confirm the file is committed and at the top level, not in a subdirectory. For a subdirectory, append#subdirectory=path/to/pkgto the URL.uvxcannot find the command after installing. The console-script name differs from what you typed. It is the left side of[project.scripts](textkithere); the full form isuvx --from <source> <command>.- Push rejected because the GitHub repo already has a commit. You created the
repo with a README. Either
git pull --rebase origin mainfirst, or recreate the GitHub repo empty. --versionreports the wrong number.version("textkit")reads installed metadata. Bumpversioninpyproject.toml, commit, tag, and (for a cached run) add--refresh.
Recap
- A
uvx-distributable CLI is auv init --packageproject whose[project.scripts]entry names the command to run. - Keeping
dependencies = [](standard library only) makes the package resolve from a Git URL with nothing extra to fetch. uv build+uvx --from ./proves the packaging locally;uvx --from git+file://$(pwd)proves the Git install path without a push.- Once the repo is on GitHub,
uvx --from git+https://github.com/<user>/<repo> <command>runs it anywhere, and@<tag>pins it for reproducibility — distribution with no index and no release upload.
Next improvements
- Publish to PyPI when the tool outgrows a repo link, so users type
uvx textkitwith no--from. See Publish a FastMCP Server to PyPI and Run It Anywhere withuvxfor the build-and-uv publishflow. - Add a GitHub Actions workflow that runs
uv run pyteston every push, so a brokenmainnever becomes an install someone gets. - Cut releases with tags and a short CHANGELOG so
@<tag>pins carry meaning. - Grow the CLI with more subcommands or third-party dependencies;
uvxresolves them from the same Git install.