Part of the shll toolkit — see all projects there.
A small Go CLI that turns one config file (hop.yaml) into a personal directory of all your git repos — navigate, clone, run commands, and batch-update them from any directory.
curl -fsSL https://shll.ai/install | sh -s -- hop # install hop (+ shll) via Homebrew
shll shell-setup # wire the shell shim into your rc file
hop add -r ~/code # walk your code dir, build hop.yaml from git remotesThe first line installs hop (plus the shll meta-CLI) via Homebrew, handling tap trust automatically; the next two wire the shell shim into your rc file and build hop.yaml from your existing clones — that's the whole setup. Now open a new terminal — or exec zsh (bash: exec bash): shll shell-setup edits your rc file, so the shim (the hop function, h alias, and tab completion) only exists in shells started after it. Then confirm it all works:
hop ls # every repo from hop.yaml — the bootstrap worked
h web # substring-match and cd into a repo (any partial name) — the shim worksTo install the entire shll toolkit instead:
curl -fsSL https://shll.ai/install | sh- One config, every machine —
hop.yamllives at~/.config/hop/hop.yamland lists every repo you care about (with groups). Keep it in your dotfiles and symlink that path to it, and your repo directory follows you between laptops. - Substring navigation —
h web<TAB>orhop webmatcheswebappandcds your shell straight there. No morecd ~/code/sahil87/webapp. - Run anything inside a repo, from anywhere —
hop dotfiles cursor .opens your dotfiles in Cursor without changing your cwd. Works for any tool, PATH binary, or shell alias:hop webapp git status,hop infra-tf terraform plan,hop loom npm test. - Batch git ops over groups —
hop --all pullpulls every cloned repo.hop work syncrebases-and-pushes every repo in theworkgroup. Group-level fan-out built in. - Bootstrap from disk, not yaml-by-hand —
hop add -r ~/codewalks your existing clones, readsgit remote, and populateshop.yamlfor you. Comment-preserving merges, idempotent re-runs. - Plays nicely with
wt—hop <name> opendelegates to wt's app menu, so you get the same "open in editor / terminal / file manager / cd here" experience for every repo in the registry. Thehop <name>/<wt-name>suffix lands you straight inside a worktree (h webapp/feat-xcds you there,hop webapp/feat-x git statusruns git in it), andhop ls --treesshows worktree state across every repo at a glance.
One grammar: hop <selection> <action>. The selection comes first — always. Then an action: a builtin verb (cd, open, where), a git batch verb (pull, push, sync), or any command, PATH binary, or shell alias.
Colored half = what you type · grey half = what the
halias or tab completion fills in.
General form — hop <selection> <action>:
h web<TAB> cursor . # → hop webapp cursor .
h dot<TAB> ls -la # → hop dotfiles ls -la
h web<TAB> git status # → hop webapp git status
h web<TAB> pull # → hop webapp pull (git batch verb)The action is whatever follows the selection — a builtin verb, a git verb, a PATH binary, or even a shell alias you've defined (hop webapp p). The shim runs it with cwd set to the repo, then returns you to where you started.
Plural form — the selection can be a group or --all, fanning the git batch verbs across many repos:
hop --all pull # pull every cloned repo
hop work sync # 'work' is a group → sync each repo in it
hop --all pushA plural selection accepts only the batch verbs (pull/push/sync) — cd, open, where, and arbitrary tools across many repos are refused (running an interactive tool across N repos makes no sense).
Worktree form — the selection slot accepts an optional /<wt-name> suffix. Everything else is unchanged; every action inherits it:
h web<TAB> # multi-worktree repo → TAB surfaces webapp, webapp/feat-x, webapp/hotfix
h web<TAB>/fe<TAB> git status # → hop webapp/feat-x git status
h web<TAB>/<TAB> cursor . # → TAB after / lists worktrees of webapp
hop web<TAB>/main where # → main-worktree path (same as bare `hop webapp where`)The /<wt-name> resolves via wt list --json, so wt must be on PATH for any /-suffixed query — bare hop webapp is unaffected. The full grammar (subcommands, verbs, flags) is in Grammar at a glance below.
To upgrade later, run hop update — self-upgrades via Homebrew. When hop was installed from source or a release tarball, it prints a hint and exits without invoking brew.
git clone https://github.com/sahil87/hop.git
cd hop
just installBuilds the binary and copies it to ~/.local/bin/hop. Make sure that directory is on your $PATH.
Fuller guide: Installing and setting up hop — install via shll & from-source, shell integration in depth, and first-run bootstrap.
The shell shim — the hop shell function, the h alias, and tab completion — is what makes hop <name> actually cd your shell. shll shell-setup wires it once for every shll tool; installed from source, add eval "$(hop shell-init zsh)" (or bash) to your rc file yourself. The binary tells the shim how to dispatch each invocation, so navigation and running commands/aliases in a repo's directory just work — see Gotchas for the shim-vs-binary details, and the install guide for the full dispatch model.
Point hop at your existing clones and it builds hop.yaml for you: hop add -r ~/code walks the directory, reads each repo's git remote, and auto-derives groups (add -p to preview without writing). The config lives at ~/.config/hop/hop.yaml; walk depth, group rules, the annotated starter (hop config init), and the dotfiles syncing pattern are covered in the install guide.
Three things hop does. Each is a complete mental model on its own.
$ hop ls
prompt-pantry /Users/sahil/code/sahil-weaver/prompt-pantry
webapp /Users/sahil/code/sahil87/webapp
fab-kit /Users/sahil/code/sahil87/fab-kit
wt /Users/sahil/code/sahil87/wt
idea /Users/sahil/code/sahil87/idea
…
$ hop webapp where
/Users/sahil/code/sahil87/webapp
$ h web # substring match → cd into webapp
$ pwd
/Users/sahil/code/sahil87/webapp
$ h webapp/feat-x # cd into the feat-x worktree of webapp (via wt list --json)
$ pwd
/Users/sahil/code/sahil87/webapp.worktrees/feat-x
$ hop ls --trees # one-shot worktree status across every cloned repo
webapp 3 trees (main, feat-x*, hotfix↑2)
dotfiles 1 tree (main)
hop 2 trees (main, refactor-resolve)
loom (not cloned)
$ hop # bare → fzf picker over all repos, prints selection
h is the single-letter alias for the hop shell function. To invoke the binary directly (bypassing the shim — e.g. for scripting), use command hop <name> where, which prints the path instead of cd-ing.
The /<wt-name> suffix is optional, completes after <TAB> (so h webapp/<TAB> lists worktrees), and every action inherits it — hop webapp/feat-x where, hop webapp/feat-x open, hop webapp/feat-x git status, all work. Bare hop webapp is unchanged. wt must be on PATH for any /<wt>-suffixed query.
hop <selection> <action> runs anything with cwd set to that repo, then returns you to where you started. The selection is the first arg; the rest is the action — a command, PATH binary, or shell alias — forwarded verbatim and run in your shell (so your aliases and functions resolve).
$ pwd
/tmp/scratch
$ hop webapp git status
On branch main
Your branch is up to date with 'origin/main'.
nothing to commit, working tree clean
$ pwd
/tmp/scratch # cwd unchanged
A few useful variants:
hop dotfiles cursor . # open dotfiles in Cursor (the trailing . matters — see Gotchas)
hop infra-tf terraform plan # run terraform inside infra-tf
hop webapp p # run your shell alias `p` inside webapp
hop webapp open # delegates to wt's app menu (editor / terminal / cd here)The git batch verbs pull, push, and sync follow the same hop <selection> <action> grammar. The selection can be a single repo, a group, or --all:
hop webapp pull # pull a single repo
hop default pull # pull every cloned repo in the `default` group
hop --all pull # pull every cloned repo in hop.yaml
hop work sync # `git pull --rebase` then `git push` for every repo in `work`
hop --all sync # same, every repoEach command emits a per-repo ✓ / ✗ / skip line on stderr and a final summary: pulled=N skipped=M failed=K. sync skips the push when rebase hits a conflict and prints a git -C <path> rebase --continue hint. Uncloned repos are silently skipped — hop --all clone first if you want to materialize them. A plural selection (--all or a group) accepts only these batch verbs.
The rule is one shape: hop <selection> <action>. The first positional is either a subcommand or a selection (repo / worktree / group / --all); everything after a selection is the action.
| You type | hop does |
|---|---|
hop |
Bare fzf picker over all repos. |
hop <subcommand> (ls, clone, add, rm, config, update, …) |
Routes to the subcommand. |
hop <name> |
cd into the repo. |
hop <name> cd |
Same — explicit verb form. |
hop <name> where |
Prints the absolute path. |
hop <name> open |
Delegates to wt's app menu. |
hop <name> <cmd> ... |
Runs <cmd> ... (command, PATH binary, or shell alias) with cwd = repo, in your shell. |
hop <name> pull / push / sync |
Git batch verb on the selection (repo, worktree, or group). |
hop <name>/<wt> |
Same as hop <name> but lands in the named worktree (resolved via wt list --json). All actions above accept the suffix — <name>/<wt> where, <name>/<wt> open, <name>/<wt> git status, etc. |
hop --all <verb> / hop <group> <verb> |
Plural selection — runs the batch verb (pull/push/sync only) across every matched repo. |
hop ls --trees |
Per-repo worktree summary across the registry (* = dirty, ↑N = unpushed commits). |
Tab completion knows which slot you're in: hop <TAB> offers subcommands + repo names; hop webapp <TAB> offers verbs + tools; hop webapp/<TAB> offers worktree names for that repo. When hop <prefix><TAB> uniquely resolves to a cloned repo with 2+ worktrees, completion surfaces both the bare repo and <repo>/<wt> candidates inline — press Space to commit the main checkout, or pick a worktree from the menu.
Under the hood, the shell function asks the binary how to dispatch (hop --shim-plan …, an internal call) and gets back one of three answers — cd here, run this in the parent shell, or pass through to the binary. The shim never evals binary output; it runs your already-typed words. Because the binary owns the classification, the shim hard-codes no subcommand names and can't drift out of sync with the binary.
Deep-dive: hop workflows — the three jobs, worktree workflows, the shim-vs-binary dispatch model, and gotchas, with worked examples.
hop.yaml is grouped by named sections under repos:, with optional global config: fields:
config:
code_root: ~/code # optional; defaults to ~
repos:
default:
- git@github.com:sahil87/hop.git
- git@github.com:sahil87/wt.git
vendor:
dir: ~/vendor
urls:
- git@github.com:some-vendor/their-tool.gitA flat list (default above) uses convention: each URL lands at <code_root>/<org>/<name>. A map with dir: (vendor above) overrides convention: each URL lands at <dir>/<name>, with org ignored. Group names match ^[a-z][a-z0-9_-]*$.
hop <name>,hop <name> <cmd>, andh <name>need the shell shim. A binary can't change its parent shell's cwd, nor run a command in it — that's the same Unix constraint wt hits with its "Open here" menu option. Without the shim, the binary prints a hint pointing ateval "$(hop shell-init zsh)"or the workaroundcd "$(command hop <name> where)".- Running commands in a repo is shim-only. The shim cd's into the repo and runs your action in the parent shell — so PATH binaries and your shell aliases/functions resolve. In scripts and CI that bypass the shim, resolve the path yourself with
command hop <name> where(handled directly by the binary) and run the tool from there. - Substring match is on the repo name only. Not URL, not path, not group.
hop webmatcheswebappbut not the URLgit@github.com:org/webapp.git. When two repos in different groups share a name, the picker showsname [group]to disambiguate. - No
--forceon thepush/syncbatch verbs. Intentional — for nuanced single-repo cases, reach forhop <name> git push --forceand you'll get the full git output. The batch verbs stay safe by default. hop <name> cursor/codeneed a trailing.— e.g.hop dotfiles cursor .. Not a hop quirk: both editors take[paths...]as positional args and, when invoked with none, restore the previously open folder instead of opening the cwd. The.is what tells them "open this directory." Tools that operate on cwd by default (git status,terraform plan,ls,npm test) don't need it.- The
<name>/<wt>suffix needswtonPATH. Hop shells out towt list --jsonto resolve the worktree name (no state cached inhop.yaml— worktrees are wt's domain). Barehop <name>queries never invoke wt. wt is not installed automatically — install it via shll.ai (shll install wt, orcurl -fsSL https://shll.ai/install | sh -s -- wtif you don't haveshll) or build from source.
hop --help— full subcommand listing (rendered online at shll.ai/hop/commands)- Install guide — install, shell integration, and first-run bootstrap in depth
- Workflows deep-dive — the grammar, daily workflows, the shim model, and gotchas
- Agent skill bundle — one-page usage briefing for agents driving hop (also
hop skill, rendered at shll.ai/hop/skill) docs/specs/cli-surface.md— canonical CLI contract (every subcommand, exit codes, stdout/stderr conventions, every behavioral scenario)docs/specs/config-resolution.md— config search order andhop.yamlschema