Imported from darrickross/dotfiles (
AGENTS.md). Install upstream withnpx skills add darrickross/dotfiles. Copyright stays with the author.
AGENTS.md — Agent guidance for this dotfiles repo
Repo layout and dotfile management
The directory structure of this repo mirrors ~ exactly (.config/, .ssh/, etc.). Dotfiles are activated via Home Manager, not symlinked manually.
- Shell config is native Home Manager:
programs.bash.*options split across.config/home-manager/modules/bash/*.nix(history, prompt, aliases, shell options, logout) plus.config/home-manager/modules/wsl.nix— Home Manager generates~/.bashrcentirely from these - The repo intentionally contains no
.bashrc,.bash_profile,.profile, or.bash_logout— Home Manager generates all of them. Do not add these files back; put shell configuration in the modules - General-purpose scripts are declared as
home.fileentries inhome.nix; the Bitwarden/sops secrets scripts and their package dependencies live together in.config/home-manager/modules/secrets.nix - Plain config files tracked in this repo (
.gitconfig,.ssh/config,.aws/config,.config/gh/config.yml,.config/ohmyposh/bash_prompt.toml,.config/nix/nix.conf,.config/sops/.sops.yaml) are deployed by.config/home-manager/modules/dotfiles.nix— the repo copy is the source of truth; edit it there, then runhms. Deployed copies are read-only nix-store symlinks, so never edit a config through its~path or with a tool that rewrites its own config (gh config set,aws configure) - Adding a new managed file means declaring it in
home.nixunderhome.file(scripts) or inmodules/dotfiles.nix(config files, which must be git-tracked to be visible to the flake) and runninghms - Never hardcode the clone path (e.g.
~/projects/dotfiles) in aliases or scripts — flakes evaluate from a nix-store copy, so the clone location is unknowable at build time. Resolve it at runtime with$(dotfiles-root), which works backwards from the~/.config/home-managersymlink (a documented setup step thathmsandhmualso depend on)
Home Manager — source of truth for scripts, aliases, and packages
All shell scripts, aliases, and installed packages are managed declaratively under .config/home-manager/. Do not install packages with nix-env, pip, apt, or any other package manager.
Each module declares the packages, scripts, and aliases it needs, next to the code that needs them — even when that overlaps with another module. home.packages lists from all modules are merged and identical packages dedupe to the same store path, so declaring jq in both home.nix and modules/secrets.nix is correct, not a conflict. Do not "clean up" a dependency out of a module because another module happens to also declare it — that couples the modules invisibly.
- To add a package: add it to
home.packagesin the module that uses it — general-purpose tools inhome.nix, secrets tooling inmodules/secrets.nix, WSL-specific pieces inmodules/wsl.nix - To add a script or alias: add a
home.fileorprograms.bash.shellAliasesentry in the module it belongs to (general-purpose scripts live inhome.nix) - After any change to the nix config: run
hms(home-manager switch && exec $SHELL -l) to apply it - To update flake inputs (nixpkgs, home-manager): run
hmu(nix flake update) - Python packages are pinned in
home.nix— add new dependencies there, not withpip install
WSL2 specifics
This machine runs Linux under WSL2. Several tools forward to Windows binaries to access hardware (YubiKey USB) that is not passed through to WSL.
- GPG —
~/.local/bin/gpgis a wrapper (deployed bywsl.nix) that forwards to the Gpg4wingpg.exeon WSL and falls through to the system gpg elsewhere. The tracked.gitconfigstays portable: it[include]s~/.gitconfig.local, a filewsl.nixgenerates with the machine's absolutegpg.programpath (git skips the include where the file is absent). Interactive shells additionally alias the full gpg tool family to the Windows binaries - age-plugin-yubikey —
~/.local/bin/age-plugin-yubikeyis a wrapper that calls the Windows.exe; do not replace it with a Linux binary or the YubiKey age identity will stop working - SSH SK helper —
SSH_SK_HELPERpoints to/mnt/c/Program Files/OpenSSH/ssh-sk-helper.exe - YubiKey USB is not forwarded to WSL via usbipd; all YubiKey operations must go through these Windows wrappers
modules/wsl.nixrefuses to activate off WSL2: an activation-time check abortshome-manager switchbefore writing anything. When porting this config to a non-WSL machine, remove./modules/wsl.nixfrom the imports inhome.nix
Secrets architecture
This repo uses a two-layer system. Never collapse these layers or short-circuit them.
The consumer-facing guide — how other repos on this machine use these tools — is docs/secrets.md, which modules/dotfiles.nix also deploys to ~/.local/share/doc/cbws/secrets.md so consumer repos can reference it at a stable path. A machine-wide summary for agents is deployed to ~/.claude/CLAUDE.md from .claude/CLAUDE.md. When the workflow or command names change, update both alongside this file — stale copies of this workflow in other repos have caused real drift before.
Design rationale — one machine account, one project
The primary reason for this shape is to minimize the number of BWS projects and machine accounts: the Bitwarden Secrets Manager free tier allows only 3 projects and 3 machine accounts, which is too few to scope a project per workload. The accepted risk is a single machine account whose token reads a single project of co-mingled secrets — anything run through cbws-exec receives every secret in that project. The compensating controls are on the token's lifetime, not its scope: it only ever exists (1) sops+age(YubiKey)-encrypted on disk and (2) in the environment of the one process tree cbws-exec starts. Do not "improve" this by splitting projects or machine accounts without checking the tier limits first.
Layer 1 — BWS access token (bootstrap secret)
The Bitwarden Secrets Manager (BWS) access token is the credential that unlocks all other secrets. It is:
- Stored encrypted at
~/.local/secrets/bitwarden.yamlusing sops + age + YubiKey, alongsidedefault_project_id(the BWS projectcbws-execscopes to by default) - Encrypted under the age recipient in
.config/sops/.sops.yaml(a YubiKey-backed key, slot 1); home-manager places a copy at~/.config/sops/.sops.yaml, which is what scripts pass tosops --config - Consumed only by
cbws-exec,cbws-list-available-secrets, andcbws-secret-set, which decrypt it, use it in a child process, and let it die with that process — the token never enters the interactive shell. There is deliberately no command that exportsBWS_ACCESS_TOKENinto the shell; do not add one - Stored alongside
default_project_idandorganization_id(the latter required bycbws-secret-set— SDK write calls are organization-scoped) - Read-first:
cbws-execandcbws-list-available-secretsonly read. The single write path iscbws-secret-set(value via stdin only — never as an argument, which would leak into shell history/ps). Deletion and bulk management happen in the Bitwarden Secrets Manager web UI; do not add other write paths
The file ~/.local/secrets/bitwarden.yaml is in .gitignore and must never be committed. It is re-created by running cbws-sync-encrypted-secrets.
Layer 2 — Application secrets (Bitwarden Secrets Manager)
All application secrets live in Bitwarden Secrets Manager (BWS). They are never written to disk. Access pattern:
# One YubiKey PIN + touch per invocation, token never enters the shell
cbws-exec -- <command> # scoped to default_project_id
cbws-exec --project-id <UUID> -- <command> # override the project
# Write path (the only one): value from stdin, confirm-before-overwrite
some-generator | cbws-secret-set <KEY> # piped value
cbws-secret-set <KEY> # hidden interactive prompt
cbws-secret-set -y <KEY> < value.txt # no-tty scripting (auto-approve)
bws run (which cbws-exec wraps) sets each secret as an environment variable named after its Key field in BWS, then execs the command — write scripts to read their secrets from those environment variables. The values are never written to disk and do not appear in shell history. Direct bws calls are not part of the workflow: nothing exports BWS_ACCESS_TOKEN into an interactive shell (that would hand it to every subsequent child process for the life of the session). Writes go through cbws-secret-set (scripts/bitwarden/secret-set.py, Bitwarden Python SDK) and nothing else.
Rules for writing scripts and nix config
Never do these
- Do not hardcode secret values in any
.nix,.sh, or any tracked file - Do not store secrets in plaintext — not in
/tmp, not in env files, not in shell rc files - Do not use
set -euo pipefailin scripts that will be sourced — it leaks those options into the calling interactive shell, which causes unrelated failed commands (e.g. abws runreturning 404) to silently exit the user's terminal session. Use explicit|| { ... return 1; }guards instead - Do not use
bw unlock --rawwithout first checkingbw status—unlockonly works on an already-authenticated vault and will fail silently on a fresh machine
Sourced scripts
No sourced scripts currently exist (the former _cbws-load-local-machine-credential was removed — nothing may export BWS_ACCESS_TOKEN into the shell). If a script must ever export variables into the calling shell again, it must be sourced, not executed, and follow this pattern:
- Prefix the filename with
_to signal it is not called directly - Guard against direct execution with a
BASH_SOURCE[0] == $0check that usesexit 1(notreturn 1) so the error is clear when run as a subprocess - Use
return 1(notexit 1) on all other error paths so the calling shell is not terminated - Do not set
set -euo pipefail— handle each error path explicitly
Temporary files containing secrets
If a script must write a secret to a temporary file (e.g. for sops encryption), always:
- Create it with
mktempinside~/.local/secrets/so the.sops.yamlpath regex matches and the correct YubiKey recipient is auto-selected; pass--config "$HOME/.config/sops/.sops.yaml"explicitly — sops only discovers.sops.yamlby walking upward from the current working directory, so cwd-based discovery is not reliable in scripts - Register an
EXITtrap immediately:trap 'shred -u "$TMPFILE" 2>/dev/null || rm -f "$TMPFILE"' EXIT - Use
shred -u(notrm) as the primary cleanup method
Notes field validation
When fetching a Bitwarden item with bw get item | jq -r '.notes', always validate before use:
NOTES=$(bw get item "item-name" | jq -r '.notes')
[[ "$NOTES" != "null" && -n "$NOTES" ]] \
|| { echo "Error: notes field is empty or missing" >&2; exit 1; }
jq -r '.notes' outputs the literal string null when the field is absent, which passes the || guard but produces broken YAML when encrypted and stored.
Naming secrets in Bitwarden Secrets Manager
The Key field becomes the environment variable name injected by bws run. Requirements:
SCREAMING_SNAKE_CASE—MY_API_KEY, notmy-api-keyorMy_Api_Key- Must start with a letter or underscore — not a digit
- No hyphens — hyphens are not valid in shell variable names
- No spaces
- Do not shadow shell builtins:
PATH,HOME,USER,SHELL,IFS,PS1, etc.
Pattern: SERVICE_PURPOSE — e.g. GITHUB_TOKEN, POSTGRES_PASSWORD, STRIPE_API_KEY
Rules for writing Markdown
Line endings
All text files use LF line endings — enforced by .gitattributes. When creating new files, ensure your editor or tool does not produce CRLF.
Tables
These rules apply to any Markdown table written for humans to read: .md files, and tables embedded in code documentation (Python docstrings, heredocs, script --help text, code comments). They can safely be skipped in agent-owned files — AGENTS.md, CLAUDE.md, agent memory/scratch files, and similar — where only agents are the audience.
- Align column separator pipes so all rows in a column have the same width — pad with spaces
- The separator row (dashes) must match the width of the widest cell in each column
- Always include a space inside each cell:
| cell |not|cell|
Example of a correctly formatted table:
| Short | A longer column header |
| ------- | ---------------------- |
| value | another value |
| x | y |
scripts/markdown/fix-tables.py automates this — write the table without worrying about padding, then run it. It preserves GFM alignment markers, skips tables inside fenced code blocks, and handles wide (CJK) characters:
scripts/markdown/fix-tables.py README.md docs/*.md # rewrite files in place
scripts/markdown/fix-tables.py --check --diff *.md # report + diff, don't write
cat notes.md | scripts/markdown/fix-tables.py # stdin -> stdout (for heredoc/docstring content)
Key files
| Path | Purpose |
|---|---|
.config/home-manager/home.nix |
All scripts, aliases, and packages are defined here as home-manager managed files |
.config/home-manager/modules/ |
Native bash config (bash/*.nix: history, prompt, aliases, options, logout) and WSL2 integration (wsl.nix) |
.config/home-manager/modules/dotfiles.nix |
Registry of tracked config files home-manager deploys into $HOME — add new plain config files here |
.config/home-manager/modules/secrets.nix |
Secrets tooling: Bitwarden/sops packages and scripts (cbws-sync-encrypted-secrets, credential loaders) |
.config/sops/.sops.yaml |
sops encryption rules — age recipient is the YubiKey public key, path regex targets secrets/*.yaml |
~/.config/sops/.sops.yaml |
Deployed copy of the above, placed by home-manager — scripts pass this path to sops --config |
~/.local/secrets/bitwarden.yaml |
Encrypted BWS access token + default project id — gitignored, created by cbws-sync-encrypted-secrets |
~/.config/age/yubikey-identity.txt |
YubiKey age identity stanza — required by sops at runtime via SOPS_AGE_KEY_FILE |
scripts/bitwarden/secret-set.py |
Python source of cbws-secret-set (Bitwarden SDK; pinned in home.nix python so VS Code can import it too) |
docs/secrets.md |
Canonical consumer guide to the secrets tooling — deployed to ~/.local/share/doc/cbws/secrets.md |
.claude/CLAUDE.md |
Machine-wide Claude Code memory (secrets contract + Home Manager rules) — deployed to ~/.claude/CLAUDE.md |
Available commands (after home-manager switch)
| Command | What it does |
|---|---|
cbws-exec -- <cmd> |
Primary secrets entrypoint: decrypts the token, runs <cmd> via bws run scoped to default_project_id (or --project-id); token dies with the process |
cbws-list-available-secrets |
Lists UUID and Key of every secret the machine account can access — self-contained subprocess: always decrypts a fresh token (one YubiKey PIN + touch) |
cbws-secret-set <KEY> |
The only write path: creates/updates a secret, value from stdin, confirms overwrite (-y to skip); wraps scripts/bitwarden/secret-set.py (SDK) |
cbws-sync-encrypted-secrets |
Fetches the BWS token from Bitwarden vault and writes it encrypted to ~/.local/secrets/bitwarden.yaml |
sops-load-yubikey-recipient |
Reads the age recipient from YubiKey slot 1 into the repo's .config/sops/.sops.yaml (locates clone via dotfiles-root) |
dotfiles-root |
Prints the live clone's root, resolved backwards from the ~/.config/home-manager symlink — never hardcode paths |
dotfiles-check |
Validates the repo: nixfmt, flake eval, shellcheck on rendered scripts (scripts/checks/check.py --help) |