# Chezmoi Development

> This skill should be used when developing or modifying dotfiles using chezmoi. Covers using .chezmoidata for configuration data and modify_ scripts (or run_onchange_after_ scripts for symlinked directories) for non-destructive file merging. Particularly useful when needing to configure application settings without overwriting user preferences.

- Skill: `majiayu000/chezmoi-development` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds add majiayu000/chezmoi-development`
- Raw SKILL.md: https://api.skillmd.com/api/skills/majiayu000/chezmoi-development/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: majiayu000 (https://skillmd.com/u/majiayu000)
- Updated: 2026-09-09
- Page: https://skillmd.com/skills/majiayu000/chezmoi-development

---


# Chezmoi Development

## Overview

Develop dotfiles using chezmoi's advanced features for non-destructive configuration management. This skill focuses on two key patterns:
1. Using `.chezmoidata/` to store structured configuration data
2. Using `modify_` scripts (or `run_onchange_after_` scripts for symlinked directories) to merge configuration into existing files without overwriting user settings

## When to Use This Skill

Use this skill when:
- Developing dotfiles with chezmoi that need to configure applications
- Need to enforce required settings while preserving user preferences
- Managing JSON/YAML/TOML configuration files in dotfiles
- Application config files already exist and shouldn't be overwritten
- Working with dotfiles in a team where users may have custom settings

## Core Concepts

### .chezmoidata Directory

Store structured configuration as data files (YAML, JSON, or TOML) in `.chezmoidata/` at the root of the chezmoi source directory.

**Purpose:** Separate configuration data from templates, making it reusable across multiple files and easier to maintain.

**Location:** `~/.local/share/chezmoi/.chezmoidata/`

**Access in templates:** Reference data via dot notation (e.g., `{{ .app.config.setting }}`)

### modify_ Scripts vs run_onchange_ Scripts

There are two approaches for non-destructive configuration management in chezmoi:

#### modify_ Scripts (Preferred for regular directories)

Create executable scripts that transform existing files by reading them via stdin and outputting the modified version to stdout.

**Purpose:** Update existing files non-destructively by merging new config with existing content.

**Naming:** `modify_` + target file path + `.tmpl`
- Example: `modify_dot_config/app/settings.json.tmpl` → modifies `~/.config/app/settings.json`

**Execution:** chezmoi runs the script, captures stdout, and writes it to the target file.

**How it works:** Chezmoi provides file contents via **stdin** (use `cat -` to read), script outputs merged result to **stdout**.

**Limitation:** Cannot be used when the target directory is a symlink (chezmoi requires managing the directory).

#### run_onchange_after_ Scripts (For symlinked directories)

Create executable scripts in `.chezmoiscripts/` that read from disk and write back to disk.

**Purpose:** Update files in directories that may be symlinks (common in Coder workspaces).

**Naming:** `run_onchange_after_<name>.sh.tmpl` in `.chezmoiscripts/`
- Example: `.chezmoiscripts/run_onchange_after_update-claude-settings.sh.tmpl`

**Execution:** Script runs after other chezmoi operations, whenever the rendered script content changes.

**How it works:** Script reads from disk (`cat "$FILE"`), merges config, writes back to disk (`echo "$result" > "$FILE"`).

**Use when:** Target directory is or may be a symlink, preventing chezmoi from managing individual files within it.

## Workflow: Implementing Non-Destructive Config Management

Follow this workflow when implementing configuration management for an application:

### Step 1: Define Configuration Data

Create a data file in `.chezmoidata/` with the configuration to enforce.

**Choose file format based on data complexity:**
- YAML for nested structures with comments
- JSON for simple data or when templates need JSON output
- TOML for flat key-value pairs

**Example - `.chezmoidata/myapp.yaml`:**
```yaml
---
# Application configuration to enforce
required_settings:
  feature_flag: true
  api_endpoint: "https://api.example.com"

optional_defaults:
  theme: "dark"
  timeout: 30
