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 existskip- skip conflicting files, install the restforce- 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:
- ✅
.gitignorerules are respected automatically - ✅
.gitdirectory and.gitmodulesare 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 uninstallworks 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
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
99873d6dcf212d5d562565832bb534a6c58a805e653916fb6593958742589272
|
|
| MD5 |
b528a1f36b3c13d31c816d76a2f05e68
|
|
| BLAKE2b-256 |
7901405ad550284e57dd5b28b8df4ca4300b4b6d190751a9cb3ab68cffd1f136
|
Provenance
The following attestation bundles were made for haunt-0.2.0.tar.gz:
Publisher:
release.yml on mikepqr/haunt
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
haunt-0.2.0.tar.gz -
Subject digest:
99873d6dcf212d5d562565832bb534a6c58a805e653916fb6593958742589272 - Sigstore transparency entry: 700710075
- Sigstore integration time:
-
Permalink:
mikepqr/haunt@281016b292641ffe2141a6e10a2c3a7bc18e866f -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/mikepqr
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@281016b292641ffe2141a6e10a2c3a7bc18e866f -
Trigger Event:
release
-
Statement type:
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
bdc3c47c04d24d4126bcfa7f98161ab1584ab4e665f346d10c21fdd448a53cc8
|
|
| MD5 |
a984f12d43a692b2fafb07ab44374f67
|
|
| BLAKE2b-256 |
0072cdea41eb61c078cf436e8449f48f584ffa4e3e1cae123bb0f230ad459684
|
Provenance
The following attestation bundles were made for haunt-0.2.0-py3-none-any.whl:
Publisher:
release.yml on mikepqr/haunt
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
haunt-0.2.0-py3-none-any.whl -
Subject digest:
bdc3c47c04d24d4126bcfa7f98161ab1584ab4e665f346d10c21fdd448a53cc8 - Sigstore transparency entry: 700710077
- Sigstore integration time:
-
Permalink:
mikepqr/haunt@281016b292641ffe2141a6e10a2c3a7bc18e866f -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/mikepqr
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@281016b292641ffe2141a6e10a2c3a7bc18e866f -
Trigger Event:
release
-
Statement type: