Files
dotfiles/.config/dot/CLAUDE.md
alexion 3732ccd4d8 chore: Change task numbering to decimal.
Hex doesn't sort nicely in the file system.
2026-07-06 16:50:24 -04:00

168 lines
9.0 KiB
Markdown

# Dotfiles
This machine's dotfiles are a bare git repo at `~/.dotfiles`, checked out with
`$HOME` as its work-tree. The `dot` fish function wraps that invocation
(`git --git-dir=~/.dotfiles --work-tree=$HOME $argv`, declared with
`--wraps=git`), so every git subcommand works through it: `dot status`,
`dot add`, `dot commit`, `dot push`, etc.
This directory (`~/.config/dot`) holds the `dot` CLI's custom subcommands,
tests, and package lists, but the repo tracks files across `$HOME` — fish
config, git identity, the `dot` function itself, and more. To see everything
tracked, run `dot ls-tree -r --name-only HEAD` from `$HOME` (paths are shown
relative to cwd, so running it from elsewhere silently truncates the list).
For an agent driving this through separate tool calls: `cd ~` in one call does
not reliably carry over to the next, since each call may reset to the
project's working directory. Always `cd "$HOME"` and run the `ls-tree` (or any
other cwd-sensitive `dot`/`git` command) in that *same* call — e.g.
`cd "$HOME" && dot ls-tree -r --name-only HEAD` — rather than trusting a prior
`cd` to have stuck. Getting this wrong silently narrows the listing to
whatever the leftover cwd happens to be, which reads as "this file isn't
tracked" when it actually is.
## Always add by explicit path
`status.showUntrackedFiles=no` is set locally (see `dot init` below), and
`.gitignore` only excludes `.dotfiles` itself plus OS/editor cruft — it is
**not** a whitelist. That
means virtually everything under `$HOME` reads as untracked, and `git status`
deliberately hides all of it.
**Always run `dot add <specific-path>`.** Never `dot add -A`, `dot add .`, or
any wildcard add — that would try to stage the entire home directory (caches,
secrets, everything).
**Stage automatically after changes.** Once a tracked file is edited, run
`dot add <specific-path>` for it right away rather than waiting to be asked —
one explicit path per changed file, still never a wildcard. This does not
extend to `dot commit` or `dot push`, which still require an explicit
request.
## The dot CLI
### Architecture
`dot` is defined in one file: `~/.config/fish/functions/dot.fish`. It holds
three functions:
- `dot` (`--wraps=git`) — dispatches `init`, `help`, and any file found under
`~/.config/dot/commands/`, otherwise forwards everything to
`git --git-dir=~/.dotfiles --work-tree=$HOME $argv` (full passthrough).
- `__dot_init` — the bootstrap logic, inlined in the same file rather than
autoloaded separately, because it's the one subcommand that must work
before the dotfiles repo has ever been cloned onto a machine.
- `__dot_help` — prints usage: the built-in commands plus whatever is
currently found under `~/.config/dot/commands/`, generated by globbing that
directory rather than a hardcoded list, so it can't drift from reality.
`__dot_help`'s glob over `~/.config/dot/commands/*.fish` is duplicated in
`~/.config/fish/completions/dot.fish`'s `__dot_custom_subcommands` rather than
shared: fish only autoloads a function from a file named after that function,
so a helper defined inside `dot.fish` would be undefined if tab-completion
ran before `dot` had ever been sourced in the session. Keep both copies in
sync when the listing logic changes.
Both copies also glob one directory level deeper, matching `~/.config/dot/commands/<name>/<name>.fish`, so a subcommand's companion file (e.g. a Python helper) can live alongside it in its own directory.
`dot init`:
- refuses to run if `~/.dotfiles` already exists (no re-init support)
- clones the bare repo from `--url` (default: the hardcoded Gitea remote) —
if the clone fails, it errors out; it never falls back to `git init`
- backs up any pre-existing file that checkout would clobber into
`~/.dotfiles-backup/<timestamp>/`, then retries the checkout
- explicitly sets `status.showUntrackedFiles=no` after cloning — this is a
local-only git setting, so a fresh `git clone` never carries it over
### Adding a subcommand
Beyond `init`, `dot` looks for `~/.config/dot/commands/<name>.fish`, sources
it, and calls `_dot_<name>`.
A subcommand needing a companion file can instead live nested one level deeper, as `~/.config/dot/commands/<name>/<name>.fish` — both layouts dispatch identically.
These files are deliberately kept out of
`~/.config/fish/functions/` (fish's autoload path) so they never become
independently invokable top-level commands or clutter tab-completion outside
of `dot` itself.
1. Create `~/.config/dot/commands/<name>.fish` defining a `_dot_<name>`
function.
2. Confirm `dot <name>` dispatches to it. No other wiring is needed —
`~/.config/fish/completions/dot.fish` and `__dot_help` both discover new
command files by globbing that directory, and `--wraps=git` still covers
raw git subcommands.
3. Implement a `help` subcommand: check for `help` as `_dot_<name>`'s first
positional argument before `argparse`, and call a `_dot_<name>_usage`
function that prints usage and every flag. If `_dot_<name>` itself
dispatches to nested subcommands, apply this same check-then-dispatch
pattern at that level too — there's no central `--help` handling in
`dot.fish` to lean on; each level is responsible for its own.
`_dot_<name>_usage` should print its text as a single multi-line
`echo "..."` string (fish preserves literal newlines inside double
quotes) rather than one `echo` per line.
4. Add a row to `~/.github/README.md`'s command table for it — one row per
distinct use case, with paths written relative to `$HOME`
(`~/.config/dot/...`), not relative to the README's own location.
5. Add a case to `~/.config/dot/tests/dot.fish` covering it, including its
`help` output, and run `fishtape ~/.config/dot/tests/dot.fish` until it
passes.
### Testing
Tests live at `~/.config/dot/tests/dot.fish`, run with
`fishtape ~/.config/dot/tests/dot.fish`. Fishtape is installed via Fisher
(`fisher install jorgebucaran/fishtape`) and tracked in
`~/.config/fish/fish_plugins` — a real, restorable dependency for developing
`dot`, but never required just to use it.
- Each scenario overrides `$HOME` (`set -gx HOME (mktemp -d)`) before calling
`dot`, so tests never touch the real `~/.dotfiles`.
- Build a throwaway bare "remote" fixture with `git init --bare` plus a
seeded commit, and explicitly set its `HEAD`
(`git --git-dir=$remote symbolic-ref HEAD refs/heads/main`). Pushing with
`git push origin HEAD:main` does **not** update the bare repo's `HEAD`
symref — skip this and a clone of the fixture can end up "on a branch yet
to be born."
- Don't use `.gitconfig` as a fake pre-existing "conflict" file in a
fixture — git parses `$HOME/.gitconfig` as its own global config on every
invocation, and garbage content there spams "key does not contain a
section" errors that drown out the real assertion. Use a harmless file
like `.bashrc` instead.
- `@test "description" <expr> <op> <expected>` mirrors fish's `test` builtin
(`-eq`, `-ne`, `=`, `-e`, `-f`, `-d`, `-n`, `-z`); `-a`/`-o` combinators
aren't supported.
## Gotchas
- `~/.claude/` (Claude Code's own config: skills, agents, commands, etc.) is
a plain directory, not a separate git repo of its own — plain `git` commands
run from inside it report "not a git repository". It's tracked the same way
as everything else under `$HOME`: through the `dot` bare repo. Use
`dot add`/`dot status` on paths under `~/.claude/`, not a `git` invocation
scoped to that directory, and don't assume an unrelated repo (e.g. a
separate skills-source checkout elsewhere) is the tracked copy just because
it also holds a copy of the same files.
- `~/.claude/` and this project's own `.claude/` (e.g. `~/.config/dot/.claude/`)
are two different directories that both happen to exist. Project-relative
paths referenced in specs, task breakdowns, or other project docs — like
`.claude/spec/<slug>.md` or `.claude/tasks/<NNNN>-<slug>.md` — are relative
to this project directory (`~/.config/dot/.claude/...`), not to
`$HOME/.claude/`. Writing to `$HOME/.claude/tasks/` instead of
`~/.config/dot/.claude/tasks/` silently lands files in Claude Code's own
global config dir instead of the project.
- An agent's `Bash` tool runs commands through **zsh**, not fish, so the
`dot` fish function (defined in `~/.config/fish/functions/dot.fish`) is
not on that shell's autoload path. Typing `dot <subcommand>` there
silently resolves to `/usr/bin/dot` (Graphviz) instead, producing
confusing "can't open <arg>: No such file or directory" / "syntax error
near '--'" errors rather than a clear "command not found". Either invoke
it as `fish -c "dot <subcommand> ..."`, or bypass the wrapper and call
`git --git-dir=$HOME/.dotfiles --work-tree=$HOME <args>` directly.
## Keybindings
Whenever a keybind is added, changed, or removed in *any* config on this
machine (tmux, KDE, neovim, fish, whatever), add or update its row in
[`~/.github/keybindings.md`](../../.github/keybindings.md) in the same
change. That file is the single reference for every keybind across tools —
it drifts the moment a bind changes somewhere without a matching edit there.