<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Matt I Michie</title>
    <description>The latest articles on DEV Community by Matt I Michie (@influx).</description>
    <link>https://dev.to/influx</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3776637%2Fa2dc4eea-d6d6-48b0-8963-8a16d0a8f66a.png</url>
      <title>DEV Community: Matt I Michie</title>
      <link>https://dev.to/influx</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/influx"/>
    <language>en</language>
    <item>
      <title>Building a Programming Language for Fun</title>
      <dc:creator>Matt I Michie</dc:creator>
      <pubDate>Tue, 17 Feb 2026 01:23:51 +0000</pubDate>
      <link>https://dev.to/influx/building-a-programming-language-for-fun-okf</link>
      <guid>https://dev.to/influx/building-a-programming-language-for-fun-okf</guid>
      <description>&lt;p&gt;I've been building a programming language. It started as an experiment and turned into sixty thousand lines of Go. That's what happens when you try to answer the question: what would a language look like if Lisp and Python had a kid?&lt;/p&gt;

&lt;h2&gt;
  
  
  The Premise
&lt;/h2&gt;

&lt;p&gt;I've always liked two things about Lisp that most people hate: the parentheses and the homoiconicity. Code is data, data is code. It's a simple idea with deep consequences. Macros work because you're manipulating the same structures the interpreter reads. There's an elegance to it that never wore off for me.&lt;/p&gt;

&lt;p&gt;But I also write a lot of Python. Python gets things right that Lisp never bothered with: readable syntax for common operations, a pragmatic standard library, data structures that do what you expect without ceremony. Python's dict comprehensions are nicer than anything Lisp has for the same task.&lt;/p&gt;

&lt;p&gt;So I built a language that tries to have both. S-expressions when you want them, Pythonic syntax when you don't. The same interpreter handles both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Lisp style
(def factorial (n) (if (&amp;lt;= n 1) 1 (* n (factorial (- n 1)))))

# Pythonic style
def factorial(n): (if (&amp;lt;= n 1) 1 (* n (factorial (- n 1))))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can mix them freely. The parser desugars the Pythonic forms into the same AST. Under the hood, it's all S-expressions.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Actually Built
&lt;/h2&gt;

&lt;p&gt;The interpreter is written in Go, which turned out to be a good choice for a language runtime. Go's interfaces map cleanly to the kind of type dispatch a dynamic language needs. The garbage collector handles memory management for hosted objects without me having to think about it.&lt;/p&gt;

&lt;p&gt;The feature list got long. Classes with inheritance and method resolution order. A protocol system with Python-style dunder methods so you can define &lt;code&gt;__add__&lt;/code&gt; and &lt;code&gt;__getitem__&lt;/code&gt; on your own types. Exception handling with proper tracebacks. Generators with yield. List comprehensions, context managers, f-strings. Most of the Python features I actually use day-to-day.&lt;/p&gt;

&lt;p&gt;The protocol system was the most satisfying piece to design. Operator dispatch goes through three tiers: check for a dunder method first, then a protocol implementation, then fall back to type-based defaults. It means the language is extensible in the same way Python is. Define &lt;code&gt;__iter__&lt;/code&gt; and &lt;code&gt;__next__&lt;/code&gt; on your class and for-loops just work.&lt;/p&gt;

&lt;p&gt;I also spent time on Python standard library compatibility. Pure Python stdlib modules can run directly when possible. For C extension modules, I wrote Go stubs that provide the same interface. The goal isn't perfect compatibility. It's enough compatibility that useful code works without modification.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture Rabbit Hole
&lt;/h2&gt;

&lt;p&gt;At some point I started thinking about what this could become if the architecture was right. The idea is a multi-frontend, multi-backend design: multiple source languages parse into a shared intermediate representation, which can target multiple backends.&lt;/p&gt;

&lt;p&gt;Right now there's one frontend (M28's hybrid syntax) and one backend (a tree-walking interpreter). But the architecture is designed so a Python frontend could parse into the same IR, and a bytecode VM or LLVM backend could execute it. Solve the problem once in the middle layer and every frontend/backend combination benefits.&lt;/p&gt;

