Skip to content

Repository files navigation

Package version

confctl

A build-system-like tool for managing system configurations declaratively. Write Python functions that describe what your system should look like, and confctl resolves dependencies, installs packages, renders templates, and runs commands to make it happen.

$ confctl tools/kitty

Install

# Run directly (no install)
$ uvx confctl tools/kitty
# Or install permanently
$ uv tool install confctl
# or
$ pipx install confctl

Quick start

Create a directory for your configurations and add a .confbuild.py file:

my-configs/
├── .confbuild.py
└── zsh/
├── .confbuild.py
└── .zshrc.j2

The root .confbuild.py sets up global context and registers resolvers:

# .confbuild.py (root)importosCONFCTL_RESOLVERS= ["confctl.contrib.homebrew"]
user= {
"config": os.path.expanduser("~/.config"),
"bin": os.path.expanduser("~/.local/bin"),
}

Module-level variables (like user above) become context available to all configs. CONFCTL_RESOLVERS registers additional resolvers (homebrew, pipx, pyenv, etc.).

A config for zsh might look like:

# zsh/.confbuild.pydefmain(conf):
conf["brew::zsh-syntax-highlighting", "brew::zsh-autosuggestions"]
conf(editor="nvim")
conf.render(".zshrc.j2", "~/.zshrc")

And the template:

# ~/.zshrc (managed by confctl)
export EDITOR={{ editor }}
source $(brew --prefix)/share/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh
source $(brew --prefix)/share/zsh-autosuggestions/zsh-autosuggestions.zsh

Apply it:

$ confctl zsh

How it works

confctl follows a build-system model:

  1. Specs identify what to build: tools/kitty, brew::neovim, uvx::ruff, asdf::nodejs@18
  2. Resolvers know how to handle each spec type
  3. Dependencies are resolved recursively before the current target builds
  4. A worker process executes the build graph while the main process renders a live TUI showing progress

Writing configurations

The .confbuild.py file

Each configuration lives in a .confbuild.py file. confctl loads it as a Python module, finds the target function, and calls it with a conf object (a ConfDep instance).

defmain(conf):
# This function is the default target.# "conf" provides actions for installing, configuring, and running commands.pass

Target resolution: When you run confctl zsh, confctl looks for zsh/.confbuild.py and calls the function named after the directory (zsh), falling back to main. You can have multiple targets in one file:

defkitty(conf):
"""Default target (matches directory name)."""conf[":theme"] # depend on the "theme" target belowconf.render("kitty.conf.j2", "~/.config/kitty/kitty.conf")
deftheme(conf):
"""Secondary target, invoked as kitty:theme or as a dependency."""conf.sh("git clone --depth 1 https://github.com/dexpota/kitty-themes.git ~/.config/kitty/themes")

Module-level variables

Variables defined at the top level of a .confbuild.py file are loaded into context automatically:

# git/.confbuild.pyGIT_NAME="Your Name"GIT_EMAIL="you@example.com"defgit(conf):
conf.render(".gitconfig.j2", "~/.gitconfig")

Inside .gitconfig.j2, {{ GIT_NAME }} and {{ GIT_EMAIL }} are available.

The special variable CONFCTL_RESOLVERS (only in the root config) registers additional resolver modules.

Actions

The conf object exposes these actions:

Setting context variables — conf(...)

conf(editor="nvim", shell="zsh")
conf(paths={"bin": "~/.local/bin", "config": "~/.config"})

Sets variables accessible in templates and child configs. Nested dicts become scoped contexts. String values support lazy Jinja2 evaluation — they're rendered when first accessed, not when set.

Declaring dependencies — conf[specs]

# Single dependencyconf["brew::ripgrep"]
# Multiple dependenciesconf["brew::git", "brew::gh", "brew::lazygit"]
# Internal target (same file)conf[":theme"]
# Subdirectory configconf["./kitty"]
# Relative pathconf["../fonts"]

Dependencies are resolved and built before execution continues. The return value is the resolver result (e.g., a Path for path specs, status info for brew specs).

Rendering templates — conf.render(src, dst)

conf.render("kitty.conf.j2", "~/.config/kitty/kitty.conf")

Renders a Jinja2 template from src (relative to the current config directory) to dst. All context variables are available in the template. Parent directories are created automatically.

Rendering strings — conf.render_str(template)

result=conf.render_str("Hello {{ name }}")

Renders a Jinja2 template string and returns the result.

Running shell commands — conf.sh(cmd)

conf.sh("killall kitty || true")
conf.sh("git clone {{ repo_url }} {{ dest_dir }}")

Executes a shell command. The command string is rendered as a Jinja2 template first. Returns a result object that supports:

result=conf.sh("brew list --versions")
if"neovim"inresult: # check if string appears in output
...
ifresult: # truthy if exit code == 0
...

Running with sudo — conf.sudo(cmd)

conf.sudo("cp {{ src }} /etc/target")

Same as conf.sh() but with interactive sudo password prompt.

Showing messages — conf.msg(text)

conf.msg("Configuration complete!")

Displays a message in the TUI output.

Accessing dependency context

In templates, use the dep() function to access variables from other configs:

# In .zshrc.j2
{{ dep('../brew').zsh_profile }}
{{ dep('../nvm').zsh_rc }}
{{ dep('./starship').zsh_rc }}

This is how shell configs compose — each tool defines a zsh_rc snippet, and the shell template pulls them all together.

Special template variables

These are always available in templates:

VariableDescription
current_config_dirAbsolute path to the directory containing the current .confbuild.py
envAccess to environment variables ({{ env.HOME }}, {{ env.USER }})
dep(path)Function to access another config's context

Plus any variables set via conf(...) or module-level definitions, and everything from parent contexts.

Specs and resolvers

A spec tells confctl what to resolve. The format is resolver::spec_value.

Built-in resolvers

ResolverSpec formatWhat it does
confpath/to/config[:target]Loads and builds a .confbuild.py configuration
pathpath::~/some/fileReturns a Path object, creates parent directories
dirdir::~/some/dirReturns a Path object, creates the directory

The conf resolver is the default — you don't need the conf:: prefix:

conf["tools/kitty"] # same as conf["conf::tools/kitty"]conf["tools/kitty:theme"] # call the "theme" target

Contrib resolvers

Register these in your root .confbuild.py:

CONFCTL_RESOLVERS= [
"confctl.contrib.homebrew",
"confctl.contrib.pipx",
"confctl.contrib.pyenv",
"confctl.contrib.uvx",
"confctl.contrib.asdf",
"confctl.contrib.mise",
]
ResolverSpec formatWhat it does
brewbrew::package, brew::package@versionInstalls a Homebrew formula/cask (skips if already installed)
pipxpipx::package, pipx::package@versionInstalls a Python tool via pipx
pyenvpyenv::python@versionInstalls a Python version via pyenv
uvxuvx::package, uvx::package@versionInstalls a Python tool via uv tool install
asdfasdf::plugin@version, asdf::pluginInstalls a tool version via asdf (defaults to latest)
misemise::tool@version, mise::toolInstalls a tool version via mise (defaults to latest)

All contrib resolvers auto-bootstrap their underlying tool if it's not found — installing via Homebrew first, then falling back to official install scripts.

Project organization patterns

Pattern: tool installation + configuration

The most common pattern — install a tool and render its config:

defkitty(conf):
conf["brew::kitty"]
conf(font="FiraCode Nerd Font", font_size="11.0")
conf.render("kitty.conf.j2", "~/.config/kitty/kitty.conf")

Pattern: shell integration

Tools that need shell integration export a zsh_rc variable:

# starship/.confbuild.pydefmain(conf):
conf["brew::starship"]
conf(zsh_rc='eval "$(starship init zsh)"')
conf.render("starship.toml", "~/.config/starship.toml")

Then the shell config pulls it in:

{# zsh/.zshrc.j2 #}
{{ dep('../starship').zsh_rc }}

Pattern: conditional setup

Use regular Python for conditional logic:

defmain(conf):
conf["brew::neovim"]
themes_dir=Path("~/.config/kitty/themes").expanduser()
ifnotthemes_dir.exists():
conf.sh("git clone --depth 1 https://github.com/dexpota/kitty-themes.git {{ themes_dir }}")

Pattern: orchestrator config

A top-level config that aggregates sub-configs:

# macos/.confbuild.pydefmacos(conf):
conf[
":common",
"./brew",
"./git",
"./kitty",
"./tmux",
"./zsh",
]

Pattern: file iteration

Process multiple files from a directory:

frompathlibimportPathdefcommands(conf):
conf(scripts_dir=conf["dir::~/.local/opt/scripts"])
forfinPath(__file__).parent.rglob("*.sh"):
dest=conf.scripts_dir/f.nameconf.render(f, dest)
dest.chmod(0o700)

CLI usage

# Build specific configs
confctl tools/kitty
confctl tools/kitty tools/tmux
# Specify configs root directory
confctl -C ~/my-configs tools/kitty
# Use environment variable for configs rootexport CONFCTL_CONFIGS_ROOT=~/my-configs
confctl tools/kitty

MCP server

confctl ships with an optional MCP (Model Context Protocol) server so LLM clients (Claude Code, Claude Desktop, etc.) can discover and run your configs directly.

Install with the mcp extra:

uv tool install 'confctl[mcp]'# or: pipx install 'confctl[mcp]'

Run over stdio:

confctl mcp -C ~/my-configs
# or
CONFCTL_CONFIGS_ROOT=~/my-configs confctl mcp

Exposed tools:

ToolArgumentsDescription
list_specsconfigs_root?Scans the configs root for .confbuild.py files and returns discovered specs.
run_specsspecs: list[str], configs_root?Runs the given specs in a worker process and returns a human-readable summary of the op tree. Empty list runs the root config's main target.
run_specs_jsonsame as run_specsSame as run_specs but returns raw JSON (ops, logs, errors).

Register with Claude Desktop (~/Library/Application Support/Claude/claude_desktop_config.json):

{
"mcpServers": {
"confctl": {
"command": "confctl",
"args": ["mcp", "-C", "/Users/you/my-configs"]
}
}
}

Or with Claude Code:

claude mcp add confctl -- confctl mcp -C ~/my-configs

Security notes

confctl is a configuration tool, not a sandbox — running it against a config means running the Python inside it. Keep the following in mind:

  • Only run against configs you trust. Every .confbuild.py is imported and executed; top-level code runs at import time, main(conf) runs after.
  • confctl mcp exposes this to an LLM client. The server only imports .confbuild.py files under the root you pass via -C. A compromised or prompt-injected client cannot escape that root, but anything inside it is executable code — don't point the server at a directory you haven't audited.
  • CONFCTL_SUDO_PASS is inherited by every shell command confctl spawns. Prefer running sudo -v interactively first and letting the cached credential carry the session; use the env var only for short-lived, non-interactive runs.
  • Auto-bootstrap uses the upstream curl | sh installers for brew, asdf, mise, pyenv, and uv. There is no checksum pinning — this is the vendor-recommended path and you opt in per resolver.

Development

Requires Python >= 3.12 and uv.

uv venv &&source .venv/bin/activate && uv sync
# Lint & format
ruff check src/
ruff format src/
# Type check
mypy src/confctl/
# Tests (Docker)
docker build -f Dockerfile.test -t confctl-test .&& docker run --rm confctl-test

About

Simple configuration manager

Topics

Resources

Stars

2 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages