Track, sync, and reproduce your software environment across macOS, Windows, Arch Linux, Ubuntu-like Linux, and WSL2.
genv is a thin layer over the package managers you already use. Desired state lives in one git-friendly genv.json. Applied state lives in a machine-local lock file. Run genv apply and the machine matches the spec.
Current release: latest (v4.1.0+) — schema v7 PowerShell parity, schema v8 portable multi-target configs.
genv add git # track + install
genv apply --dry-run # preview reconcile
genv apply --yes # apply without prompt
genv status # show drift
genv migrate --write # upgrade a legacy spec to v8
genv export --target ubuntu --out ./u # single-target snapshot + report
genv map --target arch # assist-only manager suggestions| Platform | Install |
|---|---|
| macOS | brew tap ks1686/tap && brew install --cask genv |
| Arch / Manjaro | paru -S genv or yay -S genv (or genv-bin) |
| Other Linux | GitHub release tarball (see below) |
| Windows | Scoop (self-hosted bucket) or GitHub release zip (see below) |
| Any (from source) | go install github.com/ks1686/genv@latest (Go 1.24+) |
Linux x86-64 example (replace the version to match Releases):
curl -Lo genv.tar.gz https://github.com/ks1686/genv/releases/latest/download/genv_4.4.1_linux_amd64.tar.gz
tar -xzf genv.tar.gz
sudo mv genv /usr/local/bin/
genv versionWindows (Scoop, after Scoop itself is installed):
scoop bucket add ks1686 https://github.com/ks1686/scoop-bucket
scoop install genvThe bucket is self-hosted (not Scoop extras). scoop install genv needs a root
genv.json on that bucket, which the first stable tag uploaded.
Windows (PowerShell zip):
Invoke-WebRequest -Uri https://github.com/ks1686/genv/releases/latest/download/genv_4.4.1_windows_amd64.zip -OutFile genv.zip
Expand-Archive genv.zip -DestinationPath .
# put genv.exe on PATH, then:
genv versionwinget and Chocolatey installers for the genv binary are not published
(GoReleaser Pro). Once genv is on PATH, it still manages packages through
winget, Scoop, and Chocolatey.
Release archives ship cosign-signed checksums (keyless). Darwin binaries are also Developer ID signed and notarized when Apple secrets are configured — see SECURITY.md.
Platform walkthroughs: Linux · macOS · Windows · WSL2 · multi-machine
genv init # optional wizard
genv add git
genv add neovim --version "0.10.*"
genv scan # bulk-adopt user-facing installs (not brew deps / stdlib)
genv status
genv apply --dry-run
genv apply --yesDefault paths (respect $XDG_CONFIG_HOME):
| File | Location | Role |
|---|---|---|
| Spec | ~/.config/genv/genv.json |
Desired state — edit / commit this |
| Lock | ~/.config/genv/genv.lock.json |
Applied state — machine-local, never commit |
Put shared settings in defaults, OS-specific packages under targets.*, commit the spec, then apply per machine:
genv migrate --file genv.json --write # if upgrading from v1–v7
genv map --target ubuntu # see manager gaps (read-only)
genv apply --target ubuntu --dry-run
genv apply --target ubuntu --yesActive target resolution: --target → $GENV_TARGET → host classification.
Full guide: docs/multi-machine.md.
| Target | Detected when | Typical managers |
|---|---|---|
macos |
macOS | brew, mas |
windows |
native Windows | winget, scoop, choco |
arch |
native Arch / Arch-like | pacman, paru, yay |
ubuntu |
Ubuntu-like Linux or Ubuntu-like WSL2 | apt, snap, linuxbrew |
wsl-arch |
Arch-like WSL2 | pacman, paru, yay |
linux |
optional catch-all (set via --target / GENV_TARGET) |
apt, dnf, apk, snap, linuxbrew, … |
WSL2 does not inherit native arch automatically. Put shared bits in defaults; use targets.ubuntu or targets.wsl-arch for distro-specific packages.
Also available (explicit prefer / managers): bun, npm, pnpm, yarn, deno, volta, uv, pipx, pip-user, poetry, conda, mamba, pixi, cargo, go, rustup, gem, composer, dotnet-tool, ghcup, stack, opam, juliaup, sdkman, asdf, mise, krew, helm, vscode.
On schema v1-v8, external is a track-only pseudo-manager for software installed outside a package manager. Schema v9 can instead attach a managed external recipe that discovers GitHub Releases or structured HTTP releases, verifies the selected artifact, and installs/removes direct executables, archives, or installer scripts. See managed external releases and SCHEMA.md.
Schema v8 also accepts top-level adapters for plugin CLIs genv does not ship built-in (claude plugin, gh extension, …). Set prefer to the adapter name. Details and examples: SCHEMA.md.
Native apt, dnf, and apk adapters are registered system managers (prefer: apt|dnf|apk). Use genv map / genv export when moving a spec across Linux families.
Recommended shape for new configs:
{
"schemaVersion": "8",
"defaults": {
"env": {
"EDITOR": { "value": "nvim" }
},
"shell": {
"aliases": {
"ll": { "value": "ls -lah" }
}
}
},
"targets": {
"macos": {
"packages": [
{ "id": "git", "prefer": "brew" },
{ "id": "ripgrep", "prefer": "brew" }
]
},
"ubuntu": {
"packages": [
{ "id": "git", "prefer": "apt" },
{ "id": "ripgrep", "prefer": "apt" }
],
"env": {
"EDITOR": null
}
},
"windows": {
"packages": [
{
"id": "git",
"prefer": "winget",
"managers": { "winget": "Git.Git", "scoop": "git", "choco": "git" }
}
],
"shell": {
"aliases": {
"ll": { "value": "Get-ChildItem", "shell": "powershell" }
}
}
}
},
"updates": {
"enabled": true,
"interval": "24h",
"autoApply": false,
"notify": true
},
"repo": {
"url": "https://github.com/example/dotfiles",
"ref": "main"
}
}v8 rules (short):
- Desired state lives under
defaultsand/ortargets.<id>— not as top-levelpackages/env/shell/files/services/hooks. - No per-record
hostin v8 (use target buckets). - Target map entries may be
nulltombstones to drop a default for one OS (EDITORabove). repoandupdatesstay top-level.- Schema v7 adds
"shell": "powershell"(POSIX-only when omitted). On native Windows, genv preferspwsh, else Windows PowerShell, for profile fragments and hooks.
Legacy v1–v7 specs still load. Convert with genv migrate. Field-by-field reference: SCHEMA.md.
- Read the spec and the lock.
- For v8: resolve the active target, merge
defaults+ target (+ tombstones). - Refuse a foreign lock (wrong target / OS / unavailable managers). Recover with
genv apply --force-new-lock(backs up the lock) or by removing it locally. - Install packages in the spec but not the lock; uninstall lock entries removed from the spec.
- Reconcile env, shell, files, services, hooks as configured.
- Update the lock (v8 records
target+goos).
Convenience commands (add / remove / adopt / disown / scan) update the spec and usually the live system in one step. genv add installs first and only persists the spec after a successful install (unresolved or failed installs exit 4 and leave the spec unchanged; use adopt to track without installing). On v8 they write into targets.<active> (--target or $GENV_TARGET / classification).
genv scan adopts user-facing installs by default: Homebrew brew leaves plus casks (not the full formula tree), Ruby gems that are not default or bundled with the interpreter, and pip-user packages that are not dependencies of other user-site packages (minus installer/stdlib-like noise such as certifi / setuptools). npm/pnpm/yarn already list top-level globals only (and scan never proposes npm itself). uv proposes tool names from uv tool list headers, not - entrypoint bullets. rustup toolchains are not proposed. Pass --all or --deps to adopt every ListInstalled name, including Homebrew libraries and language stdlib. Scan still never proposes -, npm via npm, or toolchain:*. Preview with --dry-run; text mode prompts unless --yes is set.
genv pull fetches genv.json and relative files assets from repo.url. It never overwrites the lock or secrets.
| Command | Purpose |
|---|---|
add / remove (rm) |
Track + install / untrack + uninstall |
adopt / disown |
Track without install / untrack without uninstall |
scan |
Bulk-adopt user-facing installs (--dry-run, --yes; --all / --deps for full trees) |
list (ls) |
Show lock-tracked packages |
status |
Spec ↔ lock drift (--files includes content drifted, --offline, --target) |
apply |
Reconcile (--dry-run, --yes, --json, --force, --backup, --strict, --quiet, --skip-packages, --timeout <d>, --no-hooks, --hook-timeout <d>, --target, --force-new-lock, --state-dir, --source-root <dir>) |
validate |
Validate spec + genv-managed agent executables |
upgrade |
Upgrade tracked packages plus OS vendor updates (--all, --only / leftover IDs, --skip, --only-manager, --skip-manager, --target; --json wet-run requires --yes) |
updates |
Background checker (check / start / stop / status; --target, --only, --skip, --only-manager, --skip-manager on check/start) |
profile |
Named overlays (list / create / switch; refused on schema v8) |
pull |
Fetch spec + file assets from repo |
migrate |
v1–v7 → v8 targets |
export |
Single-target snapshot + report + assets |
map |
Assist-only manager mapping suggestions |
init / edit |
Wizard / $EDITOR |
env / shell / service / files |
Env vars, aliases, user services (launchd / systemd templates), files adopt |
completion |
bash / zsh / fish / powershell |
clean |
Clear detected manager caches |
version / help |
Build info / usage |
Install for your shell (auto-detects from $SHELL when omitted):
genv completion install # bash, zsh, or fish
genv completion install powershellTab completion on add / adopt suggests package names from available managers (Homebrew-style local dumps when possible). After you accept a name, interactive genv add still asks which manager to use when multiple match.
--file <path>— spec path (default under~/.config/genv/)--lock-file <path>— lock path (defaultgenv.lock.jsonnext to--file)--state-dir <dir>— directory for lock and env/shell fragments (default: directory of--file)--target <id>— v8 target for apply / status / upgrade / updates / mutate / export / map / scan--host <name>— legacy host filter override for v1–v7 records / hooks (defaults via host classification, not hostname)--json— machine-readable envelope on stdout; subprocess noise on stderr
genv apply --target windows --yes # adopt live apps, install only the missing
genv apply --skip-packages --yes # links + env only
genv apply --timeout 30m --hook-timeout 2m # cap each subprocess / hook (default 10m; 0 disables)
genv apply --target ubuntu --dry-run --json
genv apply --force --backup --yes # overwrite mismatched files; keep *.backup.*
genv files adopt ~/.foo --dry-run # seed missing source, backup live file, link
genv apply --target ubuntu --force-new-lock --yes # after a foreign lock refuse
genv apply --dry-run --file ./worktree/genv.json --source-root ~/.config/genv
genv status --target windows # present vs missing vs ok
genv adopt cursor --target windows # lock Anysphere.Cursor if already installed
genv export --target macos --out ./dist/macos --strict
genv migrate --writeFile mismatches without --force no longer block packages/services: non-conflicting file ops still apply, each mismatched path is printed, and apply exits 4 if any remain.
Declare a LaunchAgent or systemd --user unit in the spec instead of a postApply hook:
"services": {
"syncthing": {
"launchd": { "plist": "launchd/com.example.syncthing.plist" },
"systemd": { "unit": "systemd/syncthing.service" }
}
}genv apply renders the template (__HOME__ and the other files.templates[] placeholders), writes ~/Library/LaunchAgents/<Label>.plist or ~/.config/systemd/user/<basename>.service, and loads it. Editing the template and applying again re-bootstraps the launchd job or restarts the systemd unit. genv service status syncthing reads supervisor state. Removing the service from the spec unloads it and deletes the unit file. See SCHEMA.md.
genv updates start registers a user systemd timer (Linux), launchd job (macOS), or Task Scheduler task (schtasks, Windows). Default behavior is check / log / notify for tracked packages only. Set "autoApply": true in the updates block to apply those tracked upgrades automatically. The timer is non-interactive: it never prompts for sudo or UAC, and skips packages that need elevation (logged in updates.log). OS vendor and firmware updates are not part of the checker — use genv upgrade for that. Details: SCHEMA.md.
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Bad arguments / unknown command |
| 2 | I/O or serialization error |
| 3 | Spec failed validation |
| 4 | Semantic error (also status when drift exists; foreign lock refuse) |
Install resolution: detect available managers → honor prefer → try managers map → fall back to system managers using the package id. Language / toolchain / plugin managers (including v8 spec adapters) are explicit-only (must set prefer or managers) so git never silently resolves through npm.
Upgrades: genv upgrade applies tracked-package updates, then OS vendor updates for the active target (macOS softwareupdate, Windows Update Agent COM via PowerShell (often needs an elevated session; genv does not auto-elevate; WUA ResultCodes are mapped to readable errors), Arch pacman -Syu, Ubuntu apt-get upgrade). Firmware uses fwupdmgr on Linux when present; macOS firmware ships through softwareupdate, and Windows firmware is skipped as vendor-specific. genv updates check and the timer stay tracked-packages-only and share the tracked planner with upgrade. The timer rewrites refresh/apply sudo to sudo -n and skips unelevated winget/choco (and paru/yay) rather than prompting. Before outdated detection, the planner refreshes each index-based manager that still has candidates (brew update, sudo apt-get update, sudo pacman -Sy, and the equivalents on paru/yay/dnf/apk/scoop/winget). Live registries (mas, npm/bun, uv/pipx, cargo, volta, choco, snap, vscode) are queried as-is. A failed refresh keeps that manager's packages and prints a warning — same conservative rule as a failed outdated query. A manager whose binary is gone (Available() false) is skipped with an explicit reason instead of planning brew update / brew upgrade that cannot run. By default they plan packages with a detected update; --all on upgrade still refreshes, then plans every unconstrained tracked package. Leftover positional IDs (genv upgrade git) are treated as --only. Human upgrade prompts unless --yes; upgrade --json wet-run also requires --yes (or --dry-run to plan only). Packages with a non-empty version are always skipped; genv does not yet plan range-satisfying upgrades. Batched where the manager allows (brew, pacman/paru/yay, apt/dnf/apk, mas, snap, scoop, choco, …). The vscode manager compares against the marketplace's newest stable version (pre-release builds are ignored; --install-extension --force cannot install them). It invokes cursor when that CLI is on PATH, otherwise code. brew outdated uses --greedy so auto-updating casks are not hidden after the index fetch.
| Area | State |
|---|---|
| Core CLI + declarative apply | Stable |
| macOS / Windows / Arch / Ubuntu / WSL targets | Stable (v4.0.0) |
| Schema v7 PowerShell profiles | Stable |
| Schema v8 portable targets | Stable |
Background updates + profiles + hooks |
Stable |
| apt / dnf / apk adapters | Stable |
| Publish genv to Scoop | Self-hosted bucket ks1686/scoop-bucket; uploads when SCOOP_BUCKET_GITHUB_TOKEN is set |
| Publish genv to winget / choco | Not published; publishers are GoReleaser Pro-only |
Historical milestone checklists: ROADMAP.md. Release notes: CHANGELOG.md. Tag-driven publishing: RELEASING.md.
See CONTRIBUTING.md.
MIT