The Human's Guide to the Command Line: From Script to System

This is Part 3 of The Human’s Guide to the Command Line. If you missed Part 1 or Part 2, go check those out first, you’ll need the setup from both.

In Part 2, we built a note-taking app. It works. But running it looks like this:

uv run ~/code/note/note.py add "call the dentist"

That’s… not great. Real command-line tools don’t make you remember where a Python file lives. You just type the name and it works. By the end of this post, you’ll be able to type:

note add "call the dentist"

From anywhere on your computer. Let’s make that happen.

Why Can’t I Just Type note?

When you type a command like ls or brew, your shell doesn’t search your entire computer for it. It checks a specific list of directories, one by one, looking for a program with that name. That list is called your PATH.

You can see yours right now:

echo $PATH

You’ll get a long string of directories separated by colons. Something like /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin. When you type brew, the shell checks each of those directories until it finds a match.

Right now, note.py is sitting in ~/code/note/. That folder isn’t in your PATH, so the shell has no idea it exists. We need to turn our script into something installable, and put it somewhere the shell knows to look.

Restructuring Your Project

Python has specific expectations about how a project needs to be organized before it can be installed as a tool. Right now your project looks like this:

note/
  pyproject.toml
  note.py

We need to reorganize it into a package. Here’s what we’re aiming for:

note/
  pyproject.toml
  src/
    note/
      __init__.py
      cli.py

Let’s do it step by step. Open Ghostty and navigate to your project:

cd ~/code/note

Create the new directories

mkdir -p src/note

The -p flag means “create parent directories too.” This creates both src/ and note/ inside it in one shot.

Move your script

mv note.py src/note/cli.py

Your note-taking code now lives at src/note/cli.py instead of note.py.

Create the package marker

touch src/note/__init__.py

This creates an empty file. Python uses __init__.py to recognize a folder as a package — it can be completely empty, it just needs to exist.

Update pyproject.toml

Open your project in Zed:

zed .

Find pyproject.toml in the sidebar and open it. You need to add two things. First, tell uv where your code lives by adding this section:

[tool.uv]
package = true

Then add this section to define your command:

[project.scripts]
note = "note.cli:main"

That line is doing something specific: it’s saying “when someone types note, find the note package, go into the cli module, and call the main function.” That’s the same main() function at the bottom of the code we wrote in Part 2.

Your full pyproject.toml should look something like this (the exact version numbers may differ):

[project]
name = "note"
version = "0.1.0"
description = "A simple command-line note taker"
requires-python = ">=3.12"
dependencies = []

[tool.uv]
package = true

[project.scripts]
note = "note.cli:main"

Verify the structure

Run this in Ghostty to make sure everything looks right:

ls -R src/

You should see:

src/note:
__init__.py  cli.py

Installing It For Real

Here’s the moment. From your project directory (~/code/note), run:

uv tool install .

That . means “install the project in the current folder” — same dot from Part 2 when we ran zed . to mean “open this folder.”

What just happened? uv created an isolated environment for your tool and dropped a link to it in ~/.local/bin/. That directory is on your PATH (or should be), which means your shell can now find it.

Try it:

note list

You should see your notes from Part 2 (or “No notes yet” if you cleared them). Try adding one:

note add "I installed my first CLI tool"
note list

That works from anywhere. Open a new terminal tab, navigate to your home directory, your Desktop, wherever… note just works now.

If you get “command not found”: Run uv tool update-shell and then restart your terminal. This adds ~/.local/bin to your PATH in .zshrc so your shell knows where to find tools installed by uv.

One thing to know about editing

When you ran uv tool install ., uv copied your code into its own isolated environment. That means if you go back and edit src/note/cli.py, your changes won’t show up until you reinstall:

uv tool install --force .

The --force flag tells uv to overwrite the existing installation. During development, you could also use uv tool install -e . (the -e is for “editable”) which keeps a live link to your source code so changes show up immediately without reinstalling.


You went from a Python script you had to run with uv run note.py to a real command that works from anywhere on your machine.

Welcome to your first CLI!

/ Programming / Python / Cli