Cli
-
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
lsorbrew, 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 $PATHYou’ll get a long string of directories separated by colons. Something like
/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin. When you typebrew, the shell checks each of those directories until it finds a match.Right now,
note.pyis 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.pyWe need to reorganize it into a package. Here’s what we’re aiming for:
note/ pyproject.toml src/ note/ __init__.py cli.pyLet’s do it step by step. Open Ghostty and navigate to your project:
cd ~/code/noteCreate the new directories
mkdir -p src/noteThe
-pflag means “create parent directories too.” This creates bothsrc/andnote/inside it in one shot.Move your script
mv note.py src/note/cli.pyYour note-taking code now lives at
src/note/cli.pyinstead ofnote.py.Create the package marker
touch src/note/__init__.pyThis creates an empty file. Python uses
__init__.pyto 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.tomlin 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 = trueThen 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 thenotepackage, go into theclimodule, and call themainfunction.” That’s the samemain()function at the bottom of the code we wrote in Part 2.Your full
pyproject.tomlshould 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.pyInstalling 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 ranzed .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 listYou 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 listThat works from anywhere. Open a new terminal tab, navigate to your home directory, your Desktop, wherever…
notejust works now.If you get “command not found”: Run
uv tool update-shelland then restart your terminal. This adds~/.local/binto your PATH in.zshrcso 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 editsrc/note/cli.py, your changes won’t show up until you reinstall:uv tool install --force .The
--forceflag tells uv to overwrite the existing installation. During development, you could also useuv tool install -e .(the-eis 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.pyto a real command that works from anywhere on your machine.Welcome to your first CLI!
/ Programming / Python / Cli
-
CLI First, GUI Never
I’ve been enjoying building CLI tooling and so here’s why your next app should be a CLI not a GUI.
CLI tools have longevity. If you design them for composability, there’s a good chance they’ll stick around because they’re much easier to plug into an existing ecosystem of other CLI tools. GUIs on the other hand come and go, as aesthetics change and as popular UI libraries rise and fall.
I’ll be honest, this is just a thinly veiled excuse for myself to explore building CLIs in different languages. I’ve built them in TypeScript with Bun and I’ve built them in Python, but TUIs in Go really changed the game for me on what is possible.
Tips for Building CLIs That Last
Output JSON Structured output is way easier to parse with scripts, and agents appreciate the additional context that JSON provides. If you’re building tools in 2026, you’re building them for humans and machines.
Wrap existing CLIs instead of re-implementing their APIs. You’re trading a raw API dependency for a versioned, maintained interface. Someone else is absorbing the upstream churn.
Prefer stdin/stdout over files where possible. This works better if you ever want to containerize your tool, and it plays nicely with Unix piping.
Logging matters. This kinda goes with JSON but any sort of logging is so important I’ll add it twice. Having some logging is non-negotiable, but structured logging really matters if you’re sending logs to a centralized provider.
Single binaries are way easier to distribute than a zip file or a bunch of code someone has to set up. It’s fairly straightforward to set up GitHub auto-releases, though there are some steps that can trip you up. One approach: auto-create a new patch version on every commit to main.
Three CLIs I Built (For Inspiration)
Here are some personal examples to hopefully inspire you to build your own:
-
lsm — A local secrets manager. Instead of
.envfiles sitting on disk, it decrypts and optionally injects secrets into your application runtime.lsm exec -- pnpm dev -
repjan — A Go TUI that wraps the
ghCLI to help you manage all your old repos. -
positive.help CLI — Personal tooling for managing a website entirely from the command line. No admin dashboard needed.
The Agentic Argument
In the age of agentic development, CLIs that your agents can call are incredibly useful. An agent can call a CLI a lot easier than they can click buttons in a GUI.
A GUI usually requires browser automation, maybe some scraping. It’s getting a bit easier now with APIs that return markdown from a site, but not everybody knows how to use those tools, and there’s usually a cost.
If you want a signal, look at the adoption curves of agent-focused CLI tools over the last six months. GitHub stars aren’t a perfect metric, but the direction is hard to argue with.
So this is your sign, you don’t need a framework. You don’t need a design system. If it’s good enough for
grep, it’s good enough for your tool./ Golang / Development / Cli / Tooling
-