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"] |
preserve-unknown |
Keep keys found only in the current file (see below) | # preserve-unknown true |
Lines starting with ## are comments — use them to record why a key is app-owned:
## Zed rewrites this whenever you switch models. Let it win.
# ignore ["agent", "default_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)
Every key has an owner, and the owner's value wins:
| Where the key is | Owner | Result |
|---|---|---|
| Declared in the script | you | Managed value wins |
Named by an ignore path, and present in the current file |
the app | Current value wins |
Named by an ignore path, but missing from the current file |
— | Managed value is used (not deleted) |
| Present only in the current file | nobody | Dropped — unless preserve-unknown is set |
By default the managed config is the whole truth: a key survives only if the
script declares it or an ignore names it. Everything else in the current file
is dropped.
That makes the ignore list load-bearing in a way that's easy to miss. Apps add
settings over time — a theme, an editor mode, a new panel — and each one you
haven't predicted is silently destroyed on the next chezmoi apply. You only
notice once the preference is gone.
# preserve-unknown true inverts that default. An unrecognized key is kept
instead of deleted, so the ignore list stops being the only thing standing
between you and losing settings you never chose to manage:
# version 1
# format json
## The app owns these outright: its value wins over ours.
# ignore ["effortLevel"]
# ignore ["hooks"]
## Anything else it invents later is kept, not destroyed.
# preserve-unknown true
#---
{ "env": { "TEAMS": "1" } }
It does not weaken the script's authority. A key the script declares still wins,
and an ignore still hands its key to the app — preserve-unknown only decides
what happens to keys nobody claimed.
The trade-off: removing a key from the script no longer removes it from the
file. A stale key lingers until you delete it from the target explicitly. That
failure is visible in chezmoi diff and easily undone, whereas silently
discarding a user's settings is neither — which is why the option exists.
Nested keys are handled per-key, not per-object: if the script manages
agent.default_profile and the app adds agent.dock, the script still wins on
default_profile while dock is preserved. Arrays are values, not trees — the
script replaces them wholesale rather than merging element by element.
The directive is off by default, so existing scripts keep their current behavior
until you opt in. It has no effect on plaintext, which merges by block marker
rather than by key (you'll get a warning).
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