```

**Access in templates:**
```bash
{{ .myapp.required_settings.feature_flag }}
{{ .myapp.optional_defaults.theme }}
```

### Step 2: Create modify_ Script

Create a script that merges configuration data into the target file.

**Script requirements:**
1. Read existing file from stdin (NOT from disk!)
2. Load configuration from `.chezmoidata` via template
3. Merge configuration (required settings take precedence)
4. Output merged result to stdout

**File naming for modify_ scripts:**
- For `~/.config/myapp/settings.json` → `dot_config/myapp/modify_settings.json.tmpl`
- The directory structure mirrors the target path
- The `modify_` prefix goes on the **filename** (not the directory)
- The file must be executable (`chmod +x`)

**Example - `dot_config/myapp/modify_settings.json.tmpl`:**
```bash
{{- if .include_defaults -}}
#!/bin/bash
set -e

# Read existing settings from stdin (chezmoi provides current file contents)
# If stdin is empty (file doesn't exist), use empty object
existing=$(cat - || echo '{}')
if [ -z "$existing" ]; then
    existing='{}'
fi

# Load configuration from .chezmoidata
required='{{ .myapp.required_settings | toJson }}'
defaults='{{ .myapp.optional_defaults | toJson }}'

# Merge: existing + defaults + required (right side wins)
echo "$existing" | jq --argjson defaults "$defaults" \
                       --argjson required "$required" \
                       '. * $defaults * $required'
{{- end }}
```

**CRITICAL for modify_ scripts:** Chezmoi provides file contents via **stdin**, not by file path. Always use `cat -` to read from stdin. (Note: `run_onchange_` scripts read from disk instead - see the distinction in Core Concepts above.)

**For YAML files, use `yq` instead of `jq`:**
```bash
# Merge YAML files
existing=$(cat "$HOME/.config/app/config.yaml" || echo '{}')
required='{{ .app.config | toYaml }}'
echo "$existing" | yq eval-all '. as $item ireduce ({}; . * $item)' - <(echo "$required")
```

### Step 3: Make Script Executable (Optional - if needed)

Ensure the modify script is executable in the source directory:

```bash
chmod +x ~/.local/share/chezmoi/dot_config/myapp/modify_settings.json.tmpl
```

**Note:** Chezmoi automatically creates parent directories when writing files, so you typically don't need `run_before_` scripts just to create directories.

**Only use `run_before_` scripts when you need to:**
- Remove old symlinks that would conflict with new files
- Set special directory permissions
- Install dependencies (like `jq` for JSON processing)

### Step 4: Test the Implementation

Preview changes before applying:

```bash
# View what would be written to the file
chezmoi cat ~/.config/myapp/settings.json

# Show diff between current and new state
chezmoi diff

# Apply with dry run
chezmoi apply --dry-run --verbose
```

Test merge logic manually:

```bash
# Extract and test the modify script
chezmoi execute-template < modify_dot_config/myapp/settings.json.tmpl > /tmp/test_modify.sh
chmod +x /tmp/test_modify.sh

# Test with sample input
echo '{"userSetting":"value"}' | /tmp/test_modify.sh
```

## Common Patterns

### Pattern: Merge with jq

Merge JSON objects where required settings override existing ones:

```bash
echo "$existing" | jq --argjson required "$required_settings" \
                       '. * $required'
```

The `*` operator performs recursive merge with right-side precedence.

### Pattern: Conditional Configuration

Apply different config based on environment or profile:

```yaml
# .chezmoidata/app.yaml
{{ if eq .profile "work" -}}
config:
  api_url: "https://work.api.com"
{{ else if eq .profile "personal" -}}
config:
  api_url: "https://personal.api.com"
{{ end -}}
```

### Pattern: Environment Variable References

Include environment variables in configuration:

```yaml
# .chezmoidata/app.yaml
config:
  api_key: "{{ env "APP_API_KEY" }}"
  debug: {{ env "DEBUG" | default "false" }}
```

### Pattern: Multi-File Configuration

Use same data across multiple files:

```
.chezmoidata/
  brand.yaml                          # Logo paths, colors, fonts

