Personal macOS (Apple Silicon) configuration, symlinked into place with dotbot.
Warning
These are one person's dotfiles. Fork the repo, read what it does, and strip out what you do not want before running anything. What bites:
./install— force-links over~/.bin,~/.gitconfig,~/.config/nvim,~/.config/kittyand~/.config/vscode, replacing real files already sitting at those paths, and copies a file into~/Dropbox.git/.gitconfig— carries my name, email, GitHub user and signing key path, and setscommit.gpgsign, so a machine without that key cannot commit at all.macos/defaults.sh— rewrites 24 system preferences, then restarts Finder and Dock.macos/pinned-casks.sh— installs four apps at deliberately old versions.
# 1. Xcode command line tools (provides git)
xcode-select --install
# 2. Homebrew
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# 3. Clone — the path matters, $DOTFILES is hardcoded to ~/.dotfiles
# (`dotfiles-private` is a private submodule; without access to it the clone
# still works and `./install` just skips the rules it carries)
git clone --recurse-submodules https://github.com/xuncheng/dotfiles.git ~/.dotfiles
cd ~/.dotfiles
# 4. Create all the symlinks
./install
# 5. Restart the shell — a login shell, so .zprofile puts brew on the path
exec zsh -l./install is idempotent — re-run it any time links are added or changed.
It only creates symlinks. Four more steps are run once per machine:
| Path | Contents |
|---|---|
install.conf.yaml |
dotbot manifest: the source-of-truth list of every symlink |
install |
dotbot entry point (syncs the submodule, then applies the yaml) |
dotbot/ |
git submodule |
dotfiles-private/ |
private git submodule: work-only config, kept out of this repo |
config/zsh/ |
zsh config, linked to ~/.config/zsh ($ZDOTDIR) |
config/nvim/ |
Neovim config (LazyVim based) |
config/tmux/ |
tmux config |
config/kitty/ |
kitty terminal config + themes |
config/bat/ |
bat config ($BAT_CONFIG_PATH) |
config/rg/ |
ripgrep config ($RIPGREP_CONFIG_PATH) |
config/vscode/ |
VS Code custom CSS |
git/ |
gitconfig, global gitignore, commit template, log helpers |
claude/ |
Claude Code settings.json + rules/ (into ~/.claude/) |
bin/ |
personal scripts, linked to ~/.bin (on $PATH) |
npm/ |
npmrc (linked to ~/.npmrc) |
dropbox/ |
rules.dropboxignore, copied into ~/Dropbox by install |
macos/ |
Brewfile (linked to ~/.Brewfile) and the macOS setup scripts |
vim/ |
legacy Vim config, read as ~/.vim/vimrc (Vim 8+) |
Startup is split across three files by role:
.zshenv— read by every zsh, login or not. Environment variables and$PATH. SetsZDOTDIR=~/.config/zsh, which is why the rest of the config lives under~/.configinstead of$HOME..zprofile— login shells only. Runsbrew shellenvand caches$BREW_PREFIX(must come after brew is on the path, hence not in.zshenv)..zshrc— interactive shells. Sources everyscripts/*.zsh, then~/.config/zsh-local/, then initialises rbenv, then loads zsh-syntax-highlighting last.
Add new shell config as a file in config/zsh/scripts/ — it is picked up
automatically, no source line needed. Syntax highlighting must stay last in
.zshrc, since it has to wrap widgets defined by everything before it.
Anything this repo does not carry goes in ~/.config/zsh-local/, where every
*.zsh is sourced. It is the one extension point, and it does not care what put
a file there — hand-written on a single machine, or symlinked in by another repo,
both work and neither needs an edit here.
It sits outside $ZDOTDIR on purpose: that path is a symlink into this repo, so
a file dropped under it at runtime would land in this repo's work tree. Its slot
in .zshrc is also deliberate — after completion.zsh has run compinit, so
those files can call compdef, and before rbenv and syntax highlighting.
Note
~/.zshrc in $HOME is not used. Because $ZDOTDIR is set, zsh reads
~/.config/zsh/.zshrc instead.
A LazyVim install; this repo only carries the
overrides in lua/config/ and lua/plugins/. Enabled language extras are
tracked in lazyvim.json, plugin versions in lazy-lock.json.
Prefix is C-s. C-h/j/k/l move between panes and transparently cross into
Neovim splits, paired with the nvim-tmux-navigation plugin on the editor
side. prefix + r reloads the config.
git/.gitconfig is linked to ~/.gitconfig. git l, git r, git hp and
friends are pretty-log aliases implemented in git/.githelpers.
user.email is the GitHub users.noreply.github.com address rather than a
real mailbox: commit metadata is public and permanent, and an address tied to
an employer stops linking those commits to the account once it is removed from
it.
commit.gpgsign is on unconditionally, so a machine cannot commit until it
has a signing key — a missing key fails the commit loudly, rather than
quietly writing unsigned history.
The key is per machine and never copied between them, so a lost laptop costs one revocation on GitHub instead of a new identity. It is also separate from any authentication key, so the two can be revoked independently.
# 1. A passphrase, asked for twice in step 2 and once in step 3. Copy it from
# the output — the clipboard is not safe here, pasting the commands clobbers it
openssl rand -base64 24
# 2. Generate — the comment is what names the key in GitHub's list
ssh-keygen -t ed25519 -C "git signing $(hostname -s)" \
-f ~/.ssh/id_ed25519_signing
# 3. Store the passphrase in the login keychain and load the key into ssh-agent
ssh-add --apple-use-keychain ~/.ssh/id_ed25519_signing
# 4. Copy the public key
pbcopy < ~/.ssh/id_ed25519_signing.pubAdd the copied key at github.com/settings/keys as a Signing Key — Authentication Key is a separate list and produces no Verified badge. Then commit and confirm GitHub shows Verified.
Note
The filename is fixed at id_ed25519_signing, since user.signingkey in
git/.gitconfig points at that path. Step 3 is needed once per key: it is
what puts the passphrase in the keychain. ssh-agent starts empty after a
reboot, and ssh-keygen -Y sign consults neither ssh_config nor the
keychain, so config/zsh/scripts/ssh-agent.zsh reloads the keychain's keys
into the agent on the first shell of each boot.
Tip
git log --show-signature wants a gpg.ssh.allowedSignersFile that is not
set up here; a gpgsig header in git cat-file commit HEAD proves a commit
was signed without it.
Two things are managed: ~/.claude/settings.json and the rule files under
~/.claude/rules/. The rest of ~/.claude is local state — sessions/,
projects/, history.jsonl, shell-snapshots/ — and is deliberately left
alone, which is why install.conf.yaml links individual paths rather than the
directory.
This is the user scope, the lowest precedence Claude Code reads. Anything in
a project's .claude/settings.json (team-shared) or .claude/settings.local.json
(personal, gitignored) overrides it. There is no user-level settings.local.json
— per-machine or per-project deviations go in the project's local file.
Warning
Keep secrets out of settings.json — it is committed. Anything sensitive
(API keys, an env block with tokens) goes in settings.local.json.
Claude Code reads every .md under ~/.claude/rules/ as global instructions,
so the directory is assembled from two sources:
| Link | Source | Repo |
|---|---|---|
~/.claude/rules/common |
claude/rules/ |
this repo, public |
~/.claude/rules/private |
dotfiles-private/claude/rules/ |
private submodule |
Anything that should not be public — client and project names, environment IDs,
internal conventions — goes in the private submodule; the public claude/rules/
holds only general engineering habits. Real credentials belong in neither: the
private repo is still hosted on GitHub.
The private link is guarded by an if: in install.conf.yaml, so a machine
without access to the private repo installs everything else and skips it,
instead of failing. install.conf.yaml inits submodules before the link
step, so a fresh clone is fully set up in a single ./install run.
brew bundle --global # install everything, read from the ~/.Brewfile linkmacos/Brewfile is edited by hand: grouped by purpose, and deliberately
missing the four casks pinned-casks.sh holds at an old version.
brew bundle dump --file=- # print the installed state, to diff by eyeNever dump --force, which rewrites the file from whatever happens to be
installed — the grouping goes flat and those four casks come back unpinned.
Nor brew bundle cleanup, which uninstalls rather than lists, and counts the
same four as unlisted.
./macos/pinned-casks.shFour apps have to stay on an old release — CleanShot X, Keyboard Maestro,
RunJS and the WeChat DevTools. A Brewfile has no way to say which version of a
cask it wants, so those four are left out of macos/Brewfile and installed
here instead, each from the homebrew-cask definition at the commit that
shipped its version.
All four ship their own updater, so after installing, turn auto-update off
inside each app; otherwise it walks straight back to the latest release and the
pin is meaningless. brew upgrade leaves them alone (they are auto_updates
casks), but brew upgrade --greedy would not.
./macos/defaults.shEvery line is a deviation from the macOS factory defaults; anything the factory already gets right is deliberately absent, so the file stays a diff rather than a dump. It is grouped by area and commented line by line — read it for what it sets and why.
Deliberately not wired into install.conf.yaml: ./install is a symlink
sync meant to be re-run any time, while this mutates system state and restarts
Finder and Dock. It asks for sudo up front, and some of what it writes only
takes effect on the next login.
MIT, see LICENSE. The dotbot/ submodule and the files under vim/colors/
and vim/autoload/ carry their own upstream licenses.