Introduction
macOS dotfiles managed with nix-darwin and home-manager. Homebrew provides GUI apps; CLI tools are declared across Nix and mise.
Applying
sudo darwin-rebuild switch --flake ~/.dotfiles/nix#ranolp-work-MBP-26
Or with the shell alias:
rebuild
Secrets
Copy nix/home/local.nix.example to nix/home/local.nix and fill in private values (GPG signing key); local.nix is gitignored.
Nix
System configuration layer. Managed by nix-darwin with home-manager.
Managed by: nix flake (nix/flake.nix)
Flake Inputs
| Input | Source | Notes |
|---|---|---|
| nixpkgs | nixpkgs-unstable | Main package set |
| nixpkgs-mise | pinned nixpkgs revision | Supplies the cached aarch64-darwin mise package |
| nix-darwin | LnL7/nix-darwin master | macOS system config |
| home-manager | nix-community master | User config |
| homebrew-brew | Homebrew/brew | Locked Homebrew source |
| nix-homebrew | zhaofengli/nix-homebrew | Declarative Homebrew integration |
| nur | nix-community/NUR | Community package set, including Firefox add-ons |
Overlays
- nixpkgs-mise: overlays the pinned
misepackage on macOS to use the cached aarch64-darwin binary. - nur: exposes the NUR package set as
pkgs.nur(Firefox add-ons).
Nix Settings
| Setting | Value |
|---|---|
| experimental-features | nix-command, flakes |
| optimise.automatic | true |
Home Packages (nix, not mise)
| Package | Purpose |
|---|---|
| age | Encryption tool |
| bun | Runtime for the Herdr browser plugin |
| ffmpeg | Composes Jira QA review videos |
| gnupg | GPG toolchain |
| nix-your-shell | nix develop/nix-shell → nushell |
macOS adds pinentry_mac, pinentry-tty, xcodes, docker-compose, gmp, and libyaml in nix/home/darwin/default.nix. Home Manager links the Compose plugin and installs the Xcodes release binary from nix/home/darwin/packages/xcodes.nix.
macOS Defaults
nix/darwin/default.nix enables Touch ID for sudo, hides the Dock automatically, disables recent apps, shows file extensions and hidden files in Finder, selects dark appearance, speeds up key repeat, and enables trackpad clicking and three-finger drag. It also disables the Bluetooth menu-bar item.
The activation script assigns the bottom-left Dock hot corner to Lock Screen, maps F18 to the Korean input toggle, disables Spotlight's Cmd+Space shortcut, and maps Cmd+Shift+S to the screenshot toolbar. It also disables the macOS 26 SwiftUI glass effect.
Services
macOS enables Syncthing and Espanso through Home Manager. Espanso's signed 2.3.0 app is downloaded and copied during activation; its match packages are declared in nix/home/darwin/default.nix.
xpkg
The Windows counterpart to nix: one command installs every declared package, font and config on a Windows machine.
Managed by: xpkg/install.nu, run through the rebuild alias.
rebuild is the apply command on every host
Every host has a rebuild shell alias that runs that host's apply command. When the user says "I ran rebuild", that command ran.
| Host | rebuild runs | Declared in |
|---|---|---|
| Windows (nushell) | nu ~/.dotfiles/xpkg/install.nu | xpkg/configs/nushell/config.nu |
| macOS | sudo darwin-rebuild switch --flake ~/.dotfiles/nix#ranolp-work-MBP-26 | nix/home/darwin/default.nix |
| Arch Linux on WSL | home-manager switch --flake ~/.dotfiles/nix#ranolp-archwsl -b before-hm | nix/home/linux/default.nix |
How an agent installs something on Windows
The user asks for "install X" or "upgrade X". The agent's whole job is the declaration:
- Add X to
xpkg/windows/default.tomlin the section that fits (table below). - If the installer code cannot express it yet, extend
xpkg/windows/install.nu. - Verify without installing:
nu-check --debug xpkg/windows/install.nuprintstrue, andopen xpkg/windows/default.toml | get <section>shows the new entry. - Commit on
mainand hand overrebuild.
The user downloads and installs X by running rebuild. The agent never runs winget install, wsl --install, or a function from install.nu itself, even when the user said "install". On 2026-11-13 an agent ran wsl --install archlinux and a Build Tools install by hand instead of declaring them, and the user had to stop it.
What rebuild does on Windows
xpkg/install.nu relaunches itself elevated through gsudo once when it is not admin; every later step inherits that elevation. It then runs its units concurrently wherever one does not need another's result:
- Settings, in the background --
xpkg/configs/install.nu --systemstarts as a background job and runs the units that only write the registry or powercfg, all at the same time: keyboard, lock, explorer, theme, chrome, audio, littlebigmouse, bin. - Packages, in the foreground --
xpkg/windows/install.nuappliesxpkg/windows/default.tomlwhile the settings run. It keeps the terminal because the WSLpasswdprompt needs it; each winget or direct install prints its result line when it finishes. A Packages failure is recorded rather than aborting, so the settings output still prints andrebuildexits non-zero at the end. - Refresh
PATHfrom the registry, so tools a package just installed are found by the later steps. - Settings output -- the captured output of step 1 prints under
== Settings. - Configs and Fonts --
xpkg/configs/install.nu --rebuildrenders the nushell and mise configs from the sharednix/home/configssources and runsmise install, one after the other. Then it runs every remaining unit at the same time: gitconfig, gnupg, espanso, firefox, terminal, prismlauncher, vscode, claude, codex, opencode, pi, and Fonts (xpkg/apps/@fonts/install.nu, which downloads every font infonts.tomland installs it for the current user). vscode, claude, codex, pi and opencode run only when that tool is installed.
gitconfig stays one unit, because concurrent git config writers collide on .gitconfig.lock. prismlauncher needs the JDK, pi needs pnpm, and Fonts needs uv on a cache miss; all three come from mise install, which is why that step finishes first.
Each unit is a nu <script> child whose stdout and stderr are captured together (xpkg/utils/parallel.nu). When a unit finishes, one block prints: ok <name> 1.2s in green or !! <name> exit N in red, then the unit's output indented 6 spaces. Blocks appear in finishing order and never interleave. Colours the unit script prints itself survive the capture; an external tool it runs sees a pipe instead of a terminal and may print without colour. The last line is == rebuild done in <N>s, the wall time of the elevated run. When Packages, Settings or Configs and Fonts failed, a red !! failed: <groups> line follows it and rebuild exits 1.
Run on its own, xpkg/configs/install.nu relaunches itself elevated when needed and applies every config, the settings units included, in one parallel group after mise install. It does not install fonts.
Inside Packages, winget and msiexec installs stay serial, because Windows Installer allows one MSI install at a time. An install that exits with 0x8A150102 (winget) or 1618 (msiexec), both meaning another install is running, is retried up to 10 times, 15 s apart. [direct.*] installers download in parallel, then install one at a time.
xpkg/windows/default.toml
The packages step reads winget list once, skips everything already installed, installs the rest, and never uninstalls anything except ids listed in remove.
| Section | Holds | Installed with |
|---|---|---|
[winget] packages | winget ids for every Windows machine | winget install -eh --id <id> |
[profiles.<hostname>] packages | winget ids for one machine only | same |
[winget.override] | "<id>" = "<installer args>" for an id whose default install is not what is wanted | winget install ... --override "<args>" |
skip_dependencies | ids installed with --skip-dependencies | |
remove | ids to uninstall when present | winget uninstall |
[direct."<name>"] | software not on winget: url, silent args, optional zip member; <name> is the start of its installed DisplayName | download, then run the installer or msiexec |
[wsl.<distro>] | a wsl --list --online distro name and its user; optional sparse = true, home-manager = true | wsl --install <distro> --no-launch, then first-boot setup; sparse runs wsl --manage <distro> --set-sparse true --allow-unsafe on an existing distro; home-manager runs xpkg/setup-scripts/linux/arch.sh in the distro on every rebuild |
A winget package installed but not declared is reported as a warning only.
Overrides apply on first install only
An installed package is skipped, so changing its [winget.override] line does nothing on a machine that already has it. Change the installed components by hand (for Visual Studio, the Visual Studio Installer's Modify), or uninstall it and run rebuild.
Microsoft.VisualStudio.BuildTools is the current override. Its bare install has no compiler, so the override adds the C++ build tools workload, MSVC x64/x86 and the Windows 11 SDK 10.0.26100.
WSL distros
For each [wsl.<distro>] missing from wsl -l -q, while the registry key HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending exists, the distro is skipped with a "reboot, then rebuild" notice. Otherwise:
wsl --install <distro> --no-launch.- If the distro still does not exist, the machine lacked the Virtual Machine Platform feature:
wsl --installonly turned it on and exited 0.rebuildprintsreboot Windows, then run rebuild againand stops for that distro. xpkg/windows/wsl-first-boot.shruns as root: it initialises the pacman keyring, installs sudo, createsuserinwheel, and writes/etc/wsl.confwith systemd on anduseras the default.passwd <user>asks the user for a password in the terminal.wsl --terminate <distro>sowsl.conftakes effect.
A distro that already exists is skipped, so the first-boot script never re-runs on it, except for sparse = true: when fsutil sparse queryflag on <BasePath>\ext4.vhdx (BasePath from the distro's HKCU\Software\Microsoft\Windows\CurrentVersion\Lxss key) says the file is not sparse, rebuild runs wsl --terminate <distro> then wsl --manage <distro> --set-sparse true --allow-unsafe and prints a !! line on failure. --allow-unsafe is required because Microsoft disabled sparse VHD in WSL 2.7 citing potential data corruption, so the key is opt-in. An already-sparse distro prints ==.
With home-manager = true, every rebuild (on an existing distro, and right after a successful first boot) pipes xpkg/setup-scripts/linux/arch.sh into the distro twice: wsl -d <distro> -u root -- bash -s -- system, then wsl -d <distro> -- bash -s -- home as the default user. The system phase runs pacman -S --needed git nix, enables nix-daemon.socket (systemd must be on, which the first-boot wsl.conf does), writes experimental-features = nix-command flakes into /etc/nix/nix.conf and runs systemctl try-restart nix-daemon.service only when that line changed, then creates /nix/store with nix store info if missing: the Arch nix package does not create it, and a user nix run fails with error: opening file "/nix/store": No such file or directory until it exists. The home phase clones https://github.com/RanolP/.dotfiles.git to ~/.dotfiles only when that path is missing, never touching an existing checkout, then runs nix run home-manager/master -- switch --flake ~/.dotfiles/nix#ranolp-archwsl -b before-hm. Each step prints == (already done) or ++ (changed); rebuild relays those lines and, on a non-zero exit, prints a !! line with the last three output lines. After that, rebuild inside the distro is home-manager switch directly.
The script also runs standalone on any Arch machine, as the normal user: bash xpkg/setup-scripts/linux/arch.sh with no argument runs the system phase through sudo and then the home phase. system or home as the argument runs that phase alone.
First install: the setup entry
The root setup file, served at https://dotfiles.ranolp.dev/setup, is one file that is valid POSIX sh, cmd batch and PowerShell at once, so the same URL bootstraps from any of the three shells:
- sh (Arch Linux, or Git Bash on Windows):
curl -fsSL https://dotfiles.ranolp.dev/setup | sh - PowerShell:
irm https://dotfiles.ranolp.dev/setup | iex - cmd:
curl -fsSL https://dotfiles.ranolp.dev/setup -o %TEMP%\setup.cmd && %TEMP%\setup.cmd
Each shell reads past the other two branches: sh sees line 1 as an echo to /dev/null and exits on line 3, cmd sees lines 2 and 3 as labels and stops at @exit /b, and PowerShell sees lines 1 to 6 as a here-string piped to Out-Null. Under cmd the first line echoes echo @' and @' before anything runs; that noise is harmless. The file stays LF: cmd runs it correctly with LF line endings, while CRLF would break the sh branch.
The sh branch runs xpkg/setup-scripts/sh.sh, which pipes linux/arch.sh into bash on Arch (detected from /etc/os-release), hands Git Bash (uname -s starting MINGW or MSYS) to the PowerShell script below, and exits 1 on anything else. The cmd and PowerShell branches both run xpkg/setup-scripts/windows.ps1 (Windows PowerShell 5.1 compatible), which installs Git.Git and Nushell.Nushell through winget only when winget list --exact --id <id> reports them absent, reloads Path from the registry, clones https://github.com/RanolP/.dotfiles.git to ~\.dotfiles only when that path is missing (an existing checkout is never touched), then runs nu ~\.dotfiles\xpkg\install.nu, the same entry as rebuild. Each step prints ==, ++ or !! like arch.sh; a failed step throws rather than calling exit, so irm | iex does not close the caller's window.
Homebrew
Declarative Homebrew managed by nix-homebrew, locked to the Homebrew/brew flake input.
Managed by: nix/darwin/default.nix
Activation Policy
| Setting | Value |
|---|---|
| autoUpdate | false |
| upgrade | true |
| greedyCasks | true |
| cleanup | zap |
| extraFlags | --force |
Brews (CLI formulas)
| Formula | Purpose |
|---|---|
| git-absorb | Auto-fixup commits |
| git-filter-repo | Rewrite git history |
| mdbook | Build this documentation |
| libmagic | Library used by the reuse pipx tool |
Casks (GUI apps)
| Cask | App |
|---|---|
| ghostty | Terminal emulator |
| raycast | Launcher |
| karabiner-elements | Keyboard remapping |
| linearmouse | Mouse/trackpad customization |
| discord | Messaging |
| bitwarden | Password manager |
| figma | Design tool |
| slack | Messaging |
| android-commandlinetools | Android SDK manager |
| temurin | OpenJDK (for Android builds) |
| google-chrome | Browser |
| notion | Notes |
| keybase | Encrypted messaging / file storage |
| openusage | Usage monitor |
| shottr | Screenshot utility |
| menubarx | Menu bar browser |
| proxyman | HTTP debugging proxy |
| thaw | Menu bar utility |
The displaylink, obs, steam, and tailscale-app casks are scoped to ranolp-work-MBP-26 in nix/darwin/default.nix.
Fonts (casks)
| Cask | Font |
|---|---|
| font-iosevka-nerd-font | Iosevka Nerd Font — terminal / editor |
| font-pretendard | Pretendard — UI / Korean text |
Nushell
Primary interactive shell.
Managed by: nix/home/programs/nushell.nix and the files under nix/home/configs/nushell/
Config Files
| Source file | Purpose |
|---|---|
nix/home/configs/nushell/env.common.nu | Shared PATH, editor, GitHub token, and Claude updater settings |
nix/home/configs/nushell/env.darwin.nu | macOS PATH, Android SDK, and Colima socket |
nix/home/configs/nushell/env.linux.nu | Linux profile PATH |
nix/home/configs/nushell/config.nu | Startup hooks, prompt behavior, and ccc profile selector |
PATH Order (highest to lowest priority)
~/Library/Android/sdk/emulator~/Library/Android/sdk/platform-tools/etc/profiles/per-user/ranolp/bin(nix per-user)~/.local/bin~/.local/share/mise/shims/nix/var/nix/profiles/default/bin(nix default)- Existing
PATH, with Homebrew'sbinandsbinappended on macOS
Environment Variables
| Variable | Value |
|---|---|
ANDROID_HOME | ~/Library/Android/sdk |
GITHUB_TOKEN | gh auth token (auto-populated) |
DOCKER_HOST | unix://~/.colima/default/docker.sock |
EDITOR | nvim |
VISUAL | code --wait |
Aliases
| Alias | Expands To |
|---|---|
cat | bat |
ls | eza --icons=auto --git --group-directories-first --header --time-style=relative |
rebuild | sudo /run/current-system/sw/bin/darwin-rebuild switch --flake ~/.dotfiles/nix#ranolp-work-MBP-26 |
pr | gh pr view -w |
repo | gh repo view -w |
g | git |
gst | git status |
gl | git pull |
gp | git push |
gc | git commit -v |
nix-your-shell
nix develop and nix-shell drop into nushell instead of bash. The nushell snippet is generated at home-manager activation and cached at ~/.cache/nix-your-shell.nu.
Zsh
Fallback shell and Claude Code Bash-tool shell.
Managed by: nix/darwin/default.nix (system), nix/home/darwin/default.nix (Claude wrapper), nix/home/darwin/programs/zsh.nix (home), nix/home/default.nix (mise zsh integration)
Configuration
The account login shell is /bin/sh. Home Manager makes the user-profile zsh resolve to Apple's /bin/zsh.
The Claude Code wrapper sets SHELL=/bin/zsh to short-circuit Claude Code's shell probe, which would otherwise select the nix zsh and encounter its SIGCHLD startup hang.
Ghostty launches Nushell as the primary interactive shell. Interactive Zsh remains configured with aliases and the mise activate hook.
Non-interactive Zsh reads .zshenv, which adds ~/.local/bin and the mise shims to PATH, disables the Claude Code autoupdater, and configures the Android SDK paths.
Only shells with CLAUDECODE set disable history. Guards in both .zshenv and .zshrc unset HISTFILE and zero the history sizes because Home Manager's .zshrc history block can restore HISTFILE after .zshenv runs.
Shared history, autosuggestions, and syntax highlighting are disabled.
Home Manager sets completionInit = "", leaving completion initialization out of the user's .zshrc.
nix-darwin sets enableCompletion = false and enableBashCompletion = false, leaving compinit and bashcompinit out of /etc/zshrc. These commands slowly scan nix-store paths in $fpath.
Starship
Cross-shell prompt.
Managed by: nix/home/programs/starship.nix
Prompt Modules (left to right)
directory → git_branch → git_commit → git_state → git_status → nodejs → python → rust → golang → kotlin → java → swift → $fill → cmd_duration → time → newline → character
The $fill module is a spacer — it pushes cmd_duration and time to the right edge of the terminal on the same line as the left modules.
When Nushell is in a WSL Windows-drive directory under /mnt/, it switches to starship-winfs.toml, which omits language modules that scan project files. The generated variant is declared in nix/home/programs/starship.nix.
Module Settings
| Module | Setting | Value |
|---|---|---|
| cmd_duration | min_time | 5000ms |
| cmd_duration | format | [took $duration]($style) |
| cmd_duration | style | yellow |
| time | disabled | false |
| time | format | [🕐 $time]($style) |
| time | style | cyan |
Zellij
Terminal multiplexer.
Managed by: nix/home/programs/zellij.nix
Keybinds
| Shortcut | Action |
|---|---|
| Super+D | New pane to the right |
| Super+Shift+D | New pane below |
These mirror the split bindings in Ghostty.
Behaviour
| Setting | Value |
|---|---|
| exit_on_close | true |
| on_force_close | quit |
| show_release_notes | false |
| show_startup_tips | false |
| default_layout | default |
Layout
Bare default layout — no tab bar, just a pane area (children).
Ghostty
Terminal emulator.
Managed by: nix/home/darwin/programs/ghostty.nix (config), installed via Homebrew cask
Settings
| Setting | Value |
|---|---|
| theme | Nord |
| font-family | Iosevka Nerd Font Mono, Pretendard |
| font-size | 16 |
| command | Nushell from the Home Manager profile |
Keybinds
| Binding | Action |
|---|---|
| Super+D | New split to the right |
| Super+Shift+D | New split below |
| Super+Alt+D | Fallback split to the right |
| Super+Alt+Shift+D | Fallback split below |
| Super+Alt+T | New tab |
| Super+Alt+W | Close surface |
Karabiner routes the unmodified Super shortcuts through herdr-key; the Alt shortcuts are Ghostty fallbacks when Herdr is not active.
package = null in home-manager — config is managed declaratively but the app binary comes from the Homebrew cask.
Herdr
Terminal workspace manager configured for Nushell, Ghostty, and the pinned browser and hands-free plugins.
Managed by: the macOS mise overlay and Home Manager configuration in nix/home/default.nix.
Configuration
Home Manager writes the Herdr configuration with Nushell as the default shell, Nord theme, and tab actions unbound. Workspaces remain the grouping level, with the tab bar hidden; the agent panel sorts by priority. prefix+d toggles dictation and prefix+g toggles gaze mouse through the hands-free plugin.
The activation entries in nix/home/default.nix link the pinned herdr-browser and herdr-handsfree plugin sources. The hands-free plugin uses a prebuilt macOS release binary. The browser plugin manifest is adjusted to call the declared Bun runtime.
nix/home/darwin/configs/karabiner/karabiner.json sends Ghostty's Cmd+D, Cmd+Shift+D, Cmd+T, and Cmd+W shortcuts to herdr-key. The helper in nix/home/darwin/default.nix sends them to Herdr when its window is active and otherwise redispatches the corresponding Ghostty shortcut.
Git
Managed by: nix/home/programs/git.nix
Identity
| Setting | Value |
|---|---|
| user.name | RanolP |
| user.email | me@ranolp.dev |
Workflow
| Setting | Value |
|---|---|
| init.defaultBranch | main |
| push.autoSetupRemote | true |
| pull.rebase | true |
| merge.conflictstyle | zdiff3 |
| rerere.enabled | true |
Signing
| Setting | Value |
|---|---|
| commit.gpgSign | true |
| user.signingKey | BB9C29B5FA1C8305 |
See GnuPG for GPG setup.
Diff and Pager
| Setting | Value |
|---|---|
| core.pager | delta |
| interactive.diffFilter | delta --color-only |
| delta.navigate | true |
| delta.side-by-side | false |
| diff.colorMoved | default |
| diff.algorithm | histogram |
fast-apply
Local file-edit command for exact replacements and anchored block merges.
Managed by: nix/home/configs/bin/fast-apply, installed by nix/home/default.nix as an executable.
Modes
| Command | Behavior |
|---|---|
fast-apply FILE --old OLD --new NEW | Replaces exactly one match; --all opts into replacing every match |
fast-apply FILE --lazy | Reads a snippet from stdin and merges text between ordered, exact-match anchors |
Either edit mode with --dry-run | Prints the proposed diff without writing |
fast-apply --selftest | Runs the script's offline assertions |
Edits refuse ambiguous or missing matches, write atomically, and print a diff. nix/home/configs/claude/hooks/file-edit-guard.py can direct shell-based file edits to this command.
VS Code
Managed by: nix/home/darwin/programs/vscode.nix via programs.vscode (home-manager)
mutableExtensionsDir = false — extensions are fully declarative; manually installed extensions are removed on rebuild.
Extensions
| Extension | Purpose |
|---|---|
| dbaeumer.vscode-eslint | ESLint integration |
| esbenp.prettier-vscode | Prettier formatter |
| arcticicestudio.nord-visual-studio-code | Nord color theme |
| vscode-icons-team.vscode-icons | File icon theme |
| eamodio.gitlens | Git history / blame |
| github.copilot | AI completions |
| github.copilot-chat | AI chat |
| github.vscode-pull-request-github | GitHub pull requests |
| thenuprojectcontributors.vscode-nushell-lang | Nushell language support |
| shd101wyy.markdown-preview-enhanced 0.8.25 | Enhanced markdown preview |
Undeclared Extensions (installed manually)
These are present but not yet declared in nix — they get wiped on rebuild:
anthropic.claude-codemermaidchart.vscode-mermaid-chartterrastruct.d2tintinweb.graphviz-interactive-preview
Settings
| Setting | Value |
|---|---|
| editor.fontFamily | Iosevka Nerd Font Mono, Pretendard |
| editor.fontSize | 14 |
| editor.fontLigatures | true |
| editor.formatOnSave | true |
| editor.minimap.enabled | true |
| editor.unicodeHighlight.nonBasicASCII | false |
| workbench.colorTheme | Nord |
| workbench.iconTheme | vscode-icons |
| update.mode | none |
| extensions.autoUpdate | off |
| extensions.autoCheckUpdates | false |
| files.autoSave | onFocusChange |
| diffEditor.hideUnchangedRegions.enabled | true |
| scm.defaultViewMode | tree |
| terminal.integrated.defaultProfile.osx | nu |
| terminal.integrated.profiles.osx.nu.path | Home Manager profile's nu executable |
| git.autofetch | all, every 60 seconds |
Keybinds
| Binding | Action |
|---|---|
| Cmd+D | Split focused terminal |
| Cmd+Shift+D | Split focused terminal |
Mise
Tool version manager. Replaces nvm, pyenv, rbenv, etc.
Managed by: nix/home/default.nix via programs.mise (home-manager). Shared pins live in nix/home/mise-global.toml; macOS adds a small platform-specific set.
Settings
| Setting | Value |
|---|---|
| experimental | true |
| pipx.uvx | true (use uv as pipx backend) |
| Nushell integration | disabled; shims are on PATH |
| Zsh integration | enabled |
Tools
| Tool | Version | Scope |
|---|---|---|
| node | 24.18.0 | Shared |
| python | 3.14.6 | Shared |
| rust | 1.96.1 | Shared |
| uv | 0.11.29 | Shared |
| fzf | 0.74.0 | Shared |
| bat | 0.26.1 | Shared |
| eza | 0.23.4 | Shared |
| ripgrep | 15.2.0 | Shared |
| fd | 10.4.2 | Shared |
| jq | 1.8.2 | Shared |
| duckdb | 1.5.4 | Shared |
| gh | 2.100.0 | Shared |
| delta | 0.19.2 | Shared |
| difftastic | 0.70.0 | Shared |
| claude | 2.1.288 | Shared |
| npm:@earendil-works/pi-coding-agent | 0.80.10 | Shared |
| npm:@getgrit/cli | 0.1.0-alpha.1743007075 | Shared |
| codex | 0.155.1 | Shared |
| npm:agent-browser | 0.34.0 | Shared |
| npm:agent-device | 0.20.9 | Shared |
| npm:ntn | 0.21.8 | Shared |
| npm:slopless | 0.2.23 | Shared |
| pipx:reuse | 6.2.0 | Shared |
| pipx:google-colab-cli | 0.7.4 | Shared |
ubi:namespacelabs/foundation (nsc) | 0.0.573 | Shared |
| colima | 0.10.3 | macOS |
| lima | 2.1.4 | macOS |
| docker-cli | 29.6.0 | macOS |
| herdr | 0.7.5 | macOS |
On macOS, a Home Manager launchd agent runs the pin-bump script daily at 10:30; the script's seven-day guard limits successful updates to weekly. Its source is nix/home/configs/mise/bump.py.
Claude Code
AI coding assistant CLI.
Managed by: nix/home/default.nix; the CLI version is pinned through mise in nix/home/mise-global.toml.
Configuration
| Source | Purpose |
|---|---|
nix/home/configs/.agents/AGENTS.md and nix/home/configs/claude/CLAUDE.md | Shared and Claude-specific session rules |
nix/home/configs/claude/settings.json | Claude Code settings, plugins, and hook registration |
nix/home/configs/claude/statusline.sh | Three-line statusline |
nix/home/configs/claude/agents/ | Custom agent definitions |
nix/home/configs/claude/hooks/ | Hook scripts linked by Home Manager |
nix/home/configs/claude/mods/ | Local Claude Code plugins |
nix/home/configs/.agents/skills/ | Skills shared with Codex |
nix/home/configs/claude/mcp.json and nix/home/configs/claude/profile-wiring.sh | MCP server list and profile wiring |
Home Manager generates a writable settings file from the repository config during activation. The selected concise-adhd-korean output style is generated from the shared nix/home/configs/.agents/output-manner.md rules.
The shared skill list in nix/home/default.nix links local skills into both Claude Code and Codex. It also fetches pinned skill sources for Anthropic, Notion, and TypeSafe, plus a Supermemory search skill.
Local Plugins
nix/home/configs/claude/settings.json loads three plugins from nix/home/configs/claude/mods/:
| Plugin | Role |
|---|---|
clm | Folds earlier conversation turns into a ledger, provides the /clm board view, and coordinates co-worker sessions through the clm_coworker tool |
codex-subagent | Starts a Codex app-server bridge for Codex-backed agent tools |
time-budget | Gives estimate, checkpoint and overrun feedback; the extend tool records extra time in extensions.jsonl |
Mod tests (mods/*/hooks/*.test.ts) import claude-code/testing, a synthetic module the claude binary injects (describe, expect, mock, test, tier), so bun test fails with "Cannot find module" on every mod. Run them with claude plugin test . from the mod folder; it takes a folder and runs every *.test.ts under it. claude test ... is a different thing: the wrapper reads test as a prompt and starts a session. The loader also requires that $ be passed only into a function declared in the same file as the hook, never across an import, so a helper module takes plain values.
clm co-workers
The earlier clm later queue (deferred requests held in session state, the Later queue band and /clm pop) is gone. Deferred work is now a shared board issue that a co-worker session claims.
Roles: the main session is the orchestrator and plans, lands and reassigns issues; co-worker sessions claim ready issues and work them in their own worktrees. The co-worker count is scalable: main sets maxCoworkers through the plan action (default 2, positive integer).
Deferred work: the prompt intent classifier returns deferred items (up to five) for work the user bounds as later ("나중에", "이건 다음에", "todo로"). An item whose quote is not an exact substring of the prompt is dropped. Each remaining item becomes a todo issue on the board, reusing the pending create when the titles match. Co-worker sessions skip this; main sends each live co-worker the new issue ids.
Board issues carry priority, ready and a claimant, and the board shows them on each card. The ready-to-land status has its own board column; only the co-worker tool can set it, because fold ops cannot produce it.
The clm_coworker tool takes an action, and each action is limited to one role except list:
claim(co-worker): takes the given or the next ready unclaimed issue of the repository (highestpriority, then oldest), starts its worktree, and marks itdoing.ready(co-worker): checkpoint-commits the worktree, records its base and head SHAs, setsready-to-land, returns the co-worker to idle, and sends main<issue> is ready to land (<base>..<head>).release(co-worker): drops the claim and returns the issue totodo; the worktree stays attached to the issue.plan(main): setspriority,readyormaxCoworkers; settingreadytriggers dispatch.revoke(main): clears the claim, returns the issue totodo, tells the old claimant to leave the worktree unchanged and, withto(a co-worker session id), tells that co-worker to claim it.land(main): see below.list(either): main's live session, co-workers with live or stale state, and the board with priority, ready and claimant.
Environment: a co-worker session starts with CLM_COWORKER=1 and CLM_MAIN_SESSION=<main session id>; without CLM_COWORKER=1 a session is a main. Each session registers its role and repository on session.start. The launch also passes main's own CLAUDE_CONFIG_DIR, so the two sessions run under the same configuration and can message each other.
On-disk state, all under ~/.claude-work/clm/ except the event log:
claims/<issue>/owner.json: anmkdirlock per issue. A claim whose owner has no live registry record is reclaimed.coworkers/<session>.json: registry record (statestarting,idleorbusy, issue, heartbeat, mux; main also storesmaxCoworkers). A heartbeat is written every 15 s and is live for 60 s.worktrees/: one worktree per claimed issue.~/.claude-work/tracker/events/<session>.jsonl: the event log that the board folds from every session.
Messaging: the hooks send every hand-off themselves with $.session.send({ to: { sessionId }, text }), the same delivery as the model's SendMessage tool, addressed by the recipient's registry session id. A message to main goes to the newest live registry record with role main and the same stable main id, because /clear gives main a new session id. When a send is not delivered, $.ui.log records the reason and the tool result says the recipient could not be notified. The receiving model can answer with SendMessage to the sender of the message. An idle session that receives a message wakes and acts on it.
Land: land runs in main for a ready-to-land issue with a claimant and worktree. It refuses a dirty main checkout. Otherwise it runs git cherry-pick --no-commit <base>..<head>, which stages the commits without committing; main commits them afterwards in the repository's own subject format. On success the worktree is removed, the issue becomes done, and the co-worker is told so. On a conflict the cherry-pick is aborted, the issue returns to doing, and the claimant is told to rebase onto the current main HEAD and call ready again.
Auto-spawn: main polls every 5 s and after plan, revoke and land. When a ready unclaimed issue exists and a live co-worker is idle, main sends that co-worker a claim request, once per co-worker and issue, so the 5 s poll does not repeat it. A new co-worker is launched only when no live co-worker of this main is idle or starting, the live count is below maxCoworkers, and the one-minute load average is under 0.75 times the core count. The launch is a clm-coworker-<n> session started unfocused in the repository root with a brief to claim, work only in the returned worktree, and call ready.
Multiplexer detection reads the environment: HERDR_SOCKET_PATH or HERDR_ENV selects herdr (herdr tab create --no-focus, then herdr agent start), ZELLIJ selects zellij (zellij run --no-focus), TMUX selects tmux (tmux new-window -d). With none, no co-worker starts and the debug log records a manual command. The tmux launch is unverified: tmux is not installed on this machine. Load reading uses /proc/loadavg and nproc when /proc/loadavg is readable (Linux), otherwise sysctl -n vm.loadavg and sysctl -n hw.ncpu (macOS).
Statusline
nix/home/configs/claude/statusline.sh renders three lines from the session data:
- Line 1: account email, folder, branch, staged and modified file counts, and pull request review state; model, effort, and thinking state appear at the right.
- Line 2: cost and context percentage, changed line counts, and session duration.
- Line 3: five-hour and weekly rate-limit usage, with reset times when available and
unknownwhen the usage data is absent.
The script caches Git status briefly per session and right-aligns each row to the detected terminal width.
Alternative considered: Starship native statusline
Starship ships starship statusline claude-code as a drop-in Claude Code statusline. Evaluated 2026-07-03 and not adopted — it is not feature-complete against the script above:
- Its three modules (
claude_model,claude_context,claude_cost) cover only lines 1-2. - Rate limits (line 3) exist only in the unmerged, stalled upstream PR starship#7442 (
claude_usagemodule), with no maintainer review as of 2026-06-21. - There is no upstream module for effort/thinking, PR number + review state, or staged/modified file counts.
Revisit if #7442 merges and effort/PR modules land upstream.
Hooks
The scripts under nix/home/configs/claude/hooks/ are registered in nix/home/configs/claude/settings.json by event and tool matcher. For example, git-push-guard.py restricts pushes to claude/* branches unless the repository's explicit local bypass is enabled; git-integrity-guard.py separately denies force-push and commit bypass flags.
Codex
OpenAI coding agent CLI, configured through Home Manager and mise.
Managed by: nix/home/configs/codex/config.toml and nix/home/default.nix.
Main Settings
| Setting | Value |
|---|---|
| model | gpt-5.6-terra |
| model reasoning | high; plan mode uses xhigh |
| service tier | fast |
| approvals | on-request, reviewed by auto-review |
| sandbox | workspace-write, with network access |
| web search | live |
| file opener | VS Code |
| startup update check | disabled |
| default subagent | gpt-6-luna, xhigh reasoning |
Because web search is live by default, a codex exec run that must stay offline passes -c web_search="disabled" -c tools.web_search=false and shows a probe proving no web tool answered. The codex-subagent mod already writes both settings into its worker config.
The agents section caps depth at 2 and concurrent threads per session at 8. The TUI status line shows the current directory, Git branch, model reasoning, context use, and five-hour and weekly limits.
Agents and Hooks
Home Manager links the code-reviewer, oracle, and prose-editor agent definitions from nix/home/configs/codex/agents/.
Codex runs shared policy scripts through nix/home/configs/codex/hooks/claude-hook-adapter.py. nix/home/configs/codex/config.toml registers them for Bash and apply_patch calls, tool completion, prompt submission, and session stop.
The generated Codex agent instructions combine nix/home/configs/.agents/AGENTS.md, nix/home/configs/codex/AGENTS.md, and the shared nix/home/configs/.agents/output-manner.md. The skills declared in nix/home/default.nix are linked for both Codex and Claude Code.
Home Manager generates the writable Codex config from the repository file during activation and retains local project trust and MCP server entries outside this repository.
Karabiner-Elements
Keyboard remapping.
Managed by: nix/home/darwin/configs/karabiner/karabiner.json (linked by nix/home/darwin/default.nix), installed via Homebrew cask
MacBook Internal Keyboard — Windows-style Layout
Remaps the built-in keyboard so modifier positions match a standard Windows keyboard muscle memory.
| Physical Key | Remapped To |
|---|---|
| Fn | Left Command |
| Left Control | Fn |
| Left Command | Left Control |
| Right Command | F18 (한영 toggle) |
Dareu Z82 (vendor 9741, product 48)
External keyboard remapping for the Dareu Z82.
| Physical Key | Remapped To |
|---|---|
| Left Control | Left Command |
| Left Command | Left Option |
| Left Option | Left Control |
| Right Option | F18 (한영 toggle) |
F18 → 한영
F18 is mapped to the Korean input method toggle (symbolic hotkey ID 60) via the macOS activation script in nix/darwin/default.nix.
Ghostty Shortcuts
In Ghostty, Karabiner routes Cmd+D, Cmd+Shift+D, Cmd+T, and Cmd+W to herdr-key. The helper sends the action to Herdr when its window is active and otherwise redispatches the split or tab action to Ghostty.
LinearMouse
Mouse and trackpad customization.
Managed by: nix/home/darwin/configs/linearmouse/linearmouse.json, linked by nix/home/darwin/default.nix; the app comes from the Homebrew cask
Ghostty: Middle-Click
| App | Button | Action |
|---|---|---|
| com.mitchellh.ghostty | Middle click (2) | none (disabled) |
Prevents accidental paste-on-middle-click in the terminal.
All Mice: Gestures
Middle-click drag gestures (threshold: 50px):
| Direction | Action |
|---|---|
| Up | Mission Control |
| Down | App Exposé |
| Left | Move to right Space |
| Right | Move to left Space |
All Mice: Other
| Setting | Value |
|---|---|
| universalBackForward | true |
| vertical scroll reverse | true (natural scrolling disabled) |
Raycast
Launcher. Replaces Spotlight.
Managed by: Homebrew cask
Spotlight's Cmd+Space shortcut (symbolic hotkey ID 64) is disabled via the activation script in nix/darwin/default.nix.
Firefox
Firefox Developer Edition browser.
Managed by: the firefox@developer-edition Homebrew cask in nix/darwin/default.nix installs the app; nix/home/darwin/programs/firefox.nix declares the default profile and add-ons.
What firefox.nix does
Home Manager's programs.firefox module manages the profile, not the app: package = null leaves installation to the Homebrew cask.
The activation script unregisters stale nix-store Firefox copies and asks the Homebrew app to become the default HTTPS handler with Firefox's -setDefaultBrowser option.
The profile gets Bitwarden, uBlock Origin, Dark Reader, and Tampermonkey from NUR. It also pins react-devtools, kagi-search, maxfocus, simple-translate, and multi-account-containers to immutable AMO file URLs and hashes in nix/home/darwin/programs/firefox.nix.
Why Firefox Developer Edition
We use Firefox Developer Edition. Here is why.
- We reject Chromium-based browsers to help against browser-engine monopoly.
Rejected: Google Chrome, Microsoft Edge, Ungoogled Chromium, Arc from The Browser Company, Dia from The Browser Company, Opera, Vivaldi, Naver Whale - Browser extensions must be free to use. In particular, uBlock Origin must be usable.
- We reject Safari.
Rejected: Apple Safari - We don't obsess over anonymity beyond what's needed.
Rejected: Tor Browser - We need confidence that the browser will keep being maintained.
Rejected: LibreWolf - DRM playback must work, for services like Laftel and Netflix.
Rejected: Zen Browser, Pale Moon - The browser must embrace diversity.
Rejected: Brave [2014 Apr]
Alternatives considered
Opera Neon, Zen, Arc, Dia: interesting new-style browsers, but judged too risky to use as a daily driver.
Other platforms
Windows installs the Mozilla.Firefox.DeveloperEdition winget package declared in xpkg/windows/default.toml. WSL skips Firefox entirely.
Chrome
Google Chrome browser.
Managed by: Homebrew cask
Enterprise Policy: Bitwarden Extension
Bitwarden is force-installed via managed Chrome policy so it's always present regardless of profile sync state.
Policy file: /Library/Google/Chrome/policies/managed/extensions.json
{
"ExtensionInstallForcelist": [
"fcoeoabgfenejglbffodgkkbkcdhcgfn;https://clients2.google.com/service/update2/crx"
]
}
Written by the darwin activation script in nix/darwin/default.nix.
Bitwarden
Password manager.
Managed by: Homebrew cask
Also force-installed in Chrome via enterprise policy — see Chrome.
Discord
Messaging.
Managed by: Homebrew cask
Slack
Messaging.
Managed by: Homebrew cask
Notion
Notes and documents, with the ntn CLI and its matching agent skill.
Managed by: Homebrew cask
The CLI version is pinned as npm:ntn in nix/home/mise-global.toml. nix/home/default.nix also links the pinned Notion notion-cli skill into both agent skill trees. On macOS, nix/home/darwin/default.nix provides a notion shim that directs callers to the official ntn command.
Figma
Design tool.
Managed by: Homebrew cask
Keybase
Encrypted messaging and file storage.
Managed by: Homebrew cask
Espanso
Cross-platform text expander written in Rust.
Managed by: home-manager service (services.espanso)
Runs as a background launchd agent (org.nix-community.home.espanso). Configured to start with espanso daemon directly to prevent espanso from self-registering a second launchd plist.
Packages
| Package | Source | Notes |
|---|---|---|
| typsi | pkgs.fetchFromGitHub pinned to a commit | Typst-y symbol/emoji expansions via \subset.eq\, :arm.mech:, etc. |
| nannosym | Same typsi checkout | Nanno's Symbols |
Packages are placed at ~/.config/espanso/match/packages/ via xdg.configFile — no espanso install needed.
Syncthing
Continuous file sync across devices.
Managed by: home-manager service (services.syncthing.enable = true)
Runs as a background launchd service. Not a Homebrew cask — the daemon is managed declaratively by home-manager.
Android SDK
Android development environment.
Managed by: Homebrew casks in nix/darwin/default.nix and SDK activation in nix/home/darwin/default.nix
Components
| Component | Source |
|---|---|
| android-commandlinetools | Homebrew cask |
| temurin (JDK) | Homebrew cask |
| SDK packages, AVD | installed by home-manager activation through Android CLI (android, launched by the commandlinetools cask) |
Environment
| Variable | Value |
|---|---|
ANDROID_HOME | ~/Library/Android/sdk |
JAVA_HOME | resolved via /usr/libexec/java_home |
Android SDK paths are also prepended to PATH in nix/home/configs/nushell/env.darwin.nu — see Nushell.
SDK Packages (auto-installed on activation)
platform-toolsplatforms/android-35build-tools/35.0.0emulatorsystem-images/android-36/google_apis_playstore/arm64-v8a
Emulator
Activation creates the medium_phone AVD once with android emulator create medium_phone, which uses the Google Play system image above. Start it with android emulator start medium_phone.
The commandlinetools cask stays, because its zap trashes ~/.android, which holds every AVD and Android CLI itself. The standalone android-cli cask would also collide with it on the android binary.
Xcodes
Xcode version manager CLI.
Managed by: nix/home/darwin/packages/xcodes.nix (Home Manager package from the GitHub release binary, version 1.6.2)
Why Not Homebrew?
The Homebrew formula for xcodes builds from source using xcbuild, which requires Xcode to already be installed — a chicken-and-egg problem. This package uses the prebuilt binary from GitHub releases instead.
The package fetches the aarch64 macOS release archive from GitHub and installs its xcodes executable.
Xcode Selection
The activation script in nix/darwin/default.nix runs xcode-select -s for the latest installed Xcode*.app when one is present.
GnuPG
GPG toolchain for commit signing.
Managed by: nix/home/default.nix, nix/home/darwin/default.nix, and nix/home/linux/default.nix
Packages
| Package | Purpose |
|---|---|
| gnupg | Core GPG toolchain |
| pinentry_mac | Passphrase dialog on macOS |
| pinentry-tty | TTY pinentry on macOS |
| pinentry-curses | Pinentry on Linux |
gpg-agent.conf
nix/home/configs/gnupg/gpg-agent.conf is linked by Home Manager. The gpg-agent restarts automatically (gpgconf --kill gpg-agent) when the file changes.
The signing key used for git commits is BB9C29B5FA1C8305 — see Git.
jira-cli
Fine-grained editing of a Jira card's body. Select a node inside the card's Atlassian Document Format (ADF) tree with a CSS selector, stage the edit, then flush a batch of edits as a single write.
Managed by: nix/home/programs/jira-cli.nix — a writeShellScriptBin wrapper that runs nix/home/configs/jira-cli/jira.py through uv run --script. uv comes from mise, not from nixpkgs, so the wrapper resolves it off the caller's PATH and fails with a named instruction when it is missing.
jira.py is a PEP 723 entry point that dispatches into the jira_cli/ package beside it, one module per domain (the table below names them). Its dependencies (lxml, cssselect, jsonschema) are declared in the script header and resolved by uv at run time, so nothing is compiled during a rebuild.
Why it exists
The web editor is the only practical way to change one table cell, one heading, or one list item, and it makes those surgical changes slow and error-prone. This CLI turns them into one command.
Two constraints shaped the design: no OAuth app registration, and no credential to manage or rotate. Both hold. Auth is Dynamic Client Registration plus PKCE against the Atlassian MCP endpoint, so the first run registers its own public client and the refresh token renews access silently from then on.
Layers
| Layer | Module | What it does |
|---|---|---|
| Auth | jira_cli/auth.py | DCR on first login, PKCE authorization code, refresh on 401 or near expiry. ~/.config/jira-cli/credentials.json, mode 0600. |
| Transport | jira_cli/mcp.py | MCP JSON-RPC over HTTPS with urllib, session id, SSE-or-JSON unwrapping. |
| ADF | jira_cli/adf.py | Projects the ADF onto an lxml tree — one element per node, tag = the node's type, attrs as XML attributes, and a ptr attribute holding the RFC 6901 pointer. cssselect translates the selector to XPath. Also holds the JSON-pointer ops, the schema validation, the rendered plain-text view, and show, types, media ls, schema update. |
| Queue | jira_cli/edit.py | ~/.config/jira-cli/queue.json. Each entry holds the issue key, the selector, the operation, the new content, the resolved pointers, and a deep snapshot of every matched node. |
| Apply | jira_cli/edit.py | Dry-runs every queued card, then writes. editJiraIssue with contentFormat: "adf", one call per card. |
| Cards | jira_cli/cards.py | search, info, comments — reads that need no ADF tree, plus the one-line field flattening the listings share. |
| Workflow | jira_cli/flow.py | ~/.config/jira-cli/workflow.json, one entry per project:issuetype, holding the sampled status graph. |
| Self-check | jira_cli/selfcheck.py | The offline assertions behind jira selfcheck, run against fixture.adf.json. |
Commands
jira login [--force] # authorize this machine
jira edit queue -i KEY 'selector' < new.json # replace the matched node
jira edit queue -i KEY 'selector' --before < new.json
jira edit queue -i KEY 'selector' --after < new.json
jira edit queue -i KEY 'selector' --append < new.json
jira edit queue -i KEY 'selector' --delete
jira edit queue -i KEY 'selector' --text '새 제목'
jira edit queue -i KEY 'selector' --all # allow a multi-node match
jira edit queue -i KEY --jq 'select(.type=="media")' # escape hatch
jira edit status
jira edit drop N | --all
jira edit apply [--token=7f3a9c2e]
jira show -i KEY [--pointer /content/3] # default: the selector's XML view
jira show -i KEY --json # raw ADF, the shape stdin wants
jira show -i KEY --rendered # lossy plain text, cheapest to read
jira search 'project = PROJ AND status != Done' # JQL, one row per card
jira info -i KEY # status, assignee, parent, labels
jira flow -i KEY [--refresh] # every reachable status, and the route to each
jira move -i KEY 'Dev Done' # walk that route for real, hop by hop
jira types [NAME] # ADF vocabulary, from the schema
jira media ls -i KEY
jira schema update
jira selfcheck
jira --help carries the whole mental model — the staging workflow, a selector cheat sheet, what reads stdin, and the safety rules — so an agent can drive the tool cold without a skill. jira edit queue --help and jira edit apply --help add the per-command detail.
Three views of a body
jira show prints the same card three ways, and the default is the XML projection the selector layer already builds. Measured on nix/home/configs/jira-cli/fixture.adf.json:
| View | Size | What it is for |
|---|---|---|
--xml (default) | 2527 chars, 39% smaller than the JSON | The selector surface: tag = node type, attrs as attributes, mark space-separated, ptr the JSON pointer. Read this to write a selector. |
--json | 4101 chars | Raw ADF. The only write format — copy a node here, edit it, feed it to jira edit queue. |
--rendered | 134 chars | Markdown-ish plain text. The cheapest read, and never a write path. |
The default changed to --xml because reading is the common case and the JSON view spends most of its bytes on punctuation. --xml is accepted explicitly so a script can state the view it wants.
--rendered walks the ADF rather than the lxml projection, so a link keeps its href and a media node keeps its id, where --xml shows only the bare mark name. It still drops every pointer, so nothing read this way can be edited and pushed back.
One consequence of making the projection a printed format: an attribute now renders as ADF's own literal instead of Python's repr. A boolean reads false, never False, which also means table[isNumberColumnEnabled="false"] matches — the selfcheck asserts exactly that.
Reading a card's fields
jira search 'JQL' and jira info -i KEY cover what the body-editing commands cannot see. Both are read-only.
search prints one row per card with the four fields that place it — status, assignee, parent, summary — because a wider default makes every row wrap. --fields status,priority,labels widens it, -n sets the page size (1-100, the MCP search caps a page at 100), and --json prints the raw response. A Jira timestamp is trimmed to the minute in both commands, since the milliseconds and the offset widen every row and no one reads them.
jira types answers the one question the help text cannot hold: the ADF vocabulary. With no argument it prints the 43 node types and the 17 mark types apart from each other, because a mark is never an element name and reaches a selector only through text[mark~="…"]. With a name it prints that type's required fields and its attrs schema. Both are derived from the schema file, so neither goes stale when jira schema update runs.
Each queue flag maps to exactly one RFC 6902 operation: replace, add at index, add at index+1, add at /-, remove. move and copy are excluded on purpose, because both mutate two locations and that breaks the one-selector-one-snapshot guard.
jira media ls exists because the MCP surface has no attachment tool, so file upload is impossible here. Upload through the web UI, then this command lists the media nodes already on the card with their ids so the CLI can position and reference them. External images work through a media node with type: "external" and a url.
Safety rules
A multi-match aborts. A selector matching two nodes is an error unless --all is passed. Taking the first match silently edits the wrong paragraph.
Pre-flight, then write. apply re-fetches every queued card and dry-runs the whole batch before writing anything. Jira has no transaction, so a half-applied batch cannot be rolled back, and a clean zero-write abort is the only safe failure.
Drift needs a content-hash token. When a re-resolved node no longer deep-equals its snapshot, apply prints both values and refuses:
$ jira edit apply
✗ PLAYGROUND-157 /content/3 큐에 담을 때와 내용이 다릅니다
큐 스냅샷 : "설계 포인트 — 상태 모델"
현재 카드 : "설계 포인트 — 상태 모델 (v2)"
그래도 밀어붙이려면:
jira edit apply --token=7f3a9c2e
The token is the first 8 hex of a SHA-256 over the drifted state, not a random string. A random token would need storage, expiry, and single-use bookkeeping, and it would still admit the worst case: a token issued at 10:00 and pasted at 10:30 silently authorizing a third change that arrived in between. A content hash cannot do that, because a further change yields a different hash and the stale token stops matching. One token covers the whole apply.
Selectors re-resolve at apply time. Within one batch a --delete shifts every later sibling's index, so a frozen pointer would patch the wrong node with no error. The pointer stored in the queue is diagnostic only, which is what lets jira edit status print without a network call.
Local schema validation before every write. The vendored draft-04 ADF schema validates the finished document. The whole-document schema is one giant anyOf, so its top-level error blames the innocent parent and dumps the parent's entire JSON. Validation instead walks bottom-up and checks each node against only the definitions that declare that node's own type, so the report names the node that is actually wrong:
✗ PLAYGROUND-157 would become invalid ADF, nothing written:
/content/0: Additional properties are not allowed ('content' was unexpected)
Status transitions
Jira's transitions endpoint answers for a card's current status only, so a card can never show a chain longer than one hop — To Do → In Progress → Dev Done is invisible from a card sitting in To Do. jira flow samples the graph instead: for every status it does not yet know, one JQL query finds a card of the same project and issue type sitting in that status, and that card's transitions become the edges leaving it. Every call is a read, and the graph is cached per project:issuetype in ~/.config/jira-cli/workflow.json. --refresh re-samples.
jira flow -i <KEY> prints the card's current status, then one row per reachable status, cheapest route first. A row is a route and its transition ids: --<id>--> is a single transition, ==<id>==<id>==> is a chain in the order move will fire them, and ~~???~~> is a status Jira can reach that this graph has no route to. The card's own status is never a row, even where the workflow defines a self-loop back to it, because listing it would say nothing.
Coverage is every status the sample reached, not every status the project defines — Jira's MCP surface has no call that enumerates a workflow's statuses. Rows come from two places: a status any sampled transition points at, and a status already in the cache from an earlier card. A status that no sampled transition points at, and that no inspected card has ever sat in, is invisible; there is no ~~???~~> row for it, because its name was never learned. A status holding no card at all cannot be sampled, so it can only ever be seen as somebody's destination, and anything behind it prints as ~~???~~>. The closing line says the same thing from the other side: the graph is a sample, not the workflow definition. A sample is also one specific card, so a transition open to it may be closed to yours.
jira move walks the route for real. The sampled graph decides where to go; the card's own transition list is re-read before every hop and decides whether this card may go there. A refused hop stops the walk, names how far it got, and leaves the earlier hops applied — Jira has no transaction over a status change, so there is nothing to roll back to. After the last hop the card's status is re-read and compared with the target, which is what catches a workflow post function moving it somewhere else.
Schema
nix/home/configs/jira-cli/adf-schema-56.7.3.json is vendored verbatim from npm @atlaskit/adf-schema@56.7.3 (dist/json-schema/v1/full.json), Apache-2.0, Atlassian Pty Ltd. jira schema update downloads the current upstream schema to ~/.config/jira-cli/adf-schema.json, which takes precedence over the vendored copy when it exists. Delete that file to fall back to the pinned version.
Known limits
Markdown is a lossy view of a card and is never used as a write path — reading a card as markdown degrades an attached image to a blob: URL on a staging host. The MCP access token buys nothing on the REST gateway, so there is no REST fallback for anything the MCP surface omits. It is not a flat rejection, which is what makes it easy to misread — measured 2026-08-20 against api.atlassian.com/ex/jira/<cloudId>/rest/api/3/*:
| Endpoint | Result |
|---|---|
issue/{key} | 404 이슈가 존재하지 않거나 이를 볼 수 있는 권한이 없습니다 — while the same token reads that same card fine through getJiraIssue |
project/search | 200, total: 0 |
status | 200, [] |
project/{key}/statuses | 404 project not found |
myself, statuscategory | 401 |
The token authenticates at the gateway but carries no user identity there, so every project and issue reads as invisible rather than forbidden. The MCP endpoint applies the identity server-side; REST does not. One consequence shapes jira flow: project/{key}/statuses is the only call that enumerates a workflow's status set, and it is unreachable, so sampling real cards is the ceiling. File upload, bulk edit across cards from one selector, sprint and board operations, and Confluence are all out of scope. --rendered is a read path only: it has no pointers, so no edit can be expressed against it.
Agent Incidents
Long-form background for the rules in AGENTS.md at the repository root. Each entry records a failure that produced a rule, or the detail behind a rule too long to keep in a file that loads into every agent session. Read one after a rule surprises you, or before changing the rule itself.
2026-08-31 -- an agent installed packages imperatively instead of editing declarations
Every tool, app and version on this host is declared in exactly one file, so an install performed on the machine is drift that the next rebuild silently reverts or preserves at random.
Asked to find upgradeable software, an agent ran npm i -g corepack npm pi-subagents and brew upgrade. That left pi-subagents at a version no file declared, and it moved npm to 12.0.2 under a mise-managed node = "24.18.0" that reverts it on the next mise install. Meanwhile the flake inputs the agent never looked at were six weeks stale -- and those were the actual upgrade.
The answer to "upgrade X" is to edit the declaration and hand the rebuild over. The three declaration surfaces are also the three places an "what is upgradeable?" report comes from: mise outdated for the [tools] table of nix/home/mise-global.toml, brew outdated --greedy for homebrew.casks / homebrew.brews in nix/darwin/default.nix, and the lastModified of each nix flake metadata input for everything nixpkgs ships. A cask carries no version in the declaration -- nix-homebrew always installs the latest, so a cask upgrade is a brew upgrade the rebuild performs, not a version edit.
declarative-package-guard.py now denies the imperative form -- npm i -g, pipx install, cargo install, brew install|upgrade, mise use -g, nix profile install -- and names the file to edit instead.
2026-10-07 -- uncommitted fixes were stashed by another session, which fixed the same bug again
Several Claude sessions work in this one ~/.dotfiles checkout at once, all on main. A session finished fixes to the codex-subagent, clm and time-budget plugins, ran their tests and the nix build, and left them uncommitted while it waited for a "commit" request. Another session needed a clean tree, stashed those changes, and committed its own fix for the same codex 404 bug as b86b66b (claude: keep codex worker steps from falling through to Claude). Unstashing then conflicted in codex-subagent/hooks/register.ts and its test, and the work had to be reconciled by hand.
The user's verdict: "니가 커밋 안해서 그렇잖아..." and then "checkpoint마다 main에 커밋하고 푸시 전에 rebase를 해야하지". So a finished unit goes onto local main as a commit the moment its tests and build pass, with no permission asked. A commit is visible to every other session through git log, and a commit cannot be stashed out from under its author. The push still waits for an explicit request, and it always runs git fetch and git rebase origin/main first, because another session may have pushed in between. The ship skill carries the full sequence.
2026-08-31 -- a clean dry-run was handed over and the real rebuild died on simple-translate
nix build ... --dry-run never downloads anything. It prints the plan and stops. That makes it the right tool for auditing the "will be built" list for a source compile, and completely blind to a fixed-output hash mismatch, an eval error, or a failing builder.
A dry-run was reported clean and the rebuild was handed to the user. It died on simple-translate, because nix/home/darwin/programs/firefox.nix pinned a fixed hash to the moving addons.mozilla.org/firefox/downloads/latest/<slug>/latest.xpi alias, and AMO had published 3.1.0 over the pinned 3.0.1.
Two consequences. First, a hand-pinned AMO addon must name an immutable /downloads/file/<id>/<name>-<ver>.xpi URL rather than the latest.xpi alias. Second, the check that catches this class of failure is the real build: cd ~/.dotfiles/nix && nix build .#darwinConfigurations.ranolp-work-MBP-26.system --no-link. It needs no sudo and produces the exact derivation darwin-rebuild switch will activate, so a green run is evidence the handed-over rebuild will work.
espanso -- why it is fetched as a notarized release rather than built or installed as a cask
espanso is the worked example behind the GOLDEN RULE that no package may be built from source.
In nixpkgs it is source-only on darwin, so taking the nixpkgs package means compiling it. Its Homebrew cask is not an escape either: the cask's nested-dmg unpack is broken under nix-homebrew.
So nix/home/darwin/default.nix fetches the official upstream release, then mounts it and dittos the notarized app into ~/Applications at activation. That is a download, never a compile. The ditto step matters for a second reason: unlike a 7zz unpack, it preserves the code signature. A re-packed bundle triggers a "Espanso is damaged" error at launch, and codesign cannot re-seal the bundle inside the nix sandbox.
The broken seal also explains why espanso once lost Accessibility on every release bump (2026-08-11). macOS records a TCC grant made to an app whose seal does not validate as a bare cdhash requirement, which names one build; System Settings still showed the toggle on while the new build ran unpermitted. sudo tccutil reset Accessibility com.federicoterzi.espanso, then restarting the launchd job so the validly signed app asked again, produced identifier "com.federicoterzi.espanso" and anchor apple generic and certificate leaf[subject.OU] = "6424323YUH", which every later release from the same signer satisfies. Check the stored requirement with sudo sqlite3 "/Library/Application Support/com.apple.TCC/TCC.db" "select quote(csreq) from access where client='com.federicoterzi.espanso';" and decode it with csreq -r <blob> -t.
2026-08-27 -- file-edit-guard.py was registered without being deployed and stopped every Bash call
A PreToolUse hook on the Bash matcher runs in front of every Bash call in every session on this machine, so a bad one is a total work stoppage that only a rebuild can lift.
file-edit-guard.py was registered in nix/home/configs/claude/settings.json while both its home.file entry in nix/home/default.nix and its git add were missing. Python exited 2 on the absent file, PreToolUse read exit 2 as a block, and every Bash call in every session died with can't open file '/Users/ranolp/.claude/hooks/file-edit-guard.py'. It stayed broken until the entry was added and the user rebuilt.
Two structural facts caused it. Nix deploys the hooks directory as one explicit entry per script, never by directory recursion, so a new script that is not listed is not deployed. And a flake reads the git tree, so a file that was never git added is invisible to the rebuild even when it exists on disk.
The exit-code contract, and what verify-claude-hook.sh actually tests
Measured on python 3.14.6 (2026-08-28), a hook's exit status decides what Claude Code does with the call it guards:
- exit
1-- an uncaught exception. Claude Code treats this as a non-blocking error, so the call proceeds unguarded. - exit
2-- what a missing script file produces.PreToolUsereads exit2as a block.
That second case is the work stoppage described in the 2026-08-27 incident above.
./scripts/verify-claude-hook.sh nix/home/configs/claude/hooks/<name>.py runs the hook against a clean python:<host-version>-slim container -- --network none, --read-only, the repo mounted read-only, no ~/.claude, no site-packages -- over a battery of real stdin shapes: empty stdin, non-JSON, a JSON array, a null / numeric / list command, an unterminated heredoc, and a 1MB command.
It grades each result:
STOP-- the hook blocks or hangs every matched call. This is the total work stoppage.OPEN-- the guard crashed, so the call proceeds unguarded.SLOW-- the hook is slow enough to notice.
A hook that scores any STOP never gets registered.
2026-09-22 -- the user rewrote an agent's PR body and cut it to a seventh
Reported from another session working on a CI build-cache fix in a work repository. The user edited the agent's body by hand, showed both versions side by side, and told the agent to send the case here: "내가 고치는 걸 보고 잘 배워서 dotfiles한테 사례집 보내라". The agent's body ran about 2,900 bytes; the user's covered the same scope in about 400, roughly a seventh.
That repo's .github/PULL_REQUEST_TEMPLATE.md defines exactly four top-level headers: ## 개요, ## 작업 내역, ## 관련 카드, ## 변경 체크리스트.
The agent's version opened ## 개요 with five bullets (a measurement, two refuted hypotheses, the scope of application), added a top-level ## 실측 결과 section holding a four-row table plus three paragraphs explaining which number came from what, and wrote ## 작업 내역 as a numbered list of <short sha> <commit subject> items, each carrying two or three 리뷰 포인트: sub-bullets.
The user's version kept the same scope in this shape, with the measurements standing in for the real ones:
- CI에서 빌드 캐시가 적용되지 않아 활성화한다.
## 작업 내역
- 캐시 경로만이 문제였으므로 경로를 수정하고 캐시 히트를 확인
- 원격 빌드 캐시를 추가로 사용한다
- 캐시 오염을 막기 위해 버킷을 저장소별로 분리한다
### 실측 결과
- 빌드 시간 : <전> -> <후> (-N%p)
- 캐시 적중 태스크 : <전> -> <후> (-N%p)
증거: 1회차 (링크), 2회차 (링크)
Five rules come out of that edit.
## 개요 is one line. It says what was wrong and what was done, nothing else. Four of the agent's five 개요 bullets did not belong there. A refuted hypothesis stays out of the body entirely until a reviewer asks for it.
No ## section beyond the ones the repo template names. The agent created a top-level ## 실측 결과; the user demoted it to ### 실측 결과 under ## 작업 내역, saying "실측 결과 <- 이런 섹션은 표준이 아님". Anything extra goes in as a sub-header of an existing section.
작업 내역 is a list of what was done, not a list of commits. The user deleted every short sha and every commit subject and cut each item to one line. The reviewer already sees the commit list on the PR screen, so copying it into the body buys nothing. nix/home/configs/.agents/skills/github-master/guides/pr.md had asked for one numbered item per commit opening with the short sha and the commit subject; on the same day the user replaced that with one short line per group of commits that does one thing, and set the bar for a 리뷰 포인트: sub-bullet at "without it a Haiku-level reader could not understand the change".
A number is one A -> B (-N%p) line, not a table. The agent built a four-row table and the user kept the two load-bearing rows as plain lines. A table earns its place only at three or more columns with a different kind of thing per row.
Evidence is a 증거: <링크> line, not commentary. The agent spent three paragraphs attributing each number to a cause; the user replaced them with the two run links. Whatever the reviewer can confirm by opening the link does not get explained in the body.
One notation to carry verbatim: the user writes a reduction as -N%p, using the percentage-point symbol for a ratio change. Write it their way.
2026-10-15 -- an unasked native build froze the user's Mac (heavy-local-build-unasked)
A self-review brief for a work ticket said "if the existing APK cannot run the bundle, build it yourself". The worker took that as permission and started assembleDebug without asking. At the same moment another session's emulator, Metro and jest were running on the same machine; load average climbed to 25, and the Gradle and Kotlin daemons alone held about 180% CPU. The user's words: "이딴 짓거리를 제발 함부로 저지르지 마라 나도 컴퓨터를 써야 할 거 아님;;" -- they need the machine for their own work.
The Mac is the user's working computer, and several Claude sessions share it, so heavy jobs from different sessions stack on top of the user's own work. The (load) clauses of "Size the unit first" in nix/home/configs/claude/CLAUDE.md now make every heavy local job (a native build such as gradlew or xcodebuild, an extra emulator or simulator boot, Metro --reset-cache) an explicit question with its cost in one line. A worker brief says "stop and report if a build is needed" rather than "build if needed". An approved job runs under renice -n 15, and the emulators, Metro instances and Gradle daemons (./gradlew --stop) the session started are stopped when the check is done.
2026-07-30 -- a probe build replaced a shared design doc with four placeholder boxes
To diagnose a rendering problem, an agent published a stripped build of the user's design doc onto the doc's own live artifact URL. The good version was replaced by a page carrying four dashed "다이어그램 N -- 이 버전에서는 제외" placeholders, and the user was the one who noticed ("isn't it regression"). The version history made it recoverable, but the shared link stayed degraded in the meantime. The same session also inverted the user's version numbers ("v3 sharable, latest isn't") and stripped work that was already correct. A throwaway artifact answers the same diagnostic question at no cost to the user's link, so every probe goes to a fresh artifact, the live target only ever receives a complete state, and the user's version numbers are read back before acting on them. Rule: "DO (live target)" in nix/home/configs/claude/CLAUDE.md.
2026-08-18 -- an invented backend API shipped through a patched shared package
Wiring a feature whose backend contract did not exist yet, an agent created PUT/DELETE reservation endpoints and a new notification type out of nothing, and attached them to the shared API-endpoints package through a pnpm patch. A grep of the server repository for the feature's names returned zero hits: path, method, status codes and notification string were all invented, modelled on a neighbouring endpoint's shape and labelled a "temporary patch". The user's first reaction was "대체 왜 환각으로 api를 추가했나", and the next day everything was reverted. Three things failed: a goal word in the PR title ("실배선") outranked the missing contract, a cloned neighbouring shape was treated as a contract, and the "temporary" label was treated as an excuse. A missing contract is a stop condition: leave a labelled dummy and write what is missing in the code comment and the PR body.
2026-09-03 -- a review card was nearly dropped as "no iOS device" while a booted iPad was attached
Checking an iOS-only safe-area defect, an agent wrote "iOS 기기 필요 -- 판정 포기 유력" into the hand-off and reported the card as blocked. The user asked whether their iPad could be driven with agent-device; one agent-device devices --platform ios showed it booted=true, with the UI test runner signed and installed and the app already on it. The agent then proposed a simulator twice more. The user: "진짜 ㅅㅂ 다 해줬는데 안 쓰는 거 개선해야 한다". The existing "name you do not recognize" rule already covered "about to conclude that some capability is unavailable", but its only prescribed check was which, a PATH-level query; nothing told the agent to list what is attached behind a tool it already had. Hence "DO (inventory)": run the listing query and cite its empty output before declaring a device, account, certificate or build absent.
2026-09-22 -- a CLI's JSON output printed a live bearer token into the transcript
An agent ran an unfamiliar nsc cache gradle setup -o json without reading its --help first, and the command printed a live bearer token (nsct_...) straight into the conversation. Reading the help would have shown that the command writes credentials to stdout. The user: "앞으로 실행하기 전에 제발 도구 설명좀 읽자". Hence the help-first rule for an unfamiliar CLI, and "DO (secret)": send output that may carry a credential to a scratchpad file and extract only the needed field with jq.
2026-09-22 -- "send this to the dotfiles session" became a worker editing the rules files
The user told an agent to send an example of its failure to the dotfiles session. No session by that name was listed, so instead of stopping, the agent switched to the dotfiles:evolve and rule-write route and spawned a worker that would write the ~/.dotfiles rules files directly. The instruction was a message, not rule authoring, and that repository deploys to every session. The worker was stopped before any file changed. The user: "ㅅㅂ dotfiles를 직접 작성하는 월권은 대체 왜 한 건데". When a named target is not there, show what was found and end the turn on "which one?".
2026-09-30 -- a label-filtered volume delete removed two volumes nobody listed
To clean up a finished agent worktree stack, an agent handed the user docker volume rm $(docker volume ls -q --filter label=com.docker.compose.project=$P). Compose gives a volume with a fixed name: the project label of whichever stack first created it, so the filter also matched two fixed-name local database and secrets volumes from another project, and they were deleted (local development data only). An earlier listing command had been refused by the permission classifier, and the agent handed over the delete without any listing. A filtered delete is now preceded by the same filter's listing, and the delete names those targets as literals; with no listing, no delete is handed over.
2026-10-01 -- a "flatten" redesign was built for a span real data never contained
A trace-viewer prototype fixture put an invented span under every request root. The user asked to flatten that span into the request row, and the work was done. Afterwards it turned out the real telemetry has no such span at all. The user: "flatten할 게 없었네 애초에 니가 잘못된 걸 만든 게 문제였네". A fixture now holds only the span names and fields the real system's tests or source list, labels every stand-in, and a design question about fixture data first checks that the data exists.
2026-10-06 -- a "x" prompt saving was measured against the agent's own detour
Optimising a per-item LLM extraction prompt, an agent's early variants sent one item per call, re-sending the whole instruction block per item. Moving to larger batches then produced a "
2026-10-19 -- a release-order answer listed bare PR numbers, twice in a row
Asked for the order of a multi-repository release, an agent answered with chains such as "# -> #" and "##\d+, 7+ hex characters and [A-Z]+-\d+ before sending. A Stop hook that forces a rewrite was rejected as the guard (""다시 쓰게" 하면 경험이 매우 별로였음").
2026-11-05 -- a deploy-split answer read the remote narrowly and contradicted its own output
Told "PR 개많아서 나눠서 배포하려는 중" (many PRs, deploying them in batches), an agent working across two repositories from a non-repo cwd ran gh pr list --author @me --state open and a merged list capped at 8. Its answer said a range of PRs had merged while its own output showed three of them open, missed a bot-authored release PR merged a minute earlier, and passed over mergeStateStatus: UNKNOWN on a PR that a later gh pr view showed CONFLICTING. The user: "ㅅㅂ 리모트나 읽어 똥멍청이야". The old (state) rule only said to read "the target's live state", which the agent did, so the gap was scope and resolution, not whether to read. The rule now scopes the read to the question (every author and bot, open and just merged, every repo the work spans), resolves UNKNOWN/pending fields by re-query, and ties each stated PR state to that output. A UserPromptSubmit hook that injected a PR snapshot was built and reverted: the user judged a case-specific hook worth little next to a correct instruction ("case-work적인 훅 말고 지시를 제대로 고쳐").