Skip to content

Creating Modules

This guide walks you through creating a new module for the dotfiles management system.

A module is a self-contained unit that installs and configures a specific tool or application. Modules consist of:

  • module.yml - Metadata and configuration
  • install.sh - Main installation logic
  • os/*.sh - OS-specific setup (optional)
  • verify.sh - Post-installation verification (optional)
  • files/ - Configuration files to deploy (optional)
Terminal window
mkdir -p modules/mymodule
cd modules/mymodule
name: mymodule
description: Install and configure My Tool
version: 1.0.0
priority: 100
dependencies: []
os: [] # Empty means all platforms
requires: []
tags:
- development
- tools
files:
- source: files/config.conf
dest: ~/.config/mymodule/config.conf
type: symlink
prompts:
- key: theme
message: "Which theme would you like to use?"
type: choice
options:
- dark
- light
default: dark
#!/usr/bin/env bash
set -euo pipefail
# Log what we're doing
log_info "Installing mymodule..."
# Install the package
pkg_install mymodule
# Create config directory
mkdir -p ~/.config/mymodule
# Get user's choice from prompt
THEME="${DOTFILES_PROMPT_THEME:-dark}"
log_info "Configuring with theme: $THEME"
log_success "mymodule installed successfully"
Terminal window
chmod +x install.sh
Terminal window
# From the dotfiles root directory
./bin/dotfiles install mymodule --dry-run
# Actually install
./bin/dotfiles install mymodule
modules/mymodule/
├── module.yml # Module metadata
├── install.sh # Main installation script
├── verify.sh # Verification script (optional)
├── os/ # OS-specific scripts (optional)
│ ├── macos.sh
│ ├── ubuntu.sh
│ └── arch.sh
└── files/ # Configuration files (optional)
├── config.conf
├── theme.conf.tmpl # Template file
└── aliases.sh
name: mymodule # Module identifier (lowercase, alphanumeric, hyphens)
description: "Brief description" # Short description
version: 1.0.0 # Semantic version
priority: 100 # Execution order (default: 100, lower runs first)
dependencies: # Other modules required first
- git
- zsh
os: # Supported platforms (empty = all)
- macos
- ubuntu
- arch
requires: # System requirements (commands that must exist)
- git
- curl
tags: # Categorization
- development
- shell
timeout: 10m # Script timeout (default: 5m)
# Accepts: "10s", "5m", "1h", etc.

Files to deploy from the module directory to the system:

files:
- source: files/config.conf # Path relative to module directory
dest: ~/.config/app/config # Destination (~ expands to home)
type: symlink # "symlink", "copy", or "template"
- source: files/theme.tmpl
dest: ~/.config/app/theme
type: template # Rendered as Go template

File Types:

  • symlink - Creates symbolic link (default)
  • copy - Copies file preserving permissions
  • template - Renders as Go template before writing

Invariant — never symlink a config the tool rewrites. A symlink points the destination straight at the repo source, so anything that writes to that path at runtime (a shell appending to ~/.zshrc, git config --global, starship rewriting ~/.config/starship.toml) writes back into the repo and permanently dirties the checkout — which then blocks git pull on the next update. Use symlink only for read-only reference files the owning tool never modifies. For anything the tool may rewrite, use template or copy so the deployed file is a materialized copy the tool can change freely. If a generated config is produced by your install.sh via a redirect (cmd > "$DEST"), call demote_symlink "$DEST" from lib/helpers.sh first so a pre-existing legacy symlink is migrated instead of followed. dotfiles validate flags a symlink aimed at a known tool-writable destination.

Migrating an existing module from symlink to template/copy is safe and automatic: on the next install the engine replaces the old link with a real file, backing up any content that lives outside a managed repo first.

Interactive questions to ask during installation:

prompts:
# Text input
- key: api_key
message: "Enter your API key:"
type: input
default: ""
# Yes/No confirmation
- key: enable_feature
message: "Enable advanced features?"
type: confirm
default: "true"
# Multiple choice
- key: theme
message: "Select a theme:"
type: choice
options:
- dark
- light
- auto
default: dark

Answers are available as environment variables in scripts: $DOTFILES_PROMPT_KEY_NAME (uppercase).

Main installation logic. This is required.

#!/usr/bin/env bash
set -euo pipefail
# Helpers are automatically available
log_info "Installing mymodule..."
# Check if already installed
if pkg_installed mymodule; then
log_info "mymodule already installed, skipping..."
exit 0
fi
# Install package
pkg_install mymodule
# Additional setup
mkdir -p ~/.config/mymodule
# Success!
log_success "mymodule installed"

Optional scripts for platform-specific setup:

os/macos.sh
#!/usr/bin/env bash
set -euo pipefail
log_info "Running macOS-specific setup..."
# macOS-specific commands
defaults write com.myapp theme -string "dark"
os/ubuntu.sh
#!/usr/bin/env bash
set -euo pipefail
log_info "Running Ubuntu-specific setup..."
# Add PPA or configure apt
sudo add-apt-repository -y ppa:myapp/ppa
os/arch.sh
#!/usr/bin/env bash
set -euo pipefail
log_info "Running Arch-specific setup..."
# AUR installation or Arch-specific config

Optional post-installation verification:

#!/usr/bin/env bash
set -euo pipefail
log_info "Verifying mymodule installation..."
# Check command exists
if ! command -v mymodule &>/dev/null; then
log_error "mymodule command not found"
exit 1
fi
# Check config file
if [[ ! -f ~/.config/mymodule/config.conf ]]; then
log_error "Config file not found"
exit 1
fi
log_success "Verification passed"

All scripts have access to helper functions from lib/helpers.sh:

Terminal window
log_info "Informational message"
log_warn "Warning message"
log_error "Error message"
log_success "Success message"
Terminal window
if is_macos; then
echo "Running on macOS"
elif is_ubuntu; then
echo "Running on Ubuntu"
elif is_arch; then
echo "Running on Arch Linux"
fi
Terminal window
if has_sudo; then
sudo apt install package
fi
if is_interactive; then
# Show interactive prompts
fi
if is_dry_run; then
log_info "[dry-run] Would install package"
exit 0
fi
Terminal window
# Check if installed
if pkg_installed git; then
echo "Git is installed"
fi
# Install packages (skips if already installed)
pkg_install git curl wget
# Works across brew (macOS), apt (Ubuntu), pacman (Arch)
Terminal window
# Create symlink (backs up existing file)
link_file "$DOTFILES_MODULE_DIR/files/config" ~/.config/app/config
# Copy file (backs up existing file)
copy_file "$DOTFILES_MODULE_DIR/files/script.sh" ~/bin/script.sh
Terminal window
# Clone a GitHub repo into a local directory (idempotent, dry-run safe)
github_clone "my-tool" "$HOME/.local/share/my-tool"
# Short form uses $DOTFILES_USER_GITHUB_USER automatically:
# github_clone "my-tool" DEST → github.com/<github_user>/my-tool
# Long form for any GitHub repo:
github_clone "other-user/their-tool" "$HOME/.local/share/their-tool"
# Optionally specify a branch/tag (default: "main"):
github_clone "my-tool" "$HOME/.local/share/my-tool" "v2.0"

Skips silently if the destination already exists, so it is safe to re-run.

Terminal window
# Render template (calls back to Go CLI)
render_template "$DOTFILES_MODULE_DIR/files/config.tmpl" ~/.config/app/config
# Get secret from the configured provider (calls back to Go CLI)
API_KEY=$(get_secret "op://vault/item/field")

The default secrets provider is noop. With no provider configured, get_secret fails (non-zero exit, empty output) — under set -euo pipefail a bare KEY=$(get_secret ...) will abort the script. Only call get_secret when a provider is configured (secrets.provider: 1password, via your overlay or DOTFILES_SECRETS_PROVIDER), and guard it so a missing secret degrades gracefully, e.g.:

Terminal window
if API_KEY=$(get_secret "op://vault/item/field" 2>/dev/null); then
# use "$API_KEY"
else
log_warn "No secret provider configured; skipping API key setup"
fi
Terminal window
# These respect --unattended flag automatically
# Text input
NAME=$(prompt_input "Enter your name:" "John Doe")
# Confirmation
if prompt_confirm "Enable feature?" "true"; then
echo "Feature enabled"
fi
# Choice
THEME=$(prompt_choice "Select theme:" "dark" "light" "auto")

Your scripts receive these environment variables:

Terminal window
$DOTFILES_OS # Operating system: darwin, ubuntu, arch
$DOTFILES_ARCH # Architecture: amd64, arm64
$DOTFILES_PKG_MGR # Package manager: brew, apt, pacman
$DOTFILES_HAS_SUDO # "true" or "false"
Terminal window
$DOTFILES_HOME # User's home directory
$DOTFILES_DIR # Dotfiles repository path
$DOTFILES_BIN # Path to dotfiles CLI binary
$DOTFILES_MODULE_DIR # Current module directory
$DOTFILES_MODULE_NAME # Current module name
Terminal window
$DOTFILES_INTERACTIVE # "true" if interactive terminal
$DOTFILES_DRY_RUN # "true" in --dry-run mode
$DOTFILES_VERBOSE # "true" in verbose mode
Terminal window
$DOTFILES_USER_NAME # From config.yml
$DOTFILES_USER_EMAIL # From config.yml
$DOTFILES_USER_GITHUB_USER # From config.yml

Prompt answers are available as DOTFILES_PROMPT_<KEY> (uppercase):

module.yml
prompts:
- key: theme
message: "Select theme:"
type: choice
options: [dark, light]
default: dark
install.sh
THEME="${DOTFILES_PROMPT_THEME}" # "dark" or "light"

Templates use Go’s text/template syntax.

Templates have access to:

.User.name // User's full name (lowercase keys!)
.User.email // User's email
.User.github_user // GitHub username
.OS // Operating system
.Arch // Architecture
.Home // Home directory
.DotfilesDir // Dotfiles repository path
.XDGConfigHome // Resolved XDG_CONFIG_HOME (env or ~/.config)
.Module.<key> // This module's config.yml settings only (settings, NOT prompt answers)
.Secrets // Always an empty map here — secrets reach scripts via the get_secret helper
.Env.<VAR> // Environment overrides, incl. DOTFILES_PROMPT_* (prompt answers)

Key facts: .User keys are lowercase (.User.name, not .User.Name — the latter renders empty). .Module contains only this module’s config.yml modules.<name>.* settings (including content-overlay values, type-preserving); prompt answers are NOT in .Module — read them from .Env as DOTFILES_PROMPT_*, e.g. {{ index .Env "DOTFILES_PROMPT_SSH_KEY_TYPE" }}. .Secrets is always an empty (non-nil) map during rendering; use the get_secret shell helper for secret values.

files/gitconfig.tmpl
[user]
name = {{ .User.name }}
email = {{ .User.email }}
[github]
user = {{ .User.github_user }}
{{- if eq .OS "darwin" }}
[credential]
helper = osxkeychain
{{- else }}
[credential]
helper = cache --timeout=900
{{- end }}
[init]
defaultBranch = {{ .Module.default_branch | default "main" }}
{{ env "HOME" }} // Get environment variable
{{ default "value" .Module.key }} // Default value if empty
{{ .User.name | upper }} // Uppercase
{{ .User.name | lower }} // Lowercase
{{ contains "substring" .OS }} // String contains
{{ join "," .Module.features }} // Join slice
{{ .Module.name | trimSpace }} // Trim whitespace

Use the github_clone helper to include tools from your own (or others’) GitHub repositories. Each such tool should be its own module so it gets independent dependency tracking, idempotence checking, and selective installation.

modules/my-tool/module.yml
name: my-tool
description: My personal CLI tool from GitHub
requires: [git]
modules/my-tool/install.sh
#!/usr/bin/env bash
set -euo pipefail
TOOL_DIR="$HOME/.local/share/my-tool"
# Clones github.com/<DOTFILES_USER_GITHUB_USER>/my-tool if not already present
github_clone "my-tool" "$TOOL_DIR"
mkdir -p "$HOME/.local/bin"
ln -sf "$TOOL_DIR/bin/my-tool" "$HOME/.local/bin/my-tool"
log_success "my-tool installed"

The short-form repo name ("my-tool") is expanded using $DOTFILES_USER_GITHUB_USER from your config.yml. Use "other-user/repo" to clone from a different account.

If several small repos always travel together, one module can clone all of them:

modules/personal-tools/install.sh
github_clone "scripts" "$HOME/.local/share/scripts"
github_clone "prompts" "$HOME/.local/share/prompts"

Declare dependencies to ensure modules run in the correct order:

name: mymodule
dependencies:
- git # Git must be installed first
- zsh # Zsh must be installed first

Dependencies are:

  • Transitive - If you depend on git and git depends on ssh, you automatically depend on ssh too
  • Resolved automatically - The system uses topological sorting to determine execution order
  • Cycle-detected - Circular dependencies are rejected with a clear error message

Modules with the same dependencies run in priority order:

priority: 50 # Lower numbers run first

Default: 100

Examples:

  • 1password: 10 (runs very early, no dependencies)
  • ssh: 20 (no dependencies)
  • git: 30 (depends on ssh)
  • zsh: 40 (depends on git)
  • neovim: 50 (depends on git)

Within the same priority level, modules are sorted alphabetically by name.

os:
- macos
- ubuntu

If os is empty or omitted, the module runs on all platforms.

Use OS-specific scripts:

modules/mymodule/
├── install.sh # Runs on all platforms
├── os/
│ ├── macos.sh # Only runs on macOS
│ ├── ubuntu.sh # Only runs on Ubuntu
│ └── arch.sh # Only runs on Arch

Or use conditionals in install.sh:

Terminal window
if is_macos; then
brew install mypackage
elif is_ubuntu; then
sudo apt install mypackage
elif is_arch; then
sudo pacman -S mypackage
fi
Terminal window
dotfiles install mymodule --dry-run -v

Shows what would happen without making changes.

Terminal window
dotfiles install mymodule -v

Shows detailed execution information.

To test reinstallation:

Terminal window
rm ~/.dotfiles/.state/mymodule.json
dotfiles install mymodule

Add tests in test/integration/test_install.sh:

Terminal window
# --- Test: MyModule verification ---
echo ""
echo "--- Test: MyModule verification ---"
assert_command_exists "mymodule is installed" "mymodule"
assert_file_exists "config exists" "$HOME/.config/mymodule/config.conf"

Scripts should be safe to run multiple times:

Terminal window
# Good: Check before installing
if ! pkg_installed mypackage; then
pkg_install mypackage
fi
# Bad: Always tries to install
pkg_install mypackage

Use set -euo pipefail to fail fast:

#!/usr/bin/env bash
set -euo pipefail # Exit on error, undefined vars, pipe failures

The helpers automatically backup files:

Terminal window
# Automatically backs up ~/.zshrc if it exists
link_file "$DOTFILES_MODULE_DIR/files/zshrc" ~/.zshrc

Backups are created with .backup-TIMESTAMP suffix.

Only declare direct dependencies:

Terminal window
# Good
dependencies:
- git
# Bad: zsh already depends on git, transitive deps are automatic
dependencies:
- git
- ssh # ssh is a dependency of git

Use descriptive log messages:

Terminal window
log_info "Installing neovim package..."
log_info "Creating ~/.config/nvim directory..."
log_info "Installing packer.nvim plugin manager..."
log_success "Neovim configured successfully"

Check dry-run and other flags:

Terminal window
if is_dry_run; then
log_info "[dry-run] Would install mypackage"
exit 0
fi

Provide clear prompt messages and sensible defaults:

prompts:
- key: theme
message: "Which color theme would you like? (dark recommended for most terminals)"
type: choice
options: [dark, light, auto]
default: dark

Custom & override modules via the content overlay

Section titled “Custom & override modules via the content overlay”

You don’t have to add a module to this repo. If you set DOTFILES_CONTENT_DIR to a content directory, its modules/ is discovered alongside the engine’s, with same-name content-wins precedence:

~/.config/dotfiles/modules/
mymod/ # a CUSTOM module — a new name the engine doesn't ship
git/ # an OVERRIDE — same name as a built-in, replaces it wholesale

Each content module is a normal module directory (module.yml, install.sh, files/, …) authored exactly as described above. An override is whole-module replacement, not a merge: the content module.yml is used in full instead of the built-in one. Precedence is keyed on the module’s name (which defaults to the directory name) — to override built-in git, the content module’s name must be git, not just its directory. A content module whose name matches no built-in is simply added as a custom module. dotfiles list and dotfiles status tag each module built-in, override, or custom so overrides are visible. A content profile (~/.config/dotfiles/profiles/*.yml) can then list your custom or overridden modules by name. See Content Overlay & Packaging for the two-repo model and a copy-me example at docs/examples/content-repo/, or config.overlay.example.yml for an annotated overlay config.

dotfiles new still scaffolds into this repo’s modules/; copy the generated directory into your content dir to make it a content module.

See existing modules for reference:

  • Extending - Ship your modules from a personal content overlay (custom + override), step by step
  • Content Overlay - The two-repo model your custom modules live in
  • CLI Reference - Full command documentation
  • Idempotence - How re-runs and change detection work
  • Rollback Guide - Uninstalling modules and reversing changes
  • CI/CD Guide - Add integration tests for your module