Skip to content

Repository files navigation

chezmoi-split

A chezmoi plugin for managing configuration files that are co-managed by both chezmoi and an application.

Problem

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

Solution

chezmoi-split solves this by:

  1. Acting as a script interpreter for chezmoi modify files
  2. Merging chezmoi-managed config with app-owned paths from the current file
  3. Preserving app's runtime changes while still managing the base configuration

Installation

go install github.com/thirteen37/chezmoi-split/cmd/chezmoi-split@latest

Make sure $GOPATH/bin (usually ~/go/bin) is in your PATH for the plugin to work.

Usage

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

Script format

#!/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"
    }
  }
}

How it works

  1. Chezmoi sees the .tmpl suffix and renders all template syntax first
    • {{ onepasswordRead "..." }} becomes the actual secret
    • {{ .chezmoi.homeDir }} becomes /Users/you
  2. Chezmoi executes the modify script via shebang (chezmoi-split)
  3. chezmoi-split parses directives (lines starting with #) until #--- separator
  4. chezmoi-split reads managed config from template section, current file from stdin
  5. chezmoi-split merges them, preserving ignore paths from current, outputs result

Directives

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

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)

Merge behavior

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

preserve-unknown

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

Example

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.

TOML example

#!/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"]).

INI example

#!/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"].

Plaintext example

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.

Features

  • 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

License

MIT

About

A chezmoi plugin for managing co-managed configuration files

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages