Claude-skill-registry 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.
git clone https://github.com/majiayu000/claude-skill-registry
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/chezmoi-development" ~/.claude/skills/majiayu000-claude-skill-registry-chezmoi-development && rm -rf "$T"
skills/data/chezmoi-development/SKILL.mdChezmoi Development
Overview
Develop dotfiles using chezmoi's advanced features for non-destructive configuration management. This skill focuses on two key patterns:
- Using
to store structured configuration data.chezmoidata/ - Using
scripts (ormodify_
scripts for symlinked directories) to merge configuration into existing files without overwriting user settingsrun_onchange_after_
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:
→ modifiesmodify_dot_config/app/settings.json.tmpl~/.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
via template.chezmoidata - Merge configuration (required settings take precedence)
- Output merged result to stdout
File naming for modify_ scripts:
- For
→~/.config/myapp/settings.jsondot_config/myapp/modify_settings.json.tmpl - The directory structure mirrors the target path
- The
prefix goes on the filename (not the directory)modify_ - 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
instead of yq
: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
scripts when you need to:run_before_
- Remove old symlinks that would conflict with new files
- Set special directory permissions
- Install dependencies (like
for JSON processing)jq
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:
→ creates symlink atsymlink_dot_config/app.tmpl~/.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
scripts - Execute in alphabetical orderrun_before_ - Update entries - Process all entries in alphabetical order by target name:
- Regular files (
,dot_
, etc.)private_ - Symlinks (
)symlink_ - Modified files (
)modify_ - Directories
- Scripts (
)run_
- Regular files (
- Run
scripts - Execute in alphabetical orderrun_after_
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 |
|---|---|---|
| Entire directory should point elsewhere | Link → |
| Merge config into existing file | Merge marketplace config into |
regular file | Fully manage file content | Template from scratch |
| Install dependencies, clean up old state | Install , remove old symlinks |
| Post-install tasks, restart services | Run |
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
from.npm.global.chezmoidata/packages.yaml
prefix means the script executes when its rendered content changesrun_onchange_- When you add/remove packages in
, the rendered script changes, triggering re-executionpackages.yaml - Each package is checked before installation to avoid redundant installs
- Uses
to ensure npm is available from mise-managed Node.jsmise exec
Adaptable to other package managers:
- apt: Create
with.chezmoidata/packages.yaml
and useapt: [...]{{ range .apt }} - brew: Create with
and usebrew: [...]{{ range .brew }} - pip: Create with
and usepip: [...]{{ range .pip }}
Reference Documentation
For a complete, working example of this pattern, see:
- Real-world Claude Code marketplace configurationreferences/chezmoidata-modify-example.md
Quick Reference
| Task | Command |
|---|---|
| Preview file output | |
| Show changes | |
| Test template | |
| View template data | |
| Apply changes | |
| Dry run | |
| Pattern | Purpose |
|---|---|
| Store structured configuration data |
| Merge config into existing file |
| Ensure prerequisites before applying |
| Convert data to JSON in template |
| Merge JSON with right-side precedence |