| name | chezmoi-development |
| description | 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. |
Chezmoi Development
Overview
Develop dotfiles using chezmoi's advanced features for non-destructive configuration management. This skill focuses on two key patterns:
- Using
.chezmoidata/ to store structured configuration data
- 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:
---
required_settings:
feature_flag: true
api_endpoint: "https://api.example.com"
optional_defaults:
theme: "dark"
timeout: 30
Access in templates:
{{ .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:
- Read existing file from stdin (NOT from disk!)
- Load configuration from
.chezmoidata via template
- Merge configuration (required settings take precedence)
- 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:
{{- if .include_defaults -}}
set -e
existing=$(cat - || echo '{}')
if [ -z "$existing" ]; then
existing='{}'
fi
required='{{ .myapp.required_settings | toJson }}'
defaults='{{ .myapp.optional_defaults | toJson }}'
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:
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:
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:
chezmoi cat ~/.config/myapp/settings.json
chezmoi diff
chezmoi apply --dry-run --verbose
Test merge logic manually:
chezmoi execute-template < modify_dot_config/myapp/settings.json.tmpl > /tmp/test_modify.sh
chmod +x /tmp/test_modify.sh
echo '{"userSetting":"value"}' | /tmp/test_modify.sh
Common Patterns
Pattern: Merge with jq
Merge JSON objects where required settings override existing ones:
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:
{{ 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:
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:
{{ if eq .profile "work" -}}
/shared/work/.config/myapp
{{ else -}}
/shared/default/.config/myapp
{{ end -}}
Conditional symlinks:
{{- 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:
- Read source state - Parse all files in chezmoi source directory
- Read destination state - Check current state of target files
- Compute target state - Determine what changes are needed
- Run
run_before_ scripts - Execute in alphabetical order
- Update entries - Process all entries in alphabetical order by target name:
- Regular files (
dot_, private_, etc.)
- Symlinks (
symlink_)
- Modified files (
modify_)
- Directories
- Scripts (
run_)
- 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:
mkdir -p "$HOME/.config/app"
/shared/.config/app
✅ CORRECT - Use symlink_ declaratively:
{{ if .is_coder -}}
/shared/.config/app
{{ end -}}
✅ ALSO CORRECT - Let chezmoi create directories automatically:
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:
{{- 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:
symlink_dot_claude.tmpl → /shared/.claude
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:
mkdir -p "$HOME/.config/app"
✅ Only when needed:
mkdir -p "$HOME/.config/app"
chmod 700 "$HOME/.config/app"
Always Handle Missing Files
Check if target file exists before reading:
if [ -f "$HOME/.config/app/settings.json" ]; then
existing=$(cat "$HOME/.config/app/settings.json")
else
existing='{}'
fi
Validate JSON/YAML Before Writing
Ensure output is valid before chezmoi writes it:
result=$(echo "$existing" | jq --argjson required "$required" '. * $required')
echo "$result" | jq empty
echo "$result"
Use Template Guards
Control when scripts execute based on configuration:
{{ if .include_defaults -}}
{{ end -}}
{{ if eq .profile "work" -}}
{{ end -}}
Separate Data from Logic
❌ Bad - Hardcode config in template:
echo '{"api":"https://api.com","timeout":30}' > ~/.app/config.json
✅ Good - Reference .chezmoidata:
config:
api: "https://api.com"
timeout: 30
echo '{{ .app.config | toJson }}'
Document Configuration Structure
Add comments to data files explaining what each setting does:
---
database:
max_connections: 100
timeout: 30
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:
Solution 2 - Use symlink_ declaratively:
/shared/.config/app
Solution 3 - Remove existing directory:
if [ -d "$HOME/.config/app" ] && [ ! -L "$HOME/.config/app" ]; then
[ -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:
chezmoi execute-template < modify_dot_app/settings.json.tmpl
Verify script is executable:
chmod +x ~/.local/share/chezmoi/modify_dot_app/settings.json.tmpl
Data Not Available in Template
List all available template data:
chezmoi data | jq
Check .chezmoidata file is valid:
yq eval .chezmoidata/app.yaml
jq . .chezmoidata/app.json
Merge Produces Incorrect Result
Test jq merge manually:
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:
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:
---
npm:
global:
- "@anthropic-ai/claude-code"
- "typescript"
2. Create installation script .chezmoiscripts/run_onchange_after_install-npm-packages.sh.tmpl:
#!/bin/bash
{{ if .include_defaults -}}
set -e
run_npm() {
if command -v mise >/dev/null 2>&1; then
mise exec -- npm "$@"
else
npm "$@"
fi
}
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
{{ 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 |