&lt;p&gt;I've designed the bytecode VM on paper. Register-based, targeting a 3-10x speedup over the tree walker. Haven't built it yet. The interpreter is fast enough for everything I use it for, and there's always another language feature that's more interesting to implement than making the existing ones faster.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Build a Language
&lt;/h2&gt;

&lt;p&gt;People don't ask "why" as much as you'd expect. Maybe because the answer is obvious: because it's interesting. You learn things building an interpreter that you can't learn any other way. How scoping actually works. Why tail call optimization matters. What makes a type system feel good to use versus feel like it's fighting you.&lt;/p&gt;

&lt;p&gt;Every language is a set of opinions about how programmers should think. Building one forces you to articulate your own opinions and then live with the consequences. I think operator overloading should use protocols. I think immutable data should be the default. I think S-expressions are underrated. Now I have a language that embodies those opinions, and I can see where they work and where they don't.&lt;/p&gt;

&lt;p&gt;It's also the longest-running side project I have. Languages are never done. There's always another feature, another optimization, another edge case in the parser. It's the kind of project that rewards showing up for an hour on a Tuesday night, making one thing slightly better, and closing the laptop.&lt;/p&gt;

&lt;p&gt;The code is on &lt;a href="https://github.com/mmichie/m28" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; if you're curious. It's a toy in the sense that I wouldn't deploy it to production. But it's the most instructive toy I've ever built.&lt;/p&gt;

</description>
      <category>programming</category>
      <category>go</category>
      <category>lisp</category>
      <category>languagedesign</category>
    </item>
    <item>
      <title>Simulating Blackjack the Hard Way</title>
      <dc:creator>Matt I Michie</dc:creator>
      <pubDate>Tue, 17 Feb 2026 01:16:53 +0000</pubDate>
      <link>https://dev.to/influx/simulating-blackjack-the-hard-way-395p</link>
      <guid>https://dev.to/influx/simulating-blackjack-the-hard-way-395p</guid>
      <description>&lt;p&gt;I built a blackjack simulator. That sentence undersells it. What I actually built is a 28,000-line Python framework for simulating card games with an event-driven architecture, immutable state management, and realistic shuffle physics. For blackjack.&lt;/p&gt;

&lt;p&gt;This is what happens when a software engineer gets curious about casino math.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Itch
&lt;/h2&gt;

&lt;p&gt;It started the way these things always start: I wanted to understand the numbers. Not the hand-wavy "the house has a 0.5% edge" stuff you read online, but the actual mechanics. How does basic strategy change when the dealer hits soft 17? What does card counting really buy you? How many riffle shuffles does it take before a deck is actually random?&lt;/p&gt;

&lt;p&gt;I could have read a book. Instead I wrote a simulator. Then I rewrote it. Then I rewrote it again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting the Simulation Right
&lt;/h2&gt;

&lt;p&gt;The first rule I set for myself was no shortcuts. If you're simulating blackjack to understand blackjack, you can't approximate the parts you find inconvenient. Every card comes from a shoe. Every shuffle follows real physics. Every hand resolves by the actual casino rules: splits, doubles, surrender, insurance, the whole mess.&lt;/p&gt;

&lt;p&gt;The shuffle simulation is where things got interesting. A perfect Fisher-Yates shuffle is trivial to implement, and it produces a uniformly random deck. But casino dealers don't perform perfect shuffles. They do riffle shuffles, and the mathematics of imperfect riffles are well-studied. It takes about seven riffle shuffles to adequately randomize a deck. Fewer than that and there's exploitable structure left in the card order. I implemented three shuffle types with configurable fidelity so I could study exactly how much information survives an imperfect shuffle.&lt;/p&gt;

&lt;p&gt;Card counting was the other rabbit hole. The basic Hi-Lo system is straightforward: low cards add one, high cards subtract one, divide by decks remaining. But professional play deviations are where it gets complicated. The correct play changes based on the true count. Sometimes you should hit a 16 against a dealer 10. Sometimes you shouldn't. The threshold depends on how deep you are into the shoe.&lt;/p&gt;

