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.

install
source · Clone the upstream repo
git clone https://github.com/majiayu000/claude-skill-registry
Claude Code · Install into ~/.claude/skills/
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"
manifest: skills/data/chezmoi-development/SKILL.md
source content

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
:

---
# 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:

  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
:

{{- 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
    jq
    for 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:

  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:

# .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

ApproachUse WhenExample
symlink_
Entire directory should point elsewhereLink
~/.config/app
/shared/.config/app
modify_
Merge config into existing fileMerge marketplace config into
settings.json
dot_
regular file
Fully manage file contentTemplate
~/.bashrc
from scratch
run_before_
Install dependencies, clean up old stateInstall
jq
, remove old symlinks
run_after_
Post-install tasks, restart servicesRun
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.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

TaskCommand
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
PatternPurpose
.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