| 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 (orrun_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:
---
# Application configuration to enforce
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
.chezmoidatavia 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 -}}
#!/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:
# 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:
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
jqfor JSON processing)
Step 4: Test the Implementation
Preview changes before applying:
# 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:
# 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:
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:
# .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:
# .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:
{{ 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_)
- Regular files (
- 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:
# .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:
# 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:
# 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:
# 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:
# ❌ 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:
# .chezmoiscripts/run_before_setup.sh.tmpl
mkdir -p "$HOME/.config/app" # Chezmoi will do this!
✅ Only when needed:
# .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:
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:
# 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:
{{ 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:
echo '{"api":"https://api.com","timeout":30}' > ~/.app/config.json
✅ Good - Reference .chezmoidata:
# .chezmoidata/app.yaml
config:
api: "https://api.com"
timeout: 30
# modify_dot_app/config.json.tmpl
echo '{{ .app.config | toJson }}'
Document Configuration Structure
Add comments to data files explaining what each setting does:
---
# 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:
# Delete the run_before script that creates the directory
# Let chezmoi handle it via symlink_ or modify_
Solution 2 - Use symlink_ declaratively:
# symlink_dot_config/app.tmpl
/shared/.config/app
# Don't create ~/.config/app anywhere else!
Solution 3 - Remove existing directory:
# .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:
# View rendered script to see if template guard blocked it
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:
# For YAML
yq eval .chezmoidata/app.yaml
# For JSON
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:
# .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:
---
# 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:
#!/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.globalfrom.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 execto ensure npm is available from mise-managed Node.js
Adaptable to other package managers:
- apt: Create
.chezmoidata/packages.yamlwithapt: [...]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 |