Keeping Tags as Plain Text — Adding Features to brain-cli Without Losing the Point
Why I Built This
brain-cli started as a tiny CLI with exactly four commands: add, list, show, search. Under the hood, it just appends lines to Markdown files. No database, no index — a thin wrapper around plain text.
After using it for a while, one thing started to bug me. The more notes piled up, the harder it got to find the one I wanted. search only does substring matching, so there was no way to say "just show me the work-related notes" or "just the study log." So I added tagging.
What I Built
First, tagging on add:
brain add "Built my first CLI command." --tag learning --tag cli
--tag (short form -t) can be repeated, and internally each tag just gets turned into a #tag string appended to the line:
- Built my first CLI command. #learning #cli
Next, filtering search by tag:
brain search SSH --tag work
It's a simple AND condition — only lines that match both the query and the tag get shown.
Finally, a tags command to see everything you've been using:
brain tags
Tags
┏━━━━━━━━━┓
┃ Tag ┃
┡━━━━━━━━━┩
│ #python │
│ #study │
└─────────┘
It scans every note, collects words starting with #, dedupes them, and prints them alphabetically. That's the whole implementation.
Why Tags Stay Plain Text
This is the part I actually spent time thinking about. When implementing tags, there were two real options:
- Store tags as structured data (YAML frontmatter, JSON metadata, etc.)
- Keep tags as literal
#tagstrings inside the line
I went with option 2, without much hesitation. The core value of brain-cli, as I see it, is that the storage is transparent plain text. You can open ~/.brain-cli/notes/*.md in any editor, with or without the CLI, and it just works. You can hand-write #tag directly in your editor and it behaves exactly the same as if you'd used --tag.
If I'd made tags structured metadata, brain-cli would have become a tool that "breaks" the moment you bypass the CLI. That would contradict the simplicity the tool was built around in the first place. When adding a feature, keeping the existing design intact mattered more than making the feature technically "cleaner."
What I Deliberately Didn't Build
A couple of other commands came up as candidates alongside tagging. I considered both and skipped them — here's why.
brain rm (delete a note)
I decided this wasn't worth building. Since storage is just Markdown files, you can already delete one with rm ~/.brain-cli/notes/2026-08-09.md. A dedicated command would mostly add complexity — confirmation prompts, safety checks — for very little actual benefit.
brain edit (open a note in $EDITOR)
This is the one feature I still think might be worth adding later. Fixing a typo or adding a follow-up thought isn't something you can do by just re-running add — that's a real, recurring friction point. Since it would just delegate to $EDITOR, the implementation itself would stay simple. If there's a "next feature," this is probably it.
The general rule I used: bias toward not adding things, but let real, recurring friction override that bias.
How I Actually Built It
Each feature went through the same cycle: implement, write tests, update the README, commit. Instead of building everything at once, I finished one feature completely before starting the next. By the end, there were 10 passing tests and two commits pushed to main.
Takeaway
The part that actually took time wasn't writing the code — it was deciding how much to structure, and what not to build. The more a tool grows, the harder it gets to keep it simple. This time, I think that trade-off worked out.
Top comments (0)