modify_dot_config/app1/settings.json.tmpl   # References {{ .brand.logo }}
modify_dot_config/app2/config.toml.tmpl     # References {{ .brand.colors }}
dot_bashrc.tmpl                             # References {{ .brand.theme }}
```

## Symlinks and Execution Order

### Using symlink_ Prefix

Chezmoi creates symlinks declaratively using the `symlink_` prefix in the source state.

**Naming:** `symlink_` + target path + `.tmpl` (template optional)
- Example: `symlink_dot_config/app.tmpl` → creates symlink at `~/.config/app`

**Content:** The file content (with trailing newline stripped) becomes the symlink target.

**Example - `symlink_dot_config/myapp.tmpl`:**
```bash
{{ if eq .profile "work" -}}
/shared/work/.config/myapp
{{ else -}}
/shared/default/.config/myapp
{{ end -}}
```

**Conditional symlinks:**
```bash
{{- if .is_coder -}}
/shared/.config/app
{{- end -}}
```

If the content is empty or whitespace-only after template processing, the symlink is removed.

### Execution Order

Understanding execution order is critical to avoid race conditions:

1. **Read source state** - Parse all files in chezmoi source directory
2. **Read destination state** - Check current state of target files
3. **Compute target state** - Determine what changes are needed
4. **Run `run_before_` scripts** - Execute in alphabetical order
5. **Update entries** - Process all entries in alphabetical order by target name:
   - Regular files (`dot_`, `private_`, etc.)
   - Symlinks (`symlink_`)
   - Modified files (`modify_`)
   - Directories
   - Scripts (`run_`)
6. **Run `run_after_` scripts** - Execute in alphabetical order

**Key insight:** All entry types (files, symlinks, modify scripts) are processed together in step 5, sorted alphabetically by their final target path.

### Avoiding Race Conditions

**❌ WRONG - Creating directory in run_before prevents symlinking:**
```bash
# .chezmoiscripts/run_before_00_setup.sh.tmpl
mkdir -p "$HOME/.config/app"

# Later, this fails because ~/.config/app already exists as a directory
# symlink_dot_config/app.tmpl
/shared/.config/app
```

**✅ CORRECT - Use symlink_ declaratively:**
```bash
# symlink_dot_config/app.tmpl
{{ if .is_coder -}}
/shared/.config/app
{{ end -}}

# modify_dot_config/app/settings.json.tmpl
# This works because symlink is created first (alphabetically)
```

**✅ ALSO CORRECT - Let chezmoi create directories automatically:**
```bash
# No run_before script needed!
# modify_dot_config/app/settings.json.tmpl
# Chezmoi automatically creates ~/.config/app/ when writing the file
```

### When to Use Each Approach

| Approach | Use When | Example |
|----------|----------|---------|
| `symlink_` | Entire directory should point elsewhere | Link `~/.config/app` → `/shared/.config/app` |
| `modify_` | Merge config into existing file | Merge marketplace config into `settings.json` |
| `dot_` regular file | Fully manage file content | Template `~/.bashrc` from scratch |
| `run_before_` | Install dependencies, clean up old state | Install `jq`, remove old symlinks |
| `run_after_` | Post-install tasks, restart services | Run `systemctl --user daemon-reload` |

**IMPORTANT:** Chezmoi automatically creates parent directories when writing files. You do NOT need `run_before_` scripts to create directories for `modify_` scripts or regular files.

### Pattern: Conditional Symlinking in Coder

For Coder workspaces with persistent `/shared/` storage:

```bash
# symlink_dot_config/gh.tmpl - Link to shared GitHub CLI config
{{- if .is_coder -}}
/shared/.config/gh
{{- end -}}
```

If `.is_coder` is false, the symlink won't be created. If it's true, the symlink points to persistent storage.

### Pattern: Symlink vs Modify Decision

**Use symlink when:**
- Entire directory managed externally (e.g., `/shared/`)
- Content is already in a persistent location
- No need to merge with existing content

**Use modify when:**
- Need to merge with existing user settings
- Want to preserve user customizations
- Enforcing required settings while allowing optional ones

**Example scenario - Claude Code config:**
```bash
# ❌ BAD - Symlink loses user settings
symlink_dot_claude.tmpl → /shared/.claude

