Repository files navigation

🐡 Puff

CICrates.io

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.

Puff demo

Why Puff

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 .env file 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.

How It Works

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)
  1. You tell puff which files to manage (puff add).
  2. Puff moves them to its central storage and creates symlinks in their place.
  3. Your application reads the symlink transparently, no code changes needed.
  4. On a new machine (or in a new worktree), puff init or puff link recreates the symlinks.

Puff also supports managing entire directories, not just individual files.

Getting Started

1. Initialize a project

cd /path/to/my-app
puff init -n my-app

This registers the project with puff. If you omit -n, puff will prompt you for a name interactively.

2. Add files to puff

puff add .env -g
puff add config/secrets.json

The -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.

3. Check what puff manages

puff status

This shows the project name and all managed files and directories for the current project.

4. Set up on a new machine

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-app

Puff 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.

Installation

Homebrew (Linux and macOS, recommended)

brew install marcinjahn/tap/puff

WinGet (Windows, recommended)

winget install marcinjahn.puff --source winget

This installs a pre-built binary and adds it to your PATH.

Cargo

cargo install puff

This builds puff from source and places the binary in ~/.cargo/bin/.

cargo-binstall

If you have cargo-binstall installed, you can install a pre-built binary directly:

cargo binstall puff

This downloads a pre-built binary from GitHub Releases instead of compiling from source.

GitHub Releases

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/puff

Alternatively, 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.

Building from Source

git clone https://github.com/marcinjahn/puff
cd puff
cargo install --path .# or `just install`

Command Reference

CommandDescription
puff initInitialize 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 statusShow the puff status of the current directory.
puff listList 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 cdOpen 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).

Storage Locations

Puff stores managed files and its configuration in OS-standard directories:

OSData (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
WindowsC:\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 notconfig.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.

Shell Completions

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-Expression

Recipes

Syncing Puff Configs via a Private Git Repository

Instead 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 directorycd 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 main

On 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 projectcd /path/to/my-app
puff init --associate my-app

Keeping 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.

Using Puff with Git Worktrees

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-app

That'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/Zshworktree-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
# Fishfunction worktree-new
set project_name (basename (pwd))
git worktree add $argv[1] $argv[2]; andcd$argv[1]; and puff link$project_nameend

Automatic Puff Linking with Claude Code Worktree Hooks

Claude 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:

  • WorktreeCreate fires when Claude Code needs an isolated worktree for a subagent. It receives JSON on stdin with cwd (the repo root) and name (a unique identifier). The script creates a git worktree at ~/worktrees/<name>, runs puff link inside it, and prints the worktree path to stdout. Claude Code handles worktree cleanup automatically.
  • The || true ensures 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.

Cross-Platform Support

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.

License

Puff is licensed under the Apache License 2.0.

About

Puff is a CLI tool that manages private configuration files of your dev projects

Topics

Resources

Stars

3 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Repository files navigation

🐡 Puff

CICrates.io

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.

Puff demo

Why Puff

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 .env file 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.

How It Works

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)
  1. You tell puff which files to manage (puff add).
  2. Puff moves them to its central storage and creates symlinks in their place.
  3. Your application reads the symlink transparently, no code changes needed.
  4. On a new machine (or in a new worktree), puff init or puff link recreates the symlinks.

Puff also supports managing entire directories, not just individual files.

Getting Started

1. Initialize a project

cd /path/to/my-app
puff init -n my-app

This registers the project with puff. If you omit -n, puff will prompt you for a name interactively.

2. Add files to puff

puff add .env -g
puff add config/secrets.json

The -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.

3. Check what puff manages

puff status

This shows the project name and all managed files and directories for the current project.

4. Set up on a new machine

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-app

Puff 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.

Installation

Homebrew (Linux and macOS, recommended)

brew install marcinjahn/tap/puff

WinGet (Windows, recommended)

winget install marcinjahn.puff --source winget

This installs a pre-built binary and adds it to your PATH.

Cargo

cargo install puff

This builds puff from source and places the binary in ~/.cargo/bin/.

cargo-binstall

If you have cargo-binstall installed, you can install a pre-built binary directly:

cargo binstall puff

This downloads a pre-built binary from GitHub Releases instead of compiling from source.

GitHub Releases

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/puff

Alternatively, 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.

Building from Source

git clone https://github.com/marcinjahn/puff
cd puff
cargo install --path .# or `just install`

Command Reference

CommandDescription
puff initInitialize 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 statusShow the puff status of the current directory.
puff listList 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 cdOpen 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).

Storage Locations

Puff stores managed files and its configuration in OS-standard directories:

OSData (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
WindowsC:\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 notconfig.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.

Shell Completions

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-Expression

Recipes

Syncing Puff Configs via a Private Git Repository

Instead 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 directorycd 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 main

On 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 projectcd /path/to/my-app
puff init --associate my-app

Keeping 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.

Using Puff with Git Worktrees

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-app

That'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/Zshworktree-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
# Fishfunction worktree-new
set project_name (basename (pwd))
git worktree add $argv[1] $argv[2]; andcd$argv[1]; and puff link$project_nameend

Automatic Puff Linking with Claude Code Worktree Hooks

Claude 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:

  • WorktreeCreate fires when Claude Code needs an isolated worktree for a subagent. It receives JSON on stdin with cwd (the repo root) and name (a unique identifier). The script creates a git worktree at ~/worktrees/<name>, runs puff link inside it, and prints the worktree path to stdout. Claude Code handles worktree cleanup automatically.
  • The || true ensures 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.

Cross-Platform Support

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.

License

Puff is licensed under the Apache License 2.0.

About

Puff is a CLI tool that manages private configuration files of your dev projects

Topics

Resources

Stars

3 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

🐡 Puff

CICrates.io

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.

Puff demo

Why Puff

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 .env file 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.

How It Works

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)
  1. You tell puff which files to manage (puff add).
  2. Puff moves them to its central storage and creates symlinks in their place.
  3. Your application reads the symlink transparently, no code changes needed.
  4. On a new machine (or in a new worktree), puff init or puff link recreates the symlinks.

Puff also supports managing entire directories, not just individual files.

Getting Started

1. Initialize a project

cd /path/to/my-app
puff init -n my-app

This registers the project with puff. If you omit -n, puff will prompt you for a name interactively.

2. Add files to puff

puff add .env -g
puff add config/secrets.json

The -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.

3. Check what puff manages

puff status

This shows the project name and all managed files and directories for the current project.

4. Set up on a new machine

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-app

Puff 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.

Installation

Homebrew (Linux and macOS, recommended)

brew install marcinjahn/tap/puff

WinGet (Windows, recommended)

winget install marcinjahn.puff --source winget

This installs a pre-built binary and adds it to your PATH.

Cargo

cargo install puff

This builds puff from source and places the binary in ~/.cargo/bin/.

cargo-binstall

If you have cargo-binstall installed, you can install a pre-built binary directly:

cargo binstall puff

This downloads a pre-built binary from GitHub Releases instead of compiling from source.

GitHub Releases

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/puff

Alternatively, 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.

Building from Source

git clone https://github.com/marcinjahn/puff
cd puff
cargo install --path .# or `just install`

Command Reference

CommandDescription
puff initInitialize 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 statusShow the puff status of the current directory.
puff listList 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 cdOpen 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).

Storage Locations

Puff stores managed files and its configuration in OS-standard directories:

OSData (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
WindowsC:\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 notconfig.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.

Shell Completions

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-Expression

Recipes

Syncing Puff Configs via a Private Git Repository

Instead 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 directorycd 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 main

On 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 projectcd /path/to/my-app
puff init --associate my-app

Keeping 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.

Using Puff with Git Worktrees

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-app

That'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/Zshworktree-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
# Fishfunction worktree-new
set project_name (basename (pwd))
git worktree add $argv[1] $argv[2]; andcd$argv[1]; and puff link$project_nameend

Automatic Puff Linking with Claude Code Worktree Hooks

Claude 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:

  • WorktreeCreate fires when Claude Code needs an isolated worktree for a subagent. It receives JSON on stdin with cwd (the repo root) and name (a unique identifier). The script creates a git worktree at ~/worktrees/<name>, runs puff link inside it, and prints the worktree path to stdout. Claude Code handles worktree cleanup automatically.
  • The || true ensures 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.

Cross-Platform Support

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.

License

Puff is licensed under the Apache License 2.0.

About

Puff is a CLI tool that manages private configuration files of your dev projects

Topics

Resources

Stars

3 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

🐡 Puff

CICrates.io

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.

Puff demo

Why Puff

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 .env file 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.

How It Works

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)
  1. You tell puff which files to manage (puff add).
  2. Puff moves them to its central storage and creates symlinks in their place.
  3. Your application reads the symlink transparently, no code changes needed.
  4. On a new machine (or in a new worktree), puff init or puff link recreates the symlinks.

Puff also supports managing entire directories, not just individual files.

Getting Started

1. Initialize a project

cd /path/to/my-app
puff init -n my-app

This registers the project with puff. If you omit -n, puff will prompt you for a name interactively.

2. Add files to puff

puff add .env -g
puff add config/secrets.json

The -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.

3. Check what puff manages

puff status

This shows the project name and all managed files and directories for the current project.

4. Set up on a new machine

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-app

Puff 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.

Installation

Homebrew (Linux and macOS, recommended)

brew install marcinjahn/tap/puff

WinGet (Windows, recommended)

winget install marcinjahn.puff --source winget

This installs a pre-built binary and adds it to your PATH.

Cargo

cargo install puff

This builds puff from source and places the binary in ~/.cargo/bin/.

cargo-binstall

If you have cargo-binstall installed, you can install a pre-built binary directly:

cargo binstall puff

This downloads a pre-built binary from GitHub Releases instead of compiling from source.

GitHub Releases

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/puff

Alternatively, 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.

Building from Source

git clone https://github.com/marcinjahn/puff
cd puff
cargo install --path .# or `just install`

Command Reference

CommandDescription
puff initInitialize 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 statusShow the puff status of the current directory.
puff listList 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 cdOpen 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).

Storage Locations

Puff stores managed files and its configuration in OS-standard directories:

OSData (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
WindowsC:\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 notconfig.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.

Shell Completions

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-Expression

Recipes

Syncing Puff Configs via a Private Git Repository

Instead 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 directorycd 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 main

On 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 projectcd /path/to/my-app
puff init --associate my-app

Keeping 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.

Using Puff with Git Worktrees

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-app

That'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/Zshworktree-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
# Fishfunction worktree-new
set project_name (basename (pwd))
git worktree add $argv[1] $argv[2]; andcd$argv[1]; and puff link$project_nameend

Automatic Puff Linking with Claude Code Worktree Hooks

Claude 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:

  • WorktreeCreate fires when Claude Code needs an isolated worktree for a subagent. It receives JSON on stdin with cwd (the repo root) and name (a unique identifier). The script creates a git worktree at ~/worktrees/<name>, runs puff link inside it, and prints the worktree path to stdout. Claude Code handles worktree cleanup automatically.
  • The || true ensures 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.

Cross-Platform Support

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.

License

Puff is licensed under the Apache License 2.0.

About

Puff is a CLI tool that manages private configuration files of your dev projects

Topics

Resources

Stars

3 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Repository files navigation

🐡 Puff

CICrates.io

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.

Puff demo

Why Puff

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 .env file 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.

How It Works

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)
  1. You tell puff which files to manage (puff add).
  2. Puff moves them to its central storage and creates symlinks in their place.
  3. Your application reads the symlink transparently, no code changes needed.
  4. On a new machine (or in a new worktree), puff init or puff link recreates the symlinks.

Puff also supports managing entire directories, not just individual files.

Getting Started

1. Initialize a project

cd /path/to/my-app
puff init -n my-app

This registers the project with puff. If you omit -n, puff will prompt you for a name interactively.

2. Add files to puff

puff add .env -g
puff add config/secrets.json

The -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.

3. Check what puff manages

puff status

This shows the project name and all managed files and directories for the current project.

4. Set up on a new machine

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-app

Puff 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.

Installation

Homebrew (Linux and macOS, recommended)

brew install marcinjahn/tap/puff

WinGet (Windows, recommended)

winget install marcinjahn.puff --source winget

This installs a pre-built binary and adds it to your PATH.

Cargo

cargo install puff

This builds puff from source and places the binary in ~/.cargo/bin/.

cargo-binstall

If you have cargo-binstall installed, you can install a pre-built binary directly:

cargo binstall puff

This downloads a pre-built binary from GitHub Releases instead of compiling from source.

GitHub Releases

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/puff

Alternatively, 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.

Building from Source

git clone https://github.com/marcinjahn/puff
cd puff
cargo install --path .# or `just install`

Command Reference

CommandDescription
puff initInitialize 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 statusShow the puff status of the current directory.
puff listList 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 cdOpen 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).

Storage Locations

Puff stores managed files and its configuration in OS-standard directories:

OSData (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
WindowsC:\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 notconfig.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.

Shell Completions

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-Expression

Recipes

Syncing Puff Configs via a Private Git Repository

Instead 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 directorycd 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 main

On 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 projectcd /path/to/my-app
puff init --associate my-app

Keeping 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.

Using Puff with Git Worktrees

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-app

That'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/Zshworktree-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
# Fishfunction worktree-new
set project_name (basename (pwd))
git worktree add $argv[1] $argv[2]; andcd$argv[1]; and puff link$project_nameend

Automatic Puff Linking with Claude Code Worktree Hooks

Claude 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:

  • WorktreeCreate fires when Claude Code needs an isolated worktree for a subagent. It receives JSON on stdin with cwd (the repo root) and name (a unique identifier). The script creates a git worktree at ~/worktrees/<name>, runs puff link inside it, and prints the worktree path to stdout. Claude Code handles worktree cleanup automatically.
  • The || true ensures 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.

Cross-Platform Support

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.

License

Puff is licensed under the Apache License 2.0.

About

Puff is a CLI tool that manages private configuration files of your dev projects

Topics

Resources

Stars

3 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

🐡 Puff

CICrates.io

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.

Puff demo

Why Puff

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 .env file 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.

How It Works

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)
  1. You tell puff which files to manage (puff add).
  2. Puff moves them to its central storage and creates symlinks in their place.
  3. Your application reads the symlink transparently, no code changes needed.
  4. On a new machine (or in a new worktree), puff init or puff link recreates the symlinks.

Puff also supports managing entire directories, not just individual files.

Getting Started

1. Initialize a project

cd /path/to/my-app
puff init -n my-app

This registers the project with puff. If you omit -n, puff will prompt you for a name interactively.

2. Add files to puff

puff add .env -g
puff add config/secrets.json

The -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.

3. Check what puff manages

puff status

This shows the project name and all managed files and directories for the current project.

4. Set up on a new machine

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-app

Puff 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.

Installation

Homebrew (Linux and macOS, recommended)

brew install marcinjahn/tap/puff

WinGet (Windows, recommended)

winget install marcinjahn.puff --source winget

This installs a pre-built binary and adds it to your PATH.

Cargo

cargo install puff

This builds puff from source and places the binary in ~/.cargo/bin/.

cargo-binstall

If you have cargo-binstall installed, you can install a pre-built binary directly:

cargo binstall puff

This downloads a pre-built binary from GitHub Releases instead of compiling from source.

GitHub Releases

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/puff

Alternatively, 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.

Building from Source

git clone https://github.com/marcinjahn/puff
cd puff
cargo install --path .# or `just install`

Command Reference

CommandDescription
puff initInitialize 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 statusShow the puff status of the current directory.
puff listList 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 cdOpen 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).

Storage Locations

Puff stores managed files and its configuration in OS-standard directories:

OSData (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
WindowsC:\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 notconfig.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.

Shell Completions

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-Expression

Recipes

Syncing Puff Configs via a Private Git Repository

Instead 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 directorycd 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 main

On 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 projectcd /path/to/my-app
puff init --associate my-app

Keeping 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.

Using Puff with Git Worktrees

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-app

That'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/Zshworktree-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
# Fishfunction worktree-new
set project_name (basename (pwd))
git worktree add $argv[1] $argv[2]; andcd$argv[1]; and puff link$project_nameend

Automatic Puff Linking with Claude Code Worktree Hooks

Claude 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:

  • WorktreeCreate fires when Claude Code needs an isolated worktree for a subagent. It receives JSON on stdin with cwd (the repo root) and name (a unique identifier). The script creates a git worktree at ~/worktrees/<name>, runs puff link inside it, and prints the worktree path to stdout. Claude Code handles worktree cleanup automatically.
  • The || true ensures 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.

Cross-Platform Support

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.

License

Puff is licensed under the Apache License 2.0.

About

Puff is a CLI tool that manages private configuration files of your dev projects

Topics

Resources

Stars

3 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

🐡 Puff

CICrates.io

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.

Puff demo

Why Puff

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 .env file 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.

How It Works

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)
  1. You tell puff which files to manage (puff add).
  2. Puff moves them to its central storage and creates symlinks in their place.
  3. Your application reads the symlink transparently, no code changes needed.
  4. On a new machine (or in a new worktree), puff init or puff link recreates the symlinks.

Puff also supports managing entire directories, not just individual files.

Getting Started

1. Initialize a project

cd /path/to/my-app
puff init -n my-app

This registers the project with puff. If you omit -n, puff will prompt you for a name interactively.

2. Add files to puff

puff add .env -g
puff add config/secrets.json

The -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.

3. Check what puff manages

puff status

This shows the project name and all managed files and directories for the current project.

4. Set up on a new machine

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-app

Puff 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.

Installation

Homebrew (Linux and macOS, recommended)

brew install marcinjahn/tap/puff

WinGet (Windows, recommended)

winget install marcinjahn.puff --source winget

This installs a pre-built binary and adds it to your PATH.

Cargo

cargo install puff

This builds puff from source and places the binary in ~/.cargo/bin/.

cargo-binstall

If you have cargo-binstall installed, you can install a pre-built binary directly:

cargo binstall puff

This downloads a pre-built binary from GitHub Releases instead of compiling from source.

GitHub Releases

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/puff

Alternatively, 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.

Building from Source

git clone https://github.com/marcinjahn/puff
cd puff
cargo install --path .# or `just install`

Command Reference

CommandDescription
puff initInitialize 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 statusShow the puff status of the current directory.
puff listList 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 cdOpen 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).

Storage Locations

Puff stores managed files and its configuration in OS-standard directories:

OSData (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
WindowsC:\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 notconfig.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.

Shell Completions

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-Expression

Recipes

Syncing Puff Configs via a Private Git Repository

Instead 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 directorycd 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 main

On 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 projectcd /path/to/my-app
puff init --associate my-app

Keeping 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.

Using Puff with Git Worktrees

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-app

That'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/Zshworktree-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
# Fishfunction worktree-new
set project_name (basename (pwd))
git worktree add $argv[1] $argv[2]; andcd$argv[1]; and puff link$project_nameend

Automatic Puff Linking with Claude Code Worktree Hooks

Claude 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:

  • WorktreeCreate fires when Claude Code needs an isolated worktree for a subagent. It receives JSON on stdin with cwd (the repo root) and name (a unique identifier). The script creates a git worktree at ~/worktrees/<name>, runs puff link inside it, and prints the worktree path to stdout. Claude Code handles worktree cleanup automatically.
  • The || true ensures 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.

Cross-Platform Support

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.

License

Puff is licensed under the Apache License 2.0.

About

Puff is a CLI tool that manages private configuration files of your dev projects

Topics

Resources

Stars

3 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Repository files navigation

🐡 Puff

CICrates.io

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.

Puff demo

Why Puff

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 .env file 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.

How It Works

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)
  1. You tell puff which files to manage (puff add).
  2. Puff moves them to its central storage and creates symlinks in their place.
  3. Your application reads the symlink transparently, no code changes needed.
  4. On a new machine (or in a new worktree), puff init or puff link recreates the symlinks.

Puff also supports managing entire directories, not just individual files.

Getting Started

1. Initialize a project

cd /path/to/my-app
puff init -n my-app

This registers the project with puff. If you omit -n, puff will prompt you for a name interactively.

2. Add files to puff

puff add .env -g
puff add config/secrets.json

The -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.

3. Check what puff manages

puff status

This shows the project name and all managed files and directories for the current project.

4. Set up on a new machine

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-app

Puff 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.

Installation

Homebrew (Linux and macOS, recommended)

brew install marcinjahn/tap/puff

WinGet (Windows, recommended)

winget install marcinjahn.puff --source winget

This installs a pre-built binary and adds it to your PATH.

Cargo

cargo install puff

This builds puff from source and places the binary in ~/.cargo/bin/.

cargo-binstall

If you have cargo-binstall installed, you can install a pre-built binary directly:

cargo binstall puff

This downloads a pre-built binary from GitHub Releases instead of compiling from source.

GitHub Releases

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/puff

Alternatively, 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.

Building from Source

git clone https://github.com/marcinjahn/puff
cd puff
cargo install --path .# or `just install`

Command Reference

CommandDescription
puff initInitialize 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 statusShow the puff status of the current directory.
puff listList 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 cdOpen 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).

Storage Locations

Puff stores managed files and its configuration in OS-standard directories:

OSData (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
WindowsC:\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 notconfig.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.

Shell Completions

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-Expression

Recipes

Syncing Puff Configs via a Private Git Repository

Instead 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 directorycd 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 main

On 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 projectcd /path/to/my-app
puff init --associate my-app

Keeping 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.

Using Puff with Git Worktrees

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-app

That'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/Zshworktree-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
# Fishfunction worktree-new
set project_name (basename (pwd))
git worktree add $argv[1] $argv[2]; andcd$argv[1]; and puff link$project_nameend

Automatic Puff Linking with Claude Code Worktree Hooks

Claude 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:

  • WorktreeCreate fires when Claude Code needs an isolated worktree for a subagent. It receives JSON on stdin with cwd (the repo root) and name (a unique identifier). The script creates a git worktree at ~/worktrees/<name>, runs puff link inside it, and prints the worktree path to stdout. Claude Code handles worktree cleanup automatically.
  • The || true ensures 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.

Cross-Platform Support

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.

License

Puff is licensed under the Apache License 2.0.

About

Puff is a CLI tool that manages private configuration files of your dev projects

Topics

Resources

Stars

3 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages