# Providers

> What each zc-workstation provider reads, what it runs, and what it leaves to you.

A provider reads the machine and returns actions. It never writes during a plan. Each one also counts what is in place, for `status`, `doctor` and the dashboard.

## packages

Installs what a manifest declares. **It never uninstalls**: a package you drop from the manifest stays installed, because removing software is your call.

   
| Manager | Manifest | Reads installed | Installs with |
| --- | --- | --- | --- |
| 
`brew`

 | 

A Brewfile: `tap`, `brew`, `cask` and `mas` entries

 | 

`brew list --formula`, `brew list --cask`, `brew tap`, `mas list`

 | 

`brew tap`, `brew install`, `brew install --cask --adopt`, `sudo mas install ID`

 |
| 

`apt`

 | 

One package per line, `#` comments

 | 

`dpkg-query`, packages in state `ii`

 | 

`sudo apt-get install -y`

 |
| 

`dnf`

 | 

One package per line, `#` comments

 | 

`rpm -qa`

 | 

`sudo dnf install -y`

 |
| 

`winget`

 | 

The file `winget export` writes

 | 

`winget list --id ID --exact`, per package

 | 

`winget install --id ID --exact --silent`

 |

On a machine with no apt package lists yet, such as a fresh install or a container, apply runs `sudo apt-get update` before the first install.

For `list` and the Inventory page, versions and descriptions come from `brew info --json=v2 --installed` and `mas list` on macOS, `dpkg-query` on apt systems, and `rpm -qa` on dnf systems. winget items list without them. If the details cannot be read, items still list, without versions.

Newer versions and undeclared packages come from:

   
| Manager | Newer versions | Installed on purpose | Upgrades with |
| --- | --- | --- | --- |
| 
`brew`

 | 

`brew outdated --json=v2`, skipping pinned formulae; `mas outdated`

 | 

`brew leaves --installed-on-request`, `brew list --cask`, `brew tap`, `mas list`

 | 

`brew upgrade`, `brew upgrade --cask`, `mas upgrade ID`

 |
| 

`apt`

 | 

`apt list --upgradable`

 | 

`apt-mark showmanual`

 | 

`sudo apt-get install --only-upgrade -y`

 |
| 

`dnf`

 | 

`dnf list --upgrades`

 | 

`dnf repoquery --userinstalled`

 | 

`sudo dnf upgrade -y`

 |

winget has neither yet. These reads are best effort: if one fails, items still list without it.

-   With `upgrade: true`, the plan has one `UPDATE` per outdated declared package, such as `upgrade with brew, 3.7c to 3.8`. Without it, nothing is upgraded. A manager that cannot report newer versions gets one `MANUAL` step instead.
    
-   `capture` writes a Brewfile (`tap`, `brew`, `cask` and `mas` lines) or a package list from the "installed on purpose" reads. Dependencies are left out, since the manager pulls them in again.
    
-   Taps are installed before the formulae that need them.
    
-   Other Brewfile entries, such as `vscode` or `go`, are `MANUAL`: the plan names them, and you install them yourself.
    
-   `mas` entries are `MANUAL` until `mas` is installed.
    
-   If the manager itself is missing, the plan is one `MANUAL` step to install it.
    
-   An app installed outside the manager is not seen. Its cask is installed with `--adopt`, so Homebrew takes over the app already there; if the app differs from the cask, Homebrew refuses, and you delete the app or remove the entry.
    

## dotfiles