# ✅ GOOD - Modify merges marketplace config with user settings
modify_dot_claude/settings.json.tmpl → merges settings
```

## Best Practices

### Parent Directories Are Created Automatically

Chezmoi creates parent directories automatically. Do NOT create directories in `run_before_` scripts unless you have a specific reason (like setting permissions).

**❌ Unnecessary:**
```bash
# .chezmoiscripts/run_before_setup.sh.tmpl
mkdir -p "$HOME/.config/app"  # Chezmoi will do this!
```

**✅ Only when needed:**
```bash
# .chezmoiscripts/run_before_setup.sh.tmpl
# Only if you need special permissions
mkdir -p "$HOME/.config/app"
chmod 700 "$HOME/.config/app"
```

### Always Handle Missing Files

Check if target file exists before reading:

```bash
if [ -f "$HOME/.config/app/settings.json" ]; then
    existing=$(cat "$HOME/.config/app/settings.json")
else
    existing='{}'  # Sensible default
fi
```

### Validate JSON/YAML Before Writing

Ensure output is valid before chezmoi writes it:

```bash
# Validate JSON
result=$(echo "$existing" | jq --argjson required "$required" '. * $required')
echo "$result" | jq empty  # Will fail if invalid
echo "$result"
```

### Use Template Guards

Control when scripts execute based on configuration:

```bash
{{ if .include_defaults -}}
# Only execute when include_defaults is true
{{ end -}}

{{ if eq .profile "work" -}}
# Only execute for work profile
{{ end -}}
```

### Separate Data from Logic

**❌ Bad - Hardcode config in template:**
```bash
echo '{"api":"https://api.com","timeout":30}' > ~/.app/config.json
```

**✅ Good - Reference .chezmoidata:**
```yaml
# .chezmoidata/app.yaml
config:
  api: "https://api.com"
  timeout: 30
```

```bash
# modify_dot_app/config.json.tmpl
echo '{{ .app.config | toJson }}'
```

### Document Configuration Structure

Add comments to data files explaining what each setting does:

```yaml
---
# Database configuration for application
database:
  # Maximum number of connections in the pool
  max_connections: 100

  # Connection timeout in seconds
  timeout: 30

  # Enable query logging (set to false in production)
  log_queries: true
```

### Script Ordering Matters

Use clear numeric prefixes to control execution order:

```
.chezmoiscripts/
  run_before_00_install-dependencies.sh.tmpl
  run_before_10_setup-directories.sh.tmpl
  run_before_20_remove-old-symlinks.sh.tmpl
  run_onchange_after_50_configure-apps.sh.tmpl
```

## Troubleshooting

### Race Condition: Directory Created Before Symlink

**Problem:** Want to symlink a directory, but it already exists as a real directory.

**Cause:** A `run_before_` script or another entry creates the directory before the symlink is processed.

**Solution 1 - Remove directory creation:**
```bash
# Delete the run_before script that creates the directory
# Let chezmoi handle it via symlink_ or modify_
```

**Solution 2 - Use symlink_ declaratively:**
```bash
# symlink_dot_config/app.tmpl
/shared/.config/app

# Don't create ~/.config/app anywhere else!
```

**Solution 3 - Remove existing directory:**
```bash
# .chezmoiscripts/run_before_00_cleanup.sh.tmpl
if [ -d "$HOME/.config/app" ] && [ ! -L "$HOME/.config/app" ]; then
    # Backup if needed
    [ -n "$(ls -A "$HOME/.config/app")" ] && \
        mv "$HOME/.config/app" "$HOME/.config/app.backup.$(date +%s)"
    rm -rf "$HOME/.config/app"
fi
```

### modify_ Script Not Running

**Check template guard:**
```bash
# View rendered script to see if template guard blocked it
chezmoi execute-template < modify_dot_app/settings.json.tmpl
```

**Verify script is executable:**
```bash
chmod +x ~/.local/share/chezmoi/modify_dot_app/settings.json.tmpl
```

### Data Not Available in Template

**List all available template data:**
```bash
chezmoi data | jq
```

**Check .chezmoidata file is valid:**
```bash
# For YAML
yq eval .chezmoidata/app.yaml

# For JSON
jq . .chezmoidata/app.json
```

### Merge Produces Incorrect Result

**Test jq merge manually:**
```bash
existing='{"user":"setting"}'
required='{"new":"value"}'

