Creating Modules
Creating Modules
Section titled “Creating Modules”This guide walks you through creating a new module for the dotfiles management system.
Overview
Section titled “Overview”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)
Quick Start
Section titled “Quick Start”1. Create Module Directory
Section titled “1. Create Module Directory”mkdir -p modules/mymodulecd modules/mymodule2. Create module.yml
Section titled “2. Create module.yml”name: mymoduledescription: Install and configure My Toolversion: 1.0.0priority: 100dependencies: []os: [] # Empty means all platformsrequires: []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: dark3. Create install.sh
Section titled “3. Create install.sh”#!/usr/bin/env bashset -euo pipefail
# Log what we're doinglog_info "Installing mymodule..."
# Install the packagepkg_install mymodule
# Create config directorymkdir -p ~/.config/mymodule
# Get user's choice from promptTHEME="${DOTFILES_PROMPT_THEME:-dark}"log_info "Configuring with theme: $THEME"
log_success "mymodule installed successfully"4. Make Scripts Executable
Section titled “4. Make Scripts Executable”chmod +x install.sh5. Test Your Module
Section titled “5. Test Your Module”# From the dotfiles root directory./bin/dotfiles install mymodule --dry-run
# Actually install./bin/dotfiles install mymoduleModule Structure
Section titled “Module Structure”Complete Example
Section titled “Complete Example”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.shmodule.yml Schema
Section titled “module.yml Schema”Required Fields
Section titled “Required Fields”name: mymodule # Module identifier (lowercase, alphanumeric, hyphens)description: "Brief description" # Short descriptionversion: 1.0.0 # Semantic versionOptional Fields
Section titled “Optional Fields”priority: 100 # Execution order (default: 100, lower runs first)dependencies: # Other modules required first - git - zshos: # Supported platforms (empty = all) - macos - ubuntu - archrequires: # System requirements (commands that must exist) - git - curltags: # Categorization - development - shelltimeout: 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 templateFile 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
symlinkpoints the destination straight at the repo source, so anything that writes to that path at runtime (a shell appending to~/.zshrc,git config --global,starshiprewriting~/.config/starship.toml) writes back into the repo and permanently dirties the checkout — which then blocksgit pullon the next update. Usesymlinkonly for read-only reference files the owning tool never modifies. For anything the tool may rewrite, usetemplateorcopyso the deployed file is a materialized copy the tool can change freely. If a generated config is produced by yourinstall.shvia a redirect (cmd > "$DEST"), calldemote_symlink "$DEST"fromlib/helpers.shfirst so a pre-existing legacy symlink is migrated instead of followed.dotfiles validateflags asymlinkaimed at a known tool-writable destination.Migrating an existing module from
symlinktotemplate/copyis 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.
Prompts
Section titled “Prompts”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: darkAnswers are available as environment variables in scripts: $DOTFILES_PROMPT_KEY_NAME (uppercase).
Writing Scripts
Section titled “Writing Scripts”install.sh
Section titled “install.sh”Main installation logic. This is required.
#!/usr/bin/env bashset -euo pipefail
# Helpers are automatically availablelog_info "Installing mymodule..."
# Check if already installedif pkg_installed mymodule; then log_info "mymodule already installed, skipping..." exit 0fi
# Install packagepkg_install mymodule
# Additional setupmkdir -p ~/.config/mymodule
# Success!log_success "mymodule installed"OS-Specific Scripts (os/*.sh)
Section titled “OS-Specific Scripts (os/*.sh)”Optional scripts for platform-specific setup:
#!/usr/bin/env bashset -euo pipefail
log_info "Running macOS-specific setup..."
# macOS-specific commandsdefaults write com.myapp theme -string "dark"#!/usr/bin/env bashset -euo pipefail
log_info "Running Ubuntu-specific setup..."
# Add PPA or configure aptsudo add-apt-repository -y ppa:myapp/ppa#!/usr/bin/env bashset -euo pipefail
log_info "Running Arch-specific setup..."
# AUR installation or Arch-specific configverify.sh
Section titled “verify.sh”Optional post-installation verification:
#!/usr/bin/env bashset -euo pipefail
log_info "Verifying mymodule installation..."
# Check command existsif ! command -v mymodule &>/dev/null; then log_error "mymodule command not found" exit 1fi
# Check config fileif [[ ! -f ~/.config/mymodule/config.conf ]]; then log_error "Config file not found" exit 1fi
log_success "Verification passed"Available Helper Functions
Section titled “Available Helper Functions”All scripts have access to helper functions from lib/helpers.sh:
Logging
Section titled “Logging”log_info "Informational message"log_warn "Warning message"log_error "Error message"log_success "Success message"OS Detection
Section titled “OS Detection”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"fiEnvironment Checks
Section titled “Environment Checks”if has_sudo; then sudo apt install packagefi
if is_interactive; then # Show interactive promptsfi
if is_dry_run; then log_info "[dry-run] Would install package" exit 0fiPackage Management
Section titled “Package Management”# Check if installedif 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)File Operations
Section titled “File Operations”# 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.shGitHub / Git Operations
Section titled “GitHub / Git Operations”# 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.
Templates and Secrets
Section titled “Templates and Secrets”# 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_secretfails (non-zero exit, empty output) — underset -euo pipefaila bareKEY=$(get_secret ...)will abort the script. Only callget_secretwhen a provider is configured (secrets.provider: 1password, via your overlay orDOTFILES_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"elselog_warn "No secret provider configured; skipping API key setup"fi
Interactive Prompts
Section titled “Interactive Prompts”# These respect --unattended flag automatically
# Text inputNAME=$(prompt_input "Enter your name:" "John Doe")
# Confirmationif prompt_confirm "Enable feature?" "true"; then echo "Feature enabled"fi
# ChoiceTHEME=$(prompt_choice "Select theme:" "dark" "light" "auto")Environment Variables
Section titled “Environment Variables”Your scripts receive these environment variables:
System Information
Section titled “System Information”$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"$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 nameExecution Context
Section titled “Execution Context”$DOTFILES_INTERACTIVE # "true" if interactive terminal$DOTFILES_DRY_RUN # "true" in --dry-run mode$DOTFILES_VERBOSE # "true" in verbose modeUser Configuration
Section titled “User Configuration”$DOTFILES_USER_NAME # From config.yml$DOTFILES_USER_EMAIL # From config.yml$DOTFILES_USER_GITHUB_USER # From config.ymlPrompt Answers
Section titled “Prompt Answers”Prompt answers are available as DOTFILES_PROMPT_<KEY> (uppercase):
prompts: - key: theme message: "Select theme:" type: choice options: [dark, light] default: darkTHEME="${DOTFILES_PROMPT_THEME}" # "dark" or "light"Working with Templates
Section titled “Working with Templates”Templates use Go’s text/template syntax.
Template Context
Section titled “Template Context”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:
.Userkeys are lowercase (.User.name, not.User.Name— the latter renders empty)..Modulecontains only this module’sconfig.ymlmodules.<name>.*settings (including content-overlay values, type-preserving); prompt answers are NOT in.Module— read them from.EnvasDOTFILES_PROMPT_*, e.g.{{ index .Env "DOTFILES_PROMPT_SSH_KEY_TYPE" }}..Secretsis always an empty (non-nil) map during rendering; use theget_secretshell helper for secret values.
Example Template
Section titled “Example Template”[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" }}Template Functions
Section titled “Template Functions”{{ 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 whitespaceCloning from GitHub
Section titled “Cloning from GitHub”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.
Example: personal CLI tool
Section titled “Example: personal CLI tool”name: my-tooldescription: My personal CLI tool from GitHubrequires: [git]#!/usr/bin/env bashset -euo pipefail
TOOL_DIR="$HOME/.local/share/my-tool"
# Clones github.com/<DOTFILES_USER_GITHUB_USER>/my-tool if not already presentgithub_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.
Batching related repos
Section titled “Batching related repos”If several small repos always travel together, one module can clone all of them:
github_clone "scripts" "$HOME/.local/share/scripts"github_clone "prompts" "$HOME/.local/share/prompts"Dependencies
Section titled “Dependencies”Declare dependencies to ensure modules run in the correct order:
name: mymoduledependencies: - git # Git must be installed first - zsh # Zsh must be installed firstDependencies are:
- Transitive - If you depend on
gitandgitdepends onssh, you automatically depend onsshtoo - Resolved automatically - The system uses topological sorting to determine execution order
- Cycle-detected - Circular dependencies are rejected with a clear error message
Priority
Section titled “Priority”Modules with the same dependencies run in priority order:
priority: 50 # Lower numbers run firstDefault: 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.
Platform Support
Section titled “Platform Support”Limit to Specific Platforms
Section titled “Limit to Specific Platforms”os: - macos - ubuntuIf os is empty or omitted, the module runs on all platforms.
Platform-Specific Logic
Section titled “Platform-Specific Logic”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 ArchOr use conditionals in install.sh:
if is_macos; then brew install mypackageelif is_ubuntu; then sudo apt install mypackageelif is_arch; then sudo pacman -S mypackagefiTesting Your Module
Section titled “Testing Your Module”Dry Run
Section titled “Dry Run”dotfiles install mymodule --dry-run -vShows what would happen without making changes.
Verbose Output
Section titled “Verbose Output”dotfiles install mymodule -vShows detailed execution information.
Reset State
Section titled “Reset State”To test reinstallation:
rm ~/.dotfiles/.state/mymodule.jsondotfiles install mymoduleIntegration Tests
Section titled “Integration Tests”Add tests in test/integration/test_install.sh:
# --- 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"Best Practices
Section titled “Best Practices”1. Idempotency
Section titled “1. Idempotency”Scripts should be safe to run multiple times:
# Good: Check before installingif ! pkg_installed mypackage; then pkg_install mypackagefi
# Bad: Always tries to installpkg_install mypackage2. Error Handling
Section titled “2. Error Handling”Use set -euo pipefail to fail fast:
#!/usr/bin/env bashset -euo pipefail # Exit on error, undefined vars, pipe failures3. Backup Files
Section titled “3. Backup Files”The helpers automatically backup files:
# Automatically backs up ~/.zshrc if it existslink_file "$DOTFILES_MODULE_DIR/files/zshrc" ~/.zshrcBackups are created with .backup-TIMESTAMP suffix.
4. Minimal Dependencies
Section titled “4. Minimal Dependencies”Only declare direct dependencies:
# Gooddependencies: - git
# Bad: zsh already depends on git, transitive deps are automaticdependencies: - git - ssh # ssh is a dependency of git5. Clear Logging
Section titled “5. Clear Logging”Use descriptive log messages:
log_info "Installing neovim package..."log_info "Creating ~/.config/nvim directory..."log_info "Installing packer.nvim plugin manager..."log_success "Neovim configured successfully"6. Respect Flags
Section titled “6. Respect Flags”Check dry-run and other flags:
if is_dry_run; then log_info "[dry-run] Would install mypackage" exit 0fi7. Document Prompts
Section titled “7. Document Prompts”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: darkCustom & 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 wholesaleEach 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.
Examples
Section titled “Examples”See existing modules for reference:
- ssh module - Simple module with templates and secrets
- git module - Module with OS-specific scripts
- zsh module - Complex module with external dependencies (Zinit)
- neovim module - Minimal module with symlinks
Next Steps
Section titled “Next Steps”- 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