Skip to main content

Symlink dotfiles manager

Project description

haunt

A dotfiles symlink manager. GNU stow in Python, with a registry.

Installation

# Run directly with uvx (no install needed)
uvx haunt install ~/.dotfiles

# Or install globally with uv
uv tool install haunt

# Or install with pip
pip install haunt

Dependencies

We recommend installing haunt with uv, which installs Python, haunt and its dependencies.

If you choose in install manually, haunt requires Python 3.12+.

Quickstart

You have a home directory with some existing files, and a dotfiles package:

/Users/mike
├── .config
│   └── starship.toml
└── dotfiles
    ├── .bashrc
    └── .config
        └── nvim
            └── init.lua

A package is just a directory containing files you want to link to from your home directory.

To install the package:

$ haunt install ~/dotfiles
Creating symlinks:
  /Users/mike/.bashrc -> /Users/mike/dotfiles/.bashrc
  /Users/mike/.config/nvim/init.lua -> /Users/mike/dotfiles/.config/nvim/init.lua

2 symlinks created

The files in the package are symlinked from your home folder, and existing files are preserved.

/Users/mike
├── .bashrc -> dotfiles/.bashrc
├── .config
│   ├── nvim
│   │   └── init.lua -> ../../dotfiles/.config/nvim/init.lua
│   └── starship.toml
└── dotfiles
    ├── .bashrc
    └── .config
        └── nvim
            └── init.lua

Uninstall the package (using the package name, which is the directory basename):

$ haunt uninstall dotfiles
Removing symlinks:
  /Users/mike/.bashrc
  /Users/mike/.config/nvim/init.lua

2 symlinks removed

To see all installed packages, use haunt list.

Commands

haunt install

haunt install [OPTIONS] PACKAGE [TARGET]
  • PACKAGE - directory containing files to symlink (required)
  • TARGET - where to create symlinks (default: $HOME)
  • --dry-run, -n - show what would happen without doing it
  • --on-conflict - how to handle conflicts:
    • abort (default) - stop if any files exist
    • skip - skip conflicting files, install the rest
    • force - replace files/symlinks (but never directories)

haunt uninstall

haunt uninstall [OPTIONS] PACKAGE
  • PACKAGE - package name to uninstall (required)
  • --dry-run, -n - show what would happen without doing it

Package names are derived from the directory basename. For example, haunt install ~/dotfiles creates a package named dotfiles. To see all installed packages, use haunt list.

haunt list

haunt list [OPTIONS] [PACKAGE]

List installed packages with their symlinks.

  • PACKAGE - show only this package (optional, shows all if omitted)
  • --verbose, -v - show all symlinks with status validation

Example output:

$ haunt list
dotfiles
  Package: ~/dotfiles
  Target: ~/
  Installed: 2025-11-12 13:45:23
  Symlinks: 3

nvim-config
  Package: ~/nvim-config
  Target: ~/.config
  Installed: 2025-11-12 14:30:15
  Symlinks: 5

Verbose mode checks each symlink and reports issues:

$ haunt list --verbose dotfiles
dotfiles
  Package: ~/dotfiles
  Target: ~/
  Installed: 2025-11-12 13:45:23
  Symlinks:
    Correct
      ~/.bashrc -> ~/dotfiles/.bashrc
    Inconsistent with Registry
      ~/.vimrc -> ~/dotfiles/.vimrc (link missing)
      ~/.zshrc -> /other/file (expected ~/dotfiles/.zshrc)
      ~/.profile -> ~/dotfiles/.profile (source file missing)

  To fix inconsistent symlinks:
    haunt install ~/dotfiles ~/

Inconsistent symlink types:

  • (link missing): symlink doesn't exist at expected location
  • (expected ...): symlink points to wrong target
  • (source file missing): symlink exists but source file is gone

Conflict handling

By default haunt aborts on any conflict:

$ echo "important config" > ~/.bashrc
$ haunt install ~/.dotfiles
✗ Conflicts detected:
  /Users/mike/.bashrc (file)

Use --on-conflict=skip to install non-conflicting files, or --on-conflict=force to replace files and broken symlinks.

haunt will never replace existing directories with symlinks, even with --on-conflict=force.

Multiple packages

haunt creates symlinks to files, not directories. This lets multiple packages install into the same directory:

/Users/mike/dotfiles
├── shell
│   ├── .bashrc
│   └── .config
│       └── starship.toml
└── nvim
    └── .config
        └── nvim
            └── init.lua

Install both packages:

$ haunt install ~/dotfiles/shell
Creating symlinks:
  /Users/mike/.bashrc -> /Users/mike/dotfiles/shell/.bashrc
  /Users/mike/.config/starship.toml -> /Users/mike/dotfiles/shell/.config/starship.toml