&lt;p&gt;I implemented all of this because I wanted the numbers to be right.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture Obsession
&lt;/h2&gt;

&lt;p&gt;The simulation code worked fine as a monolith. I could run thousands of hands and get statistically valid results. But I kept looking at the code and seeing things I wanted to fix.&lt;/p&gt;

&lt;p&gt;The game logic was tangled up with the I/O. Strategy decisions were coupled to hand resolution. State was mutable and scattered across half a dozen objects. It worked, but it was the kind of code that made me uneasy. The kind where adding a new feature meant understanding every other feature first.&lt;/p&gt;

&lt;p&gt;So I did a four-phase rewrite. Event-driven architecture, immutable state with frozen dataclasses, platform adapters to decouple the engine from any specific interface, and async support throughout. The kind of engineering that is completely unjustifiable for a side project and completely satisfying to build.&lt;/p&gt;

&lt;p&gt;The core insight was treating every state change as an event. A card is dealt: that's an event. A player hits: event. The dealer reveals their hole card: event. This makes the game engine a pure state machine. Feed it actions, get back new states. No side effects, no hidden mutations, easy to test, easy to verify.&lt;/p&gt;

&lt;p&gt;It also means you can record an entire game as a sequence of events and replay it later. When my simulation produced a result that looked wrong, I could step through every decision and see exactly where the math diverged from my expectations. Usually the math was right and my expectations were wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  What 350,000 Hands Per Second Tells You
&lt;/h2&gt;

&lt;p&gt;At this point the simulator can run about 350,000 games per second. That's enough to get statistically meaningful results on almost any question you want to ask.&lt;/p&gt;

&lt;p&gt;Some things I've confirmed that you probably already knew: basic strategy works. Card counting works, barely, under ideal conditions. The Martingale betting system is a reliable way to go broke slowly.&lt;/p&gt;

&lt;p&gt;Some things that surprised me: the variance in blackjack is brutal. You can play perfect basic strategy and lose for hours. The math says you'll come out slightly ahead over thousands of hands, but "slightly" is doing a lot of heavy lifting in that sentence. I have a much better intuition now for why card counting requires both a large bankroll and an iron stomach.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Build This
&lt;/h2&gt;

&lt;p&gt;People build side projects for different reasons. Some want to ship a product. Some want to learn a technology. I wanted to understand a system, and building a simulation was the most thorough way I knew to do it.&lt;/p&gt;

&lt;p&gt;The architecture work was its own reward. Not because anyone will ever need an event-driven blackjack engine with immutable state management, but because the patterns transfer. The same separation of concerns that makes a card game testable makes a distributed system debuggable. The same event-driven approach that lets me replay a hand of blackjack is the same approach that lets you replay a production incident.&lt;/p&gt;

&lt;p&gt;The code is on &lt;a href="https://github.com/mmichie/cardsharp" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; if you want to look at it. Fair warning: it's a side project that kept growing. Twenty-eight thousand lines of Python for a card game. Some projects are just like that.&lt;/p&gt;

</description>
      <category>python</category>
      <category>programming</category>
      <category>sideprojects</category>
      <category>simulation</category>
    </item>
    <item>
      <title>Nixifying My Dotfiles</title>
      <dc:creator>Matt I Michie</dc:creator>
      <pubDate>Tue, 17 Feb 2026 01:14:16 +0000</pubDate>
      <link>https://dev.to/influx/nixifying-my-dotfiles-2o6a</link>
      <guid>https://dev.to/influx/nixifying-my-dotfiles-2o6a</guid>
      <description>&lt;p&gt;I wrote about &lt;a href="https://mattmichie.com/2026/02/17/bootstrapping-my-dotfiles/" rel="noopener noreferrer"&gt;bootstrapping my dotfiles&lt;/a&gt; a few days ago. Homebrew, GNU Stow, a bootstrap script. It works on my Mac. The problem is I also use Linux, and the Brewfile is worthless there. Every time I SSH into a Linux box I end up cloning the repo, running &lt;code&gt;stow.sh&lt;/code&gt; for the configs, and then installing packages by hand for twenty minutes. Same tools, different package managers, nothing shared.&lt;/p&gt;

&lt;p&gt;I'd been putting off trying Nix because it looked like a lot of ceremony for what is fundamentally &lt;code&gt;brew install&lt;/code&gt; plus symlinks. But the cross-platform thing kept nagging at me, so I made a branch and started experimenting.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Stack
&lt;/h2&gt;

&lt;p&gt;nix-darwin handles system-level macOS configuration: defaults, Homebrew casks, system packages. home-manager handles user-level everything: packages, dotfile symlinks, shell setup. Both are configured through Nix's functional language and the whole thing is pinned with a flake.&lt;/p&gt;

&lt;p&gt;On macOS, &lt;code&gt;darwin-rebuild switch&lt;/code&gt; applies the entire stack. On Linux, &lt;code&gt;home-manager switch&lt;/code&gt; handles the user side. Same repo, same package list.&lt;/p&gt;

&lt;h2&gt;
  
  
  Restructuring
&lt;/h2&gt;

&lt;p&gt;The old repo had one directory per tool in stow's expected layout: &lt;code&gt;zsh/.zshrc&lt;/code&gt;, &lt;code&gt;nvim/.config/nvim/init.lua&lt;/code&gt;, &lt;code&gt;git/.gitconfig&lt;/code&gt;. Stow would symlink each directory's contents into &lt;code&gt;$HOME&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I moved everything into a &lt;code&gt;configs/&lt;/code&gt; directory and let home-manager handle the symlinks with &lt;code&gt;mkOutOfStoreSymlink&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nix"&gt;&lt;code&gt;&lt;span class="nv"&gt;home&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;file&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="s2"&gt;".zshrc"&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nv"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;lib&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;file&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;mkOutOfStoreSymlink&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;dotfiles&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/zsh/.zshrc"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;mkOutOfStoreSymlink&lt;/code&gt; is important. Without it, Nix copies files into &lt;code&gt;/nix/store/&lt;/code&gt; and the symlink points there, read-only. With it, the symlink points back to the git working tree, so I can edit configs and see changes immediately. Same behavior as stow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Packages
&lt;/h2&gt;

&lt;p&gt;The 345-line Brewfile became a home-manager module. All CLI tools go in one list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nix"&gt;&lt;code&gt;&lt;span class="nv"&gt;home&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;packages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kn"&gt;with&lt;/span&gt; &lt;span class="nv"&gt;pkgs&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="nv"&gt;ripgrep&lt;/span&gt; &lt;span class="nv"&gt;sd&lt;/span&gt; &lt;span class="nv"&gt;fd&lt;/span&gt; &lt;span class="nv"&gt;fzf&lt;/span&gt; &lt;span class="nv"&gt;zoxide&lt;/span&gt;
  &lt;span class="nv"&gt;eza&lt;/span&gt; &lt;span class="nv"&gt;bat&lt;/span&gt; &lt;span class="nv"&gt;dust&lt;/span&gt; &lt;span class="nv"&gt;duf&lt;/span&gt; &lt;span class="nv"&gt;procs&lt;/span&gt;
  &lt;span class="nv"&gt;xh&lt;/span&gt; &lt;span class="nv"&gt;delta&lt;/span&gt; &lt;span class="nv"&gt;difftastic&lt;/span&gt; &lt;span class="nv"&gt;hexyl&lt;/span&gt;
  &lt;span class="nv"&gt;starship&lt;/span&gt; &lt;span class="nv"&gt;atuin&lt;/span&gt; &lt;span class="nv"&gt;vivid&lt;/span&gt; &lt;span class="nv"&gt;gum&lt;/span&gt;
  &lt;span class="nv"&gt;hyperfine&lt;/span&gt; &lt;span class="nv"&gt;just&lt;/span&gt; &lt;span class="nv"&gt;watchexec&lt;/span&gt; &lt;span class="nv"&gt;tealdeer&lt;/span&gt; &lt;span class="nv"&gt;tokei&lt;/span&gt;
  &lt;span class="nv"&gt;shellcheck&lt;/span&gt; &lt;span class="nv"&gt;neovim&lt;/span&gt; &lt;span class="nv"&gt;tmux&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plus another 80 or so for Go, Rust, Python toolchains, infrastructure tools, network utilities, media processing. Everything was in nixpkgs. Two packages had different names: &lt;code&gt;git-delta&lt;/code&gt; is &lt;code&gt;delta&lt;/code&gt; in nixpkgs, and &lt;code&gt;dust&lt;/code&gt; is &lt;code&gt;du-dust&lt;/code&gt;. Everything else matched.&lt;/p&gt;

&lt;p&gt;GUI apps can't come from Nix on macOS because &lt;code&gt;.app&lt;/code&gt; bundles don't work well in the Nix store. nix-darwin has a Homebrew integration module that manages casks declaratively:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nix"&gt;&lt;code&gt;&lt;span class="nv"&gt;homebrew&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nv"&gt;enable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nv"&gt;onActivation&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;cleanup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"zap"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nv"&gt;casks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s2"&gt;"ghostty"&lt;/span&gt; &lt;span class="s2"&gt;"wezterm"&lt;/span&gt; &lt;span class="s2"&gt;"google-chrome"&lt;/span&gt;
    &lt;span class="s2"&gt;"docker-desktop"&lt;/span&gt; &lt;span class="s2"&gt;"slack"&lt;/span&gt; &lt;span class="s2"&gt;"1password"&lt;/span&gt;
    &lt;span class="s2"&gt;"aerospace"&lt;/span&gt; &lt;span class="s2"&gt;"spotify"&lt;/span&gt; &lt;span class="s2"&gt;"vlc"&lt;/span&gt;
  &lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;cleanup = "zap"&lt;/code&gt; removes any cask that isn't in the list. So if I install something to try it and don't add it here, it gets cleaned up on the next rebuild. The cask list becomes the source of truth instead of whatever accumulates on the machine over time.&lt;/p&gt;

&lt;h2&gt;
  
  
  macOS Defaults
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;osx/.osx&lt;/code&gt; script was 165 lines of &lt;code&gt;defaults write&lt;/code&gt; commands. nix-darwin replaces it with typed options:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nix"&gt;&lt;code&gt;&lt;span class="nv"&gt;system&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;defaults&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;dock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nv"&gt;tilesize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;57&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nv"&gt;autohide&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nv"&gt;autohide-delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nv"&gt;mru-spaces&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These get applied on every &lt;code&gt;darwin-rebuild switch&lt;/code&gt;. If a macOS update resets something, the next rebuild puts it back. I don't have to remember to re-run a script.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Rust Binary
&lt;/h2&gt;

&lt;p&gt;My custom &lt;code&gt;starship-segments&lt;/code&gt; binary was previously compiled locally with the Homebrew-provided libgit2 and the resulting binary committed to the repo. With Nix, &lt;a href="https://github.com/ipetkov/crane" rel="noopener noreferrer"&gt;Crane&lt;/a&gt; builds it as a proper derivation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nix"&gt;&lt;code&gt;&lt;span class="nv"&gt;starshipSegmentsFor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;system&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt;
    &lt;span class="nv"&gt;craneLib&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;crane&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;mkLib&lt;/span&gt; &lt;span class="nv"&gt;nixpkgs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;legacyPackages&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;system&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="kn"&gt;in&lt;/span&gt;
  &lt;span class="nv"&gt;craneLib&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;buildPackage&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;craneLib&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;cleanCargoSource&lt;/span&gt; &lt;span class="sx"&gt;./starship-segments&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;strictDeps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;buildInputs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;pkgs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;lib&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;optionals&lt;/span&gt; &lt;span class="nv"&gt;pkgs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;stdenv&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;isDarwin&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="nv"&gt;pkgs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;apple-sdk_15&lt;/span&gt; &lt;span class="nv"&gt;pkgs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;libiconv&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This actually caught a real problem. The old binary linked against &lt;code&gt;/opt/homebrew/opt/libgit2/lib/libgit2.1.9.dylib&lt;/code&gt;. When I removed Homebrew packages in favor of Nix, that library vanished and Ghostty wouldn't even open because the tmux launch script called &lt;code&gt;starship-segments&lt;/code&gt; during prompt setup. dyld error, immediate crash. The Nix-built version pins its own libgit2 in the store, so the dependency is always satisfied.&lt;/p&gt;

&lt;p&gt;Crane also handles cross-platform automatically. The flake defines the build for &lt;code&gt;aarch64-darwin&lt;/code&gt; and &lt;code&gt;x86_64-linux&lt;/code&gt;, so the binary gets compiled for whatever system you're on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Flake
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;flake.nix&lt;/code&gt; defines two entry points:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nix"&gt;&lt;code&gt;&lt;span class="nv"&gt;darwinConfigurations&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="s2"&gt;"mims-mbp"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;nix-darwin&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;lib&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;darwinSystem&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="nv"&gt;homeConfigurations&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="s2"&gt;"mim@linux"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;home-manager&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;lib&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;homeManagerConfiguration&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both import the same shared modules. The darwin config adds system defaults and Homebrew casks. The linux config just sets the home directory. A &lt;code&gt;justfile&lt;/code&gt; wraps the platform detection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Apply everything — auto-detects macOS vs Linux&lt;/span&gt;
just switch
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bootstrap on a new machine is three commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sSf&lt;/span&gt; &lt;span class="nt"&gt;-L&lt;/span&gt; https://install.determinate.systems/nix | sh &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nb"&gt;install
&lt;/span&gt;git clone https://github.com/mmichie/dotfiles ~/src/dotfiles
&lt;span class="nb"&gt;cd&lt;/span&gt; ~/src/dotfiles &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; just switch
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What Broke
&lt;/h2&gt;

&lt;p&gt;Two things, both the same pattern: hardcoded Homebrew paths.&lt;/p&gt;

&lt;p&gt;My Ghostty launch command runs &lt;code&gt;~/bin/tmux-attach-or-new&lt;/code&gt;, which had &lt;code&gt;TMUX=/opt/homebrew/bin/tmux&lt;/code&gt; on line 3. tmux was now in &lt;code&gt;/etc/profiles/per-user/mim/bin/tmux&lt;/code&gt; courtesy of Nix. Ghostty showed a "failed to launch" error and wouldn't open at all. Fix: use &lt;code&gt;command -v tmux&lt;/code&gt; instead.&lt;/p&gt;

&lt;p&gt;The starship config had &lt;code&gt;command = "~/.config/starship/starship-segments git"&lt;/code&gt;, pointing to the old stow'd binary. The Nix-built binary was on PATH but at a different location. Fix: change it to just &lt;code&gt;command = "starship-segments git"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Both were five-minute fixes, but the first one locked me out of my terminal until I figured it out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where It Stands
&lt;/h2&gt;

&lt;p&gt;The experiment is on a &lt;a href="https://github.com/mmichie/dotfiles/tree/nix" rel="noopener noreferrer"&gt;nix branch&lt;/a&gt;. I've been running it on my Mac and everything works: prompt, tmux, all 120+ CLI tools, macOS defaults, casks. &lt;code&gt;nix flake check&lt;/code&gt; passes.&lt;/p&gt;

&lt;p&gt;The real test is the next time I set up a Linux box. If &lt;code&gt;home-manager switch --flake .#mim@linux&lt;/code&gt; gives me my full shell environment in one command, I'll merge the branch. That's the whole reason I did this.&lt;/p&gt;

&lt;p&gt;The Nix language took some getting used to. It's functional, it's lazy, and the error messages are occasionally unhelpful. I spent more time reading documentation than writing configuration. But the old setup was four scripts run in the right order on the right platform. The new setup is &lt;code&gt;just switch&lt;/code&gt; on either.&lt;/p&gt;

</description>
      <category>nix</category>
      <category>dotfiles</category>
      <category>linux</category>
      <category>macos</category>
    </item>
  </channel>
</rss>
