Architecture
This document explains how the dotfiles repository is organized, how configuration reaches your home directory, how shells start up, and how the project stays secure and consistent through CI/CD.
Table of Contents
- Deployment Models
- XDG Base Directory Hierarchy
- Shell Startup Flow
- AI Tool Permission Model
- CI/CD Pipeline
- Adding New Tools
Deployment Models
The repository supports two deployment strategies, each with different trade-offs.
Dotter (Primary)
dotter is the recommended deployer. It supports profiles, conditional Windows paths, and templating.
# .dotter/global.toml (excerpt)
[default]
depends = ["agent", "dev", "editor", "shell", "terminal"]
[agent]
depends = ["claude", "codex", "kimi", "opencode"]
[dev]
depends = ["mise"]
[editor]
depends = ["helix", "zed"]
[shell]
depends = ["bash", "powershell", "zsh"]
[terminal]
depends = ["alacritty", "windows-terminal"]Typical workflow:
mise run deploy:check # dry-run preview
mise run deploy:apply # dotter deploy --force
mise run deploy:undeployActivate an optional profile at any time:
dotter deploy -p bashWindows-only paths use if = "dotter.windows":
[alacritty.files]
"dotfiles/.config/alacritty/alacritty.toml" = {
target = "~\\AppData\\Roaming\\alacritty\\alacritty.toml",
type = "symbolic",
if = "dotter.windows"
}NixOS / Home Manager
For NixOS, nixos/home.nix creates a fixed list of out-of-store symlinks using config.lib.file.mkOutOfStoreSymlink. Every configured path is asserted to exist at evaluation time:
home.file = builtins.listToAttrs (
map (target: {
name = target;
value = { source = link "${dotfilesRoot}/${target}"; };
}) dotfileLinks
);This path is useful when the Nix flake is the source of truth for the machine, but it covers only the Linux/Wayland subset of the dotfiles.
Partial sync
vendir fetches two very different kinds of thing: a couple of megabytes of plugins and themes, and about 1.1 GB of wallpaper images. Nothing in CI needs the second kind, so they live in separate configs.
| Command | Config | Lock files |
|---|---|---|
mise run deploy:sync | both | both pairs |
mise run deploy:sync:config | vendir.yml | vendir.lock.yml / vendir.lock.windows.yml |
mise run deploy:sync:wallpapers | vendir.wallpapers.yml | vendir.lock.wallpapers.yml / vendir.lock.wallpapers.windows.yml |
Splitting by file rather than by vendir sync --directory is deliberate. --directory matches directories[].path joined with contents[].path exactly — no prefix, no globs — so it would take one flag per component, maintained by hand alongside vendir.yml. And a parent such as dotfiles/.config cannot simply be declared as a directories[].path, because vendir owns that path outright: it deletes everything under it that the config does not declare.
Deployment splits the same way. dotfiles/.local/share/backgrounds belongs to the wallpapers dotter package, deliberately outside default; a desktop that wants them runs mise run deploy:sync:wallpapers and adds "wallpapers" to packages in .dotter/local.toml.
XDG Base Directory Hierarchy
The configuration enforces XDG Base Directory compliance to keep $HOME clean.
# dotfiles/.config/nushell/env.nu (excerpt)
$env.XDG_CONFIG_HOME = ($env.XDG_CONFIG_HOME? | default ($nu.home-dir | path join ".config"))
$env.XDG_CACHE_HOME = ($env.XDG_CACHE_HOME? | default ($nu.home-dir | path join ".cache"))
$env.XDG_DATA_HOME = ($env.XDG_DATA_HOME? | default ($nu.home-dir | path join ".local" "share"))
$env.XDG_STATE_HOME = ($env.XDG_STATE_HOME? | default ($nu.home-dir | path join ".local" "state"))| Purpose | Default path | Example contents |
|---|---|---|
| Config | ~/.config | Editor, shell, terminal, AI tool configs |
| Data | ~/.local/share | Tool data, plugin state, wallpapers |
| Cache | ~/.cache | Generated completions, download caches |
| State | ~/.local/state | History, persistent sessions |
| User binaries | ~/.local/bin | Personal scripts and manually installed tools |
Wallpapers follow that split: they are data, not configuration, so vendir fetches them into dotfiles/.local/share/backgrounds/ and hyprpaper.conf reads ~/.local/share/backgrounds/active. Keeping them out of ~/.config is also what lets CI skip a 1.1 GB download — see Partial sync.
Sensitive files such as SSH keys and OAuth tokens are stored outside the repository and referenced by path, never embedded in config files.
Shell Startup Flow
Each shell has its own startup chain. Keeping the chains short and modular makes startup fast and debugging easy.
Bash
~/.bash_profile
→ dotfiles/.config/bash/bash_profile
→ loads profile.d/*.sh (XDG, PATH, environment)
→ ~/.bashrc
→ dotfiles/.config/bash/bashrc
→ loads shell/shell.d/*.sh
→ loads bash/bash.d/*.bashZsh
~/.zshenv
→ sets ZDOTDIR and XDG variables
~/.config/zsh/.zprofile
→ loads shell/profile.d/*.sh
~/.config/zsh/.zshrc
→ loads shell/shell.d/*.sh
→ loads zsh/zsh.d/*.zshNushell
~/.config/nushell/env.nu
→ XDG, PATH, tool-specific environment
~/.config/nushell/config.nu
→ core settings
→ use modules/*.nu
→ generate autoload configs for Starship, Zoxide, Carapace on first runPowerShell
~/.config/powershell/profile.ps1
→ ~/.config/powershell/config.ps1
→ XDG setup
→ cached tool inits
→ aliases and functionsShared POSIX logic lives in dotfiles/.config/shell/, while shell-specific logic lives in dotfiles/.config/bash/, dotfiles/.config/zsh/, and dotfiles/.config/nushell/.
AI Tool Permission Model
Four AI agents are configured: Claude Code, Codex, Kimi Code, and OpenCode. All four use a default-deny permission model.
Permission Categories
| Category | Typical Default | Description |
|---|---|---|
| Read | Allow / Deny | Read source files; deny secrets and private keys |
| Write | Ask | Create or modify files |
| Execute | Ask | Run shell commands, build scripts, tests |
| Network | Ask | Fetch URLs, install packages, call APIs |
| Destructive | Deny | rm -rf, git push, kubectl delete, publishing |
Examples from Configs
OpenCode denies dangerous bash patterns and asks for edits:
// dotfiles/.config/opencode/opencode.jsonc
"permission": {
"*": "ask",
"bash": {
"*": "ask",
"rm -rf *": "deny",
"git push*": "deny",
"git status*": "allow"
}
}Claude Code's global settings carry an explicit allow list of read-only commands:
// dotfiles/.config/claude/settings.json (excerpt)
"permissions": {
"allow": [
"Bash(git status:*)",
"Bash(git diff:*)",
"Bash(mise run fmt:*)"
]
}Kimi Code uses ordered rules so sensitive-file denies are evaluated before the broad Read allow:
# dotfiles/.config/kimi-code/config.toml (excerpt)
[[permission.rules]]
decision = "deny"
pattern = "Read(*.env)"
reason = "Block reading local env files."
[[permission.rules]]
decision = "allow"
pattern = "Read"
reason = "Safe read-only file access."Codex uses workspace sandboxing with explicit filesystem globs:
# dotfiles/.config/codex/config.toml (excerpt)
[permissions.workspace.filesystem]
":workspace_roots" = { "." = "write", "**/.env" = "deny", "**/*.key" = "deny" }Why Ask vs Deny?
- Ask is used for state-mutating operations (writes, shell execution, network). This keeps the agent helpful while preventing silent changes.
- Deny is reserved for irreversible or high-risk actions (deleting files, force pushes, privilege escalation) and for reading sensitive material (keys, credentials, history).
- Allow is limited to read-only inspection commands that are safe to run repeatedly, such as
git status,git diff, andmise run fmt:all.
CI/CD Pipeline
GitHub Actions (.github/workflows/ci.yml) runs in three stages.
Stage 1: Quality Gates
All jobs run in parallel and must pass before Stage 2.
| Job | Purpose |
|---|---|
guard | Classify the run as trusted or fork; every other job depends on it |
fmt | Check dprint, stylua, shfmt, and Justfile formatting |
lint | Run yamllint, actionlint, shellcheck, selene, markdownlint, etc. |
security | Scan for secrets, vulnerabilities, licenses, and policy violations — TruffleHog, Semgrep, Trivy, OSV, Grype, Grant, OPA, zizmor |
antivirus | ClamAV malware scan |
docs | aube install/audit, VitePress build, Lychee link check, site hardening checks |
guard publishes a trusted output that gates every privileged step. A fork runs all of the above and none of the steps that write to the repository — no SARIF upload, no pull-request comment, no deploy.
Vulnerabilities, licenses and exploitability
Three questions, three tools, one document that says when an answer does not apply here:
| Question | Tool | Configuration |
|---|---|---|
| What is in it? | Syft (from the artifact pipeline) | — |
| Is it vulnerable? | Trivy, OSV and Grype | .grype.yaml |
| Which licenses are in it? | Grant, plus Trivy's license scanner | .grant.yaml |
| Does it apply to us? | OpenVEX | misc/vex/dotfiles.openvex.json |
Findings are never silenced with an ignore file. A suppression is a VEX statement with an author, a timestamp, the affected package and an OpenVEX justification, and misc/policy/vex.rego fails the build if one of those is missing.
The license question is answered for the deployed tree, not just the committed one. vendir pulls in tmux plugins and yazi flavors that are gitignored but end up in $HOME, so the security job runs mise run deploy:sync:config first and then asks mise run security:trivy:license and mise run security:grant about the result. Wallpapers are not part of that sync (see Partial sync); their licenses are covered when the wallpaper set is released.
The documentation site is covered too: docs/src/public/_headers ships a Content-Security-Policy and the usual hardening headers, misc/semgrep/web.yaml rejects executable markup in SVG and Markdown, and mise run docs:security proves the headers reached docs/dist and that no bundled JavaScript carries a known advisory.
Stage 2: Validation and Performance
Both jobs run only after Stage 1 succeeds and use tools pinned by mise. The one exception is the performance job's shell interpreters: bash, zsh and retry have no mise registry entry, so that job installs them with nix profile add — the only use of Nix in CI.
| Job | Purpose |
|---|---|
validate | mise run deploy:apply (dotter), install the global toolchain, then mise run validate:all |
performance | mise run deploy:sync:config, then retry --times=3 -- mise run bench:ci; compares shell startup ratios with the baseline |
validate:all covers AI agents, MCP servers, editors, shells, multiplexers, and CLI tools; for example hx --health all for Helix, zellij setup --check for Zellij, and ya env for Yazi.
Stage 3: Release Artifact and Deploy
The artifact job runs mise run artifact:dotfiles:all — it packs, describes, scans and verifies the dotfiles release set (the wallpapers set builds only on pushes to main). dotfiles/ is published as something you can check rather than trust:
| File | Answers |
|---|---|
dotfiles.tar.gz | the bytes themselves |
sbom.spdx.json | what is inside |
licenses.json | where each component came from and under what terms |
vex.openvex.json | which findings do not apply here, and why |
manifest.json | which commit and which vendir state produced this |
checksums.sha256 | that nothing changed in transit |
The archive also carries .dotter/ and INSTALL.md, which is what makes an unpacked release deployable without the repository. It is packed in two halves — git archive over an explicit pathspec for the tracked files, a second tar for the vendored plugins that git cannot see — and concatenated. Both halves are deterministic, ownership is zeroed, timestamps come from the commit, and gzip -n runs as its own step because tar --gzip would stamp the current time.
Wallpapers are a separate set, built only on pushes to main — ~1.1 GB of images that no pull request needs, and they stay out of releases.
Tagged commits publish the set as a GitHub Release with build provenance (.github/workflows/release.yml), but only when the tag sits on a main commit whose CI went green. To check a release before you trust it:
gh release download --repo arttet/dotfiles
sha256sum --check checksums.sha256
gh attestation verify dotfiles.tar.gz --repo arttet/dotfiles
jq -r '.source.commit, .build.vendir.lock.sha256' manifest.json
jq -r '.warnings[]' licenses.json
grype sbom:sbom.spdx.json --vex vex.openvex.json
tar -xzf dotfiles.tar.gzUntagged builds still land as CI artifacts (gh run download --name dotfiles-artifacts), but those expire after 90 days and carry no provenance.
The license inventory reports rather than blocks. Some vendored components are GPL-3.0 or AGPL-3.0 while this repository is MIT; they are used deliberately, so the point is that you can see them.
Deploy
Pull requests get a Cloudflare Pages preview (mise run deploy:cloudflare:preview). On pushes to main (not scheduled), the docs go to Cloudflare Pages production and to GitHub Pages.
The site travels as a packed archive with a checksums.sha256 beside it, not as a raw directory, so the deploy job can check what it is about to publish: docs:verify:hash runs before docs:unpack, and a corrupted archive stops the deploy instead of reaching the internet. GitHub Pages uses its own verified channel and is unaffected.
Adding New Tools
When adding a new tool config, follow this checklist to keep deployment, validation, and documentation in sync:
- Create the config file under
dotfiles/.config/<tool>/. - Map it for deployment in
.dotter/global.toml. - Add a NixOS link in
nixos/home.nixif the tool is Linux/Wayland relevant. - Add a validation task (
validate:<group>:<tool>) inmise.tomland list it invalidate:all. - Document hotkeys in
docs/src/<section>/<tool>.mdand add the entry todocs/src/cheatsheet.md. - Update the docs navigation in
docs/.vitepress/config.mts. - Run
mise run check:allbefore pushing.
This keeps the repository self-describing: every deployed file has a documented path, a validation step, and a place in the architecture.