Go
-
My Test Agreed With My Bug
The fix was one character. I appended
"s"to a string. That’s the entire diff in the file that mattered:func printTimeoutValue(timeout time.Duration) string { seconds := int64(math.Ceil(timeout.Seconds())) if seconds < 1 { seconds = 1 } return strconv.FormatInt(seconds, 10) + "s" }Before that, it returned
"120". After,"120s". The service had been deployed and broken for every job that used this path, and the test suite was green the whole time. That last part is the reason I’m writing this down.The setup
I’d built a small HTTP gateway in Go that wraps a CLI tool. The whole point of it is that callers get almost no control. They can submit a prompt and an optional bounded timeout. They cannot pick the executable, the agent, the model, the output format, the working directory, any additional flags, or anything resembling shell syntax. The gateway assembles a fixed argv and runs it with
exec.Command, no shell in between:args := []string{ "-p", prompt, "--agent", r.agent, "--output-format", "json", "--print-timeout", printTimeoutValue(timeout), "--mode", "plan", }I like this shape. It’s boring and it’s hard to abuse. The client-controlled surface is two fields, both bounded, and everything else is server-owned config.
And it was completely non-functional, because
printTimeoutValuewas producing a value the CLI refuses to parse.The reproduction is two lines
I didn’t want to take my own commit message’s word for it, so I ran the actual binary:
$ agy -p "hi" --print-timeout 120 --output-format json invalid value "120" for flag -print-timeout: time: missing unit in duration "120"There it is. The flag is a
time.Duration, registered through Go’sflagpackage, which parses withtime.ParseDuration. That function wants a unit. A bare integer isn’t a duration, it’s just a number, andParseDurationsays so.The tell was in the help text the whole time, and I’d read past it:
--print-timeout Timeout for print mode wait (default 5m0s)5m0sis whattime.Duration.String()produces. If a CLI renders its default that way, the flag is a duration and it’s going to be strict about units. I looked at that line, saw “timeout”, thought “seconds”, and moved on.The failure mode is worth noting too. This isn’t a runtime error partway through the work.
flagrejects it during parsing, so the process died before it did anything at all. Every one of these jobs failed before inference started.The part that actually bothers me
Here’s the assertion that was sitting in the test file:
want := []string{"-p", "ok", "--agent", "WebResearcher", "--output-format", "json", "--print-timeout", "5", "--mode", "plan"}Read that carefully. The test checks that the gateway builds exactly the argv I intended. It’s a good test. It’s the right kind of test for this code, because the whole security story is “the argv is fixed and the client can’t touch it,” and that deserves a lock. It passed. It had always passed.
It passed because I wrote the assertion from the same wrong assumption that produced the bug. I believed the flag took an integer. So the code emitted
"5"and the test demanded"5", and they agreed with each other perfectly, all the way into production.A test that encodes your assumption doesn’t verify your assumption. It just makes it harder to notice. This is the failure mode that unit tests are structurally bad at: anything where the contract lives outside your process. My test knew what I meant to send. It had no idea what the other side would accept, because nothing in that test ever went near the real CLI.
And then the boundary hid the evidence
The second thing that went wrong is that this took longer to diagnose than a one-character bug has any right to take.
The gateway captures the subprocess’s stdout and stderr into size-limited buffers and, on a non-zero exit, throws stderr away:
if err != nil { return runnerResult{}, fmt.Errorf("antigravity exited unsuccessfully: %w", err) }That’s deliberate. The service’s own docs say public errors and structured logs must not include prompts, bearer tokens, CLI auth state, raw stdout, raw stderr, or raw diagnostics. I wrote that rule, I still think it’s correct, and it meant the operator-visible error was
antigravity exited unsuccessfully: exit status 1.The string
time: missing unit in duration "120"was captured into a buffer and discarded, by design, every single time. The one line that explains the whole failure was right there and I’d built a machine to make sure nobody ever saw it.I don’t have a clean resolution for that tension. Redacting subprocess stderr from public errors is the right default when the subprocess handles credentials. But “we never log it anywhere, at any level, for anyone” and “we redact it from the public error” are different policies, and I’d conflated them.
What I took from it
Five things, concretely:
- When a CLI prints a default like
5m0s, that flag is a duration. Go renderstime.Durationthat way. Read the default, not just the flag name. - An argv assertion locks in your intent, not the callee’s contract. It’s still worth having. It just isn’t evidence that the command works.
- Non-zero exit before any output is a parsing failure, not a logic failure. Empty stdout plus instant exit means you never got started.
- Redaction policy and logging policy are separate decisions. Keep the subprocess’s stderr somewhere internal even when the public error says nothing.
- Regression coverage for a formatter should include the boring edges. The three cases I added were exact seconds (
120s), a fractional value rounding up (1500ms becomes2s), and the floor (zero becomes1s).
The fix took a second. Finding it did not. If you’re wrapping someone else’s CLI, at least once, run the exact argv you’re generating and paste the output into the test as a comment. Your assertion can only ever be as right as you were when you wrote it.
Sources
- time.ParseDuration — the parser behind duration flags, and the source of the “missing unit” error
- flag.Duration — how a duration flag gets registered, and why it fails at parse time
- os/exec — running a subprocess with a fixed argv and no shell
I’d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at @[email protected].
- When a CLI prints a default like
-
Four Embedding Models, All 1024 Dimensions, All Incompatible
If you run a vector database, you’ve probably got a startup check that compares your configured embedding dimension against the collection’s actual vector size. Mine has two of them. They’re both useless for the failure I care about.
Here are four embedding models I could plausibly point my memory server at, with the dimension each one emits:
Model Dimensions mistral-embed1024 @cf/baai/bge-large-en-v1.51024 bge-m31024 Qwen3-Embedding-0.6B1024 I checked the last two against their model configs rather than trusting my own commit note, and
hidden_sizeis 1024 in both. Mistral and Cloudflare’s BGE I’d already written into a comparison table months ago without noticing what the column was telling me.So: swap providers, keep the number, and every dimension check on the system passes. Qdrant accepts the writes. The server boots clean. Preflight is green. And your recall results are garbage, because a vector from
mistral-embedand a vector frombge-m3are both 1024 floats that mean entirely unrelated things. Cosine similarity between them isn’t wrong so much as it’s meaningless, and meaningless similarity still returns a top-k. You get five confident results that have nothing to do with the query.That’s a bad failure mode. It’s silent, it survives every check, and the symptom (bad recall quality) looks like a tuning problem rather than a data problem. You’d spend an afternoon adjusting
RECALL_MIN_SCOREbefore you’d suspect the collection.Dimension is a checksum with one byte
The mistake I made was treating dimension as an identity check when it’s a shape check. It tells you the vectors will fit. It says nothing about where they came from.
Think about what actually has to match for a vector search to mean anything. The model, obviously. But also the provider serving it, because a self-hosted
bge-m3and a hosted one behind an inference API can differ in pooling or normalization. The distance metric, since cosine and dot product rank differently on unnormalized vectors. And the dimension, which is the only one of those four I was checking.So the collection now carries a fingerprint: provider host, model, dimensions, distance, and a schema version, written into Qdrant as a well-known point. It’s non-secret by design, no keys, no account IDs, just enough to answer “did these vectors come from the same place the current config points at?” It’s validated at startup and again in preflight, and a mismatch is fatal with its own exit code, 9.
Nine is deliberately separate from the two dimension codes I already had. 4 is config disagreeing with Qdrant. 8 is config disagreeing with what the provider actually returned. 9 is the case where 4 and 8 both pass, every number agrees, and the vectors are still incompatible. Those want three different fixes, so they get three different exit codes. Telling failures apart by status instead of by grepping a log message is a habit I keep being glad about.
The hard part wasn’t detecting it
Writing the check took an afternoon. Deciding what to do about the collection that already existed took longer.
I have a live collection with real memories in it, created before any of this existed. It has no fingerprint. The naive implementation treats that as a mismatch and refuses to boot, which would mean shipping a change that bricks my own running server and everyone else’s, in defense of a problem none of them currently have.
So an absent fingerprint is a third state, not a failure. The server boots normally, preflight succeeds and appends
fingerprint=unadoptedto its ok line, and you get a one-line notice at boot. Nothing breaks. You’re just told.Adoption is then an explicit command,
mem0-mcp --adopt-fingerprint, with two rules I’d encourage you to steal if you build something similar:- It refuses to relabel a collection that already records a different fingerprint. Adoption is for unlabeled collections. If there’s a label and it disagrees with your config, that’s exactly the mismatch the feature exists to catch, and letting a flag overwrite it turns the safety check into a suggestion.
- Auto-stamping only happens on a collection that’s verifiably empty. An empty collection has no vectors to be wrong about, so stamping it is free. A populated one requires you to say out loud that you know where those vectors came from.
The general principle: when you add a validation to a system that’s already running, the pre-existing state needs a name that isn’t “invalid.” Otherwise the check is unshippable, and an unshippable check gets loosened until it stops checking.
The bit I’d get wrong again
I want to be honest about where this came from, because it wasn’t foresight. I was adding Cloudflare Workers AI as a second embedding provider. It’s an OpenAI-compatible REST endpoint, so it needed no adapter at all, just a different base URL, model and token. A config change, nothing more.
I was writing the provider comparison table for the docs, put
1024in the Mistral column and1024in the Workers AI column, and sat there looking at it. The whole feature exists because I typed the same number twice in a markdown table.If I’d added a provider at 768 or 3072, the dimension check would have caught the swap on the first boot, I’d have fixed my config, and I’d never have learned the check was load-bearing for the wrong reason. The collision is what made the gap visible. 1024 is a popular number, and popular numbers are where your shape checks quietly stop being identity checks.
Go look at your own table. If two rows have the same dimension, you have this bug too, and nothing in your stack is going to tell you about it.
Sources
- Mistral embeddings —
mistral-embed, 1024 dimensions - Cloudflare Workers AI: bge-large-en-v1.5 — model card and dimensions
- Workers AI OpenAI compatibility — why no adapter was needed
- Cloudflare AI Gateway — the optional proxy layer in front of the provider
- Qdrant documentation — collections, vector params and distance metrics
I’d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at @[email protected].
-
Adding a Column With a Default Broke Every Idempotency Key
Adding a nullable column, or a column with a default, is the textbook example of a safe migration. Old rows get filled in, nothing breaks, no downtime. I’ve repeated that advice to other people.
Then I added one to a table whose rows are addressed by a hash of the request that created them, and the safe migration quietly invalidated every idempotency key already in the database.
How the replay works
The API is replay-first. A client sends an
Idempotency-Keyheader with a job submission. If that same principal and key already exist, and the incoming request is equivalent to the stored one, the API returns the persisted job instead of doing the work twice. If the key matches but the request is different, that’s a409, because the client reused a key for something else and that’s a bug on their side.“Equivalent” is the interesting word. It’s implemented as a SHA-256 over the canonical JSON encoding of the normalized request:
func digestJSON(data []byte) string { sum := sha256.Sum256(data) return hex.EncodeToString(sum[:]) }Normalization is what makes this defensible. You don’t hash the raw bytes off the wire, because then whitespace and key order would make two identical requests look different. You hash a struct that’s been round-tripped through your own types, with a few explicit fixups applied first.
One of those fixups is the problem.
The migration
I added agent routing to the job table. Two columns:
ALTER TABLE jobs ADD COLUMN requested_agent_id text NOT NULL DEFAULT 'auto', ADD COLUMN resolved_agent_id text; ALTER TABLE jobs ADD CONSTRAINT jobs_requested_agent_id_valid CHECK ( requested_agent_id = 'auto' OR requested_agent_id ~ '^[a-z][a-z0-9-]{0,62}$' );Textbook. Every existing row gets
'auto', which is exactly what those jobs did behave like, since automatic selection was the only behavior that existed when they ran. The column is honest about history.At the same time, the request normalizer picked up a matching rule: if
requested_agent_idis missing or blank, set it toautobefore hashing.Also reasonable. Now
{},{"requested_agent_id": ""}, and{"requested_agent_id": "auto"}all hash the same, which is what you want.Put those two reasonable things together and here’s what happens to a client that submitted a job last month and retries it today with the same key and a byte-identical body:
- The stored digest was computed when the field didn’t exist. The canonical JSON had no
requested_agent_idkey at all, because the struct field isomitemptyand the value was empty. - The new digest is computed with normalization applied. The canonical JSON now contains
"requested_agent_id":"auto". - Different bytes. Different SHA-256. The digests don’t match.
- The API concludes the client reused an idempotency key for a different request, and returns
409.
The client did nothing wrong. It sent the identical request, which is the exact scenario idempotency keys exist to make safe, and it got told it was conflicting with itself. The server changed its mind about what canonical form means, and every historical row was addressed under the old definition.
The fix is a compatibility set
The repair is to accept that a stored digest is written in whatever dialect was current when it was written, and to teach the reader more than one dialect:
out := requestDigests{ canonical: json.RawMessage(canonical), primary: digestJSON(canonical), } if normalized.RequestedAgentID == agentregistry.AutoID { legacy := normalized legacy.RequestedAgentID = "" legacyDigest := digestJSON(legacyCanonical) if legacyDigest != out.primary { out.compatible = append(out.compatible, legacyDigest) } }When the normalized request resolves to
auto, compute a second digest with the field cleared back to empty, which reproduces the pre-migration encoding exactly. Then the comparison accepts a hit on either one.The detail I’m happiest with is that the compatible digests are never stored. There’s no second column, no array of historical hashes on the row, and no backfill pass. They’re recomputed on each incoming request and compared against the single value already on the row.
That matters, because the alternative is rewriting the digest of every historical row to the new canonical form. To do that you’d have to re-marshal request bodies you accepted under older validation rules, and any row you can’t faithfully reconstruct becomes a row whose idempotency key silently stops working. A migration that can corrupt the thing it’s repairing isn’t much of a repair. Leaving old rows exactly as they are and making the reader bilingual has no failure mode worse than a cache miss.
Sorting the list would have been the bug
A few days later I added
preferred_agent_ids, a bounded ordered list of agents to try, valid only when no explicit agent is requested. Capped at 16.There’s a strong instinct, when you’re putting a list into a hash key, to sort it first. Sorting gives you stable digests regardless of client-side ordering, and for something like a set of tags it’s the correct move.
Here it would have been a bug, because the order is the request.
["hermes","antigravity"]means try Hermes first.["antigravity","hermes"]means try Antigravity first. Those are different instructions that produce different work, so they have to produce different digests and they must never replay each other’s results. The list goes into the canonical JSON in exactly the order the caller sent it.The general rule I pulled out of this: sort a collection before hashing only when the collection is actually a set. If order changes behavior, order is data.
The rest of that feature is deliberately fail-closed, in ways that are easy to get backwards:
- An empty
preferred_agent_ids: []is a422, not “no preference.” An empty list is a caller mistake, and treating it as a default would silently route work somewhere the caller didn’t ask for. - Preferences do not fall back outside the list. If none of the named agents are ready, the request fails with
no_ready_agents, even when some other configured agent is sitting there ready. A preference list is a boundary, not a hint. - Duplicates, unknown IDs, and the reserved value
autoinside the list are all rejected at submission time rather than at routing time. - The default priority order applies only when the list is omitted entirely. Hermes at 10, Antigravity at 20, the fake adapter at 100, lowest first.
What I’d tell past me
Your idempotency digest is a wire format. It’s not an implementation detail, even though it lives entirely inside your process and never appears in a response body, because rows in your database are addressed by it and those rows outlive your assumptions. Adding a field with a default is backward compatible for the table. It is not automatically backward compatible for anything you hashed.
So: write down what goes into the digest, treat that document as versioned, and before any migration touches a field that feeds it, work out what happens to a client replaying a request from before the change. If the answer is a
409on an identical retry, you need a compatibility digest, and you need it in the same commit as the migration rather than after someone’s retry storm finds it for you.I got lucky on the ordering question. I nearly typed
sort.Strings.Sources
- Idempotency-Key HTTP header field — the IETF draft, including request-equivalence and conflict semantics
- Stripe: idempotent requests — the widely copied implementation, and its take on mismatched replays
- encoding/json Marshal — field order, and how
omitemptydecides whether a key exists at all - goose — the migration tool behind the up and down blocks above
I’d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at @[email protected].
- The stored digest was computed when the field didn’t exist. The canonical JSON had no
-
I Wrote 993 Lines of Tests for a Shell Script, Then Deleted the Script
My MCP server has a
just mcp-installrecipe that prints aclaude mcp add ...command you paste into a terminal. It reads your.envand emits one--env NAME=valuepair per line. Above that block sits a banner:Secret values are redacted below — substitute them by hand.The thing doing the redacting was a single
sed:sed -E 's/^(EMBED_API_KEY|TEI_API_KEY|NEO4J_PASSWORD)=.*/\1=<redacted>/'Three names on an allowlist. Everything else prints in full, under a banner that promises otherwise.
It took me about 19 hours to go from noticing that to deleting the entire replacement I’d built. Here’s the trip.
An allowlist fails open, and mine had already failed twice
The problem with an allowlist for secrets isn’t theoretical. It’s that the default is print the value, so every new credential leaks until somebody remembers to extend the list.
Mine had already broken twice, and I only worked that out while writing the fix. First, I renamed the
TEI_*env vars toEMBED_*and left the allowlist matchingTEI_API_KEY, a name that no longer existed. Second, the grep feeding the sed was^[A-Z_]+=with no digits in the character class, which silently dropped everyNEO4J_*row before the sed ever saw it. So theNEO4J_PASSWORDarm of that allowlist was unreachable for its entire existence. It never redacted anything. It just sat there looking reassuring.Switching to a pattern match takes ten seconds:
sed -E 's/^([A-Z0-9_]*(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)[A-Z0-9_]*)=.*/\1=<redacted>/'I checked it against a synthetic
.envfull of sentinel values. 7 of 7 secrets redacted, includingOAUTH2_TOKEN,S3_SECRET_KEYandEMBED_API_KEY2, all three of which printed in cleartext before. All 10 non-secret variables kept their values, which matters, because the emitted command is useless withoutQDRANT_HOSTandEMBED_MODELintact.Over-redacting a non-secret costs the user a hand-edit. Under-redacting a real one costs them a rotated key. Pick the direction you fail in.
Then I gave it tests, and the tests got mean
A security-relevant expression inlined in a
justrecipe has no test coverage by construction. So I pulled it intoscripts/render-mcp-env.sh(73 lines) and wrotescripts/render_mcp_env_test.go(352 lines) to drive the script throughos/exec.The tests immediately found things the sed one-liner couldn’t have handled.
Credentials hiding in values, not names. The rule only ever inspected the variable name. A value like
bolt://neo4j:hunter2@hostmatches no keyword, so it printed in full.NEO4J_URLandDATABASE_URLare the obvious cases, and they’re exactly the vars people paste into chat.PATis a trap. Adding it for personal access tokens also swallowsPATH,GOPATH,CONFIG_PATH,LOG_PATTERNandCOMPATIBILITY_MODE. A*_PATHvalue is one of the things the command needs to keep. It now matches only as a whole underscore-delimited segment.And my redaction broke the output it was redacting. This one’s my favorite. The emitted line was unquoted, so:
--env FOO=<redacted>parses in a shell as the word
FOO=plus a redirection from a file namedredacted. The block failed to paste correctly whenever a secret was present, which is to say for every actual user. The feature that existed to protect people was the feature that broke the thing for them. ShellCheck flags exactly this and I’d have caught it a week earlier if the logic had been in a file a linter could see, instead of hidden in a recipe body.Fixing the quoting meant leaving sed behind. Per-character shell quoting isn’t expressible in portable sed, so the engine became
grep | awk. I kept the awk POSIX-only and checked it byte-for-byte under mawk (what CI’s Ubuntu runner ships), gawk, andgawk --posix.The script was now 238 lines. The test file was 993.
The tests were right and the script was wrong
Here’s what those 993 lines were actually telling me, once I stopped admiring them.
The awk had to parse
.envto find names and values. The Go binary parses the same file with godotenv v1.5.1. Two parsers, one file, and nobody checking they agreed.They didn’t. Six shapes, each one verified rather than assumed:
$VARand${VAR}expansion- Whitespace trimming
NAME=with an empty value, emitted by one and dropped by the other- Names containing a
. - Indented assignments
#inline comments
Every one of those is a case where the command I told you to paste differs from what the server actually reads. That’s a worse bug than the leak, because it’s silent and it looks like it worked.
So I deleted it. The 238-line script and its 993-line test both went in a single commit, replaced by
internal/envblockat 134 lines with an 868-line test, called from the Justfile as:go run ./cmd/mem0-mcp --print-env-block .envThe binary already links godotenv. Now the pasted block can’t drift from the server’s own parsing, because there’s one parser.
The classifier also got simpler in a way that matters. It’s
strings.Containsover the uppercased name and nothing else:func isSecretName(name string) bool { upper := strings.ToUpper(name) for _, keyword := range secretKeywords { if strings.Contains(upper, keyword) { return true } } // PAT only as a whole underscore-delimited segment, or it would swallow // PATH, LOG_PATTERN, COMPATIBILITY_MODE and friends. return strings.Contains("_"+upper+"_", "_PAT_") }No regexp is reachable from it, on purpose. An anchored or whole-name match classifies
MY.API_KEYas non-secret and prints it in the clear, which was a real bug in the awk version. You can’t write that mistake in this shape.The old test suite had a guard that scanned the script’s source for forbidden patterns. The replacement is a behavioural test, and it’s stronger: it caught a deliberately mutated classifier with a hidden regexp by finding a sentinel value leaking into fixture output. Same evasion class, caught by observing output instead of reading code.
The part that made me laugh
I added a ShellCheck CI job, with a
lint-shell.shthat hard-fails if it discovers fewer than 4 shell scripts, so nobody can quietly delete one past the linter.I deleted one of the scripts it was guarding, and had to lower the floor from 4 to 3. There are three
.shfiles inscripts/now, and the default sits atmin_scripts="${MEM0_MIN_SHELL_SCRIPTS-3}".Five hours from “protect these scripts” to “one fewer script to protect.”
I don’t think the shell version was wasted. I couldn’t have argued for the Go rewrite on day one, because “awk might diverge from godotenv” is a hunch. It only became an argument once I’d written enough tests to enumerate six specific divergences and point at them. The tests didn’t make the script correct. They made the case for its deletion, which was the more useful outcome.
If you’ve got security-relevant logic inlined in a Makefile, a Justfile, or a CI step, that’s the same shape my bug was. Nothing lints it, nothing tests it, and it fails open. Pull it into a file first. You might end up deleting the file, and that’s a fine place to land.
Sources
- godotenv — the Go dotenv parser the server links, v1.5.1
- ShellCheck SC2086 — unquoted expansion, word splitting and redirection
- ShellCheck — the linter itself
- POSIX awk specification — the subset I held the script to for mawk/gawk parity
- POSIX shell command language — quoting and redirection rules behind the
<redacted>bug
I’d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at @[email protected].
-
A Dotfiles Manager That Snapshots Every Change
Managing dotfiles in 2026 is a solved problem in the same way that managing your own backups is a solved problem: there are five tools for it, all of them work, all of them require you to set up some plumbing first, and once you’re set up you still don’t have a great answer to “I just broke my shell config, get me back to yesterday.”
The conventional answer is some combination of: a git repo for your
~/.zshrcand friends, a symlink script (orstow, orchezmoi, oryadm), and the discipline to remember to commit after every change. The setup is a one-time hassle. The “wait, what did I change?” recovery story is not great. And if you want to sync across machines, you’ve now got opinions about remote repos, SSH keys on a fresh box, and which order things have to happen in.I wanted something different, so not a configuration framework, but a record of every change to the files I care about, in a place I can roll back from, with the lowest possible setup cost.
That’s what dfm is.
What It Does
dfmis a single static Go binary. You point it at the files you want to track (~/.zshrc, anything under~/.config/, whatever), and every time one of them changes it takes a content-addressed snapshot. The snapshots live on disk in~/.local/share/dotfiles/backups/. A small state database (SQLite locally, or libSQL via Turso if you want cross-machine sync) records which file maps to which snapshot at which point in time.You can roll back. You can diff against an old snapshot. You can see when you last touched a file. And because every snapshot is content-addressed, you never re-store the same bytes twice — switching themes in
~/.zshrcten times costs the size of two configs, not ten.The other half is the backup story.
dfm initwalks you through cloning (or creating, viagh) a private GitHub repo that mirrors your tracked files plus their history. The point isn’t to make you adopt a new git workflow. It’s that pulling your config onto a fresh machine should be one command, and recovering fromrm -rfshould never have a “well, hopefully my last commit was recent” caveat.Why Setup Is the Hard Part
The reason people don’t audit their dotfiles is the same reason people don’t back up their laptops: the setup is annoying, and the payoff is theoretical until it isn’t.
dfm initis a six-step interactive wizard. It detects aTURSO_DATABASE_URLenv var if you’ve got one, offers sensible defaults for everything else, lets you opt in to tracking~/.zshrcimmediately, and writes a single config file with the right permissions. Re-run it on an existing config and it pre-fills every prompt with your current value, so the cost of changing your mind later is also low.--yesaccepts every default for scripted setup.If that sounds boring, that’s the point. Boring is what makes a tool actually get used.
The AI Bit
There’s an optional AI integration.
dfm suggest <file>asks a local AI CLI (Claude Code by default, configurable) to propose an improvement to one of your tracked files, returns the proposal as a unified diff, and stores it as a pending suggestion.dfm apply <id>reviews the diff and applies it, with a fresh snapshot first, so you can roll back if the suggestion turns out to be wrong.I’m exited to try this feature out, because I’m sure there is something i"m doing wrong. The “Look at my
~/.zshrcand tell me what I could clean up” is useful feature that doesn’t require me copy and pasting or granting read or write access to my entire home directory.Where to Get It
github.com/llbbl/dotfiles-manager. Pre-built binaries for darwin and linux on arm64/amd64. Current version, as of writing, is v1.4.0.
If you’ve been meaning to actually back up your dotfiles and the friction has stopped you, this is the post where I tell you the friction is solvable.
I’d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at @[email protected].
-
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.