Puff is a CLI tool that keeps your projects' private configuration files (.env,
appsettings.json, credentials, etc.) in a central directory and replaces them
with symlinks. Your applications work exactly as before (they don't know the
files are symlinks), and all your private configs live in one place that you
can back up, version-control in a private repo, or copy to a new machine in
seconds.
Most projects have files that shouldn't be committed to version control: environment files with API keys, local database credentials, editor configs with personal preferences. These files are gitignored, which means:
- They don't transfer between machines. Set up a new laptop, and you're
recreating every
.envfile from memory or old backups. - They don't survive git worktrees. Create a worktree and you're missing every gitignored file the project needs to run.
- They're scattered everywhere. Each project keeps its own private files in its own directory, with no central view or backup strategy.
Puff solves all three problems. It moves your private files into a single managed directory, creates symlinks so your projects still find them where they expect, and gives you commands to re-link everything on a new machine or in a new worktree.
Existing tools solve adjacent problems — dotfile managers (chezmoi, GNU Stow)
target personal configs in $HOME, secret managers (Doppler, Vault) require
infrastructure, and in-repo encryption (git-crypt, SOPS) keeps secrets in version
control. Puff is different: it's project-scoped, works with any file or
directory, requires zero infrastructure, and has first-class git worktree
support.
Your project directory:
my-app/
src/
.env -> symlink
secrets.json -> symlink
Puff's central storage:
~/.local/share/puff/projects/my-app/
.env (actual file)
secrets.json (actual file)
- You tell puff which files to manage (
puff add). - Puff moves them to its central storage and creates symlinks in their place.
- Your application reads the symlink transparently, no code changes needed.
- On a new machine (or in a new worktree),
puff initorpuff linkrecreates the symlinks.
Puff also supports managing entire directories, not just individual files.
cd /path/to/my-app
puff init -n my-appThis registers the project with puff. If you omit -n, puff will prompt you for
a name interactively.
puff add .env -g
puff add config/secrets.jsonThe -g flag also adds the path to .gitignore. After this, .env is a
symlink pointing to puff's central storage. The original file contents are
preserved.
If the file doesn't exist yet, puff creates an empty one in its storage and symlinks to it.
To add a directory:
puff add config/local/Puff detects existing directories automatically. For paths that don't exist yet,
use --dir to indicate you want a directory, not a file.
puff statusThis shows the project name and all managed files and directories for the current project.
Copy puff's data directory (see Storage Locations) to the same location on the new machine, install puff, then initialize your projects. You can also keep the data directory in a private Git repo to make syncing easier.
cd /path/to/my-app
puff init --associate my-appPuff recognizes the project configs you copied over and creates all the symlinks.
If you run puff init without --associate, puff will interactively ask whether
you want to create a fresh project or associate with one of the existing
unassociated configs.
brew install marcinjahn/tap/puffThis builds puff from source and installs shell completions automatically.
cargo install puffThis builds puff from source and places the binary in ~/.cargo/bin/.
If you have cargo-binstall installed, you can install a pre-built binary directly:
cargo binstall puffThis downloads a pre-built binary from GitHub Releases instead of compiling from source.
Pre-built binaries are available on the Releases page for Linux, macOS, and Windows.
Download the archive for your platform, extract it, and place the puff binary
somewhere in your $PATH (e.g. ~/.local/bin on Linux).
macOS note: If you download a binary directly, macOS may block it with a "developer cannot be verified" warning. To resolve it, run:
xattr -d com.apple.quarantine /path/to/puffAlternatively, open Finder at the binary's location, right-click the binary, select Open, and confirm. This issue does not affect Homebrew or cargo-based installations.
git clone https://github.com/marcinjahn/puff
cd puff
cargo install --path .| Command | Description |
|---|---|
puff init |
Initialize a project in the current directory. Use -n <name> to skip the prompt, or --associate <name> to link to existing configs. |
puff add <paths...> |
Add files or directories to puff. Use -g to also add to .gitignore, --dir for non-existing directories. |
puff forget <paths...> |
Stop managing files. The files are restored to the project directory (use -d to delete them instead). |
puff status |
Show the puff status of the current directory. |
puff list |
List all projects. Use -a for associated only, -u for unassociated only. |
puff link <project> |
Create symlinks for a project's files in the current directory. Designed for worktrees and secondary working copies. |
puff project forget <project> |
Remove a project from puff. Files are restored by default (use -d to delete). |
puff cd |
Open a shell in puff's data directory. Use -p to print the path instead. |
puff completions <shell> |
Generate shell completions (bash, zsh, fish, powershell, elvish). |
Puff stores managed files and its configuration in OS-standard directories:
| OS | Data (managed files) | Configuration |
|---|---|---|
| Linux | ~/.local/share/puff/projects/ |
~/.config/puff/config.json |
| macOS | ~/Library/Application Support/com.marcinjahn.puff/projects/ |
~/Library/Application Support/com.marcinjahn.puff/config.json |
| Windows | C:\Users\<User>\AppData\Roaming\marcinjahn\puff\projects\ |
C:\Users\<User>\AppData\Roaming\marcinjahn\puff\config.json |
Each project gets its own subdirectory under projects/. The config.json file
tracks which projects exist and where they're located on disk. When transferring
to a new machine, copy the projects/ directory but not config.json (it
contains machine-specific paths), unless your projects will live under the same
paths as on the old machine. Puff will rebuild config.json as you run
puff init in each project.
Puff supports dynamic shell completions (including project name completion). Add one of the following to your shell configuration:
# Bash (~/.bashrc)
source <(puff completions bash)
# Zsh (~/.zshrc)
source <(puff completions zsh)
# Fish (~/.config/fish/completions/puff.fish)
puff completions fish | source
# PowerShell ($PROFILE)
puff completions powershell | Invoke-ExpressionInstead of manually copying the data directory between machines, you can keep it in a private Git repository (e.g. on GitHub). This gives you version history and easy syncing.
Initial setup (first machine):
puff cd
# You're now in puff's data directory
cd projects
git init
git remote add origin git@github.com:youruser/puff-configs.git
git add -A
git commit -m "Initial puff configs"
git push -u origin mainOn a new machine:
# Clone into puff's data directory
puff cd
git clone git@github.com:youruser/puff-configs.git projects
exit
# Then initialize each project
cd /path/to/my-app
puff init --associate my-appKeeping things in sync:
After adding or changing managed files, commit and push from the projects/
directory. On other machines, pull to get the latest configs. You could automate
this with a cron job or a Git hook, but even doing it manually is straightforward
since everything is in one directory.
Note: make sure the repository is private. These files likely contain secrets.
Git worktrees share the same .git directory but get a fresh working copy,
which means gitignored files are missing. Puff's link command exists
specifically for this situation.
Manual workflow:
git worktree add ../my-app-feature feature-branch
cd ../my-app-feature
puff link my-appThat's it. Puff creates symlinks for all of my-app's managed files in the
worktree directory.
Automated with a shell function:
Add this to your shell configuration to create worktrees with puff linking in one step:
# Bash/Zsh
worktree-new() {
local project_name
project_name=$(basename "$(pwd)")
git worktree add "$1" "$2" && cd "$1" && puff link "$project_name"
}
# Usage: worktree-new ../my-app-feature feature-branch# Fish
function worktree-new
set project_name (basename (pwd))
git worktree add $argv[1] $argv[2]; and cd $argv[1]; and puff link $project_name
endClaude Code can create git worktrees for subagent isolation. You can configure a hook so that puff automatically links your project's managed files into every new worktree.
Add the following to your .claude/settings.json (or
.claude/settings.local.json):
{
"hooks": {
"WorktreeCreate": [
{
"hooks": [
{
"type": "command",
"command": "bash -c 'INPUT=$(cat); CWD=$(echo \"$INPUT\" | jq -r .cwd); NAME=$(echo \"$INPUT\" | jq -r .name); DIR=\"$HOME/worktrees/$NAME\"; mkdir -p \"$(dirname \"$DIR\")\" && git -C \"$CWD\" worktree add \"$DIR\" HEAD >&2 && PROJECT=$(basename \"$CWD\") && (cd \"$DIR\" && puff link \"$PROJECT\" >&2 || true) && echo \"$DIR\"'"
}
]
}
]
}
}How this works:
WorktreeCreatefires when Claude Code needs an isolated worktree for a subagent. It receives JSON on stdin withcwd(the repo root) andname(a unique identifier). The script creates a git worktree at~/worktrees/<name>, runspuff linkinside it, and prints the worktree path to stdout. Claude Code handles worktree cleanup automatically.- The
|| trueensures that if puff linking fails (e.g. the project isn't registered with puff), worktree creation still succeeds.
You can adjust the $HOME/worktrees path to wherever you prefer worktrees to
live.
Puff runs on Linux, macOS, and Windows. Symlink behavior is consistent across platforms. On Windows, creating symlinks may require Developer Mode to be enabled or running as administrator.
Puff is licensed under the Apache License 2.0.
