Pull individual tools from a monorepo into any project.
grab is a small Bash script that lets you maintain a single monorepo of shared tools (CLIs, scripts, dev utilities, Docker helpers...) and cherry-pick them into any project on demand — without cloning the whole repo, without Git submodules, without copy-paste.
Think of it as a mix between npm install and git sparse-checkout, but dead simple.
You probably have a folder of useful scripts you copy from project to project. Over time they drift, get patched in one place but not the others, and you lose track of which version is the good one.
grab solves this with one rule: the monorepo is the source of truth, and every project pulls only what it needs.
- No submodules, no vendoring headaches
- Sparse checkout (
--filter=blob:none) — only the tools you ask for are downloaded - Per-project manifest (
.grabfile) so anyone cloning your project can rungrab installand get the same tools - Optional pinning to a branch or tag per tool
Drop the script somewhere in your $PATH and make it executable:
curl -o ~/.local/bin/grab https://raw.githubusercontent.com/Warshoow/grab-cli/master/grab
chmod +x ~/.local/bin/grabOr clone this repo and symlink it:
git clone https://github.com/Warshoow/grab-cli.git
ln -s "$PWD/grab-cli/grab" ~/.local/bin/grabRequirements: bash, git (>= 2.25 for sparse-checkout cone mode).
# 1. One-time global setup (optional but recommended)
grab setup git@github.com:you/tools.git git@github.com:you/grab.git
# 2. In any project
cd my-project
grab init git@github.com:you/tools.git
# 3. Pull a tool
grab add stripe-cli
# 4. Pin one to a tag
grab add docker-utils @v2.1
# 5. Run it
grab exec stripe-cli listen --forward-to localhost:3000That's it. The tool ends up in .grab/tools/stripe-cli/ and is tracked in .grabfile.
When you grab add <tool>, the script:
- Sparse-clones your tools monorepo into
.grab/.repo/(the first time only —--filter=blob:nonekeeps it tiny) - Adds
<tool>to the sparse-checkout cone, so only that subdirectory is materialized - Copies
.grab/.repo/<tool>/into.grab/tools/<tool>/ - Records the tool (and optional ref) in
.grabfile - Adds
.grab/to.gitignoreso the cache and tools stay out of your repo
Other contributors clone your project, run grab install, and get the same tools at the same versions.
| Command | Description |
|---|---|
grab init <repo-url> |
Initialize grab in the current project |
grab add <tool> [@ref] |
Add a tool, optionally pinned to a branch or tag |
grab remove <tool> |
Remove a tool from the project |
grab update [tool] |
Pull updates from the repo (one or all tools) |
grab push <tool> ["msg"] |
Push local changes back to the repo |
grab publish <path> [name] [-m "msg"] [--force] |
Publish a standalone dev folder (e.g. .) as a new tool in the monorepo |
grab install |
Install every tool listed in .grabfile |
grab list [--remote] |
List installed tools (or available ones in the repo) |
grab status |
Show grab status for the current project |
grab path <tool> |
Print the absolute path to a tool (handy for scripts) |
grab exec <tool> [args] |
Run a tool's entrypoint script |
grab hook <tool> |
Run a tool's post-grab hook manually |
grab clean |
Remove the cached repo (keeps installed tools) |
grab self-update |
Update the grab script itself |
grab setup [tools-repo] [grab-repo] |
Configure global defaults |
grab help |
Show help |
Aliases: rm for remove, i for install, ls for list, run for exec.
grab expects each tool to live in its own top-level directory at the root of your monorepo:
your-tools-repo/
├── stripe-cli/
│ ├── run.sh # entrypoint (optional, for `grab exec`)
│ └── TOOL.md # first line is used as description in `grab list --remote`
├── docker-utils/
│ ├── main.sh
│ └── README.md
└── devcontainer/
├── .devcontainer/
│ └── devcontainer.json
└── post-grab.sh # hook: moves .devcontainer/ to the project root
For grab exec <tool> to work, the tool must contain one of: run.sh, main.sh, <tool>.sh, or entrypoint.sh.
When you've built a tool in its own standalone folder and want to share it through the monorepo, use grab publish instead of copy-pasting it into the repo by hand:
# Publish a folder — tool name defaults to the folder's basename
grab publish ~/dev/my-new-tool
# Or run it straight from inside the tool's own folder
cd ~/dev/my-new-tool && grab publish .
# Override the tool name and pass a commit message
grab publish ./build-scripts deploy-utils -m "initial version"
# Overwrite an existing tool without the confirmation prompt
grab publish ~/dev/foo --forcegrab publish does not need a .grabfile; it resolves the monorepo from $GRAB_REPO or your global config (grab setup), so it works from any standalone folder.
What it does:
- Clones the monorepo into a throwaway temp directory (not the project's
.grab/.repo/cache), sograb publish .never copies the cache into itself and a clean dev folder stays clean — no.grab/left behind - Copies your folder in under
<name>/, dropping any nested.gitor.grabso you never embed a sub-repo or the cache - Shows a
git diff --stat, asks for a commit message (unless-mis given), then commits and pushes to the repo's default branch - If the tool already exists, it asks before overwriting (skip with
--force)
It does not install the tool into the current project — once published, pull it anywhere with grab add <name>.
The tool name must be a single top-level directory (no slashes), matching the monorepo layout above.
A tool can include a post-grab.sh script at its root. This hook runs after the tool is installed and can automate setup steps like moving files to the right place, symlinking configs, or printing instructions.
Example — a devcontainer tool whose hook copies .devcontainer/ to the project root:
#!/usr/bin/env bash
# post-grab.sh for the devcontainer tool
cp -r "${GRAB_TOOL_DIR}/.devcontainer" "${GRAB_PROJECT_DIR}/.devcontainer"
echo "Installed .devcontainer to project root"By default, grab add and grab install will ask before running a hook. You can override this:
grab add devcontainer --hook # always run the hook
grab add devcontainer --no-hook # skip the hook
grab install --hook # run all hooks without askingWithout a terminal (CI, Docker build), the question gets no answer and the hook is skipped. Pass --hook to run it there.
To run a hook manually at any time:
grab hook devcontainerThe following environment variables are available inside the hook:
| Variable | Description |
|---|---|
GRAB_TOOL_NAME |
Name of the tool (e.g., devcontainer) |
GRAB_TOOL_DIR |
Absolute path to the installed tool directory |
GRAB_PROJECT_DIR |
Absolute path to the project root |
Created by grab init at the root of your project. Format:
repo=git@github.com:you/tools.git
# Tools listed below, one per line
# format: tool_name [@branch_or_tag]
stripe-cli
docker-utils @v2.1
deploy-helper @main
An optional branch= line sets the default branch for this project (see Configuration).
A pinned tool (@ref) stays on its ref: grab install, grab update and grab push all use it.
Commit it to your project so collaborators can grab install.
Repo URLs are resolved in this order:
.grabfile(therepo=line)$GRAB_REPOenvironment variable~/.config/grab/config(set viagrab setup)
The default branch used by grab update / grab install / grab publish follows the same resolution:
.grabfile(thebranch=line)$GRAB_BRANCHenvironment variable~/.config/grab/config(thebranch=line)- the tools repo's own default branch (
origin/HEAD), elsemain
The global config file looks like:
# grab global configuration
repo=git@github.com:you/tools.git
grab_repo=git@github.com:you/grab.git
branch=main
repo— your tools monorepo (default forgrab init)grab_repo— where thegrabscript lives, used bygrab self-updatebranch— default branch to track in the tools repo (optional, defaults to the repo's default branch)
| Path | Purpose |
|---|---|
.grabfile |
Project manifest (commit this) |
.grab/tools/ |
Where installed tools live (gitignored) |
.grab/.repo/ |
Cached sparse checkout (gitignored, removable with grab clean) |
~/.config/grab/config |
Global configuration |
grab lives in its own repo, separate from your tools. Configure it once:
grab setup <tools-repo> <grab-repo>Then grab self-update will fetch the latest version, compare GRAB_VERSION, and replace itself in place (using sudo if necessary).
./test.shRuns the main commands against throwaway local repos (needs only git and bash).
- Running grab inside a VSCode devcontainer and seeing
error: could not write config file /root/.gitconfig: Resource busy? See docs/devcontainer.md — it's a bind-mount problem, not a grab bug, and there's a one-liner fix.
Potential evolutions, not committed to:
grab self-update --force— skip theGRAB_VERSIONcomparison and always replace the local script. Useful during active development when pushing changes without bumping the version.grab self-update --ref <branch|tag|sha>— pull a specific revision instead of the default branch tip.grab doctor— sanity-check the local environment (git version, sparse-checkout support, repo reachability,.grabfilevalidity).- Lockfile (
.grabfile.lock) pinning each tool to a resolved commit SHA for fully reproducible installs. - Post-install hooks per tool (e.g.
grab.hookscript run aftergrab add). - Shell completion (bash/zsh/fish) for tool names from the remote repo.
- Avoid duplicating tool data between
.grab/.repo/<tool>/(sparse-checkout cache) and.grab/tools/<tool>/(working copy) — current behavior iscp -r. Could use hard links (cp -alon Linux,mklink /Hon Windows NTFS) for zero disk overhead, or an opt-ingrab add --linkedthat symlinks directly into the cache (faster updates, but exposes.gitand breaks ongrab clean).
MIT