I love trying out new tools.

The recent explosion in Hyper Personal Software and terminal tooling, catalysed by the rise of LLM coding tools, means I’m installing and trying out new things almost daily.

I recently set up zed.dev and pi.dev on my work machine (because we’re actively encouraged to try new tooling in the LLM agent space) and I liked them both so much I wanted to install them on my personal laptop as well.

And then I remembered: it’s going to be a tedious repeat performance to install them both and configure them exactly as I like.

And then I also remembered: while I’m on my personal laptop, I’d better open up my zsh config and port over all the new aliases I set up yesterday too.

Oh! And I'd better update my other tools while I'm at it...

Suddenly a simple “oh I’ll just update my other machine to use this” thought became a long list of chores.

Dotfiles management isn’t new

I figured it was time to finally set up and use a dotfiles manager.

In the past I’ve just copied and pasted bits of my favourite bash scripts and aliases into a directory in my Obsidian vault and manually copied over things when I got a new machine. The implementation difficulty of this approach: basically zero. But the effort to keep in sync: pretty high.

So I went looking online. Chezmoi and yadm came out as some of the most popular pre-built solutions. With implementation effort being very low (configuration, set up, done) and the effort to keep in sync across machines also pretty low.

So why was I put off by them?

I think one factor I hadn’t taken into consideration is learning of the tool. My current copy and paste solution was already figured out, but with these new tools I’d have to figure out their workflows. They had almost what I wanted for managing certain files like bash configs and aliases, but not quite enough for managing the tools I want to install.

So what did I want from a dot files manager?

  1. File over app. Store all the config in clear, simple file formats. Particularly for the aliases and bash config, just store exactly what those files are.
  2. A simple abstraction over my aliases. I don’t want to edit my zsh config by hand. Something like alias add gs "git status" and then mytool sync would be ideal.
  3. Tool installations. I want to declare a tool I install across all my machines, with a check for whether it’s installed and the command to install it. Easy updating too. This is something none of the existing tools offered, (understandably, because it’s out of scope for dotfiles). But if I’m managing configurations for tools, I want to manage their installation as well.
  4. Profiles. Sometimes I want aliases or tools to only live on certain machines. None of the existing options had this in a shape that fit me.
  5. Sync. One command. Conflict resolution. Basically a git-like model because that workflow already fits my brain.

So basically: not really a dotfiles manager, more like a system configuration tool.

A custom tool to suit my needs

It’s pretty clear this thought process was driving me down the Hyper Personal Software path: I wanted a tool that’s quite a lot like other existing tools but just different enough to suit my exact needs.

So I figured I’d start building one. I clearly already did the work to figure out what and how I wanted it to work. So I may as well use LLMs to whip up a prototype and see if it can work for me, then “eject” from the LLM doing all the work and refine things myself.

I think this is one of the most enjoyable ways to build personal software today.

Reading other people’s recipes

Before writing a single line of code, I cracked open Chezmoi’s docs and yadm’s docs and read them. I didn’t want to copy their functionality exactly, but I did want to understand how they think about dotfiles.

What does each one optimise for, and what does it ignore?

By the time I was done I had a much better mental model than I’d ever have arrived at on my own. Some of the things I liked:

  • Chezmoi’s single source-of-truth git repo with a generated output file felt right.
  • The “apply” verb as the reconciliation step felt right.
  • Yadm’s “it’s just a git repo” simplicity felt right.
Pre-existing solutions are the best starting point you’ll ever get.

Someone else has already done the hard thinking about the messy edge cases. You don’t have to invent the data model from scratch, you can stand on the shoulders of folks who shipped a real thing for real users, and work out which of their ideas suits you.

I took all this learning (I wrote a ton of notes), links to all the documentation I had read. Then I started exploring the idea by talking to an LLM about my plan. Over the course of about two dozen prompts we refined a spec for implementation.

I made sure to keep things aligned with my HYPS manifesto, how I like to build my tooling, the way I wanted it to work, etc etc. Then we got to building.

What I built

I call it pauldot.

It is, predictably, a Python CLI.

It uses a very simple directory structure for recording configuration:

~/.pauldot/
├── pauldot.toml         # top-level config
├── profiles/
│   ├── base.toml        # shared base
│   └── personal.toml    # extends base
├── files/
│   ├── zshrc.base
│   ├── aliases.zsh      # managed by `pauldot alias add`
│   └── home/            # tracked dotfiles
├── tools/
│   └── tools.toml       # tool check + install commands
└── bootstrap.sh

Everything is plain TOML. The bootstrap.sh is just a script I can curl when I want to set up a new computer.

The workflow is the bit that actually matters to me though:

shell
pauldot alias add gs "git status"   # no opening .zshrc
pauldot tool add                    # interactive, writes to tools.toml
pauldot apply                       # reconcile this machine
pauldot sync                        # pull remote, push local
pauldot profile set personal        # swap context

That’s basically it. Four commands I can use daily, plus status and doctor for when I forget the workflow and need to know where I am at.

How it works

Pauldot compiles a .zshrc file from the files in the configuration directory.

For the dotfiles tracking the configuration tracks the file and hosts a copy of it too. Dotfiles are pushed back to the appropriate locations when synced. So no symlinking.

For tools the configuration tracks:

  • A “check” state to see if it’s installed. I.e. atuin -v. A stderr response considers to tool to not be installed
  • Installation bash commands for installing across both MacOS and Linux
  • Update commands too.
tools.toml
[[tool]]
name = "ripgrep"
check = "command -v rg"
install.macos = "brew install ripgrep"
update.macos ="brew upgrade ripgrep"
install.linux = "sudo apt install ripgrep"

So it basically tracks the bash scripts you run to install it. For example - most of the macOS ones are just brew commands.

A local config file tracks the state we’re currently in to avoid clashes with remote changes.

And the actual syncing? It’s just git.

To recap, the five things I wanted, lined up against what I built:

  1. File over app. The repo is the truth. aliases.zsh is just a file of aliases. tools.toml is just a list of tools.
  2. A simple abstraction over aliases. pauldot alias add writes to aliases.zsh. I never have to remember which line my git aliases live on.
  3. Tool installations. Each tool gets a check command and an install command. pauldot apply runs the check, and if it fails, runs the install. That is the entire feature.
  4. Profiles. base.toml is shared. personal.toml and work.toml extend it. The active profile lives in local state, not the repo, so the same repo can be cloned to any machine and configured differently.
  5. Sync. A simple lock in the local configuration prevents clashes and prints clear instructions on how to reconcile.

Back to zed and pi

So, the original problem that triggered all this: I had two new tools I wanted on both machines, and a pile of fresh aliases I’d forget to copy over.

On the work machine, where I first installed them I can now run:

Shell
pauldot tool add        # add zed, paste in the check + install commands
pauldot tool add        # same for pi
pauldot alias add zd "zed ."
pauldot sync            # commit and push everything to the dotfiles repo

A minute or two of work. Then on my personal laptop:

shell
pauldot sync            # pull the latest config
pauldot apply           # zed and pi get installed, aliases land in the shell

That’s it. The friction of “oh, I’d better port this over later” was a thing that risked stopping me from trying new tools in the first place.

Now there is just sync.

The maintenance question

The thing I worried about most before starting was: am I just signing myself up for a second job? Am I gonna build a tool that makes managing this more complicated?

So far, no, and I think it’s because of three deliberate choices.

The docs are for me in six months. I wrote a SPEC.md and an actual mkdocs site so I can refer back and understand what was built and how it works.

The interface is small. Around a dozen commands, each doing one obvious thing.

No lock-in. If I wake up one Tuesday and decide Pauldot is annoying me, I stop using Pauldot and I still have a git repo full of plain files. And all my config files are just there - not symlinked.

The HYPS rule “if you stop enjoying it, stop building it” only works if stopping is genuinely cheap.

Go and shape your own

The next time you find yourself frustrated with a tool that’s almost-but-not-quite right, read the docs of the closest two or three alternatives.

Learn a bit about how they work. Write down which bits you would like.

Then build the small, specific shaped thing that fits your hand. Keep the data in plain files. Keep the interface small enough that you can hold it in your head. Make sure you can walk away from it without losing anything.

And most of all - enjoy building it!