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.
-
From SUnit to Vitest: A Brief History of JavaScript Testing
I care a lot about testing. I don’t know if that’s obvious yet, but hopefully it’s obvious. I wanted to trace the lineage of the testing tools we use today, because I think understanding where they came from helps you appreciate why things work the way they do.
Where It All Started
Automated testing as we know it really started with SUnit for the Smalltalk language. Kent Beck created it back in 1989, and it established the patterns that every test framework still borrows from today.
In 1997, Kent Beck and Erich Gamma ported those ideas to Java and created JUnit. JUnit was, or is, incredibly influential to pretty much every unit testing framework you’ve ever used. The test runner, assertions, setup and teardown, all of that traces back to JUnit.
But I’m going to focus on the JavaScript side of things here.
Jest: The Facebook Era
Jest was originally created at Facebook in 2011 as part of a major platform rewrite. It became the dominant testing framework for React and Node.js codebases, and in 2022, Facebook released it to the OpenJS Foundation.
Jest works well, but it carries some baggage. It requires a transpilation pipeline, something that was common a decade ago but feels burdensome now. If you want to use ESM modules, there’s an extra step involved. It’s adds friction.
So what else is there?
Vitest: The Modern Alternative
Vitest is a modern alternative, built on top of Vite. It supports ESM modules and TypeScript out of the box; so no transpilation step needed. And because Vite has HMR (hot module replacement), the watch mode for rerunning tests is very fast.
Vitest was initially created by Anthony Fu and the company behind Vue.js. The initial commit was in December 2021, so it’s a relatively recent project. They’ve made incredible progress since then.
Vitest uses Vite under the hood. So lets look at that briefly.
Why Vite Is So Fast
Vite’s speed comes from esbuild, which is written in Go. It compiles directly to native machine code, so it bypasses the JavaScript engine’s overhead entirely. It can transform TypeScript significantly faster because it doesn’t need to go through the JS engine. And because it’s Go, it’s multithreaded.
But things are changing. In Vite 8, the bundler is moving from esbuild to Rolldown. This is new tool written in Rust that combines the best of esbuild and Rollup.
Why? Currently, Vite uses esbuild during development but switches to Rollup for production builds. Two different tools for two different use cases. Rolldown unifies both into a single tool that handles dev and production.
What Did We Learn?
Hopefully something! How about a mini review to nail it home:
- 1989: SUnit (Smalltalk) — Kent Beck starts it all
- 1997: JUnit (Java) — the template everything else follows
- 2011: Jest — Facebook’s testing framework, now under OpenJS Foundation
- 2021: Vitest — modern, fast, ESM-native testing built on Vite
- Coming soon: Rolldown replaces esbuild + Rollup in Vite 8
That’s all for now!
-
Garbage Collection: How Python, JavaScript, and Go Clean Up After Themselves
It’s Garbage day for me IRL and I wanted to learn more about garbage collection in programming. So guess what? Now you get to learn more about it too.
We’re going to focus on three languages I work with mostly, Python, JavaScript, and Go. We will skip the rest for now.
Python: Reference Counting
Python’s approach is the most straightforward of the three. Every object keeps track of how many things are pointing to it. When that count drops to zero, the object gets cleaned up immediately. Simple.
There’s a catch, though. If two objects reference each other but nothing else references either of them, the count never hits zero. That’s a reference cycle, and Python handles it with a secondary cycle detector that periodically scans for these orphaned clusters. But for the vast majority of objects, reference counting does the job without any fancy algorithms.
JavaScript (V8): Generational Garbage Collection
Most JavaScript you encounter is running on V8… so Chrome based browsers, Node and Deno. V8 uses a generational strategy based on a simple observation: most objects die young.
V8 splits memory into two main areas:
- The Nursery (Young Generation): New objects land here. This space is split into two halves and uses a scavenger algorithm. It’s fast because it only deals with short-lived variables — and most variables are short-lived.
- Old Space (Old Generation): No, not the deodorant company. Objects that survive a couple of scavenger rounds get promoted here. Old space uses a mark-and-sweep algorithm, which is slower but handles long-lived objects more efficiently.
First it asks, “How long has this object been around?” New stuff gets the quick treatment, and anything that sticks around gets put out to the farm, to be delt where time is less of a premium. It’s a smart tradeoff between speed and memory efficiency.
Go: Tricolor Mark-and-Sweep
Go’s garbage collector also uses mark-and-sweep, but with a twist called tricolor marking. Here’s how it works:
- White objects: These might be garbage but haven’t been checked yet. Everything starts as white.
- Gray objects: The collector has reached these, but it hasn’t scanned their children (the things they reference) yet.
- Black objects: These are confirmed alive — the collector has scanned them and all their references.
The collector starts from known root objects, marks them gray, then works through the gray set — scanning each object’s references and marking them gray too, while the scanned object itself turns black. When there are no more gray objects, anything still white is unreachable and gets cleaned up.
Go’s approach is notable because it runs concurrently. This helps with latency while the GC is running.
Garbage, Collected
Each approach reflects the language’s priorities:
- Python optimizes for simplicity and predictability. Objects get cleaned up correctly when they’re no longer needed
- JavaScript optimizes for speed in interactive applications. Quick cleanup for short-lived objects, thorough cleanup for the rest
- Go optimizes for low latency. Concurrent collection is great for server side processes
References
- Design of CPython’s Garbage Collector — Python Developer’s Guide deep dive into reference counting and cycle detection
- Orinoco: Young Generation Garbage Collection — V8’s parallel scavenger and generational GC design
- A Guide to the Go Garbage Collector — Official Go documentation on the concurrent tricolor collector
-
How Smart People Are Using Claude Code Skills to Automate Anything
🚀 Build agentic systems that run your business: skool.com/scrapes Don’t miss the next build - www.youtube.com/@simonscr…
-
Everything Is Eventually a Database Problem
I think there’s a saying that goes something like “code is ephemeral, but data is forever.” That’s never been more true than right now. Code is easier than ever to create, anyone can spin up a working app with an AI agent and minimal experience. But your data structure? That’s the thing that sticks around and haunts you.
Data modeling is one of those topics that doesn’t get enough attention, especially given how critical it is. You need to understand how your data is stored, how to structure it, and what tradeoffs you’re making in how you access it. Get it right early, and your code stays elegant and straightforward. Get it wrong, and your codebase becomes a forever series of workarounds…
Microservices Won’t Save You
For teams moving from a monolith to microservices, if the data stays tightly coupled, you don’t really have microservices; you have a distributed monolith with extra network hops.
Yes, data can be coupled just like code can be coupled. If all your different services are still hitting the same database with the same schema, you have a problem. You need separate data structures for your services, not a monolithic architecture hiding behind a microservices facade.
The Caching Trap
So what happens when you have a lot of data and your queries get slow? You’ve done all the easy stuff; optimized queries, added indexes, followed best practices. But things are still slow.
Every senior engineer’s first instinct is the same: “Let’s add Redis in front of it.” Or “more read replicas.” And sure, that works, but you have just added complexity and now you have to deal with cache invalidation.
What happens when you have stale data? How do you recache current data, and when does that happen?
Are you caching on the browser side too? Understanding where data can be cached and how to invalidate it is another genuinely difficult problem to solve. You’re just trading one set of problems for a different set of problems.
You Can’t Predict Every Future Question
If you’re selling things on the internet, chances are, you will care about event sourcing at some point. A lot of interesting business problems don’t care about the current state of a user, they care about the intent and history. So how you store intent and history is probably different from your ACID-compliant Postgres table that you’ve worked hard to normalize.
You can get your data structure perfect for displaying products and processing sales, then run into a completely new set of requirements that changes everything about how your data needs to be structured.
It’s genuinely hard to foresee all the potential questions you’ll need to answer in the future.
Why This Matters Now
Everything you do on a computer stores data somewhere, it’s just a matter of persistence.
Which is why; everything software-related is eventually a database problem.
Data modeling isn’t glamorous, but getting it right is the difference between a system that scales gracefully and one that fights you every step of the way.
-
Your Context Window Is a Budget — Here's How to Stop Blowing It
If you’re using agentic coding tools like Claude Code, there’s one thing you should know by now: your context window is a budget, and everything you do spends it.
I’ve been thinking about how to manage the budget. As we are learning how to use sub-agents, MCP servers, and all these powerful capabilities we haven’t been thinking enough about the cost of using them. Certainly the dollars and cents matters too if you are using API access, but the raw token budget you burn through in a single session impacts us all regardless. Once it’s gone, compaction kicks in, and it’s kind of a crapshoot on whether it knows how to pick up where we left off on the new session.
Before we talk about what you can do about it, let’s talk about where your tokens go, or primarily are used.
Why Sub-Agents Are Worth It (But Not Free)
Sub-agents are one of the best things to have in agentic coding. The whole idea is that work happens in a separate context window, leaving your primary session clean for orchestration and planning. You stay focused on what needs to change while the sub-agent figures out how.
Sub-agents still burn through your session limits faster than you might expect. There are actually two limits at play here:
- the context window of your main discussion
- the session-level caps on how many exchanges you can have in a given time period.
Sub-agents hit both. They’re still absolutely worth using and working without them isn’t an option, but you need to be aware of the cost.
The MCP Server Problem
MCP servers are another area where things get interesting. They’re genuinely useful for giving agentic tools quick access to external services and data. But if you’ve loaded up a dozen or two of them? You’re paying a tax at the start of every session just to load their metadata and tool definitions. That’s tokens spent before you’ve even asked your first question.
My suspicion, and I haven’t formally benchmarked this, is that we’re headed toward a world where you swap between groups of MCP servers depending on the task at hand. You load the file system tools when you’re coding, the database tools when you’re migrating, and the deployment tools when you’re shipping. Not all of them, all the time.
There’s likley more subtle problems too. When you have overlapping MCP servers that can accomplish similar things, the agent could get confused about which tool to call. It might head down the wrong path, try something that doesn’t work, backtrack, and try something else. Every one of those steps is spending your token budget on nothing productive.
The Usual Suspects
Beyond sub-agents and MCP servers, there are the classic context window killers:
- Web searches that pull back pages of irrelevant results
- Log dumps that flood your context with thousands of lines
- Raw command output that’s 95% noise
- Large file reads when you only needed a few lines
The pattern is the same every time: you need a small slice of data, but the whole thing gets loaded into your context window. You’re paying full price for information you’ll never use.
And here’s the frustrating part — you don’t know what the relevant data is until after you’ve loaded it. It’s a classic catch-22.
Enter Context Mode
Somebody (Mert Köseoğlu - mksglu) built a really clever solution to this problem. It’s available as a Claude Code plugin called context-mode. The core idea is simple: keep raw data out of your context window.
Instead of dumping command output, file contents, or web responses directly into your conversation, context-mode runs everything in a sandbox. Only a printed summary enters your actual context. The raw data gets indexed into a SQLite database with full-text search (FTS5), so you can query it later without reloading it.
It gives Claude a handful of new tools that replace the usual chaining of bash and read calls:
- ctx_execute — Run code in a sandbox. Only your summary enters context.
- ctx_execute_file — Read and process a file without loading the whole thing.
- ctx_fetch_and_index — Fetch a URL and index it for searching, instead of pulling everything into context with WebFetch.
- ctx_search — Search previously indexed content without rerunning commands.
- ctx_batch_execute — Run multiple commands and search them all in one call.
There are also slash commands to check how much context you’ve saved in a session, run diagnostics, and update the plugin.
The approach is smart. All the data lives in a SQLite FTS5 database that you can index and search, surfacing only the relevant pieces when you need them. If you’ve worked with full-text search in libSQL or Turso, you’ll appreciate how well this maps to the problem. It’s the right tool for the job.
The benchmarks are impressive. The author reports overall context savings of around 96%. When you think about how much raw output typically gets dumped into a session, it makes sense. Most of that data was never being used anyway.
What This Means for Your Workflow
I think the broader lesson here is that context management is becoming a first-class concern for anyone doing serious work with agentic tools. It’s not just about having the most powerful model, it’s about using your token budget wisely so you can sustain longer, more complex sessions without hitting the wall.
A few practical takeaways:
- Be intentional about MCP servers. Load what you need, not everything you have.
- Use sub-agents for heavy lifting, but recognize they cost session tokens.
- Avoid dumping raw output into your main context whenever possible.
- Tools like context-mode can dramatically extend how much real work you get done per session.
We’re still early in figuring out the best practices for working with these tools. But managing your context window? That’s one of the things that separates productive sessions from frustrating ones.
Hopefully something here saves you some tokens.
-
I got tired of plaintext .env files, so I built LSM.
lsm execwill inject secrets at runtime so they never touch the filesystem. Doppler’s idea, minus the monthly bill.How are you managing local secrets?
-
I switched to mise for version management a month ago. No regrets. No more
brew upgradebreaking Python. Built-in task runner replaced some of projects that were using Makefiles.Still juggling nvm + pyenv + rbenv?
-
I wrote about why you should stop using pip. Poetry or uv. Pick one. pip has no lockfile, no dependency resolution worth trusting, and no isolation by default.
Have you moved to uv yet? Still happy with poetry? How’s it going?
-
I wrote about when to reach for Python over Bash. Most veterans put the cutoff at 50 lines. If you’re writing nested if statements in Bash, you’ve already gone too far.
Where does Go fit in though? Overkill for scripts?
-
With AI agents, the cost of writing tests is approaching zero. Five words: “write tests on new code.” The excuses are running out.
Are you verifying the tests your AI writes, or just trusting the green checkmarks?
-
I compared npm, Yarn, pnpm, and Bun. TLDR version: pnpm wins for most teams, Bun wins if you’re already on the runtime.
Has anyone switched their whole team to Bun yet? How’d that go?
-
I wrote about securing node_modules. Socket, Snyk, Dependabot — each catches different things. Hopefully answering when to use AI to rewrite simple deps you barely use.
Anyone want to build that CLI?
-
Wrote a guide on writing a good CLAUDE.md. Takeaway: keep it under 200 lines. Every line loads into context every session, so bloat costs real tokens.
How are you handling multiple AI context files across tools?
-
When to Use Python Over Bash
When to use python over bash is really a question of when to use bash. Python is a general-purpose language that can handle just about anything you throw at it. Bash, on the other hand, has a very specific sweet spot. Once you understand that sweet spot, the decision makes itself.
What Bash Actually Is
Bash is an interactive command interpreter and scripting language, created in 1989 for the GNU project as a free software alternative to the Bourne shell. It pulled in advanced features from the Korn shell and C shell, and it’s been commonly used by Unix and Linux systems ever since.
What makes Bash unique is its approach to data flow programming. Files, directories, and system processes are treated as first-class objects. Bash is designed to take advantage of utilities that almost always exist on Unix-based systems. So think of tools like
awk,sed,grep,cat, andcurl. Another important thing to know when writing effective Bash scripts, you also need to understand the pipeline operator and how I/O redirection works.A good Bash script will look something like this:
#!/bin/bash set -euo pipefail LOG_DIR="/var/log/myapp" DAYS_OLD=30 find "$LOG_DIR" -name "*.log" -mtime +"$DAYS_OLD" -print0 | xargs -0 gzip -9 echo "Compressed logs older than $DAYS_OLD days"Simple, portable, does one thing well. That’s Bash at its best.
Where Bash Falls Short
Bash isn’t typed. There’s no real object orientation. Error handling is basically
set -eand hoping for the best. There’s notry/catch, no structured exception handling. When things go wrong in a Bash script, they tend to go wrong quietly or spectacularly, with not much in between.Python, by contrast, is optionally strongly typed and object-oriented. If you want to manipulate a file or a system process in Python, you wrap that system entity inside a Python object. That adds some overhead, sure, but in exchange you get something that’s more predictable, more secure, and scales well from simple scripts to complex logic.
Here’s that same log compression task in Python:
from pathlib import Path import gzip import shutil from datetime import datetime, timedelta log_dir = Path("/var/log/myapp") cutoff = datetime.now() - timedelta(days=30) for log_file in log_dir.glob("*.log"): if datetime.fromtimestamp(log_file.stat().st_mtime) < cutoff: with open(log_file, "rb") as f_in: with gzip.open(f"{log_file}.gz", "wb") as f_out: shutil.copyfileobj(f_in, f_out) log_file.unlink()More verbose? Absolutely. But also more explicit about what’s happening, easier to extend, and much easier to add error handling to.
The Performance Question
In some cases, performance genuinely matters. Think high-frequency trading platforms, edge devices, or massive clusters. Bash scripts excel here because there’s almost zero startup overhead. Compare that to Python, which needs to load up the interpreter before it can start executing code. You’re going from microseconds to milliseconds, and sometimes that matters.
But startup time is just one factor. When you compare the actual work being done, Python can pull ahead. String manipulation on structured data? Python wins. Parsing JSON, YAML, or any structured format? Python’s core libraries are written in C and optimized for exactly this kind of work. If you find yourself reaching for
jqoryqin a Bash script, that’s a strong signal you should be using Python instead.The Guidelines People Throw Around
You’ll see a common guideline online: if your script exceeds 100 lines of Bash, rewrite it in Python. But a lot of veterans in the industry feel like that cutoff is way too generous. Experienced engineers often put it at 50 lines, or even 25.
Another solid indicator: nested
ifstatements. Some people say “deeply nested” if statements, but let’s be honest, more than one level of nesting in Bash is already getting painful. Python handles complex branching logic far more gracefully, and you’ll thank yourself when you come back to maintain it six months later.Unit Testing Tells the Story
You can do unit testing with Bash. BATS (Bash Automated Testing System) exists, and ShellCheck is useful as a lightweight linter for catching bad practices. But despite these tools, Python’s testing ecosystem is on another level entirely. It’s fully mature with multiple frameworks, excellent mocking capabilities, and the ability to simulate network calls, external APIs, or system binaries. Complex mocking that would be difficult or impossible in Bash is straightforward in Python.
If your script needs solid testing or if it’s doing anything important, that’s a strong vote for Python.
Bash’s Biggest Win: Portability
So what does Bash actually win at? Portability. When you think about all the dependencies Python needs to run, Bash is the clear winner. You’re distributing a single
.shfile. That’s it.With Python, you have to ask: Does Python exist on this machine? Is it the right version? You’ll need a virtual environment so you don’t pollute system Python. You need third-party libraries installed via a package manager; and please friends, remember that we don’t let friends use pip. Use Poetry or uv. Pip is so bad that I’d honestly argue that Bash not having a package manager is better than Python having pip. At least Bash doesn’t pretend to manage dependencies well.
If you want something simple, something that can run on practically any Unix-based machine without setup, Bash is your answer. Even Windows can handle it these days through WSL, though you’re jumping through a few hoops.
TLDR
The decision is actually pretty straightforward:
- Use Bash when you’re gluing together system commands, the logic is linear, it’s under 50 lines, and portability matters.
- Use Python when you’re parsing structured data, need error handling, have branching logic, want proper tests, or the script is going to grow.
If you’re reaching for
jq, writing nestedifstatements, or the script is getting long enough that you’re losing track of what it does… it’s time for Python.I think in a future post we might look at when Go makes sense over Bash. There’s a lot to cover there about compiled binaries, but for now, hopefully this helps you make the call next time you’re wondering what to start your scripting with.
-
Local Secrets Manager - Dotenv Encrypter
I built a thing to solve a problem. It has helped me, maybe it will help you?
It all starts with a question.
Why isn’t there a good local secrets manager that encrypts your secrets at rest? I imagine a lot of people, like me, have a number of local applications. I don’t want to pay per-seat pricing just to keep my sensitive data from sitting in plaintext on my machine.
I built an app called LSM Local Secrets Manager to solve that problem. The core idea is simple. Encrypt your
.envfiles locally and only decrypt when you need them (sometimes at runtime).The Problem
If you’ve got a bunch of projects on your machine, each with their own
.envor.env.localfile full of API keys you’re definitely not rotating every 90 days. Those files just sit there in plaintext. Any process on your system can read them. And with AI agents becoming part of our dev workflows, the attack surface for leaking secrets is only getting easier.ThE CLAW EnteRed ChaT
I started looking at Doppler specifically for OpenCLAW. Their main selling feature is injecting secrets into your runtime so they never touch the filesystem. I was like, cool. Also I like that Doppler stores everything remotely. The only thing was the cost did not make sense for me right now. I don’t want to pay $10-20 a month for this set of features.
So what else is there?
Well GCP Secret Manager has its own set of issues.
You can’t have duplicate names per project, so something as common as
NODE_ENVacross multiple apps becomes a more work than you want to deal with. Some wrapper script that injects prefixes? No thanks. I imagine there are a thousand and one homegrown solutions to solve this problem. Again, no thanks.So what else is there?
You Find A Solution
AWS Secret Manager
A Problem for Solution Problem
AWS IAM
🫣
I have a lot more to say here on this subject but will save this for another post. Subscribe if you want to see the next post.
The Solution
The workflow is straightforward:
lsm init— Run this once from anywhere. It generates your encryption key file.lsm link <app-name>— Run this inside your project directory. It creates a config entry in~/.lsm/config.yamlfor that application.lsm import— Takes your existing.envor.env.localand creates an encrypted version.lsm clean— Removes the plaintext.envfiles so they’re not just sitting around.lsm dump— Recreates the.envfiles if you need them back.
But wait there’s more.
Runtime Injection with
lsm execRemember that cool thing I just told you about? Instead of dumping secrets back to disk, you run:
lsm exec -- pnpm devI feel like a family man from Jersey, who don’t mess around. Aye, you got, runtime injection. I got that.
Well that’s
lsmanyways. It can decrypt your secrets and inject them directly into the runtime environment of whatever command follows the--. Your secrets exist in memory for the duration of that process and nowhere else. No plaintext files hanging around for other processes to sniff.Credit to Doppler for the idea. The difference to what we are doing is your encrypted files stay local.
What’s Next
I’ve got some possible ideas of improvements to try building.
- Separate encrypt/decrypt keys — You create secrets with one key, deploy the encrypted file to a server, and use a read-only key to decrypt at runtime. The server never has write access to your secrets.
- Time-based derivative keys — Imagine keys that expire or rotate automatically.
- Secure sharing — Right now you’d have to decrypt and drop the file into a password manager to share it. There’s room to make that smoother.
I’m not sure how to do all of that yet, but we’re making progress.
Why Not Just Use Doppler?
There are genuinely compelling reasons to use Doppler or similar services. I mean bsides the remote storage, access controls and auditable logs. There’s a lot to love.
For local development across a bunch of personal projects? I don’t think you should need a SaaS subscription to keep your secrets encrypted.
LSM is still early, but the core workflow is there and it works.
Give it a try if you’re tired of plaintext
.envfiles scattered across your machine.