Programming
-
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!
-
The revenue design company | Orb
Design, execute, and operate revenue with usage-based billing. Orb helps modern software companies adapt pricing as products, usage, and costs evolve.
-
Lisette is a New Rust-to-Go Language, So I Built It a Test Library
This morning I dove into a new programming language called Lisette. I saw it from @lmika and had to take a look. It gives you Rust-like syntax but compiles down to Go, and you can import from the Go standard library directly.
It’s early in development, so a lot of things don’t exist yet. They have a roadmap posted with plans for third-party package support, a test runner, bitwise operators, and configurable diagnostics.
So Naturally, I Built a Test Library
Anyone who reads my blog knows I care a lot about testing. So when I saw “implement a test runner” sitting on the roadmap, I did what any reasonable person would do on a Monday morning. I built a testing library for Lisette called LisUnit.
I wanted something that felt familiar if you’ve used Jest or PHPUnit. Test cases are closures that return a result, and assertions work the same way. Here’s what it looks like:
lisunit.Suite.new("math") .case("add produces sum", || { lisunit.assert_eq_int(add(2, 3), 5)? Ok(()) }) .case("add is commutative", || { lisunit.assert_eq_int(add(2, 3), add(3, 2))? Ok(()) }) .run()Define a suite, chain your test cases, run it.
Why Bother?
I don’t know exactly what direction the Lisette team is headed with their own test runner, so this is just a prototype. Building a test library turns out to fun way to try out a new language because you end up touching a lot of language constucts?
I’ll probably keep poking at it as Lisette evolves. Happy Monday.
-
Lisette — Rust syntax, Go runtime
Little language inspired by Rust that compiles to Go.
-
Hiding Poems Inside Images
I built a tool that hides poems inside images. Not as metadata, not as a watermark. The actual text of the poem drives the visual pattern, and you can reconstruct the poem perfectly from the image alone.
How It Works
You give it a poem. It analyzes the syllable count, rhyme scheme, and stress patterns. Then it generates a visual pattern where those poetic features drive the aesthetics: spiral width, dot placement, block size, line weight.
The text itself is encoded into the pattern in a variety of ways. The pattern isn’t just inspired by the poem, it IS the poem. Run the decoder on the image and you get back the original text, character for character, including whitespace and punctuation.
Seven Renderers, Two Approaches
There are seven different visual styles, split into two categories.
The steganographic renderers (geometric, concentric, waveform) hide text invisibly in pixel color channels using LSB encoding. The visual pattern is purely decorative. This is a well-known technique. nothing new there.
I wanted to build something different, so I focused on visual encoding patterns. The encoding is the art, and the art is the encoding. Everything about how the image is constructed follows repeatable algorithms so it can be decoded back:
- Nautilus draws a golden spiral where line width carries the data
- Fibonacci uses a sunflower phyllotaxis dot pattern
- Mosaic creates an adaptive block grid
- Dotline connects dots with varying line weight
Each has different capacity. The nautilus spiral can hold about 2,200 characters, enough for a full Whitman poem. A fibonacci pattern holds about 1,600. Even the smallest renderer handles a haiku easily.
Right now I’m just having fun building different visuals, images that encode and decode. Eventually I might build an API around it, but for now it’s a side project…
A Basho’s haiku is here:

-
The Human's Guide to the Command Line: Your First CLI App
This is Part 2 of The Human’s Guide to the Command Line. If you missed Part 1, go check that out first — we got Homebrew and Ghostty set up, which you’ll need for everything here.
Now we’re going to do something that feels like a big leap: we’re going to write a real command-line application. A small one, but a real one. Before we get there, though, we need two things — a programming language and a code editor.
Picking a Language
You can write CLI apps in a lot of languages. JavaScript, Go, Rust — they all work. But if you’re new to programming, I think Python is the right starting point. It reads almost like English, it’s everywhere, and you won’t spend your first hour fighting a compiler.
Python is what we’ll use for this series. That said, Python is notoriously tricky to set up locally; there are version managers, virtual environments, and a whole ecosystem of packaging tools that can make your head spin.
First things first, we need a code editor (an IDE).
A Code Editor: Zed
Before we write any code, you need somewhere to write it. We’re going to install Zed — it’s fast, clean, and won’t overwhelm you with buttons.
brew install --cask zedOpen Zed from your Applications folder once it finishes.
Install the
zedCommandHere’s the move that ties everything together. Open Zed’s command palette with Cmd + Shift + P, type
cli install, and hit Enter.Now you can open any folder in Zed directly from Ghostty:
zed . # open the current folder zed note.py # open a specific fileThat
.means “right here” — you’ll see it everywhere in the terminal and it always means the same thing: the folder you’re currently in.Set Up Your Project
Time to create a home for your code. Back in Ghostty:
mkdir ~/code mkdir ~/code/note cd ~/code/noteThree commands. You just created a
codefolder in your home directory, created anotefolder inside it, and stepped into it. Now open it in Zed:zed .Zed opens with your empty project folder in the sidebar on the left. This is where we’ll create our script.
Building a Note Taker
We’re going to build a tiny app called
note. It does three things: add a note, list your notes, and clear them all. That’s it. No database, no accounts, no cloud. Just you and a JSON file.Create your script in Zed’s sidebar by clicking the New File icon, name it
note.py, and paste in the code from the next section.import argparse import json import sys from datetime import datetime from pathlib import Path # Where your notes live — ~/notes.json NOTES_FILE = Path.home() / "notes.json" def load_notes(): if not NOTES_FILE.exists(): return [] with open(NOTES_FILE) as f: return json.load(f) def save_notes(notes): with open(NOTES_FILE, "w") as f: json.dump(notes, f, indent=2) def cmd_add(args): notes = load_notes() note = { "text": args.text, "added": datetime.now().strftime("%Y-%m-%d %H:%M"), } notes.append(note) save_notes(notes) print(f"Added: {args.text}") def cmd_list(args): notes = load_notes() if not notes: print('No notes yet. Add one with: note add "your note"') return for i, note in enumerate(notes, start=1): print(f"{i}. {note['text']} ({note['added']})") def cmd_clear(args): answer = input("Delete all notes? This can't be undone. (y/n): ") if answer.lower() == "y": save_notes([]) print("All notes cleared.") else: print("Cancelled.") def main(): parser = argparse.ArgumentParser( prog="note", description="A simple command-line note taker.", ) subparsers = parser.add_subparsers(dest="command") # note add "some text" add_parser = subparsers.add_parser("add", help="Add a new note") add_parser.add_argument("text", help="The note to save") # note list subparsers.add_parser("list", help="Show all notes") # note clear subparsers.add_parser("clear", help="Delete all notes") args = parser.parse_args() if args.command == "add": cmd_add(args) elif args.command == "list": cmd_list(args) elif args.command == "clear": cmd_clear(args) else: parser.print_help() if __name__ == "__main__": main()Part 3: Python Without the Mess (uv)
Your Mac already has Python on it, but don’t use it. That version belongs to macOS which it uses internally, and if you start installing things into it you can cause yourself real headaches. We’re going to use our own Python that’s completely separate.
The tool for this is uv. It manages Python for you and keeps everything isolated so you never touch the system version.
brew install uvThat’s the whole install. Verify it worked:
uv --versionYou should see a version number printed back. If you do, you’re good.
Create Your Project
Inside your
~/code/notefolder, run:uv initThis sets up a proper Python project in the current folder. You’ll see a few new files appear in Zed’s sidebar, but don’t worry. If you see a
hello.py, which uv creates as a starter file, you can delete it.Running Your Script
Once you have code in
note.py, run it like this:uv run note.py add "call the dentist" uv run note.py list uv run note.py clearuv runhandles everything — it picks the right Python version, keeps it sandboxed to this project, and runs your script. You never typepython3directly, you never activate a virtual environment, you never install packages globally. It just works.Try it now:
uv run note.py listIf you see
No notes yet. Add one with: note add "your note"but otherwise, everything should be working.
Try this: Once your script is running, try
uv run note.py --help. You’ll get a clean description of every command, automatically. That’s one of the thingsargparsegives you for free. -
The Human's Guide to the Command Line (macOS Edition)
If you’ve ever stared at a terminal window and felt like you were looking at the cockpit of an airplane, this post is for you. The command line doesn’t have to be scary, and I’m going to walk you through setting up a genuinely great terminal experience on your Mac, from scratch.
This is Part 1 of a Series. I will update the links here as I publish the other articles.
The Mental Model
Before we type anything, here’s the thing you need to understand: the terminal is just a text-based version of Finder.
When you see a folder in Finder, you double-click to open it. In the terminal, you type
cd(change directory) to enter it. You can run applications from Finder, and you can run applications from the terminal. It’s just a different way to do things you already understand.Once that clicks, everything else falls into place.
Part 1: The Foundation (Homebrew)
On a Mac, you install apps from the App Store. On the command line, we use Homebrew — it’s basically the App Store for the terminal. It’s a package manager that makes installing tools painless.
Let’s start by opening the terminal. Hit Command + Space, type “Terminal”, and hit Enter. Then paste this:
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"It’ll ask for your Mac password. When you type it, nothing will appear on the screen. This is normal, it’s a security feature, not a bug. Just type your password and hit Enter.
Homebrew needs admin access because it creates system-level folders (usually
/opt/homebrew) and changes their ownership from the system account to your user account.Once it finishes, verify it’s working:
which brewIf that doesn’t return a path, run these two lines to add Homebrew to your shell:
echo 'eval "$(/opt/homebrew/bin/brew shellenv)"' >> ~/.zprofile eval "$(/opt/homebrew/bin/brew shellenv)"Part 2: A Better Terminal (Ghostty)
Now that we have Homebrew, the first thing we’re going to install is a better terminal. The built-in Terminal app works, but Ghostty is faster, more configurable, and just nicer to use.
brew install --cask ghosttyOnce it finishes, find Ghostty in your Applications folder and open it. You can close the old Terminal app forever, congratulations, you’ve graduated.
Part 3: Making It Beautiful (Nerd Fonts & Starship)
Standard fonts don’t have icons for folders, git branches, or cloud status. We need a Nerd Font so the terminal can speak in pictures.
Install the Font
brew install --cask font-fira-code-nerd-fontThen go to Ghostty’s settings (
Cmd + ,), find the Text section, and set your font toFiraCode Nerd Font.Install Starship
Starship is the paint job that turns a boring
$prompt into something colorful and actually helpful. It shows you what folder you’re in, what git branch you’re on, and more…brew install starshipWhile we’re at it, let’s install two plugins that make typing in the terminal way more pleasant. One whispers suggestions based on your command history, and the other color-codes your commands so you can spot typos before you hit Enter.
brew install zsh-autosuggestions zsh-syntax-highlightingWire It All Up
We need to tell your Mac to turn these features on every time you open a terminal. Copy and paste this entire block into Ghostty:
# Add Starship grep -qq 'starship init zsh' ~/.zshrc || echo 'eval "$(starship init zsh)"' >> ~/.zshrc # Add Auto-suggestions grep -qq 'zsh-autosuggestions.zsh' ~/.zshrc || echo "source $(brew --prefix)/share/zsh-autosuggestions/zsh-autosuggestions.zsh" >> ~/.zshrc # Add Syntax Highlighting grep -qq 'zsh-syntax-highlighting.zsh' ~/.zshrc || echo "source $(brew --prefix)/share/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh" >> ~/.zshrc # Refresh your terminal source ~/.zshrcEach line checks if the setting already exists before adding it, so it’s safe to run more than once. When it finishes, your prompt should look completely different. Welcome to your CLI future.
Part 4: Basic Survival Commands
Now that your terminal looks proper, here’s how you actually move around.
Command Human Translation pwd“Where am I right now?” ls“Show me everything in this folder.” cd [folder]“Go inside this folder.” cd ..“Go back one folder.” clear“Wipe the screen and start fresh.” touch [file.txt]“Create a new blank file.” That’s about 90% of what you need to navigate around.
Two Golden Rules
1. Tab is your best friend. Type
cd Docand hit Tab — the terminal will finish the wordDocumentsfor you. This works for files, folders, and even commands. If there are multiple matches, hit Tab twice to see all the options.2. Ctrl + C is the panic button. If the terminal is doing something you didn’t expect, or a command is running and won’t stop, hold Ctrl and press C. It’s the emergency brake, and it’s always there for you.
A good next step: try navigating to your Desktop using only
cdandls. Find a file there, maybe create one withtouch, and then look at it withls. Once you can do that comfortably, you’ve officially mastered the basics. -
I Benchmarked JSON Parsing in Bun, Node, Rust, and Go
I’m just going to start posting about JSON everyday. Well ok, maybe not every day, but for the next few days at least. Later this week I’ve committed to writing a guide on getting started with CLIs for non-programmers, so stay tuned for that.
This morning I benchmarked JSON parsing across four runtimes: Bun, Node, Rust, and Go.
The Results
- Bun is the overall winner on large files — 307-354 MB/s, beating even Rust’s serde_json for untyped parsing
- Rust wins on small/nested data (225 MB/s small, 327 MB/s nested) due to low overhead
- Node is close behind Bun — V8’s JSON.parse is very optimized
- Go is ~3x slower than the JS runtimes on large payloads (encoding/json is notoriously slow)
- Memory: Bun reports 0 delta (likely GC reclaims before measurement), Rust’s tracking allocator shows the true heap cost (73-96MB), Go uses 52-65MB
Rust’s numbers were the most honest here since the tracking allocator catches everything. We should take Bun result with grain of salt because benchmarking memory in GC’d languages is tricky.
The json parser in v8 in node is the exact same as what is in Chrome…
Here’s the full test results if you want to dig into the numbers yourself.
More JSON content coming soon. You’ve been warned.
-
Is There Something Better Than JSON?
Have you ever looked at a JSON file and thought, “There has to be something better than this”? I have.
JSON has served us well. It works with everything, and it’s human readable. It’s a decent default, don’t get me wrong, but the more you use it, you’ll find its limitations to be quite painful. So before we answer the question of whether there’s anything better, we should describe what’s actually wrong with JSON.
The Problems with JSON
First, there’s no type system. No datetimes, no real integers, no structs, no unions, no tuples. If you need types, and you almost always do, you’re on your own.
Second, JSON is simple, which sounds like a feature until you try to store anything complicated in it. You end up inventing your own schema, and the schema tooling out there (JSON Schema, etc.) gets verbose fast. Because the spec is so loose, validation can be inconsistent across implementations.
There’s more: fields can be reordered, you have to receive the entire document before you can start verifying it, and there are no comments. You can’t leave a note for the next person explaining why a config value is set a certain way. That’s a real problem for anything that lives in version control.
The Machine-Readable Alternatives
Now, there are plenty of binary serialization formats that solve some of these issues. Protobuf, Cap’n Proto, CBOR, MessagePack, BSON. They’re all interesting and have their place. But they’re machine readable, not human readable. You can’t just open one up in your editor and make sense of it. So let’s set those aside.
The question I’m more interested in is: is there something better than JSON that you can still read and edit as a text file?
It turns out there are two solid options.
Dhall
Dhall is a programmable configuration language. Think of it as JSON with all the things you wish JSON had: functions, types, and imports. You can convert JSON to Dhall and back, and it’s just a text file you can open in any editor. The name comes from a character in an old video game, and the language itself is interesting enough that it’s worth your time to explore.
CUE
CUE stands for Configure, Unify, and Execute. It’s similar to Dhall in that it fills the gaps JSON leaves behind, like types, validation, and constraints, while staying human readable. Where CUE really pulls ahead is in its feature set. You can import Protobuf definitions, generate JSON Schema, validate existing configs, and a lot more. In terms of raw capabilities, CUE has more going on than Dhall.
JSON isn’t going anywhere. But if you’re looking for something interesting to explore, check out both of these. They make great fun little side projects.
-
The Death of Clever Code
One positive product of working with Agentic tools is they rarely suggest clever code. No arcane one-liners, no “look how smart I am” abstractions. And, well, I’m here for it.
Before we continue it helps to understand a bit about how LLMs work. These models are optimized for pattern recognition. They’ve been trained on massive amounts of code and learned what patterns appear most frequently.
Clever code, by definition, is bespoke. It’s the unusual pattern, the one-off trick. There just isn’t enough training data for cleverness. The AI gravitates toward the common, readable solution instead.
Let me give you an example.
Show Me the Code
Here’s a nested ternary:
const result = a > b ? (c > d ? 'high' : 'mid') : (e > f ? 'low' : 'none');I’d be impressed if you could explain that correctly on your first try. What happens when there’s a bug in one of those conditions? Good luck debugging that.
Now here’s the same logic:
let result; if (a > b) { if (c > d) { result = 'high'; } else { result = 'mid'; } } else { if (e > f) { result = 'low'; } else { result = 'none'; } }A lot easier, right? If it’s easy to read, it’s easy to maintain. The AI tooling doesn’t struggle to read either version, but you might, and when there is a bug, explaining exactly what needs to change becomes the hard part.
Actually wait. It turns out, not all complexity is created equal.
Two Kinds of Complexity
Essential complexity is the complexity of the problem itself. If you’re building a mortgage calculator or doing tax calculations, there’s inherent complexity in understanding the domain. You can’t simplify that away, and you shouldn’t try.
Accidental complexity is the stuff you introduce. The nested ternary instead of the if/else. Five layers of abstraction for the sake of abstraction that only runs in a specific edge case. Generic utility functions where you’ve tried to cover every possible scenario, but realistically you only need two or three cases.
Ok but what about abstraction, since abstraction is where accidental complexity loves to hide?
Good Abstraction vs. Bad Abstraction
Abstraction shows up everywhere in programming, but let’s think about it in two flavors.
Good abstraction hides details the caller doesn’t need to care about. The interface clearly communicates what it does. Think
array.sort(), you look at it and immediately know what’s happening. Those dang arrays getting some sort of sorted. You know exactly what it does without caring about the implementation.Bad abstraction hides details you do need to understand in order to use it correctly. Think of a
processData()method that’s doing six different things with an internal state that’s nearly impossible to test. And splitting it intoprocessData1()throughprocessData6()doesn’t help either. That’s just moving the vegetables around on your plate which doesn’t mean you’ve actually finished dinner.AI Signals
So why does any of this matter for working with AI coding tools?
Because if the agents keep getting your code wrong, if they consistently misunderstand what a function does or there are incorrect modifications, that’s a signal.
It’s telling you that your code has some flavor of cleverness that makes it hard to reason about. Not just for the AI, but for your team, and for you six months from now.
The goal is to code where the complexity comes from the problem, not from the solution. The AI struggling with your code is like a canary in the coal mine for maintainability.