2 symlinks created

$ haunt install ~/dotfiles/nvim
Creating symlinks:
  /Users/mike/.config/nvim/init.lua -> /Users/mike/dotfiles/nvim/.config/nvim/init.lua

1 symlink created

Result:

/Users/mike
├── .bashrc -> dotfiles/shell/.bashrc
└── .config
    ├── nvim
    │   └── init.lua -> ../../dotfiles/nvim/.config/nvim/init.lua
    └── starship.toml -> ../dotfiles/shell/.config/starship.toml

Both packages install files into .config. haunt creates real directories (not symlinks to directories), so this works fine.

Uninstalling one package leaves the other intact:

$ haunt uninstall shell
Removing symlinks:
  /Users/mike/.bashrc
  /Users/mike/.config/starship.toml

2 symlinks removed

# .config/nvim/init.lua remains untouched

Adding and removing files from packages

To add a file to an already-installed package, copy or move it into the package directory (creating subdirectories as needed), then reinstall:

# Add a simple file
mv ~/.vimrc ~/dotfiles/.vimrc
haunt install ~/dotfiles

# Add a nested file (create directories first)
mkdir -p ~/dotfiles/.config/nvim
mv ~/.config/nvim/init.lua ~/dotfiles/.config/nvim/init.lua
haunt install ~/dotfiles

The reinstall detects the file is now missing from its original location and creates the symlink.

To remove a file from a package, delete it from the package directory, then reinstall:

rm ~/dotfiles/.vimrc
haunt install ~/dotfiles

The reinstall automatically removes symlinks for files that are no longer in the package.

Git Integration

If your package is in a git repository, haunt automatically uses git ls-files to discover files. This means:

  • .gitignore rules are respected automatically
  • .git directory and .gitmodules are excluded
  • ✅ Only tracked files are symlinked
  • ✅ Files in submodules are discovered

For non-git packages (or if git is not available), haunt falls back to discovering all files in the directory tree.

The Registry

haunt maintains state about the links it manages independently of the package directory. This means:

  • haunt uninstall works even if the package directory was moved, deleted, or its contents modified
  • ✅ Won't remove symlinks that have been manually modified to point elsewhere or replaced with files

If you need to uninstall a package that's not in the registry: Run haunt install <package-dir> first to detect existing symlinks and rebuild the registry entry, then haunt uninstall <package-name> will work normally.

The registry follows the XDG Base Directory specification:

  • Linux: ~/.local/state/haunt/registry.json (or $XDG_STATE_HOME/haunt/registry.json)
  • macOS: ~/Library/Application Support/haunt/registry.json

How is this different from GNU stow?

GNU stow is the original. It's mature, battle-tested, and does folder tree merging.

The main differences:

  • No Perl dependency - uses Python (ubiquitous on modern systems) and uv
  • Registry - see The Registry section for benefits

The core symlinking behavior is the same as stow.

How is this different from stowsh?

stowsh is my bash implementation of stow.

haunt trades stowsh's zero dependencies for a registry, maintainability and testability. If you want a single bash script with no dependencies, use stowsh!

Development

See CONTRIBUTING.md for development setup.

License

MIT

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

haunt-0.2.0.tar.gz (45.0 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

haunt-0.2.0-py3-none-any.whl (20.5 kB view details)

Uploaded Python 3

File details

Details for the file haunt-0.2.0.tar.gz.

File metadata

  • Download URL: haunt-0.2.0.tar.gz
  • Upload date:
  • Size: 45.0 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for haunt-0.2.0.tar.gz
Algorithm Hash digest
SHA256 99873d6dcf212d5d562565832bb534a6c58a805e653916fb6593958742589272
MD5 b528a1f36b3c13d31c816d76a2f05e68
BLAKE2b-256 7901405ad550284e57dd5b28b8df4ca4300b4b6d190751a9cb3ab68cffd1f136

See more details on using hashes here.

Provenance

The following attestation bundles were made for haunt-0.2.0.tar.gz:

Publisher: release.yml on mikepqr/haunt

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file haunt-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: haunt-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 20.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for haunt-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 bdc3c47c04d24d4126bcfa7f98161ab1584ab4e665f346d10c21fdd448a53cc8
MD5 a984f12d43a692b2fafb07ab44374f67
BLAKE2b-256 0072cdea41eb61c078cf436e8449f48f584ffa4e3e1cae123bb0f230ad459684

See more details on using hashes here.

Provenance

The following attestation bundles were made for haunt-0.2.0-py3-none-any.whl:

Publisher: release.yml on mikepqr/haunt

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page