Skip to content

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

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.

toml
# .dotter/global.toml (excerpt)
[default]
depends = ["agent", "editor", "shell", "terminal"]

[agent]
depends = ["claude", "codex", "kimi", "opencode"]

[editor]
depends = ["helix", "zed"]

[shell]
depends = ["bash", "powershell", "zsh"]

[terminal]
depends = ["alacritty", "windows-terminal"]

Typical workflow:

bash
just deploy check    # dry-run preview      (mise run deploy:check)
just deploy apply    # dotter deploy --force (mise run deploy:apply)
just deploy undeploy # dotter undeploy      (mise run deploy:undeploy)

Activate an optional profile at any time:

bash
dotter deploy -p bash

Windows-only paths use if = "dotter.windows":

toml
[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:

nix
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.

Windows vs Linux Differences

ConcernLinux / macOSWindows
Deployerdotterdotter
Config path~/.config~/AppData/Roaming for many GUI apps
ShellNushell, Bash, ZshNushell, PowerShell, Bash (Git Bash)
WMHyprlandWindows window manager
Benchmarksjust bench skips shells that are not installedsame

XDG Base Directory Hierarchy

The configuration enforces XDG Base Directory compliance to keep $HOME clean.

nu
# 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"))
PurposeDefault pathExample contents
Config~/.configEditor, shell, terminal, AI tool configs
Data~/.local/shareTool data, plugin state, vendored assets
Cache~/.cacheGenerated completions, download caches
State~/.local/stateHistory, persistent sessions
User binaries~/.local/binPersonal scripts and manually installed tools

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

text
~/.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/*.bash

Zsh

text
~/.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/*.zsh

Nushell

text
~/.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 run

PowerShell

text
~/.config/powershell/profile.ps1
  → ~/.config/powershell/config.ps1
      → XDG setup
      → cached tool inits
      → aliases and functions

Shared 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

CategoryTypical DefaultDescription
ReadAllow / DenyRead source files; deny secrets and private keys
WriteAskCreate or modify files
ExecuteAskRun shell commands, build scripts, tests
NetworkAskFetch URLs, install packages, call APIs
DestructiveDenyrm -rf, git push, kubectl delete, publishing

Examples from Configs

OpenCode denies dangerous bash patterns and asks for edits:

jsonc
// dotfiles/.config/opencode/opencode.jsonc
"permission": {
  "*": "ask",
  "bash": {
    "*": "ask",
    "rm -rf *": "deny",
    "git push*": "deny",
    "git status*": "allow"
  }
}

Claude Code lists allowed read-only commands and denies secret paths:

json
// dotfiles/.config/claude/settings.json (excerpt)
"permissions": {
  "allow": ["Bash(git status:*)", "Bash(git diff:*)", "Bash(just fmt:*)", ...],
  "deny": ["Read(**/.env)", "Read(**/*.pem)", "Bash(rm -rf:*)", "Bash(git push*:*)"]
}

Kimi Code uses ordered rules so sensitive-file denies are evaluated before the broad Read allow:

toml
# 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:

toml
# 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, and just fmt.

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.

JobPurpose
guardClassify the run as trusted or fork; every other job depends on it
fmtCheck dprint, stylua, shfmt, and Justfile formatting
lintRun yamllint, actionlint, shellcheck, selene, markdownlint, etc.
securitySecrets, SAST, HTML/SVG rules, Trivy, OSV, Grype, Grant, OPA policies, zizmor
codeqlSemantic analysis of GitHub Actions workflows and TypeScript sources
dependency-reviewBlock dependency changes with known high-severity advisories (pull requests)
antivirusClamAV malware scan
docsaube 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:

QuestionToolConfiguration
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?OpenVEXmisc/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 policy/vex.rego fails the build if one of those is missing.

The license question is answered for the whole deployment, not just the committed tree. vendir pulls in tmux plugins, yazi flavors and wallpapers that are gitignored but end up in $HOME, so the security job syncs them and mise run security:licenses reports every component with its license and origin, warning on anything outside MIT and Apache-2.0. The check refuses to run against an unsynced tree instead of quietly reporting on half of it.

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 — no Nix, no stow.

JobPurpose
validatemise run deploy:apply (dotter), install the global toolchain, then mise run validate:all
performancemise run bench:setup then 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 yazi --debug for Yazi.

Stage 3: 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.

Adding New Tools

When adding a new tool config, follow this checklist to keep deployment, validation, and documentation in sync:

  1. Create the config file under dotfiles/.config/<tool>/.
  2. Map it for deployment in .dotter/global.toml.
  3. Add a NixOS link in nixos/home.nix if the tool is Linux/Wayland relevant.
  4. Add a validation task (validate:<group>:<tool>) in mise.toml and list it in validate:all.
  5. Document hotkeys in docs/src/<section>/<tool>.md and add the entry to docs/src/cheatsheet.md.
  6. Update the docs navigation in docs/.vitepress/config.mts.
  7. Run just fmt and just lint before committing.

This keeps the repository self-describing: every deployed file has a documented path, a validation step, and a place in the architecture.

Platform Documentation