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.7.6): schema v10 local module composition, verified dependency-aware service changes, and genv service restart. v4.7.6 installs an external archive that contains an unused hard link or symlink next to the binary. Schema v8 portable multi-target configs and schema v9 external releases remain supported.
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.26.9+) |
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.7.6_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.7.6_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.
A spec can be assembled from local module documents instead of being one large
file. genv.json registers them; each target selects them:
{
"schemaVersion": "10",
"modules": { "base": "modules/base.json", "dev": "modules/dev.json" },
"targets": { "macos": { "useModules": ["dev"] } }
}useModules is additive — selecting dev also pulls in base because dev
requires it. Every command that reconciles (apply, status, upgrade,
updates, scan) sees the composed result, so a module's packages install and
report exactly like root ones.
Rules worth knowing before you split a spec up:
- Modules are local and trusted. They are files on disk inside the spec directory; genv never fetches or verifies a signature.
- A target overlay replaces the defaults array inside one module. Declaring
both
defaults.packagesandtargets.macos.packagesin the same module gives you only the second, exactly as in v8. Keep a module's packages in one place. - No overrides. If the root and a module declare a resource identically it is kept once and both are recorded as owners; if they differ, composition fails and names both origins. Edit the module instead.
- Module-owned resources are read-only from the CLI.
add,remove,disown,adopt,env,shell,service, andfiles adoptrefuse and tell you which module to edit. - Ask before you guess:
genv config --target macosprints the composition,genv explain package jq --target macosprints who declares it. genv exportflattens a composed spec into a single-target snapshot and records the materialized modules inreport.json.
- 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).
Human apply prompts unless --yes. Because a JSON caller cannot answer a prompt, genv apply --json is plan-only unless --yes is passed (or --dry-run to plan explicitly); a wet run without --yes returns the plan with wet-run requires --yes and changes nothing. Hooks receive the same consent through GENV_YES. An already-applied spec still reports ok, matching genv upgrade --json.
genv.json itself is written 0600 (like the lock and the private fragments) because a spec can hold env values marked sensitive. A spec left world-readable by an older genv is tightened to match the lock.
Managed links are compared by resolved path, so a relative link pointing at the same file as an absolute source counts as correct. Relative file sources and hook file paths resolve against the spec directory (--source-root overrides), which is where genv pull and genv export place bundled assets.
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 plus hook scripts from repo.url. It never overwrites the lock or secrets. Only relative sources are bundled: a source that is absolute, ~/…, or contains a $VAR is reported rather than copied, and symlinks are refused.
| 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). Exits non-zero if a manager's inventory could not be read |
list (ls) |
Show lock-tracked packages |
status |
Spec ↔ lock drift (--files includes content drifted, --offline, --verify, --target); a version-less lock entry the live inventory contradicts is unknown |
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>) |
config |
Composed environment for a target: module selection, fingerprint, counts (--registry lists modules without composing; --kind + --name shows one resource's owners) |
explain |
Where one resource comes from and whether a mutation command may change it (genv explain package jq --target macos) |
validate |
Validate spec + every registered module + 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 portable specs, schema v8 and newer) |
pull |
Fetch spec + file assets from repo |
migrate |
v1–v7 → v8 targets (refuses v9 and v10; they are already portable) |
export |
Single-target snapshot + report + assets (--verify proves live installs) |
map |
Assist-only manager mapping suggestions |
init / edit |
Wizard / $EDITOR |
env / shell / service / files |
Env vars, aliases, user services (launchd / systemd templates, Windows scheduled_task), files adopt |
completion |
bash / zsh / fish / powershell |
clean |
Clear detected manager caches. A busy uv cache is skipped (one-second wait unless UV_LOCK_TIMEOUT is set). An empty pip cache is not a failure |
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 for commands that readgenv.json(default under~/.config/genv/).updates statusandupdates stopinspect the scheduler and do not take--file.--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 status --verify # Query each manager: is this package actually installed?
genv adopt cursor --target windows # lock Anysphere.Cursor if already installed
genv export --target macos --out ./dist/macos --strict
genv export --target macos --out ./dist/macos --verify --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.
Hooks should check-then-act and exit without doing work when the system is already current. On success, print one of:
echo GENV_HOOK_STATUS=changed
echo GENV_HOOK_STATUS=skippedNon-zero exit is error. Exit 0 without a status line is treated as changed (legacy exit-only hooks). After each phase, apply prints changed, skipped (no-op), or error next to the hook name, exit code, and duration. See SCHEMA.md.
Declare a supervisor-backed service in the spec instead of a postApply hook:
"services": {
"syncthing": {
"launchd": { "plist": "launchd/com.example.syncthing.plist" },
"systemd": { "unit": "systemd/syncthing.service" },
"scheduled_task": {
"action": "C:\\Program Files\\Syncthing\\syncthing.exe",
"args": ["--no-browser"],
"trigger": "logon",
"restart_on_failure": true
}
}
}On macOS and Linux, 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.
On Windows, scheduled_task renders the Task Scheduler definition itself — no template file — and registers it with schtasks. action must be an absolute path (Task Scheduler does not search PATH); trigger is logon (default), boot, daily or weekly, with at (HH:MM) required for the last two. The default user principal registers without elevation.
genv service status syncthing reads supervisor state. genv service restart syncthing is the safe imperative counterpart: it uses brew services restart, launchd re-bootstrap, systemctl --user restart, a declared restart argv, or a stop-then-start fallback as appropriate. A scheduled Task is a trigger rather than a resident process, so restart refuses it. For a service with schema-v10 requires, restart refuses before touching anything when a dependency is down; it never restarts dependencies as an unexpected side effect. A declared health_check is run after the restart and makes the command fail if the service is not ready. Removing the service from the spec unloads it and deletes the unit file or unregisters the task. Declaring a backend for a platform you are not on is skipped, not an error, so one spec can target several. See SCHEMA.md.
A service can say what it depends on and what it watches (schema v10):
"api": {
"start": ["api-server"],
"requires": ["db"],
"watch": ["postgres", "~/.config/api/api.conf"],
"restart_policy": "ifRunning",
"health_check": { "command": ["curl", "-fsS", "localhost:8080/health"] }
}genv upgrade then restarts api only when postgres actually changed, and
genv apply restarts it only when that config file was created or rewritten.
It starts it first if it was stopped (under ifRunning), and waits for the health
check before calling the run done. A watch entry that is neither a tracked
package nor a managed file is refused rather than accepted and silently ignored.
Watching another service is not a trigger yet.
The part worth knowing is what it does not claim:
- An upgrade that ran and left the installed version unchanged causes no restart.
- A version genv cannot establish — many managers never report one — produces a deferral, not a restart on a guess. "I could not tell" is an outcome.
- A readiness failure is reported separately from a restart failure: the service did start, it is simply not healthy, and the remedies differ.
- The health check never runs from
statusor any dry run.
If genv is interrupted mid-restart, it says so on the next run instead of
assuming the service came back. The unattended updates worker will not upgrade
a package whose watched service it cannot restart and verify without a human —
it defers that package and takes the rest — and it restarts the services it can
once the upgrade lands. The same phase runs under genv upgrade --json, which
reports each service under a services key: an output flag never changes what
the command does.
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