Applies a [chezmoi](https://www.chezmoi.io) source directory. Use chezmoi’s naming (`dot_zshrc`, `private_`, `executable_`, `.tmpl`) and its templates for per-machine differences. Its secrets support keeps passwords out of the repository.

The plan is `chezmoi status`:

  
| chezmoi | Plan | Meaning |
| --- | --- | --- |
| 
`A`

 | 

`CREATE`

 | 

The file is not there yet.

 |
| 

`M`

 | 

`UPDATE`

 | 

The file differs from the source.

 |
| 

`D`

 | 

`DELETE`

 | 

The source removes it, with chezmoi’s `remove_` prefix.

 |
| 

`R`

 | 

`UPDATE`

 | 

A `run_` script will run.

 |

In the inventory each file names the app it configures: the folder under `.config`, `.local/share`, `Library/Application Support` or `AppData`, else the file’s own name (`.zshrc` is `zsh`, `.gitconfig` is `git`). A login item under `Library/LaunchAgents` is `launchd`.

A file edited since chezmoi last wrote it says so in the plan: `changed outside chezmoi, apply overwrites it`. Copy your edit into the source first if you want to keep it.

Apply runs `chezmoi apply --force` for exactly the planned paths and their parent directories, nothing else. `--force` stands in for chezmoi’s own overwrite prompt, because you already reviewed the plan.

zc-workstation ships one source of its own, `assets/theme`: the ZoolCoder colours for Ghostty, tmux, starship, SketchyBar, JankyBorders, bat, btop, eza, yazi, lazygit and fastfetch, each with a dark and a light variant. Its shell settings, such as `BAT_THEME_DARK` and `FZF_DEFAULT_OPTS`, sit in `~/.config/zoolcoder/theme.zsh`. The source creates `~/.zshrc.local` to load it, once: an existing `~/.zshrc.local` is left alone and needs the line itself, and an existing `.zshrc` needs one line, `source ~/.zshrc.local`. After apply, reload what is running: `sketchybar --reload`, `~/.config/borders/bordersrc`, and `tmux source-file ~/.config/tmux/tmux.conf`.

With `mode: symlink`, each file is a link into the source directory, so an edit in place lands in the repository. A file chezmoi must render, such as a template or a private file, is still copied.

## tools

Installs the runtimes a [mise](https://mise.jdx.dev) config pins. Only the tools declared in that file count. Tools from your global mise config or a parent directory’s config are ignored.

-   The plan lists one `CREATE` per `tool@version` that is not installed, such as `java@temurin-25`.
    
-   Apply runs `mise install` in the config’s directory.
    
-   In the inventory, each runtime shows the version mise resolved next to the one requested, such as `24.21.0` for `lts`, and the newer version `mise outdated` reports. Versions installed but asked for by no config in this target, such as a global tool or an old node, show as `not declared`.
    
-   With `upgrade: true`, the plan has one `UPDATE` per outdated runtime. Apply runs `mise upgrade` for that `tool@request` in the config’s directory, so a pin such as `3.13` moves only within `3.13`.
    
-   `capture` writes a `[tools]` table of every installed version, using the request when mise knows it.
    
-   mise refuses to read a config it does not trust. The plan then shows a `MANUAL` step: read the file, then run `mise trust`.
    
-   `doctor` checks that the shell runs each command from mise, not from another copy earlier on `PATH`, such as Homebrew’s `node`. The fix is to activate mise last in the shell profile.
    

## desktop

Sets how a Mac looks: the desktop picture, the page the [WebViewScreenSaver](https://github.com/liquidx/webviewscreensaver) screen saver shows, and the accent colour. macOS only, so the target needs `os: [darwin]`.

-   **Wallpaper.** macOS keeps one picture for every Space on every display, in its wallpaper store, `~/Library/Application Support/com.apple.wallpaper/Store/Index.plist`. System Events sees only the Spaces in view, so the plan reads the store itself, with a small JavaScript for Automation script run by `osascript`. If any Space shows another picture, the plan has one `UPDATE`. Apply sets the picture through System Events, rewrites every Space in the store, and restarts `WallpaperAgent` so the desktops redraw. The first run asks you to let your terminal control System Events. Another tool that also sets the wallpaper, such as a login agent, will undo this; turn it off.
    
-   **Accent.** `accent` is one of `blue`, `purple`, `pink`, `red`, `orange`, `yellow`, `green` or `graphite`. The plan reads `AppleAccentColor` with `defaults`; apply writes it and the matching `AppleHighlightColor`. Apps show the new colour once they restart; log out to see it everywhere.
    
-   **Screen saver.** The page, with the fonts and images its CSS loads from its folder inlined, becomes one `data:` URL. The plan compares it with the saver’s default page, `WVSSDefaultAddressURL` in the saver’s `Info.plist`, read with `plutil`. If they differ, apply writes it there with `plutil` and signs the saver again, ad hoc, with `codesign`. The saver shows its default page while its own list of pages, set in its Options, is empty.
    
-   If WebViewScreenSaver is not installed, in `~/Library/Screen Savers` or `/Library/Screen Savers`, the plan is a `MANUAL` step: run `assets/screensaver/build-saver.sh`. It builds the saver from source with Xcode and installs it for you. The Homebrew cask is disabled, because the release build fails the Gatekeeper check; a local build is not quarantined.
    
-   Choosing WebViewScreenSaver as the screen saver is yours to do, once, in System Settings, Screen Saver. macOS keeps that choice where no tool should write it.
    
-   A file that does not exist, such as a wallpaper not rendered yet, is a `MANUAL` step.
    

The saver runs sandboxed: it cannot read files outside its bundle, and macOS keeps its preferences in a container no terminal may open. That is why the page travels inside the saver itself. Query options such as `?light` do not reach a `data:` URL, so the saver shows the page’s default, dark look.

## settings

Keeps preferences at the values the config declares: on macOS the keys `defaults` reads and writes, on GNOME the keys of `gsettings`, and on Windows the user’s registry values. A target drives one system: `os: [darwin]` with `defaults`, `os: [linux]` with `gsettings` or `os: [windows]` with `registry`.

```yaml
- name: macos
  provider: settings
  os: [darwin]
  defaults:
    NSGlobalDomain:
      KeyRepeat: 2
      NSWindowResizeTime: 0.001
    com.apple.dock:
      autohide: true
    com.apple.screencapture:
      location: ~/Screenshots
```

-   The plan reads each key with `defaults read`. A key that is not set is a `CREATE`; one with another value is an `UPDATE`. Apply writes it with the type of the YAML value: `-bool`, `-int`, `-float` or `-string`.
    
-   The Dock, Finder and the screenshot settings are read only when their process starts, so a change to `com.apple.dock`, `com.apple.finder` or `com.apple.screencapture` adds an action that restarts it.
    
-   A key the config stops declaring keeps its value. Some keys need a logout to take effect.
    

On GNOME, `gsettings` maps a schema to its keys. A dark desktop with the dark Adwaita theme, a faster key repeat and the dock’s apps:

```yaml
- name: gnome
  provider: settings
  os: [linux]
  gsettings:
    org.gnome.desktop.interface:
      color-scheme: prefer-dark   # prefer-light or default for a light one
      gtk-theme: Adwaita-dark     # Adwaita for a light one
    org.gnome.desktop.peripherals.keyboard:
      delay: 250
      repeat-interval: 20
    org.gnome.shell:
      favorite-apps: [org.gnome.Nautilus.desktop, com.mitchellh.ghostty.desktop]
```

-   The plan reads each key with `gsettings get` and compares values whatever the type gsettings prints, so `uint32 250` matches `250`.
    
-   Apply runs `gsettings set` with the value as GVariant text: a string is quoted, a list becomes an array of strings. GNOME applies a change at once; nothing restarts.
    
-   A schema or key GNOME does not know is a `CREATE` whose apply fails with gsettings' message.
    

On Windows, `registry` maps a key under `HKCU` to its values. Dark mode, file extensions shown and the taskbar on the left:

```yaml
- name: windows
  provider: settings
  os: [windows]
  registry:
    'HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize':
      AppsUseLightTheme: false
      SystemUsesLightTheme: false
    'HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced':
      HideFileExt: 0
      TaskbarAl: 0
```

-   Only keys under `HKCU` (or `HKEY_CURRENT_USER`): the user’s own settings, which need no administrator. Quote a key in single quotes, so YAML keeps its backslashes.
    
-   The plan reads each value with `reg query`. A bool or an integer is a `REG_DWORD`, so `false` matches `0x0`; a string is a `REG_SZ`. A string starting with `~/` is a path in your home folder.
    
-   Apply runs `reg add` with the type. Windows applies most values at once; Explorer reads some only when it starts, so sign out to see them.
    

## services

Keeps background services running: Homebrew services, launchd agents on macOS, systemd user units and timers on Linux, and Task Scheduler tasks on Windows. A target drives one system, `os: [darwin]`, `os: [linux]` or `os: [windows]`. It starts what is stopped and never stops anything.

```yaml
- name: services
  provider: services
  os: [darwin]
  services: [sketchybar, borders, colima]
  agents: [~/Library/LaunchAgents/local.weekly-update.plist]
```

-   A service is in place when `brew services list` shows it `started`, or `scheduled` for one that runs on a timer. Otherwise apply runs `brew services restart`, which starts it at every login and recovers one that failed.
    
-   An agent is in place when `launchctl print gui/<uid>/<label>` finds it; the label is the file name without `.plist`. Otherwise apply loads it with `launchctl bootstrap`. The plist can come from a `dotfiles` target listed earlier in the config, which writes it first.
    
-   A changed plist is not reloaded. Reload it from a chezmoi `run_onchange_` script that hashes the plist.
    

On Linux, `agents` names `.service` and `.timer` files:

```yaml
- name: services-linux
  provider: services
  os: [linux]
  agents: [~/.config/systemd/user/backup.timer, ~/units/syncthing.service]
```

-   A unit is in place when `systemctl --user show` reports it enabled and active; a timer is active while it waits for its next run.
    
-   Otherwise apply runs `systemctl --user daemon-reload`, then `systemctl --user enable --now` with the file’s path, which links in a unit that lives outside systemd’s folders.
    
-   `services` works on Linux too: Homebrew on Linux runs `brew services` on systemd.
    

On Windows, `agents` names Task Scheduler task files, the `.xml` that Task Scheduler’s **Export** or `Export-ScheduledTask` writes:

```yaml
- name: tasks
  provider: services
  os: [windows]
  agents: [tasks/weekly-update.xml]
```

-   The task’s name is the file name without `.xml`. It is in place when `Get-ScheduledTask` finds it and it is not disabled; PowerShell reports the state in English whatever the system language.
    
-   A missing task is registered with `Register-ScheduledTask` from the file, in whatever encoding it has. A disabled one is enabled.
    
-   A changed file is not registered again. Re-register it by hand, or from a chezmoi `run_onchange_` script that hashes the file.
    

## keyboard

Remaps keys on Linux with [keyd](https://github.com/rvaiya/keyd), which works under X11, Wayland and the console alike. The target installs a keyd config as `/etc/keyd/default.conf` and keeps the keyd service enabled and running. Linux only, so the target needs `os: [linux]`.

```yaml
- name: keys
  provider: keyboard
  os: [linux]
  file: assets/keyd/default.conf
```

`assets/keyd/default.conf` makes Caps Lock Escape when tapped and Hyper (Control, Alt, Shift and Super together) when held, as Karabiner does on the Mac. Point `file` at your own config for other remaps.

-   keyd itself comes from your packages: `keyd` on Debian 13 and Ubuntu 25.04 or later, which name the program `keyd.rvaiya`, or the keyd COPR on Fedora. Until it is installed the plan shows a `MANUAL` step.
    
-   The plan compares the file byte for byte. Apply copies it with `sudo install -m 644`, then runs `keyd reload` when keyd is running.
    
-   keyd is in place when `systemctl show keyd` reports it enabled and active; otherwise apply runs `sudo systemctl enable --now keyd`.
    

## agents

Declares, once, what every AI coding agent gets: MCP servers, a rules file, skills, and the models Ollama serves. It writes them into Claude Code, Codex, Cursor and Gemini CLI. It adds and updates, and never removes a server, skill or model the file does not name.

```yaml
- name: ai
  provider: agents
  file: agents.yaml
```

`agents.yaml`:

```yaml
clients: [claude, codex, cursor, gemini]   # leave out for all four
servers:
  context7:
    command: npx
    args: [-y, "@upstash/context7-mcp@2.1.0"]   # pin a version; doctor warns otherwise
  github:
    url: https://api.githubcopilot.com/mcp/
    headers: {Authorization: "Bearer ${GITHUB_TOKEN}"}
rules: AGENTS.md          # relative to agents.yaml
skills: [skills/review]   # folders holding a SKILL.md
models: [qwen3:8b, nomic-embed-text]
```

   
| Client | MCP servers | Rules | Skills |
| --- | --- | --- | --- |
| 
claude

 | 

`~/.claude.json`

 | 

`~/.claude/CLAUDE.md`

 | 

`~/.claude/skills`

 |
| 

codex

 | 

`~/.codex/config.toml`

 | 

`~/.codex/AGENTS.md`

 | 

`~/.codex/skills`

 |
| 

cursor

 | 

`~/.cursor/mcp.json`

 | 

kept in Cursor’s settings

 | 

none

 |
| 

gemini

 | 

`~/.gemini/settings.json`

 | 

`~/.gemini/GEMINI.md`

 | 

none

 |

-   A server is set by name in `mcpServers`; every other key in the file stays as it was. Each file is replaced in one rename, read again just before the write, so an agent writing it in between keeps its change.
    
-   Codex reads TOML. Its servers live in a marked block of `config.toml`. A server Codex already declares outside the block is a `MANUAL` step, since a table declared twice breaks the file. An env value written `${NAME}` becomes an `env_vars` entry, which Codex passes through from your shell.
    
-   The rules file lands in a marked block, so what you wrote around it stays.
    
-   A skill is in place when every file in its folder matches.
    
-   A model is in place when `ollama list` shows it; otherwise apply runs `ollama pull`. `ollama serve` must be running. `list` shows each model.
    
-   Keep keys out of the file: write `${NAME}` and keep the value in the keychain or a password manager. `doctor` warns about plain-text keys.
    

## localdir

Keeps a directory holding the files written inline in the config. Writes go through `os.Root`, so a symlink or `..` cannot lead a write outside the directory. With `prune`, files the config does not list are deleted, after you confirm.

It is useful for a handful of small files, and it is the worked example for [writing your own provider](extending.html).
