cheche

module
v0.0.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 11, 2026 License: MIT

README

cheche

cheche is a go.mod driven dotfiles manager, built on top of chezmoi.

Why this exists

Most dotfile managers give you one repo to hold everything. That's fine until you want to split things up - share a k8s-dev config with your team, keep a personal machine's config separate from a work laptop's, or pull in someone else's zsh setup without copying and pasting it. I wanted:

  • modularity, so pieces of config can live in different repos/people/machines and get mixed together
  • a real way to declare packages, not just files
  • to write the whole thing in Go, instead of templating YAML or shell

The answer I landed on: describe your dotfiles as a Go program. Because it's just a Go module, you get go get, go.mod/go.sum pinning, and private module support for free. Splitting out a reusable chunk of config is just "make it its own package/repo" - no special tooling required. And because it's Go, you get real types, real functions, and real composition instead of fighting a templating language.

The name cheche is a nod to the engine underneath all of this: chezmoi.

How it fits together

cheche does as little as possible - chezmoi does all the actual work of applying files and running scripts on your machine. cheche's job is just:

  1. run your Go program (your "dotfiles module"), which builds a Profile describing your files, packages, and scripts, and prints it as JSON
  2. take that JSON and turn it into a chezmoi source directory (regenerated from scratch every time - it's not meant to be hand-edited)
  3. hand off to chezmoi (chezmoi apply, chezmoi diff, ...) to actually do the work

Your dotfiles program runs as a subprocess (go run . inside your module's directory) rather than being imported directly, which is what lets it carry its own go.mod - including private or third-party modules - without those becoming a dependency of cheche itself.

Installation

curl -fsSL https://raw.githubusercontent.com/alexbathome/cheche/main/install.sh | bash

This drops a cheche binary into ~/.local/bin. That's genuinely all you need - cheche bootstraps go and chezmoi itself the first time it needs them, downloading pinned versions into its own managed directory (~/.local/share/cheche) if they aren't already on your PATH. Nothing to install up front, nothing global to manage.

If you want to run those tools directly (say, from within a dotfiles script before anything else is set up), cheche go [args...] and cheche chezmoi [args...] are thin passthroughs - they resolve/bootstrap the binary and then run it with your args, forwarding stdio and exit codes straight through.

Quick start

Write a Go program that builds a Profile and calls Render:

package main

import (
	"embed"

	c "github.com/alexbathome/cheche/pkg/cheche"
)

//go:embed dot_zshrc dot_config
var dotConfig embed.FS

var profile = c.Profile{
	DotConfig: dotConfig,
	Packages: []c.Package{
		c.Apt("ripgrep"),
		c.Brew("ripgrep"),
	},
}

func main() {
	c.Render(profile)
}

Give this its own go.mod (it needs to be a self-contained module - see the Composing dotfiles across modules section for why). The embedded files are named in chezmoi's own source-name form already - dot_zshrc becomes ~/.zshrc, dot_config/starship.toml becomes ~/.config/starship.toml, and so on. cheche doesn't rewrite these names, it just hands them to chezmoi verbatim, so anything chezmoi's naming convention supports (see chezmoi's docs) works here too.

Then, from that directory:

cheche apply .

This runs your program, regenerates chezmoi's source directory from what it outputs, and runs chezmoi apply. Run it again after editing your program and it'll pick up the changes - nothing is cached or incremental on cheche's side, the source dir is fully rebuilt each time.

The CLI

  • cheche apply [target] - run the dotfiles program in target (defaults to .) and apply it with chezmoi.
  • cheche diff [target] - same, but runs chezmoi diff instead, so you can see what would change before committing to it.
  • cheche go [args...] / cheche chezmoi [args...] - run the underlying tool directly, bootstrapping it first if it isn't already resolvable.

Flags:

  • --source-dir - which chezmoi source directory to (re)generate. Defaults to chezmoi's own default (~/.local/share/chezmoi). You generally won't need to touch this.
  • --go-private - a GOPRIVATE pattern, forwarded as an env var to the go run subprocess that builds your dotfiles program. Use this if your Profile pulls in modules from a private host (e.g. --go-private github.com/yourorg/*) so go run can fetch them without hitting a public proxy first.

One thing worth internalizing: the chezmoi source directory cheche manages is fully generated. Every apply/diff wipes it and rewrites it from your program's output. Don't go hand-editing files in there - they won't survive the next run. If you want something different, change your Go program.

Building a Profile

A Profile has three fields, and you don't have to use all of them:

type Profile struct {
	DotConfig fs.FS
	Packages  []Package
	Scripts   []Script
}
DotConfig - the files themselves

DotConfig is any fs.FS - normally an embed.FS populated with a //go:embed directive, but it doesn't have to be. Every non-directory file gets walked and copied into the chezmoi source dir as-is. File names need to already be in chezmoi's source-naming form (dot_zshrc, private_dot_ssh/config, etc.) since that's what tells chezmoi where things go and how to treat them.

One gotcha: files coming out of an embed.FS always report as read-only (mode 0444) - Go's compile-time embed has no concept of an executable bit. This is a non-issue for cheche's own generated scripts and for run_once_/run_onchange_ scripts you build via Script (chezmoi runs those itself regardless of the source file's mode), but if you're embedding an executable that isn't a chezmoi script, keep it in mind.

Packages - things to install
c.Apt("ripgrep")
c.Brew("ripgrep")

Package is deliberately dumb - it's just {Manager, Name} data (PackageEntry), with no logic of its own. All the actual installation work happens later: cheche groups every package by manager and writes one run_onchange_install-<manager>-packages.sh script per group into the chezmoi source dir. Because it's run_onchange_, chezmoi only re-runs installation for a manager when that manager's package list actually changed - add a package, and only that manager's script re-runs, not everything.

Right now apt and brew are the two managers with a generated install script (apt-get install / brew install). If you need another package manager, either wire it up as a chezmoi Script yourself, or just track that install logic through a Script instead of Package - Package isn't an arbitrary plugin point today.

Scripts - the escape hatch

Not everything is "install a package" or "drop a file" - migrations, symlink setup, installers with no apt/brew package. That's what Script is for:

type Script struct {
	Name    string
	When    ScriptWhen  // Always, Once, or OnChange
	Phase   ScriptPhase // PhaseNone, Before, or After
	Order   int         // 0-99, numeric sort prefix within When/Phase
	Content []byte
}

This maps directly onto chezmoi's own run_/run_once_/run_onchange_ + before_/after_ + numeric-prefix file naming - Script is just a typed way to build a correctly-named, always-executable (0755) file, there's no separate wire format involved.

For the common "check if it's installed, and if not, curl a script into sh" pattern (starship, rustup, workmux-style tools with no package of their own), there's a helper:

c.CurlInstall(
	"starship", 10,
	"command -v starship",
	"curl -fsSL https://starship.rs/install.sh | sh -s -- --yes",
)

This runs once per machine (keyed on content hash), before regular files are applied. For anything more involved - arch remapping, checksum verification, archive extraction (zellij, kubectl, Go itself all need this) - just build a Script directly instead; CurlInstall only covers the simple case.

Script.Content is a plain []byte your Go code builds however it wants (a literal, fmt.Sprintf, whatever) - there's no chezmoi templating support here. If you need OS-specific behavior, branch on runtime.GOOS in Go when you build Profile.Scripts, rather than reaching for a .tmpl file.

Composing dotfiles across modules

This is the part that's actually different from a normal dotfiles repo.

Why your dotfiles program needs its own go.mod

The CLI runs your program via go run . with its working directory set to your dotfiles folder, so it picks up that directory's go.mod - not cheche's. This means your dotfiles module can depend on other Go modules (your own, a teammate's, a public one) the exact same way any Go program depends on anything else: add it to go.mod, go get it, and it's pinned in go.sum. Bump the version, and you've bumped that piece of your config, with a real commit hash behind it.

If a dependency lives on a private host, pass cheche apply --go-private github.com/yourorg/* so the subprocess's go run can authenticate against it.

While developing something locally (say, example_local depending on example_external in this repo), point at the local checkout with a replace directive in go.mod:

replace github.com/alexbathome/exampleexternaldotfiles => ../example_external
Merging file trees - ConfigLatten

Say you have a personal config module and a work-specific one, and you want your local repo's files layered on top of both. embed.FS is a read-only compile-time value, so you can't merge several of them at runtime the normal way - ConfigLatten solves that by composing against the fs.FS/fs.ReadDirFS interfaces instead:

dotConfig := c.ConfigLatten(
	personalConfig.DotConfig,
	k8sdevConfig.DotConfig,
	localConfig, // wins on any path collision
)

Layers are resolved last-wins: if two layers both have dot_zshrc, whichever was passed last is what actually gets written.

Tweaking one file without forking it - Mutate / AppendFile

Sometimes you don't want to override a whole file from another module, just adjust it a little. Mutate rewrites a single path's content while leaving the rest of the fs.FS (and its directory listing) untouched:

dotConfig := c.Mutate(upstream.DotConfig, "dot_zshrc", func(content []byte) []byte {
	return append(content, []byte("\nalias foo=bar\n")...)
})

AppendFile is exactly that pattern, built in:

dotConfig := c.AppendFile(upstream.DotConfig, "dot_zshrc", []byte("alias foo=bar\n"))

Both return a new fs.FS you can drop straight into ConfigLatten alongside anything else.

Filtering packages you pulled in

Packages from another module are just []c.Package - normal Go slices, so normal Go slice operations apply. A common one: dropping packages your machine can't actually install (e.g. brew packages on a container with no Homebrew):

func withoutManager(pkgs []c.Package, mgr string) []c.Package {
	out := make([]c.Package, 0, len(pkgs))
	for _, p := range pkgs {
		if p.Entry().Manager == mgr {
			continue
		}
		out = append(out, p)
	}
	return out
}

packages := append(
	[]c.Package{c.Apt("ripgrep")},
	withoutManager(upstream.Packages, "brew")...,
)

Full example

package dotfiles

import (
	"embed"

	c "github.com/alexbathome/cheche/pkg/cheche"

	// external cheche modules, pinned via go.mod/go.sum.
	// pass --go-private to `cheche` if these live on a private host.
	"github.com/alexbathome/dotfiles/pkg/k8sdev"
	"github.com/alexbathome/dotfiles/pkg/personal"
)

//go:embed dot_* dot_config/*
var localConfig embed.FS

var dotConfig = c.ConfigLatten( // flatten configs together, last wins
	personal.DotConfig,
	k8sdev.DotConfig,
	localConfig,
)

var packages = append(
	[]c.Package{
		c.Apt("dnsutils"),
		c.Apt("sl"),
		c.Apt("ripgrep"),
	},
	append(personal.Packages, k8sdev.Packages...)..., // add external packages
)

var scripts = []c.Script{
	c.CurlInstall(
		"starship", 10,
		"command -v starship",
		"curl -fsSL https://starship.rs/install.sh | sh -s -- --yes",
	),
}

var profile = c.Profile{
	DotConfig: dotConfig,
	Packages:  packages,
	Scripts:   scripts,
}

func main() {
	c.Render(profile) // logs to stderr, os.Exit(1) on error
}

Then, from the same directory as this program's go.mod:

cheche apply .   # or `cheche diff .` first, to see what would change

See example_local/ in this repo for a working version of this pattern you can actually run (it's self-contained, no external module hosting needed) - and example_external/ for what a small, importable dotfiles module looks like from the other side.

Developing on cheche itself

  • go build ./... or make build to build the CLI
  • go tool golangci-lint run or make lint to lint
  • go test ./... to run tests
  • docker build -t cheche-dev . && docker run -it --rm cheche-dev gives you a sandboxed non-root user with its own $HOME, so you can run cheche apply example_local for real without touching your actual home directory
  • docker build -f Dockerfile.bootstrap -t cheche-bootstrap-test . && docker run -it --rm cheche-bootstrap-test is the same idea but strips out go/chezmoi from PATH entirely, so it actually exercises cheche's download-and-bootstrap path instead of just finding tools already there

Releases are cut by pushing a vX.Y.Z tag - goreleaser and GitHub Actions handle the cross-compiled builds from there.

Directories

Path Synopsis
cmd
cheche command
internal
bootstrap
Package bootstrap resolves the external go and chezmoi binaries cheche depends on: a version already on PATH, else a previously self-installed copy under Dir(), else a freshly downloaded pinned version installed into Dir().
Package bootstrap resolves the external go and chezmoi binaries cheche depends on: a version already on PATH, else a previously self-installed copy under Dir(), else a freshly downloaded pinned version installed into Dir().
cli
Package cli implements the cheche command-line interface.
Package cli implements the cheche command-line interface.
goexec
Package goexec runs a target dotfiles program via `go run` and decodes the cheche.Manifest it writes to stdout.
Package goexec runs a target dotfiles program via `go run` and decodes the cheche.Manifest it writes to stdout.
sourcedir
Package sourcedir regenerates a chezmoi source directory from a cheche.Manifest.
Package sourcedir regenerates a chezmoi source directory from a cheche.Manifest.
pkg

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL