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.

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- dotfiles-management

Installs to ~/.agents/skills/dotfiles-management/ for every project on this machine.

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.

~/.agents/skills/dotfiles-management/SKILL.md

--- name: dotfiles-management description: Manages dotfiles and new-machine setup with chezmoi (templates, per-OS differences, password-manager secrets, run_onchange scripts) or GNU Stow and… license: MIT ---

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

NeedChoose
Several machines or OSes, secrets, per-host differenceschezmoi (default)
One OS, plain files, minimal tooling, symlinks are fineGNU Stow
Already using a bare git repo and happy with itKeep it; add a bootstrap script
Full declarative system config, reproducible packagesNix 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:

  1. 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.
  2. Encrypted files: chezmoi add --encrypt ~/.ssh/config with age configured; the age identity itself stays out of the repo.
  3. Environment loading at shell start from the OS keychain (security find-generic-password -s name -w on 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 ~/.zshrc already exists) stop Stow. Back the file up and move it into the package by hand. --adopt does this automatically but overwrites the repo’s version with the machine’s file; use it only on a clean git tree and review git diff afterwards.
  • Stow has no templating or secrets support; per-machine differences need separate packages or [ -f ~/.zshrc.local ] && . ~/.zshrc.local includes.

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 ~/.zshrc directly instead of the source file means the next apply reverts it. Use chezmoi edit --apply ~/.zshrc, or chezmoi re-add to pull local edits back.
  • Templates fail on machines missing a data key; use promptStringOnce or {{ .key | default "x" }} guards.
  • private_ matters for ~/.ssh and ~/.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 with time zsh -i -c exit.
  • Line endings and paths on Windows: chezmoi supports Windows, but templates need .chezmoi.os "windows" branches; do not assume ~/.config everywhere.

Verification

  • chezmoi diff is empty right after apply (idempotent), and chezmoi doctor reports no errors.
  • Secret scanner exits 0 on the source directory.
  • Fresh-machine test: run bootstrap in a container or VM (for Linux, a debian container 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.yamlgenerated from this page

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.py100 lines, 4.0 KB
#!/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.

esc