echo "$existing" | jq --argjson required "$required" '. * $required'
```

**Check operator precedence:**
- `*` recursive merge (right side wins)
- `+` concatenate (arrays append, objects merge)

### Script Fails with "command not found"

**Ensure dependencies are installed in run_before script:**
```bash
# .chezmoiscripts/run_before_00_install-jq.sh.tmpl
#!/bin/bash
if ! command -v jq &> /dev/null; then
    if [ "$(uname)" = "Darwin" ]; then
        brew install jq
    else
        sudo apt-get install -y jq
    fi
fi
```

## Declarative Package Installation

Chezmoi can install packages declaratively using a combination of `.chezmoidata/packages.yaml` and `run_onchange_` scripts. This pattern ensures packages are installed when the package list changes.

### Pattern: npm Package Installation

**1. Declare packages in `.chezmoidata/packages.yaml`:**
```yaml
---
# Package declarations for declarative installation
# Top-level keys become template variables (e.g., .npm, .apt, .brew)
npm:
  global:
    - "@anthropic-ai/claude-code"
    - "typescript"
```

**2. Create installation script `.chezmoiscripts/run_onchange_after_install-npm-packages.sh.tmpl`:**
```bash
#!/bin/bash
# Install npm packages declaratively based on .chezmoidata/packages.yaml
# This script runs when the package list changes

{{ if .include_defaults -}}
set -e

# Function to run npm commands via mise
run_npm() {
    if command -v mise >/dev/null 2>&1; then
        mise exec -- npm "$@"
    else
        npm "$@"
    fi
}

# Check if npm is available (via mise or directly)
if command -v mise >/dev/null 2>&1; then
    if ! mise exec -- npm --version >/dev/null 2>&1; then
        echo "⚠️  Node.js/npm not available via mise. Skipping npm package installation."
        exit 0
    fi
elif ! command -v npm >/dev/null 2>&1; then
    echo "⚠️  npm not found. Skipping npm package installation."
    exit 0
fi

# Install global npm packages
{{ if .npm.global -}}
{{ range .npm.global -}}
if ! run_npm list -g "{{ . }}" >/dev/null 2>&1; then
    echo "📦 Installing {{ . }}..."
    run_npm install -g "{{ . }}"
    echo "✓ Installed {{ . }}"
else
    echo "✓ {{ . }} already installed"
fi
{{ end -}}
{{ end -}}

{{ end -}}
```

**How it works:**
- The script template references `.npm.global` from `.chezmoidata/packages.yaml`
- `run_onchange_` prefix means the script executes when its rendered content changes
- When you add/remove packages in `packages.yaml`, the rendered script changes, triggering re-execution
- Each package is checked before installation to avoid redundant installs
- Uses `mise exec` to ensure npm is available from mise-managed Node.js

**Adaptable to other package managers:**
- apt: Create `.chezmoidata/packages.yaml` with `apt: [...]` and use `{{ range .apt }}`
- brew: Create with `brew: [...]` and use `{{ range .brew }}`
- pip: Create with `pip: [...]` and use `{{ range .pip }}`

## Reference Documentation

For a complete, working example of this pattern, see:
- `references/chezmoidata-modify-example.md` - Real-world Claude Code marketplace configuration

## Quick Reference

| Task | Command |
|------|---------|
| Preview file output | `chezmoi cat ~/.config/app/settings.json` |
| Show changes | `chezmoi diff` |
| Test template | `chezmoi execute-template < file.tmpl` |
| View template data | `chezmoi data` |
| Apply changes | `chezmoi apply` |
| Dry run | `chezmoi apply --dry-run --verbose` |

| Pattern | Purpose |
|---------|---------|
| `.chezmoidata/app.yaml` | Store structured configuration data |
| `modify_dot_app/config.json.tmpl` | Merge config into existing file |
| `run_before_00_setup.sh.tmpl` | Ensure prerequisites before applying |
| `{{ .app.setting \| toJson }}` | Convert data to JSON in template |
| `jq '. * $required'` | Merge JSON with right-side precedence |

