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 through uvx by the exact same Git mechanism git+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 pytest and uv run.
  • A wheel verified through uvx before it leaves your machine.
  • The git+https install path — run from main, or pinned to a released tag.

Prerequisites

  • macOS 13+ with Homebrew (brew.sh).
  • uv 0.5+brew install uv; uvx ships with it. Verify with uv --version.
  • Git and a GitHub account for the distribution step.
  • Xcode Command Line Tools (xcode-select --install) for make.

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 plain uv init) scaffolds a distributable project: a src/textkit/ package, a [project.scripts] entry, and the uv_build backend. That backend is what uvx invokes to build the package after it clones your repo.
  • --no-workspace keeps this a standalone project rather than a member of a surrounding uv workspace.
  • dist/ and build/ are ignored because uv build regenerates them; committing built wheels is an anti-pattern. uvx builds 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 = "[email protected]" }
]
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 named textkit that calls the package’s main. That script is what uvx --from git+https://…/textkit textkit runs. The first textkit after uvx --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 thing uvx can 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; uvx resolves them the same way it does for a PyPI install.
  • version = "0.1.0" is read back at runtime by the --version flag (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

  • count and slugify are 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.
  • slugify drops 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" becomes caf-del-mar, not cafe-del-mar. That is fine for a slug; note it if your inputs are heavily accented.
  • main(argv=None) takes an optional argument list. argparse defaults to sys.argv[1:] when you pass None, so the console script works with no argument, and tests can pass an explicit list like ["slug", "hi"].
  • main returns an int. The console-script wrapper uv_build generates runs sys.exit(main()), so returning 0 gives a success exit code. Subcommand handlers return their own codes.
  • version("textkit") reads the installed package version from its metadata, so --version always reports whatever is in pyproject.toml without repeating the number in code. It resolves because uvx (and uv) 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 at textkit.main, which is the package’s top-level __init__.py. Re-exporting main from cli there keeps the entry-point string short instead of textkit.cli:main. Either form works; this one reads better.
  • Keeping the logic in cli.py and only re-exporting here means importing the package is cheap and side-effect-free — no argument parsing happens until main is 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_basic pins 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_newline documents the off-by-one that wc -l also has — no final newline means zero counted lines.
  • test_main_count_from_file uses pytest’s tmp_path and capsys fixtures to exercise the file path and assert the exact printed line, so the output shown above is checked, not just described.
  • test_main_requires_subcommand confirms argparse exits with code 2 (its usage-error code) when no subcommand is given, because the subparser was created with required=True.
  • uv run executes against the project’s synced environment with textkit installed, so import textkit and the textkit script 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 build produces a wheel (.whl) and a source distribution (.tar.gz) in dist/. You do not commit these; they only confirm the package builds.
  • uvx --from ./ textkit installs the project from the current directory into a throwaway environment and runs the textkit console script — the same isolation uvx uses 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/[email protected] 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> tells uvx to clone that repo, build the package with its declared backend, and run the named console script. The repo needs a valid pyproject.toml at its root; nothing else about hosting matters.
  • @v0.1.0 pins to a Git ref (a tag, branch, or commit SHA). Without it, uvx resolves 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. uvx clones your local .git exactly as it would a remote, so the --version and slug output above is produced by the same mechanism a git+https install uses — the only difference is where the clone comes from. Because it reads committed refs, run it after git commit, and re-commit before re-testing a change.
  • A private repo works the same way; uvx uses your Git credentials (an SSH URL like git+ssh://[email protected]/<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 make prints the help screen listing every target, because .DEFAULT_GOAL := help and each target carries a ## description that the help recipe extracts.
  • make check builds the wheel and runs it through uvx --from ./ — the pre-distribution gate from Step 6.
  • make check-git runs the git+file:// rehearsal from Step 7 against the current repo. It needs at least one commit, since uvx clones committed refs.
  • make run ARGS="slug hello world" passes arguments through to the CLI for quick local iteration.

Troubleshooting

  • uvx runs an old version after you push a new commit. uvx caches the built environment per source. Force a re-clone with uvx --refresh --from git+https://github.com/your-username/textkit textkit.
  • git operation failed / does not appear to be a Python project. uvx clones the repo and looks for pyproject.toml at its root. Confirm the file is committed and at the top level, not in a subdirectory. For a subdirectory, append #subdirectory=path/to/pkg to the URL.
  • uvx cannot find the command after installing. The console-script name differs from what you typed. It is the left side of [project.scripts] (textkit here); the full form is uvx --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 main first, or recreate the GitHub repo empty.
  • --version reports the wrong number. version("textkit") reads installed metadata. Bump version in pyproject.toml, commit, tag, and (for a cached run) add --refresh.

Recap

  • A uvx-distributable CLI is a uv init --package project 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 textkit with no --from. See Publish a FastMCP Server to PyPI and Run It Anywhere with uvx for the build-and-uv publish flow.
  • Add a GitHub Actions workflow that runs uv run pytest on every push, so a broken main never 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; uvx resolves them from the same Git install.