A chezmoi plugin for managing configuration files that are co-managed by both chezmoi and an application.
Some applications (like Zed, VS Code, etc.) modify their configuration files at runtime. When using chezmoi to manage these files, you face a dilemma:
- If chezmoi fully controls the file, the app's runtime changes are lost
- If the app fully controls the file, you can't manage it with chezmoi
chezmoi-split solves this by:
- Acting as a script interpreter for chezmoi modify files
- Merging chezmoi-managed config with app-owned paths from the current file
- Preserving app's runtime changes while still managing the base configuration
go install github.com/thirteen37/chezmoi-split/cmd/chezmoi-split@latestMake sure $GOPATH/bin (usually ~/go/bin) is in your PATH for the plugin to work.
Create a modify script in your chezmoi source directory. For example, to manage ~/.config/zed/settings.json:
mkdir -p ~/.local/share/chezmoi/dot_config/zed
touch ~/.local/share/chezmoi/dot_config/zed/modify_settings.json.tmpl
chmod +x ~/.local/share/chezmoi/dot_config/zed/modify_settings.json.tmpl#!/usr/bin/env chezmoi-split
# version 1
# format json
# strip-comments true
# ignore ["agent", "default_model"]
# ignore ["features", "edit_prediction_provider"]
# ignore ["context_servers", "*", "enabled"]
#---
// My comments for the final JSON file
{
"base_keymap": "VSCode",
"vim_mode": true,
"context_servers": {
"mcp-server-github": {
"settings": {
"github_personal_access_token": "{{ onepasswordRead "op://Vault/Item/credential" }}"
}
}
},
"agent": {
"default_model": {
"provider": "zed.dev",
"model": "claude-sonnet-4"
}
}
}
- Chezmoi sees the
.tmplsuffix and renders all template syntax first{{ onepasswordRead "..." }}becomes the actual secret{{ .chezmoi.homeDir }}becomes/Users/you
- Chezmoi executes the modify script via shebang (
chezmoi-split) - chezmoi-split parses directives (lines starting with
#) until#---separator - chezmoi-split reads managed config from template section, current file from stdin
- chezmoi-split merges them, preserving
ignorepaths from current, outputs result
| Directive | Description | Example |
|---|---|---|
version |
Format version (required, must be first) | # version 1 |
format |
Config format: json, toml, ini, plaintext, or auto |
# format json |
strip-comments |
Strip // comments from JSON before parsing |
# strip-comments true |
ignore |
Path to preserve from current file (not used for plaintext) | # ignore ["agent", "model"] |
The #--- line marks the boundary between directives and template content. Lines before the JSON (like // comments) are preserved in the output.
Ignore paths use JSON array syntax to specify nested keys:
| Path | Matches |
|---|---|
["agent"] |
The entire agent object |
["agent", "default_model"] |
Only agent.default_model |
["servers", "*", "enabled"] |
enabled field in ALL objects under servers |
Wildcard (*): Matches any key at that level. Useful for preserving a field across all items in an object.
Format-specific notes:
- JSON/TOML: Full nested path support (any depth)
- INI: Paths limited to
["section", "key"](2 levels max)
- Ignored path exists in current: Value from current file is used
- Ignored path missing in current: Value from managed config is used (not deleted)
- Path not ignored: Value from managed config always wins
Managed config (in script):
{
"base_keymap": "VSCode",
"agent": {
"default_model": {"provider": "default", "model": "default-model"},
"profiles": {"ask": {"tools": ["read_file"]}}
}
}Current file (with app's runtime changes):
{
"base_keymap": "VSCode",
"agent": {
"default_model": {"provider": "user-choice", "model": "claude-sonnet"},
"profiles": {"ask": {"tools": ["read_file"]}}
}
}Ignore paths: ["agent", "default_model"]
Result after merge:
{
"base_keymap": "VSCode",
"agent": {
"default_model": {"provider": "user-choice", "model": "claude-sonnet"},
"profiles": {"ask": {"tools": ["read_file"]}}
}
}The agent.default_model is preserved from current because it's ignored, while the rest comes from the managed config.
#!/usr/bin/env chezmoi-split
# version 1
# format toml
# ignore ["user", "preferences"]
#---
[server]
host = "localhost"
port = 8080
[user]
name = "default"
preferences = { theme = "dark" }
TOML supports full nested paths like JSON (e.g., ["server", "tls", "enabled"]).
#!/usr/bin/env chezmoi-split
# version 1
# format ini
# ignore ["database", "password"]
#---
[database]
host = localhost
port = 3306
password = default
[server]
address = 0.0.0.0
INI paths are limited to section and key: ["section", "key"].
For line-based config files (shell scripts, vim configs, etc.), use block markers instead of ignore paths:
#!/usr/bin/env chezmoi-split
# version 1
# format plaintext
#---
# chezmoi:managed
export PATH="$HOME/bin:$PATH"
export EDITOR="vim"
# chezmoi:ignored
# User's custom exports go here
# chezmoi:end
Block markers:
chezmoi:managed- Content controlled by chezmoi (from template)chezmoi:ignored- Content preserved from current file (app/user-managed)chezmoi:end- Marks end of blocks
Markers are detected via substring matching and are preserved exactly as written in your template. You can format them however you want: # chezmoi:managed, // chezmoi:managed, " chezmoi:managed, etc.
Ignored blocks are matched by index: the 1st ignored block in the template gets content from the 1st ignored block in the current file.
- Single file: Directives and template in one modify script
- Chezmoi templating: Full support for secrets, variables, conditionals
- Multiple formats: JSON, TOML, INI, and plaintext support (with auto-detection)
- JSON/JSONC support: Can strip
//comments from JSON files - Plaintext support: Block-based merging for line-based configs (shell, vim, etc.)
- Header preservation: Comments before the config are passed through to output
- Wildcard paths: Use
*to match any key at a path level (structured formats) - Versioned format: Built-in versioning for future migrations
MIT