Declarative configuration management for personal workstations. Define your packages, dotfiles, git repos, and macOS defaults in YAML manifests and apply them with a single command.
Note: etch-cli is a personal fork of comtrya (archived April 2026, MIT license). The upstream project is maintained by @rawkode and contributors; all credit for the original design and implementation goes to them.
cargo install etch-cliOr build from source:
git clone https://github.com/brujack/etch-cli.git
cd etch-cli
cargo build --release
# binary at target/release/etch# Apply all manifests in the current directory
etch apply
# Apply a subset of manifests
etch apply -m one,two,three
# Apply manifests from a specific directory
etch -d ./manifests apply
# Dry run — show what would change without applying
etch apply --dry-run
# Show all actions, including those with nothing to do
etch apply --verbose
# Show flags for every subcommand in one shot
etch help-allManifests are YAML files describing actions to perform:
actions:
- action: command.run
command: echo
args:
- hello from etch
- action: package.install
name: htop
- action: file.link
from: ~/.dotfiles/.zshrc
to: ~/.zshrcHandlers run once at the end of the manifest when a notifying action made a change:
actions:
- action: macos.default
domain: com.apple.dock
key: autohide
kind: bool
value: "true"
notify: [restart-dock]
handlers:
- name: restart-dock
action: command.run
command: killall
args: [Dock]file.copy supports Tera (Jinja2-compatible) template rendering with template: true. The source file is rendered before being written to the destination.
actions:
- action: file.copy
from: nginx.conf.j2 # file in the `files/` subdirectory
to: /etc/nginx/nginx.conf
template: true
chmod: "0644"
privileged: trueAvailable context namespaces inside templates:
| Namespace | Examples |
|---|---|
user.* |
{{ user.username }}, {{ user.home_dir }} |
os.* |
{{ os.name }} (macos/linux), {{ os.arch }} (aarch64/x86_64), {{ os.hostname }} |
variables.* |
{{ variables.my_var }} — values from etch.yaml variables: |
env.* |
{{ env.HOME }} |
manifest_dir |
absolute path to the manifest directory |
Common mistake: using {{ my_var }} (bare) instead of {{ variables.my_var }}. Bare names render empty without error.
Tera supports {% if %}, {% for %}, filters (| default(value="x")), and the custom read_file_contents(path=...) function. See examples/file/files/some-file.j2 for a full reference.
See CLAUDE.md for the full action catalog with all fields documented. Complete working examples are in examples/package/.
| Action | Description |
|---|---|
command.run |
Run shell commands |
directory.create / directory.copy / directory.remove |
Manage directories |
file.copy / file.link / file.chmod / file.chown / file.flags / file.download / file.remove / file.unarchive |
Manage files, permissions, and BSD flags |
git.clone / git.pull / git.config |
Git repository and config management. git.clone skips if directory exists; set update_existing: true to pull instead of skip when directory already exists. |
package.install / package.repository / package.autoremove |
Install packages and remove unused dependencies (Homebrew, apt, snap). Optional version: field pins a package to an exact version — skips if already correct, errors if wrong version installed, installs at declared version if absent. version: requires name: (not list:); incompatible with cask: true. Homebrew: <name>@<version>; apt: <pkg>=<version>; snap: channel name. |
package.upgrade |
Upgrade installed packages via apt or snap. Runs apt list --upgradable / snap refresh --list at plan time and generates steps only when upgrades exist — no-ops if nothing to upgrade. provider required (apt/apt-get/aptitude or snap/snapcraft); optional name (single package) or list (multiple, mutually exclusive). provider: homebrew fails with a redirect to brew.upgrade. |
package.remove |
Remove installed packages (apt/snap/homebrew). name (single) or list (multiple — mutually exclusive); provider (apt, snap, homebrew); purge (bool, apt only — also removes config files via apt-get purge); cask (bool, homebrew only — required when removing a cask-installed app). Idempotent — skips packages not installed. |
brew.bundle / brew.upgrade / brew.cleanup |
Homebrew bundle, upgrades, and cache cleanup |
npm.install |
Install npm packages globally (idempotent) |
claude.install |
Install Claude Code plugins (idempotent). Accepts name: (single plugin) or list: (multiple). Queries claude plugins list at plan time and skips any plugin already installed, matching by base name so superpowers@claude-plugins-official and superpowers are treated as the same plugin. |
claude.upgrade |
Upgrade all currently installed Claude Code plugins. Discovers installed plugins at plan time via claude plugins list; generates one streaming upgrade step per plugin. No-op if no plugins are installed. |
claude.marketplace |
Add a Claude Code plugin marketplace. name (marketplace handle), source (GitHub owner/repo or full git URL), scope (optional: user/project/local, default user), sparse (optional list of paths for monorepo sparse checkout). Idempotent — skips if marketplace already registered. |
claude.marketplace.remove |
Remove a registered Claude Code plugin marketplace. name (marketplace handle), scope (optional — removes from all scopes if omitted). Idempotent — skips if marketplace not present. |
claude.plugin.update |
Update already-installed Claude Code plugins. Accepts name: (single plugin) or list: (multiple). Always runs claude plugins update — no idempotency pre-check. Use alongside claude.install (install-if-missing) to keep plugins current. |
mas.install / mas.upgrade |
Mac App Store apps (macOS). mas.install accepts a single app (name: + id:) or a list of apps (list: of {name, id} entries) — mutually exclusive. mas.upgrade upgrades all installed App Store apps. |
macos.default |
Write macOS defaults |
macos.rosetta |
Ensure Rosetta 2 is installed on Apple Silicon (macOS only). No fields. Idempotent — skips if already installed. Use where: 'os.name == "macos"' to gate on macOS. |
macos.service |
Load/unload LaunchDaemons and LaunchAgents |
macos.softwareupdate |
Install all available macOS software updates via softwareupdate --install --all. Privileged, self-idempotent. macOS only. |
systemd.service |
Enable/disable/start/stop systemd units |
terraform.tfenv |
Install tfenv (Terraform version manager) via git clone to ~/.tfenv, optionally install and activate a specific Terraform version. Idempotent. Add ~/.tfenv/bin to PATH separately. |
binary.github / binary.url |
Install binaries from releases or URLs. When version: is a pinned tag, etch status detects install mismatches (via sidecar {dir}/.{name}.version) and available updates (via cached GitHub API, 1h TTL at ~/.cache/etch/github-versions/). |
group.add / user.add / user.group / user.default_shell |
Manage Unix groups and users. user.default_shell sets the login shell via chsh; idempotent (reads current shell at plan time). username: targets another user (requires privilege escalation). |
plugin |
Load and run community or local etch plugins |
ruby.install |
Install Ruby versions via ruby-install; optional version_manager field ("rbenv" | "chruby") runs post-install steps |
ruby.chruby |
Install chruby via Homebrew; optionally set default ruby in ~/.ruby-version |
gem.install |
Install Ruby gems (idempotent) |
pip.install |
Install Python packages (idempotent) |
powershell.module |
Install PowerShell modules from PSGallery (idempotent). name (single module) or list (multiple — mutually exclusive); scope (CurrentUser default or AllUsers — AllUsers requires elevated privileges). Requires pwsh in PATH. |
pyenv.install |
Install Python versions via pyenv; optional configure_opts field sets PYTHON_CONFIGURE_OPTS before install |
pyenv.virtualenv |
Create a pyenv virtualenv (idempotent). recreate: true deletes and recreates the venv when the installed Python version differs from python_version: — use for Python patch version bumps. |
zsh.oh-my-zsh |
Install oh-my-zsh and optionally clone community plugins into ~/.oh-my-zsh/custom/plugins/ |
etch update runs an ordered sequence of tool update steps. With no flags it runs all applicable steps; use --only or --skip to filter.
etch update # run all steps
etch update --only brew,rust # Homebrew and Rust only
etch update --skip pip,gems # everything except pip and gems
etch update --only foobar # error: unknown category 'foobar'| Flag | Description |
|---|---|
--only <categories> |
Run only the listed comma-separated categories |
--skip <categories> |
Run all categories except the listed ones |
--only and --skip are mutually exclusive. An unknown category name is a hard error that lists all valid names.
| Category | What it updates | Platform |
|---|---|---|
brew |
brew upgrade + brew cleanup |
macOS/Linux |
system |
softwareupdate -ia |
macOS only |
mas |
Mac App Store apps via mas upgrade |
macOS only |
claude |
Claude plugins + npm globals (from config) | any |
packages |
apt-get upgrade + snap refresh |
Linux only |
pip |
pip install --upgrade outdated packages |
any |
rust |
rustup update + cargo-nextest |
any |
git-tools |
git pull on ai-config, dotfiles, oh-my-zsh, tpm, tfenv |
any |
gems |
gem update |
any |
cheatsh |
Re-downloads ~/bin/cht.sh via curl |
any |
Steps that require a tool not present on the machine are automatically skipped. Platform-specific steps (softwareupdate, mas, apt, snap) are silently skipped on the wrong OS.
etch update --only packagesvspackage.upgradein manifests:etch update --only packagesis an imperative one-shot upgrade of all apt/snap packages on the machine.package.upgradein a manifest is the declarative equivalent — it checks for upgradeable packages at plan time and generates steps only when needed, so it is safe to include in anetch applyrun and will no-op if everything is already up to date.
After all steps run, etch prints a fixed-order summary table and appends the same to a log file:
=== Update Summary — 2026-05-31 09:00:00 ===
[OK] brew 3 formulae (git 2.45.0, ripgrep 14.1.0, fzf 0.53.0)
[SKIP] softwareupdate not applicable
[SKIP] mas not applicable
[OK] claude 2 plugin(s) updated
[SKIP] npm npm not installed
[SKIP] apt not applicable
[SKIP] snap not applicable
[OK] pip no changes
[OK] rust no changes
[OK] ai-config 1 commit(s)
[OK] dotfiles no changes
[OK] oh-my-zsh no changes
[SKIP] tpm directory not found
[SKIP] tfenv directory not found
[SKIP] gems gem not installed
[SKIP] cheat.sh ~/bin/cht.sh not found
15 sections: 6 OK, 0 failed, 0 warnings, 9 skipped
Log appended: /Users/you/.etch-update.log
Add an update: section to ~/.config/etch/etch.yaml to configure git repos and Claude plugins:
update:
log_path: ~/.etch-update.log # default; omit to use this path
git_tools:
ai_config: "enabled" # any non-null string enables; pulls sibling of dotfiles_dir
dotfiles: "enabled" # any non-null string enables; pulls dotfiles_dir path
oh_my_zsh: true # pulls ~/.oh-my-zsh
tpm: true # pulls ~/.tmux/plugins/tpm
tfenv: true # pulls ~/.tfenv
claude:
plugins:
- superpowers
npm_globals:
- typescript
- "@anthropic-ai/claude-code"
variables:
dotfiles_dir: ~/git-repos/personal/dotfiles # required for ai_config + dotfiles git_tools
has_snap: "true" # enable snap updates (Linux)
has_rust: "true" # enable rustup updates
has_devtools: "true" # enable pip updatesgit_tools.ai_config and git_tools.dotfiles accept any non-null string (the value is unused; only presence is checked). Both require variables.dotfiles_dir — ai-config is assumed at <dotfiles_dir>/../ai-config.
Manage etch plugins — community or local collections of custom actions.
etch plugin add username/repo # install latest
etch plugin add username/repo:v1.2.0 # pin to a tag
etch plugin list # list installed plugins and sync status
etch plugin remove name # remove a plugin by name
etch plugin update [name] # update one or all pluginsPlugins are stored in the platform data directory (~/Library/Application Support/etch/plugins on macOS, ~/.local/share/etch/plugins on Linux). Each plugin is a cloned git repository. Plugin names in remove and update are the bare repo name (the part after /).
etch doctor validates system health — symlink integrity, tools in PATH, credential directory permissions, and binary version drift. It complements etch status (manifest drift) by covering system-level invariants that manifests don't check.
etch doctor # run all checks, exit 1 if any fail
etch doctor --json # machine-readable JSON output
etch doctor --missing-only # suppress passing checks, show failures only| Check | What it validates | Source |
|---|---|---|
| Symlinks | file.link targets exist and resolve |
Manifest-derived |
| Tools | Tools exist in PATH | Manifest-derived + doctor.tools: config |
| Credential dirs | Directories have mode 700 | doctor.credential_dirs: config |
| Versions | Binary --version output contains pinned string |
binary.github/binary.url atoms + doctor.versions: config |
Add a doctor: section to ~/.config/etch/etch.yaml:
doctor:
tools: # explicit tools beyond manifest-derived
- kubectl
- helm
versions: # explicit version pins (substring match against command output)
- tool: ripgrep
command: "rg --version"
expected: "14.1.0"
credential_dirs: # directories to verify have mode 700
- ~/.ssh
- ~/.tf_creds
- ~/.tshManifest-derived tool checks: brew.bundle/brew.upgrade/brew.cleanup → brew, gem.install → gem, pip.install → pip, npm.install → npm, mas.install/mas.upgrade → mas, pyenv.install/pyenv.virtualenv → pyenv, ruby.install → ruby-install, claude.install/claude.upgrade/claude.plugin.update → claude.
etch history shows what etch apply has done — a persistent record of every atom that executed successfully, written to ~/.local/share/etch/state.yaml after each apply.
etch history # table of all recorded atoms
etch history --manifest <substr> # filter by manifest name substring
etch history --json # NDJSON, one object per atomOutput columns: MANIFEST · ACTION · KEY (destination path, package name, etc.) · APPLIED AT · CHANGED (yes/no — whether the atom mutated state in that run).
The state file uses merge semantics: re-running the same action updates the existing row rather than appending — the file always reflects the most-recent outcome per (manifest, action, key) triple.
etch rollback lists and restores pre-apply file backups. Before file.copy overwrites an existing file, etch stashes the original to ~/.local/share/etch/backups/. The three most recent stashes per path are kept (configurable via ETCH_STASH_DIR env var in tests).
etch rollback # list all stashed paths with timestamps
etch rollback --path ~/.zshrc # restore latest stash for that path
etch rollback --path ~/.zshrc --dry-run # diff stash vs current; no write
etch rollback --all --yes # restore all paths, skip confirmationRestore preserves the original file permissions. If ~/.ssh/id_rsa was stashed at mode 0600, it is restored at 0600 regardless of the current umask. Old stash entries (created before v0.13.0) that have no recorded mode are silently restored without a permission change.
Stash is best-effort: stash failures log a warning and never block etch apply.
Two separate verbosity mechanisms exist:
# Apply-level: show all actions including those with nothing to do
etch apply --verbose # or: etch apply -v
etch apply --dry-run # dry-run implies --verbose automatically
# Global debug logging: show command exit codes, captured stdout/stderr
etch -v apply # DEBUG level
etch -vv apply # TRACE levelRUST_LOG is not used. The global flag (-v) must come before the subcommand — etch -v apply, not etch apply -v (that activates the apply-level verbose flag).
On Linux with systemd, etch sends all log levels (including DEBUG) to journald in addition to stdout. When an action fails with no visible error, the captured subprocess output is in journald:
# Stream live while applying
journalctl -f &
etch apply
# Read after failure
journalctl -n 100 | grep -A5 "etch\|exit code\|stdout\|stderr"Package install, upgrade, and autoremove operations stream output directly to the terminal in real time — apt/brew/snap progress appears as it runs. If a package operation fails, the error message is visible inline.
To reproduce a failure outside etch:
# package.install
sudo env DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true NEEDRESTART_MODE=a \
apt install --yes <package-list>
# package.upgrade (upgrade-all)
sudo env DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true NEEDRESTART_MODE=a \
apt-get update && \
sudo env DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true NEEDRESTART_MODE=a \
apt-get upgrade -y
# package.upgrade (named package)
sudo env DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true NEEDRESTART_MODE=a \
apt-get update && \
sudo env DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true NEEDRESTART_MODE=a \
apt-get install --only-upgrade -y <package>Note: etch propagates all three environment variables to suppress interactive prompts from dpkg post-invoke hooks.
DEBIAN_FRONTEND=noninteractivedisables debconf UI;DEBCONF_NONINTERACTIVE_SEEN=truemarks questions as seen so debconf applies defaults silently;NEEDRESTART_MODE=aauto-restarts services instead of prompting. When reproducing failures manually, include all three.
make test # lint + test
make lint # cargo clippy -D warnings
make build # cargo build --release
make semver # check for API-breaking changes vs origin/main (advisory)
make docs-debt # count undocumented public items (see API lints below)
make install-hooks # install pre-commit and pre-push hooks (run once per checkout)Every crate's [lints] table enables two Rust API Guidelines items that a compiler
can check, at warn — which make lint's -D warnings promotes to a hard failure:
| Item | Lint |
|---|---|
C-DEBUG |
missing_debug_implementations |
C-CONV |
clippy::wrong_self_convention |
C-DOCS (missing_docs) is deliberately not enforced yet. It sits at allow
with a dated count, because etch-lib has 402 undocumented public items and
documenting them is its own piece of work — see the backlog row in
docs/superpowers/README.md. make docs-debt reprints that count so the number in
lib/Cargo.toml can be refuted in one command rather than trusted.
Two types carry a hand-written Debug instead of a derived one, and must keep it:
Decrypt holds a passphrase and Exec holds an environment map. Both redact those
fields; deriving Debug on either would print the secret.
make test also runs 5 insta snapshot tests (app/tests/snapshots.rs) that lock the exact stdout format of etch -h, etch apply --help, etch version, and etch apply --dry-run. Any accidental format change fails the test. To update snapshots intentionally: INSTA_UPDATE=new cargo test --test snapshots, then cargo insta accept, then commit the updated .snap files.
Prerequisites:
brew install git-cliff— CHANGELOG generation (make changelog)pip install "ruff==0.16.1"— Python lint (make lintshells out toruff check scripts/ tests/; without it on PATH,make lint,make test, and the pre-push hook all fail)
Release binaries are signed with cosign using keyless Sigstore signing. Each release includes:
etch— compiled binaryetch.sha256— SHA256 checksumetch.bundle— cosign bundle (signature + certificate)etch.sbom.spdx.json— SPDX bill of materials
Verify the checksum:
sha256sum -c etch.sha256Verify the cosign signature:
cosign verify-blob etch \
--bundle etch.bundle \
--certificate-identity \
"https://github.com/brujack/etch-cli/.github/workflows/release-sign.yml@refs/tags/TAG" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com"Replace TAG with the release tag (e.g. v0.10.4).