Dotfiles Management skill for Codex
New laptop to ready shell in one command. Codex organises dotfiles in a chezmoi or Stow source repo, turns per-machine differences into templates instead of copies, and replaces stored tokens with password-manager lookups. It shows the diff before anything in your home directory changes, makes package and macOS settings scripts safe to rerun, and runs a bundled scanner that blocks pushes containing secrets.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- dotfiles-management Installs to ~/.agents/skills/dotfiles-management/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install dotfiles-management The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project dotfiles-management Installs to .agents/skills/dotfiles-management/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/dotfiles-management.zipunzip -o dotfiles-management.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) dotfiles-management PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $dotfiles-management, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Manages dotfiles and new-machine setup with chezmoi (templates, per-OS differences, password-manager secrets, run_onchange scripts) or GNU Stow and bare git repos, plus idempotent bootstrap scripts, Brewfiles and macOS defaults - always diffing before applying and scanning for leaked secrets before pushing. Use when the user wants to version their shell and editor config, set up a new laptop from a repo, share config between macOS and Linux, migrate from Stow or a bare repo to chezmoi, or remove a token committed to dotfiles.
Use it when
- Shell, git and editor config should be versioned and synced across machines
- A new machine needs to be set up from a repo with one command
- Config differs between macOS and Linux or work and personal machines
- You suspect secrets are stored in a dotfiles repository
Use something else when
- You want fully declarative, reproducible system packages: use nix-flakes
- You need a per-project development environment: use devcontainers
How to check the result
Run chezmoi diff immediately after apply and expect no output, run the secret scanner on the source directory and expect exit 0, and test the bootstrap in a fresh container or VM.
The mistake it prevents
Editing files in the home directory instead of the source state, so the next apply silently reverts the change.
Done well means: a fresh machine reaches a working setup with one command, applying twice changes nothing, machine differences live in templates rather than forked copies, no secret is stored in the repo, and nothing in the home directory is overwritten without a diff the user has seen.
Pick the approach
| Need | Choose |
|---|---|
| Several machines or OSes, secrets, per-host differences | chezmoi (default) |
| One OS, plain files, minimal tooling, symlinks are fine | GNU Stow |
| Already using a bare git repo and happy with it | Keep it; add a bootstrap script |
| Full declarative system config, reproducible packages | Nix and home-manager: $nix-flakes |
| Project-specific dev environment | $devcontainers, not dotfiles |
Codex and the home directory
Dotfiles target $HOME, which is outside the workspace. Open the source repo
(~/.local/share/chezmoi or ~/dotfiles) as the workspace and edit there. Commands that
write to $HOME (chezmoi apply, stow, bootstrap scripts, defaults write) need
sandbox approval and the user’s explicit go-ahead after they have seen the diff. Package
installs (brew bundle, apt) need network.
Workflow
- [ ] 1. Inventory: which files matter (shell, git, editor, ssh config, terminal, tools)
- [ ] 2. Find machine differences (OS, work vs personal, hostname) and secrets
- [ ] 3. Choose the tool (table above); add files to the source repo
- [ ] 4. Template the differences; move secrets to a password manager or encryption
- [ ] 5. Scan the repo for secrets (bundled script) before the first commit
- [ ] 6. Show the diff; apply only after the user confirms
- [ ] 7. Write bootstrap for a new machine; test it in a VM or container
chezmoi essentials
chezmoi init # creates ~/.local/share/chezmoi (a git repo)
chezmoi add ~/.zshrc ~/.gitconfig # copies into source state as dot_zshrc, dot_gitconfig
chezmoi add --template ~/.gitconfig # store as dot_gitconfig.tmpl
chezmoi diff # what apply would change in $HOME
chezmoi apply --dry-run --verbose # same, explicit
chezmoi apply # after the user approves the diff
chezmoi init --apply <github-user> # new machine: clone <github-user>/dotfiles and apply
Source-state names encode attributes: dot_ (leading dot), private_ (0600/0700),
executable_, symlink_, encrypted_, .tmpl suffix (template), and script prefixes
run_once_, run_onchange_, optionally with before_ or after_.
Template machine differences instead of keeping copies:
# dot_gitconfig.tmpl
[user]
name = {{ .name }}
email = {{ .email }}
{{- if eq .chezmoi.os "darwin" }}
[credential]
helper = osxkeychain
{{- end }}
Ask for per-machine values once, on chezmoi init, with .chezmoi.toml.tmpl in the source
root:
{{- $email := promptStringOnce . "email" "Git email" -}}
{{- $work := promptBoolOnce . "work" "Work machine" -}}
[data]
name = "Your Name"
email = {{ $email | quote }}
work = {{ $work }}
Keep repo-only files out of $HOME with .chezmoiignore (it is a template too):
README.md
AGENTS.md
scripts/
{{- if ne .chezmoi.os "darwin" }}
Library/
.config/karabiner/
{{- end }}
Without the first lines, chezmoi creates ~/README.md and ~/AGENTS.md on apply.
Secrets
Never commit tokens, private keys or passwords, even in a private repo. Options, best first:
- Password manager lookup at apply time:
{{ onepasswordRead "op://Personal/GitHub/token" }}, or the Bitwarden, pass, keyring and other template functions chezmoi supports. The repo stores only the reference. - Encrypted files:
chezmoi add --encrypt ~/.ssh/configwithageconfigured; the age identity itself stays out of the repo. - Environment loading at shell start from the OS keychain
(
security find-generic-password -s name -won macOS).
Run the bundled scanner on the source directory before every push:
python3 scripts/scan_dotfiles_secrets.py ~/.local/share/chezmoi
python3 scripts/scan_dotfiles_secrets.py ~/dotfiles --allow 'example|changeme'
It masks values in its output, skips .git, ignores template and command-substitution
lookups, and exits 1 when it finds anything. If a real secret was already pushed: rotate it
first (the history is compromised), then rewrite history with $git-history-surgery;
force-pushing the rewrite needs the user’s confirmation.
Packages and OS settings via run_onchange
# run_onchange_before_install-packages.sh.tmpl
#!/bin/sh
set -eu
{{ if eq .chezmoi.os "darwin" -}}
# Brewfile hash: {{ include "Brewfile" | sha256sum }}
brew bundle --file="{{ .chezmoi.sourceDir }}/Brewfile"
{{ else if eq .chezmoi.os "linux" -}}
sudo apt-get update && sudo apt-get install -y zsh git ripgrep fzf
{{ end -}}
The embedded hash makes chezmoi rerun the script only when the Brewfile changes. Add
Brewfile to .chezmoiignore so it is not copied to $HOME. Generate it from a machine
with brew bundle dump --file=Brewfile --force, then prune what the user does not want.
macOS defaults belong in a run_once_ or run_onchange_ script, one setting per line with
a comment, followed by restarting the affected app (killall Finder). Each line must be
safe to rerun.
GNU Stow alternative
# layout: ~/dotfiles/zsh/.zshrc, ~/dotfiles/git/.gitconfig (one folder per package)
cd ~/dotfiles
stow -n -v -t ~ zsh git # dry run: shows links it would create and conflicts
stow -t ~ zsh git # create symlinks after approval
stow -D -t ~ zsh # remove that package's links
- Conflicts (a real
~/.zshrcalready exists) stop Stow. Back the file up and move it into the package by hand.--adoptdoes this automatically but overwrites the repo’s version with the machine’s file; use it only on a clean git tree and reviewgit diffafterwards. - Stow has no templating or secrets support; per-machine differences need separate packages
or
[ -f ~/.zshrc.local ] && . ~/.zshrc.localincludes.
Bootstrap a new machine
#!/bin/sh
# bootstrap.sh - safe to run more than once
set -eu
if [ "$(uname)" = Darwin ] && ! command -v brew >/dev/null 2>&1; then
echo "Install Homebrew first: https://brew.sh" >&2; exit 1
fi
command -v chezmoi >/dev/null 2>&1 || brew install chezmoi
chezmoi init "${DOTFILES_REPO:?set DOTFILES_REPO}"
chezmoi diff | ${PAGER:-less}
printf 'Apply these changes to %s? [y/N] ' "$HOME"; read -r ok
[ "$ok" = y ] && chezmoi apply
Avoid curl ... | sh installers inside bootstrap without showing the user the URL first;
pin and verify where possible.
Gotchas
- Editing
~/.zshrcdirectly instead of the source file means the nextapplyreverts it. Usechezmoi edit --apply ~/.zshrc, orchezmoi re-addto pull local edits back. - Templates fail on machines missing a data key; use
promptStringOnceor{{ .key | default "x" }}guards. private_matters for~/.sshand~/.gnupg; SSH refuses keys and configs with loose permissions.- Shell startup time: dotfiles that call slow commands (
brew --prefix,nvm) on every shell add seconds. Cache results or lazy-load; measure withtime zsh -i -c exit. - Line endings and paths on Windows: chezmoi supports Windows, but templates need
.chezmoi.os "windows"branches; do not assume~/.configeverywhere.
Verification
chezmoi diffis empty right afterapply(idempotent), andchezmoi doctorreports no errors.- Secret scanner exits 0 on the source directory.
- Fresh-machine test: run bootstrap in a container or VM (for Linux, a
debiancontainer with the repo mounted), then open a new shell and check the prompt, aliases and git identity.
Handover
**Tool:** chezmoi, source ~/.local/share/chezmoi -> github.com/<user>/dotfiles (private)
**Managed:** zsh, git, nvim, ssh config (private_), ghostty, Brewfile
**Per-machine:** email and work flag prompted once; macOS-only Library/ ignored on Linux
**Secrets:** 1Password references only; scanner clean
**New machine:** `sh bootstrap.sh` (asks before applying) What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Dotfiles Management"
short_description: "New laptop to ready shell in one command"
default_prompt: "$dotfiles-management Put my zsh, git and nvim config into chezmoi so it works on my Mac and Linux box" scripts/scan_dotfiles_secrets.py
#!/usr/bin/env python3
"""Scan a dotfiles repo or source directory for secrets before it is committed or pushed.
Standard library only. Prints one line per finding with the value masked, and exits 1 if
anything was found, 0 if clean, 2 on bad arguments.
Usage:
python3 scan_dotfiles_secrets.py ~/.local/share/chezmoi
python3 scan_dotfiles_secrets.py ~/dotfiles --allow 'example|dummy|changeme'
Covers common token formats (GitHub, OpenAI, Anthropic-style sk- keys, AWS, Slack, Stripe,
Google API keys, npm), private key blocks, credential-bearing URLs, and suspicious
assignments such as `export API_TOKEN=...` in shell rc files. Template expressions such
as {{ onepasswordRead ... }} or $(security find-generic-password ...) are not reported,
because they fetch secrets at apply time instead of storing them.
"""
from __future__ import annotations
import argparse
import os
import re
import sys
SKIP_DIRS = {".git", "node_modules", ".venv", "__pycache__"}
MAX_BYTES = 1_000_000
PATTERNS: list[tuple[str, re.Pattern[str]]] = [
("private key block", re.compile(r"-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY")),
("GitHub token", re.compile(r"\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{60,})\b")),
("OpenAI-style key", re.compile(r"\bsk-(?:proj-|svcacct-|ant-)?[A-Za-z0-9_-]{20,}\b")),
("AWS access key id", re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b")),
("Slack token", re.compile(r"\bxox[abprs]-[A-Za-z0-9-]{10,}\b")),
("Stripe secret key", re.compile(r"\b(?:sk|rk)_live_[A-Za-z0-9]{20,}\b")),
("Google API key", re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b")),
("npm token", re.compile(r"\bnpm_[A-Za-z0-9]{36}\b")),
("credentials in URL", re.compile(r"\b[a-z][a-z0-9+.-]*://[^/\s:@]+:[^/\s:@]{3,}@[^\s]+", re.I)),
("secret assignment", re.compile(
r"""(?ix)\b(?:export\s+)?[A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|PASSWD|API_KEY|APIKEY|ACCESS_KEY)[A-Z0-9_]*
\s*[:=]\s*["']?(?P<val>[^\s"'#]{8,})""")),
]
DYNAMIC = re.compile(r"\{\{|\$\(|\$\{|`|op://|keychain|security find-|pass show|bw get|getenv|os\.environ")
def mask(value: str) -> str:
return value[:4] + "..." + value[-2:] if len(value) > 8 else "***"
def scan_file(path: str, allow: re.Pattern[str] | None) -> list[str]:
try:
if os.path.getsize(path) > MAX_BYTES:
return []
with open(path, "rb") as fh:
data = fh.read()
except OSError:
return []
if b"\0" in data[:4096]:
return [] # binary
text = data.decode("utf-8", errors="replace")
hits = []
for lineno, line in enumerate(text.splitlines(), start=1):
for label, pat in PATTERNS:
m = pat.search(line)
if not m:
continue
value = m.groupdict().get("val") or m.group(0)
if label == "secret assignment" and DYNAMIC.search(line):
continue
if allow and allow.search(line):
continue
hits.append(f"{path}:{lineno}: {label}: {mask(value)}")
break
return hits
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("root")
ap.add_argument("--allow", help="regex; matching lines are ignored (placeholders)")
args = ap.parse_args()
root = os.path.expanduser(args.root)
if not os.path.isdir(root):
print(f"not a directory: {root}", file=sys.stderr)
return 2
allow = re.compile(args.allow, re.I) if args.allow else None
findings: list[str] = []
scanned = 0
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
for name in filenames:
scanned += 1
findings += scan_file(os.path.join(dirpath, name), allow)
for f in findings:
print(f)
print(f"scanned {scanned} files, {len(findings)} finding(s)", file=sys.stderr)
return 1 if findings else 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.