CLI Surface
July 18, 2026 · View on GitHub
Canonical contract for what the
hopbinary exposes to users. Source of truth for argument parsing, exit codes, stdout/stderr conventions, and help text.
Subcommand Inventory
| Subcommand | Args | Behavior summary | Exit codes |
|---|---|---|---|
hop | (none) | fzf picker over all repos; print selected absolute path on stdout | 0 selected, 130 cancelled |
hop <name> | <name> | Binary form: print bare-name hint to stderr, exit 2 (1-arg dispatch is shell-only — shorthand for hop <name> cd). Shell-function form (after eval): --shim-plan classifies it as CD\n<path> and the shim cds there. <name> accepts an optional /<wt-name> suffix (hop <name>/<wt>) that resolves through wt list --json to a worktree path — see "Match Resolution Algorithm" below. | Binary: 2. Shell function: 0 success, 1 no match, 2 empty LHS/RHS in /-suffixed query |
hop <name> cd | <name> cd | Binary form: print cd hint to stderr, exit 2 (cd is shell-only). Shell-function form: --shim-plan classifies it as CD\n<path> and the shim cds there. | Binary: 2. Shell function: 0 success, 1 no match |
hop <name> where | <name> where | Resolve <name> and print absolute path on stdout. Replaces v0.x's top-level hop where <name> subcommand (removed). <name> accepts the /<wt-name> suffix — hop <name>/<wt> where prints the worktree's absolute path. | 0 selected, 1 no match / worktree not found / wt missing / wt list failure, 2 empty LHS/RHS, 130 cancelled |
hop <name> open | <name> open | Resolve <name>, then exec wt open <path> (positional path arg, no chdir, stdio fully inherited). wt presents its interactive app menu directly to the user's terminal. The cd-handoff for "Open here" lives in the shim's unified WT_CD_FILE side-channel: the shim's _hop_passthrough exports WT_CD_FILE pointing at a temp file on the binary invocation, and cds there if wt wrote a path. The binary is a transparent passthrough — emits no stdout. open is classified PASSTHROUGH by --shim-plan. wt is a Homebrew formula dependency (depends_on "sahil87/tap/wt"). <name> accepts the /<wt-name> suffix — wt opens its app menu targeting the worktree's path. | wt's exit code on completion; 1 if wt is missing or resolution fails; 130 fzf cancelled |
hop <name> <tool> [args...] | (shim, tool-form) | Tool-form is native grammar: any action token that is not a builtin verb (cd/where/open) or batch verb (pull/push/sync) is classified RUN_IN_PARENT\n<path> by --shim-plan. The shim cds to <path>, then runs the user's literal <tool> [args...] in the parent shell — so PATH binaries, aliases, and functions all resolve. The binary itself does NOT exec the tool (no shell-injection surface — Constitution I); invoked directly it errors with the tool-form hint. <name> accepts the /<wt-name> suffix (e.g., hop webapp/feat-x cursor .). | tool's exit code (in the parent shell); 2 if invoked directly on the binary; 1 if <name> fails to resolve |
hop clone [<name>] | --all | optional <name> or --all | Clone single (resolved) or all missing repos | 0 success, 1 path conflict, non-zero on git failure |
hop clone <url> | 1 (URL form, detected by looksLikeURL) | Ad-hoc clone with auto-registration. Flags: --group, --no-add, --no-cd, --name. Prints the landed path on stdout AND (when WT_CD_FILE is set by the shim) writes it to that side-channel so the parent shell cds on success. | 0 success, 1 missing group / path conflict / git failure |
hop <selection> pull | <selection> pull | Action token (no longer a subcommand). Wraps git pull over a single repo/worktree (substring Name match), every cloned repo in a named group (exact group match), or --all. Per-call 10-minute cloneTimeout via proc.RunCapture. stdout empty; per-repo pull: <name> ✓ <last-line> / pull: <name> ✗ <err> and skip: <name> not cloned go to stderr; batch summary summary: pulled=N skipped=M failed=K. git missing emits gitMissingHint once and aborts the batch. Classified PASSTHROUGH by --shim-plan (the binary owns the fan-out). | 0 success / batch with failed == 0; 1 single-repo not-cloned, single failure, batch failed > 0, git missing, fzf missing; 2 usage error; 130 fzf cancelled |
hop <selection> push | <selection> push | Action token. Wraps git push over a single repo/worktree, a named group, or --all. Same resolution rules as pull — delegates to the shared resolveTargets resolver and runBatch helper. stdout empty; per-repo push: <name> ✓ <last-line> / push: <name> ✗ <err> and skip: <name> not cloned go to stderr; batch summary summary: pushed=N skipped=M failed=K. No --force, no --set-upstream — Constitution III; reach for hop <name> git push --force for nuanced single-repo cases. | 0 success / batch with failed == 0; 1 single-repo not-cloned, single failure, batch failed > 0, git missing, fzf missing; 2 usage error; 130 fzf cancelled |
hop <selection> sync | <selection> sync | Action token. Auto-commits a dirty tree (fixed default message chore: sync via hop — no -m override in the reoriented form), then git pull --rebase then git push per target. Same resolution rules as pull. Two independent 10-minute timeouts per repo. Rebase CONFLICT emits a resolve manually with: git -C <path> rebase --continue hint and skips push; push failure emits sync: <name> ✗ push failed: <err>. Batch summary summary: synced=N skipped=M failed=K. | Exit codes match pull/push. |
hop --all <verb> / hop <group> <verb> | plural selection + batch verb | Plural selection: runs the batch verb (pull/push/sync) across every matched repo. hop --all pull replaces the former hop pull --all. A plural selection accepts ONLY the batch verbs — cd, open, tool-form, and a bare plural (no action) are refused with exit 2 (running an interactive action across many repos is not supported). | Exit codes match the batch verb; 2 when a non-batch action or no action is given on a plural selection |
hop ls | (none); --trees boolean flag | Default: print all repos as name<spaces>path columns. With --trees: fan wt list --json across configured cloned repos in YAML source order and emit per-row worktree summaries (name<spaces>{N} tree(s) (<wt-list>) where each wt is name[*][↑N]). Non-cloned repos surface (not cloned) without invoking wt. Per-row wt list failures degrade as inline (wt list failed: <err>); first wt-missing aborts the run with hop: wt: not found on PATH. | 0 success; 1 wt missing during --trees |
hop rm [<name>] | optional <name>; --stale boolean flag; --dry-run boolean flag; --yes/-y boolean flag | Remove a registered repo from hop.yaml. No positional → fzf picker (single-select) over registered repos; a <name> resolves via the shared match-or-fzf algorithm and removes directly (no picker, no on-disk check). Consent gate on hop rm <name> (a destructive registry write, principle №5): before writing, on a TTY it shows the resolved match (remove: <name> (<url>)) and prompts Proceed? [y/N] (default No) on stderr — y/yes proceeds, anything else (incl. bare Enter) aborts with aborted: no changes written and exit 0. --yes/-y is flag-based consent for automation: it skips the prompt (and is accepted-and-ignored on the picker shape — the pick is itself the consent — never a usage error). With no TTY and no --yes, hop rm <name> refuses fast with hop rm: consent required for removal — re-run with --yes (or preview with --dry-run) and exit 3 (no write, no hang) rather than run unattended. --stale pre-filters the picker to repos whose resolved path is missing from disk (cannot be combined with <name>). --dry-run resolves the target through the same path as a live removal but writes nothing — it previews would remove: <url> + dry-run: no changes written to stderr (or the forgiving Nothing to remove. when the URL/group is absent) and exits 0, leaving hop.yaml byte-for-byte unchanged (principle №5: a destructive write's --dry-run shares the real code path via yamled.WouldRemoveURL, the read-only half of yamled.RemoveURL). --dry-run needs no consent — it is checked before the gate, so it is never prompted or refused (composes freely with --yes). The picker paths (hop rm, hop rm --stale, the hidden alias) are ungated — the fzf pick is the consent. Status lines (removed:/wrote: on a live run, would remove:/dry-run: on a preview, aborted: on a declined prompt) go to stderr. hop config rm [--stale] [--dry-run] is a hidden picker-only alias (no --yes flag — it has no positional/consent point). | 0 success / forgiving no-op (nothing to remove, nothing stale, not-found, successful dry-run preview incl. the forgiving not-found, declined prompt); 1 fzf missing / missing hop.yaml / write failure / dry-run preview failure (unreadable hop.yaml); 2 --stale combined with a name; 3 no TTY for the picker (live or dry-run) OR consent refused on hop rm <name> (no TTY, no --yes, no --dry-run); 130 fzf cancelled |
hop shell-init <shell> | zsh or bash (required) | Emit shell function wrapper + cobra-generated completion to stdout | 0 success, 2 unsupported shell |
hop config init | (none) | Bootstrap a starter hop.yaml at the resolved location | 0 written, 1 file exists, 2 write error |
hop config where | (none) | Print the resolved config path on stdout. Renamed from v0.0.1's config path. | 0 resolved, 1 unresolvable |
hop config print | (none) | Print the resolved hop.yaml contents to stdout (raw bytes, comment-preserving). | 0 success, 1 unresolvable / read error |
hop config scan <dir> | exactly 1 (directory) | Walk <dir> (default --depth 3), discover git repos via stat + git remote, and emit a merged hop.yaml to stdout (default) or merge in place via --write (atomic, comment-preserving). Auto-derives groups: convention-match repos go to default; non-convention repos land in invented map-shaped groups keyed off the parent dir basename. | 0 success (incl. zero repos found); 1 missing hop.yaml / git missing / write failure; 2 usage error (missing arg, dir validation, --depth < 1) |
hop update | (none) | Self-update the hop binary via Homebrew. No-op (with hint) when the binary was not installed via brew. | 0 success, 1 brew failure |
hop skill | (none) (cobra.NoArgs) | Print hop's agent skill bundle (a usage briefing) as raw markdown to stdout, byte-identical to the embedded copy of docs/site/skill.md. Visible command (appears in hop --help and help-dump). No rendering, no pager, no framing (stdout is data); stderr empty on success. Adopts the toolkit skill standard (shll standards skill) — embedded via //go:embed from src/cmd/hop/skill.md, kept byte-honest against the canonical docs/site/skill.md by a drift-guard test. | 0 success, non-zero on unexpected write error |
hop -h | --help | help | (none) | Print help text on stdout | 0 |
hop -v | --version | (none) | Print version string on stdout | 0 |
hop path(v0.0.1) andhop config path(v0.0.1) were removed without aliases. The top-levelhop where <name>andhop cd <name>subcommands were removed in the v0.x repo-verb grammar flip — usehop <name> whereandhop <name> cd(or the barehop <name>shorthand) instead.hop config wheresurvives unchanged (different namespace).Grammar + shim refactor (gyo0): the grammar is now uniformly
hop <selection> <action>. The-Rflag (and itsextractDashR/runDashRargv inspection) and thehialias were removed; tool-form is native grammar dispatched via the shim'sRUN_IN_PARENTplan.pull/push/syncare no longer cobra subcommands — they are action tokens after a selection (hop <name> pull), with plural fan-out viahop --all pull/hop <group> pull(replacing the formerhop pull --all). Subcommand classification moved out of the shim into the binary's hidden--shim-planflag, which emits a fixed 3-keyword protocol (CD/RUN_IN_PARENT/PASSTHROUGH); the shim hard-codes zero subcommand names.
Match Resolution Algorithm
Used by hop (the --shim-plan CD/RUN_IN_PARENT path), hop <name> where, hop <name> cd, the batch verbs, and hop clone.
- Worktree-suffix pre-step: if
<name>contains a/, split on the first/(repo names fromhop.yamlare URL basenames and never contain/, so first-split is unambiguous even when wt worktree names themselves contain/). Empty LHS → exit 2 withhop: empty repo name before '/'; empty RHS → exit 2 withhop: empty worktree name after '/'. Otherwise, recurse on the LHS (steps 1-5 below) to resolve a repo, then run the worktree-resolution sub-step (described below). - Build the list of all known repos from
hop.yaml. Each entry has(Name, Group, Dir, URL, Path). The list preserves YAML source order (groups incfg.Groupsorder, URLs within each group in source order). - If
<name>is non-empty: filter by case-insensitive substring match onName(not Path, not URL, not Group). - If exactly 1 match: return it directly without invoking fzf.
- Otherwise (0 matches OR 2+ matches): invoke fzf with these flags, piping the full repo list (not the filtered subset) on stdin so the user can clear the query inside fzf to browse all repos:
Thefzf --query <name> --select-1 --height 40% --reverse --with-nth 1 --delimiter '\t'--select-1flag makes fzf auto-select if its filter narrows to exactly 1. - If
<name>is empty: invoke fzf without--query(full picker).
Worktree-resolution sub-step
When the pre-step splits on /, after the LHS resolves to a *repos.Repo:
- Cloned-state guard: if the resolved repo's
.gitdoes NOT exist on disk, exit 1 withhop: '<name>' is not cloned. Try: hop clone <name>BEFORE invoking wt. This guard applies ONLY to/-suffixed queries; bare queries retain their existing permissive behavior of resolving registry paths even when the repo isn't cloned. - Invoke
wt list --jsonin the repo's main checkout viainternal/proc.RunCapturewith a 5-second per-call timeout. Parse into[]WtEntrywhere each entry has{Name, Branch, Path, IsMain, IsCurrent, Dirty, Unpushed}(unknown JSON fields are silently ignored for forward-compat). - Find the entry whose
Nameequals the RHS exactly (case-sensitive — mirrors the case-sensitive group-name match inresolveTargets).hop <name>/mainnaturally resolves to the main checkout because wt'sis_main: trueentry carries that path; no special-case in hop. - Return a shallow copy of the LHS-resolved repo with
Pathreplaced by the worktree's absolute path; all other fields (Name,Group,URL,Dir) preserved.
Error surfaces (all exit code 1 unless noted; pre-formatted stderr lines):
wtmissing on PATH →hop: wt: not found on PATH.(same wording ashop <name> open)wt list --jsonnon-zero exit or malformed JSON →hop: wt list: <err>(no silent fallback to the main path — unparseable wt output is a real failure)- No matching worktree →
hop: worktree '<wt>' not found in '<repo>'. Try: wt list (in <repo-path>) or hop ls --trees
Group disambiguation in the picker
When two or more repos share the same Name across different groups, the displayed first column is <name> [<group>] rather than just <name>. When a name is unique across groups, no suffix is added. Two URLs in the same group whose derived Name collides still render an identical first column (intra-group collisions are out of scope; cross-group collisions are handled).
Stdout / stderr Conventions
- stdout: resolved absolute paths (
hopbare picker,hop <name> where), thehop lstable, version string, config path (hop config where), shell integration (hop shell-init <shell>), help text, "Created" message from hop config init, the landed path fromhop clone <url>(also mirrored toWT_CD_FILEfor cd-on-success). The--shim-planclassifier emits exactly one plan line-group on stdout:CD\n<path>,RUN_IN_PARENT\n<path>, orPASSTHROUGH. Tool-form runs in the parent shell (the shim'sRUN_IN_PARENTarm), so its output is the tool's own — not hop-owned. - stderr: status messages (
clone: <url> → <path>,skip: <reason>), error messages, hints. Thehop config initpost-write tip also goes to stderr. The--shim-planusage errors (plural-selection guard) go to stderr with exit 2. - The
hop <name> cd, barehop <name>, and tool-form (hop <name> <tool>) binary-form exit-2 hints go to stderr.
Behavioral Scenarios (GIVEN/WHEN/THEN)
Bare picker
GIVEN
hop.yamllists 3 repos WHEN I runhopwith no arguments THEN fzf opens with all 3 repos visible AND selecting one prints its absolute path to stdout AND exit code is 0
Bare-name 1-arg form (binary)
GIVEN the user invokes the binary directly (no shim) WHEN they run
hop webappTHEN the binary writes the bare-name hint (hop: bare-name dispatch is shell-only. Add 'eval "$(hop shell-init zsh)"' to your zshrc, or use: hop "<name>" where) to stderr AND stdout is empty AND exit code is 2
Unique substring match (hop <name> where)
GIVEN
hop.yamlhas exactly one repo namedwebappWHEN I runhop webapp whereTHEN fzf is NOT invoked AND stdout is the absolute path to that repo AND exit code is 0
Ambiguous substring match (hop <name> where)
GIVEN
hop.yamlhas reposwebappandwebapp-sharedWHEN I runhop webapp whereTHEN fzf opens with both candidates filtered (--query webapp) AND if the user picks one, exit code 0 AND if the user cancels (Esc), exit code 130
Zero substring match (hop <name> where)
GIVEN
hop.yamlhas reposalpha,beta,gammaWHEN I runhop zzz whereTHEN fzf opens with--query zzzand zero filtered candidates AND the user can clear the query inside fzf to see all repos and pick one AND if the user cancels, exit code 130
Group disambiguation in picker
GIVEN
hop.yamlhas a repo namedtoolsin groupdefaultand another namedtoolsin groupvendorWHEN I runhop(bare) THEN fzf shows two rows:tools [default]andtools [vendor]AND the path column (the unique key for match-back) distinguishes them
hop <name> cd binary form
GIVEN the user has NOT run
eval "$(hop shell-init zsh)"WHEN they runhop <name> cdTHEN the binary prints to stderr:hop: 'cd' is shell-only. Add 'eval "$(hop shell-init zsh)"' to your zshrc, or use: cd "$(hop "<name>" where)"AND exit code is 2
hop <name> cd shell-function form
GIVEN the user has run
eval "$(hop shell-init zsh)"WHEN they runhop <name> cdTHEN--shim-planclassifies it asCD\n<resolved-path>AND the shim runscd -- <resolved-path>AND the parent shell's working directory is changed
hop <name> <tool> binary form (tool-form attempt)
GIVEN the user invokes the binary directly (no shim) WHEN they run
hop webapp cursorTHEN the binary prints the tool-form hint to stderr:hop: 'cursor' is not a hop verb (cd, where, open, pull, push, sync). Tool-form runs in your shell — install the shim: eval "$(hop shell-init zsh)"AND exit code is 2 (tool-form runs in the parent shell via the shim, so the binary cannot honor it)
The --shim-plan protocol
The shim is a logic-free interpreter of a fixed 3-keyword protocol emitted by the binary's hidden --shim-plan flag. The shim hard-codes zero subcommand names — the list lives only in cobra, so shim/binary name-drift is structurally impossible.
hop() calls plan="$(command hop --shim-plan "$@")" || return $? and branches the first line over:
CD\n<path>→cd -- <path>(barehop <name>,hop <name> cd).RUN_IN_PARENT\n<path>→cd -- <path>; shift; "$@"— runs the user's already-parsed words in the parent shell (tool-form: PATH binaries, aliases, functions all resolve). Security (Constitution I):"$@"are the user's typed words, never anevalof binary output;<path>is used only as a quotedcdoperand — no shell-injection surface.PASSTHROUGH→_hop_passthrough "$@"→command hop "$@". The binary owns it:add,rm,clone,ls,config,update,shell-init,where,open,pull/push/sync,--help/-h/--version/completion/__complete*._hop_passthroughprovides the unifiedWT_CD_FILEcd side-channel (collapsing the former where/open/clone handoffs into one).
__complete* is forwarded directly to the binary (NOT through --shim-plan, which would classify the completion request instead of answering it). h <name> (single-letter alias) behaves identically. The hi alias and the -R flag were removed; command hop is the raw escape hatch.
Classification (first match wins):
- No args →
CD(bare picker resolves a path). $1is__complete*→PASSTHROUGH(defense-in-depth).$1is a known cobra subcommand, or a flag other than--all→PASSTHROUGH.$1 == --all→ plural selection (action =$2..).$1is an exact configured group name → plural selection (action =$2..).- Otherwise
$1is a singular repo/worktree selection (action =$2..):- no action /
cd→CD\n<path>. where/open/pull/push/sync→PASSTHROUGH(the binary owns them).- any other token →
RUN_IN_PARENT\n<path>(tool-form).
- no action /
GIVEN
hop.yamlresolveswebappto~/code/sahil87/webapp, shim installed WHEN I runhop webapp git statusTHEN--shim-planemitsRUN_IN_PARENT\n~/code/sahil87/webappAND the shim cds there, shifts offwebapp, and runsgit statusin the parent shell AND the parent shell's cwd is now~/code/sahil87/webapp
GIVEN an arbitrary tool with its own flags WHEN I run
hop webapp jq '.foo' file.jsonTHEN the shim runs the user's literal wordsjq '.foo' file.json(no re-parsing) in the parent shell
GIVEN
<name>matches no repo WHEN I runhop nope echo hiTHEN--shim-planfails resolution, prints the match-or-fzf no-candidate stderr, and the shim's|| return $?propagates exit 1
GIVEN
cursoris a shell alias/function anddotfilesresolves uniquely WHEN I runhop dotfiles cursor .THEN the shim cds into dotfiles and runscursor .in the parent shell — so aliases/functions resolve (the former-Rpath could not run shell functions)
GIVEN the user invokes the binary directly without the shim WHEN they run
/usr/local/bin/hop --shim-plan webapp git statusTHEN the binary emitsRUN_IN_PARENT\n~/code/sahil87/webappand exits 0 (classification only — it never execs the tool)
Plural selection and the interactive guard
GIVEN shim installed WHEN I run
hop --all pullTHEN--shim-planemitsPASSTHROUGHandcommand hop --all pullrunsgit pullacross every cloned repo (replacing the formerhop pull --all)
WHEN I run
hop <group> syncTHEN sync runs across every cloned repo in<group>
WHEN I run
hop --all code .(interactive action on a plural selection) THEN--shim-planprints a usage error to stderr and exits 2 — running an interactive action across many repos is refused
WHEN I run
hop --all(plural selection, no action) THEN exit 2 with a usage error — a plural selection has no single directory to cd into
hop clone <name> (registered repo)
GIVEN
<name>resolves to(name=foo, path=~/code/foo, url=git@github.com:user/foo.git)and~/code/foodoes not exist WHEN I runhop clone fooTHEN stderr showsclone: git@github.com:user/foo.git → ~/code/fooANDgit clone git@github.com:user/foo.git ~/code/fooruns (10-minute timeout) AND exit code matches git's exit code
GIVEN the same resolution, but
~/code/foo/.gitalready exists WHEN I runhop clone fooTHEN stderr showsskip: already cloned at ~/code/fooAND exit code is 0
GIVEN the same resolution, but
~/code/fooexists and is NOT a git repo WHEN I runhop clone fooTHEN stderr showshop clone: ~/code/foo exists but is not a git repoAND exit code is 1
hop clone --all
GIVEN
hop.yamlhas 5 repos, 2 already cloned WHEN I runhop clone --allTHEN stderr showsclone:lines for the 3 missing andskip:lines for the 2 cloned AND the final stderr line issummary: cloned=3 skipped=2 failed=0AND exit code is 0 iffailed == 0, else non-zero
hop clone <url> — ad-hoc URL clone with auto-registration
hop clone distinguishes URL form from name form via looksLikeURL: the argument contains :// OR (@ AND :). On URL form:
- Resolve the target group (
--group <name>, defaultdefault). Missing group → exit 1 withhop: no '<group>' group in <config-path>. .... - Compute landing path:
- Map-shaped group with
dir:set:<dir>/<name>. - Flat group:
<code_root>/<org-from-url>/<name-from-url>(theorgsegment is dropped if the URL has none). --name <override>replaces the URL-derived name.
- Map-shaped group with
- Classify on-disk state and act:
- Missing path →
git clone <url> <path>, then (unless--no-add) append URL tohop.yamlviainternal/yamled.AppendURL. Print landed path to stdout (unless--no-cd). - Already cloned (
<path>/.gitexists) → emitskip: already cloned at <path>to stderr; still appends YAML and prints path (registers an existing checkout). - Path exists, not a git repo → emit
hop clone: <path> exists but is not a git repo; exit 1; no YAML write, no stdout.
- Missing path →
- URL already in target group's
urlslist → emitskip: <url> already registered in '<group>'to stderr; no YAML write; still print path (unless--no-cd) so the shim cancdto it.
The YAML write is comment-preserving and atomic (temp file + rename via internal/yamled); see architecture.md.
GIVEN
hop.yamlhas adefaultflat group,code_root = ~/code, and~/code/sahil87/loomdoes not exist WHEN I runhop clone git@github.com:sahil87/loom.gitTHENgit cloneruns into~/code/sahil87/loomAND the URL is appended to thedefaultgroup inhop.yaml(comments preserved, atomic write) AND stdout is~/code/sahil87/loom(consumed by the shim'scd) AND exit code is 0
GIVEN the same setup, plus
--group vendorand a map-shapedvendor: { dir: ~/vendor, urls: [...] }group WHEN I runhop clone --group vendor git@github.com:other/tool.gitTHEN the landing path is~/vendor/toolAND the URL is appended tovendor.urlsinhop.yaml
GIVEN
--no-addis passed WHEN I runhop clone --no-add <url>THEN the clone proceeds buthop.yamlis NOT modified
GIVEN
--no-cdis passed WHEN I runhop clone --no-cd <url>(under the shim or not) THEN stdout suppresses the landed path, so the shim does notcd
GIVEN
--name fooWHEN I runhop clone --name foo git@github.com:user/bar.gitTHEN the landing path usesfoo, not the URL-derivedbar
hop ls
GIVEN
hop.yamlhas 3 repos across 2 groups (preserving source order: group A then group B) WHEN I runhop lsTHEN stdout shows 3 rows in YAML source order, eachname<spaces>path, aligned (column-style) AND exit code is 0 AND an emptyhop.yamlproduces no output (still exit 0)
hop rm <name> --dry-run — preview a removal without writing
GIVEN
hop.yamlregistershopandwtin thedefaultgroup WHEN I runhop rm wt --dry-runTHENwtis resolved via the same match-or-fzf path a livehop rm wtuses AND stderr showswould remove: git@github.com:sahil87/wt.gitfollowed bydry-run: no changes writtenAND noremoved:/wrote:line is emitted ANDhop.yamlis byte-for-byte unchanged on disk AND exit code is 0
GIVEN the same
hop.yamlWHEN I runhop rm --dry-run(no name — the picker path) and selectwtTHEN the picked entry is previewed the same way (would remove:+dry-run:),hop.yamlis untouched, exit 0
GIVEN a resolved repo whose URL is not present in its group WHEN the dry-run runs THEN stderr shows the forgiving
... not found in <path>. Nothing to remove.(same wording as the live path's not-found no-op) and exit code is 0 — the preview sharesyamled.RemoveURL's locate contract viayamled.WouldRemoveURL
hop rm <name> — consent gate (change clc4)
hop rm <name> is a destructive registry write, so it requires explicit consent before writing (principle №5, reconciling №1's "non-interactive by default"): a TTY prompt, --yes/-y for automation, or a fast no-TTY refusal. The gate sits on the positional path only, between resolution and the write; --dry-run is checked first and needs no consent; the picker paths are ungated (the pick is the consent).
GIVEN
hop.yamlregistershopandwt, stdin is a TTY, neither--yesnor--dry-runis passed WHEN I runhop rm wtand answery(oryes, case-insensitive) THEN stderr showsremove: wt (git@github.com:sahil87/wt.git)thenProceed? [y/N]AND the entry is removed (removed:+wrote:on stderr), stdout is empty, exit code is 0
GIVEN the same setup WHEN I run
hop rm wtand press Enter (or typen, or any non-affirmative input) THEN stderr showsaborted: no changes writtenANDhop.yamlis byte-for-byte unchanged AND exit code is 0 (a declined removal is a benign no-op, NOT an fzf-style cancellation — so 130 is not used)
GIVEN
hop.yamlregisterswt, stdin is NOT a TTY,--yesabsent,--dry-runabsent WHEN I runhop rm wtTHEN stderr showshop rm: consent required for removal — re-run with --yes (or preview with --dry-run)AND no write occurs and exit code is 3 (distinct from 130 fzf-cancel and 1 application error; reuses hop's "a terminal was required" convention with a consent-specific message naming--yes)
GIVEN
hop.yamlregisterswtWHEN I runhop rm wt --yes(with or without a TTY) THEN no prompt is shown and the entry is removed, exit code is 0
GIVEN
hop.yamlregisterswt, stdin is NOT a TTY WHEN I runhop rm wt --dry-run(no--yes) THEN the preview runs unchanged (would remove:+dry-run: no changes written, exit 0) —--dry-runwrites nothing, so it is never prompted or refused
GIVEN
hop.yamlregistershopandwt, a TTY is present WHEN I runhop rm(picker shape) and pickwtTHEN the entry is removed with NO post-pickProceed?prompt — the fzf pick is itself the consent AND passing--yeson the picker shape (hop rm --yes) is accepted and ignored (redundant, not a usage error)
GIVEN the hidden
hop config rmalias (picker-only) WHEN its flag set is inspected THEN it registers--staleand--dry-runbut NO--yes/-yflag — it has no positional/consent point, so adding one would be surface bloat (Constitution VI)
hop shell-init <shell>
WHEN I run
hop shell-init zshTHEN stdout contains the sharedposixInitprefix defininghop(),_hop_passthrough(),h()(a logic-free interpreter of the--shim-planprotocol — no_hop_dispatch, nohi) AND stdout contains the cobra-generated_hopcompletion function (appended at runtime viarootCmd.GenZshCompletion) AND stdout containscompdef _hop hso thehalias shares the completion AND runningeval "$(hop shell-init zsh)"in a zsh shell defineshopas a function (verifiable viawhence -w hop) AND exit code is 0
WHEN I run
hop shell-init bashTHEN stdout contains the same sharedposixInitprefix (works in both shells — uses[[ ]],${@:N},local) AND stdout contains the cobra-generated__start_hopbash completion function (viarootCmd.GenBashCompletionV2) AND stdout containscomplete -o default -F __start_hop h hiso the aliases share the completion AND exit code is 0
WHEN I run
hop shell-initwith no shell argument THEN stderr showshop shell-init: missing shell. Supported: zsh, bashAND exit code is 2
WHEN I run
hop shell-init fishTHEN stderr showshop shell-init: unsupported shell 'fish'. Supported: zsh, bashAND exit code is 2
hop --version / -v
WHEN I run
hop --versionorhop -vTHEN stdout is a single line containing the version string (e.g.,v0.1.0orv0.1.0-2-gabc123for dev builds fromgit describe) AND exit code is 0
NOTE: Cobra also auto-wires a
hop versionsubcommand fromrootCmd.Version; this still works (no effort spent suppressing it).
hop update
hop update self-upgrades the binary via Homebrew. It MUST detect whether the binary was installed via brew (by walking os.Executable through EvalSymlinks and checking for /Cellar/ in the resolved path); when it wasn't, it MUST exit 0 after printing a hint pointing at the manual install command — the binary cannot upgrade what it didn't install.
The brew formula is referenced as sahil87/tap/hop (fully qualified) to disambiguate from the Homebrew core hop cask (an HWP document viewer) that would otherwise shadow the formula.
Version comparison MUST normalize the leading v — the binary reports versions with the v prefix (e.g. v0.0.3 from the build's git describe ldflag), while brew info --json=v2 reports the bare form (0.0.3). The comparison uses the bare form on both sides.
GIVEN the binary was installed via Homebrew and the tap formula is at the same version WHEN I run
hop updateTHEN stdout showsCurrent version: v<X>, thenChecking for updates..., thenAlready up to date (v<X>).AND exit code is 0 ANDbrew upgradeis NOT invoked
GIVEN the binary was installed via Homebrew and the tap has a newer version WHEN I run
hop updateTHEN stdout showsUpdating v<old> → v<new>...followed bybrew upgradeoutput AND on success, stdout ends withUpdated to v<new>.AND exit code is 0
GIVEN the binary was NOT installed via Homebrew (e.g.
just local-install, manualgo install, or downloaded tarball) WHEN I runhop updateTHEN stdout showshop v<X> was not installed via Homebrew.followed by a manual-update hint pointing atbrew install sahil87/tap/hopANDbrewis NOT invoked AND exit code is 0
GIVEN
brew updateorbrew infofails (network error, brew not on PATH, etc.) WHEN I runhop updateTHEN stderr shows the failure reason AND exit code is 1
hop config scan <dir> — populate hop.yaml from on-disk repos
hop config scan walks <dir> (default --depth 3, inclusive), discovers git repositories via stat + git remote, derives groups from the on-disk layout (convention-match → default; non-convention → invented map-shaped group keyed off the parent dir basename), and emits a merged hop.yaml to stdout (default) or merges in place via --write (atomic, comment-preserving). All git invocations route through internal/proc.RunCapture with a 5-second per-call context.WithTimeout. Walk symlinks are followed with (dev, inode) loop dedup. Implementation: src/cmd/hop/config.go::newConfigScanCmd + helpers in src/cmd/hop/config_scan.go; the walker lives in src/internal/scan/ and the YAML merge in src/internal/yamled/MergeScan + RenderScan.
GIVEN
hop.yamlhascode_root: ~/codeand~/code/sahil87/hop/.gitexists withgit remote get-url originreturninggit@github.com:sahil87/hop.gitWHEN I runhop config scan ~/codeTHEN the URL lands in thedefaultflat group in the rendered YAML AND stderr summarizesmatched convention (default): 1AND exit code is 0
GIVEN the same
hop.yamland a non-convention repo at~/vendor/forks/tool/.gitwith URLgit@github.com:other/tool.gitWHEN I runhop config scan ~/vendorTHEN the rendered YAML contains an inventedforks:group withdir: ~/vendor/forksand the URL underurls:AND stderr summarizesinvented groups: 1 (forks)
GIVEN
~/workis a symlink to~/Volumes/Mac/work(a real directory containing repos) WHEN I runhop config scan ~/workTHENEvalSymlinksresolves the argument and the walk proceeds against the canonical target AND eachFound.Pathis the canonical (resolved) path
GIVEN
~/code/a/b/c/d/.gitexists at depth 4 from~/codeWHEN I runhop config scan ~/code --depth 3THEN that repo is NOT in the rendered YAML (depth bound is inclusive at 3)
GIVEN
~/code/scratch/.gitexists andgit remotereturns empty WHEN I runhop config scan ~/codeTHEN the repo is skipped with reasonno remoteAND stderr's skipped breakdown counts it AND the URL is NOT rendered into the YAML
GIVEN no
hop.yamlexists at the resolved path (and$HOP_CONFIGis unset) WHEN I runhop config scan ~/codeTHEN stderr showshop config scan: no hop.yaml found at <ResolveWriteTarget>.followed byRun 'hop config init' first, then re-run scan.AND exit code is 1 AND no walk is performed (nogitinvocations)
External Tool Availability
External tools (fzf, git, <cmd> for -R) are checked lazily — only when the subcommand actually needs them. Subcommands that resolve without an external tool MUST NOT preemptively check or fail.
| Tool | Required by | Behavior if missing |
|---|---|---|
fzf | hop (bare picker), hop <name> where (ambiguous), hop <name> <tool> (ambiguous selection), hop clone <name> (ambiguous) | Print to stderr: hop: fzf is not installed. Install it: brew install fzf (macOS) or apt install fzf (Debian). Exit 1. |
git | hop clone (any form); hop <selection> pull/push/sync; hop config scan <dir> (only when the walk finds a .git candidate — empty trees succeed without git) | Print to stderr: hop: git is not installed. Exit 1. |
<tool> | hop <name> <tool> (tool-form, run in the parent shell via the shim's RUN_IN_PARENT plan) | The shim runs the user's literal <tool> words; a missing tool surfaces via the shell's own "command not found" (not a hop error). The binary never execs the tool. |
wt | hop <name> open (any form); hop <name>/<wt> ... (any /-suffixed form, via resolveByName); hop ls --trees (lazy — first invocation only) | Print to stderr: hop: wt: not found on PATH. Exit 1. Mitigated: wt is declared as a Homebrew formula dependency (depends_on "sahil87/tap/wt"). |
brew | hop update (when installed via brew) | Print to stderr: hop update: brew not found on PATH. Exit 1. |
Subcommands that don't need a tool MUST work without it. Examples:
hop foo where(whenfoois a unique substring match) does not invoke fzf — works withoutfzfinstalled.hop lsdoes not invoke any external tool.hop shell-init zshandhop shell-init bashdo not invoke any external tool — emit stdout text only.hop config initandhop config wheredo not invoke any external tool.
Help Text
hop -h | --help | help emits help text to stdout. Cobra renders the help; the Usage: table and Notes: block come from rootLong in src/cmd/hop/root.go. Top-level structure mirrors the inventory table above.
The Usage: block enumerates (in this order): hop, hop <name>, hop <name>/<wt>, hop <name> cd, hop <name> where, hop <name> open, hop <name> git pull, hop <name> code ., hop <name> p, hop <name> pull, hop <name> push, hop <name> sync, hop <group> pull, hop --all pull, hop --all sync, hop clone <name>, hop clone <url>, hop clone --all, hop clone, hop ls, hop add <dir>, hop rm [<name>], hop shell-init <shell>, hop config init, hop config where, hop config print, hop config scan <dir>, hop update, hop -h | --help, hop -v | --version.
The Notes: block in rootLong documents:
hop <name>andhop <name> cdrequire shell integration (a binary can't change its parent shell's cwd). Without it, usecd "$(hop <name> where)".- Tool-form (
hop <name> <tool> ...) andhop <name> open's "Open here" choice run in the parent shell via the shim. pull/push/syncaccept a repo, a worktree, a group, or--allas the selection. A plural selection (--allor a group) accepts onlypull/push/sync.- On ambiguous or no-match queries, fzf opens prefilled with the user's query.
- Config lives at
~/.config/hop/hop.yaml.
Cobra Wiring
rootCmdis defined insrc/cmd/hop/root.go::newRootCmd().- Each subcommand has its own file under
src/cmd/hop/with afunc newXxxCmd() *cobra.Commandfactory. main.go::main():- Builds
rootCmd := newRootCmd(). - Sets
rootCmd.Version = version(the package-levelvar version = "dev", overridden via-ldflags "-X main.version=…"at build time). - Captures
rootForCompletion = rootCmdsoshell-initcan callGenZshCompletion/GenBashCompletionV2without threadingrootCmdthrough factories. - Inspects
os.Argsfor--shim-planviaextractShimPlan(pre-cobra). If present, callsrunShimPlan(os.Stdout, os.Stderr, rest)andos.Exit(code)— bypassing cobra entirely (the action token after the selection is an arbitrary child command line, not a hop flag). - Otherwise calls
rootCmd.Execute(). Errors are mapped to exit codes viatranslateExit.
- Builds
rootCmd.SilenceUsage = trueandrootCmd.SilenceErrors = true—translateExitis the sole stderr/exit path.- The selection-first behavior (
hopwith no args,hop <name>1-arg,hop <selection> <action>2+-arg) is implemented viarootCmd.RunE(withcobra.ArbitraryArgs) inrunRoot. Thewhere-verb branch dispatches toresolveAndPrint;opentorunOpen;pull/push/synctorunBatchVerb; the bare-name,cd-verb, and tool-form branches each return&errExitCode{code: 2, msg: ...}with the appropriate hint. Plural selection (--allor an exact group) dispatches torunPluralSelection.
Why --shim-plan bypasses cobra
The action token after the selection (e.g. git pull, code ., jq '.foo' file.json) is an arbitrary child command line, not a hop flag or subcommand. Cobra's parser would try to interpret it as hop's own flags/subcommand. Pre-Execute argv inspection (extractShimPlan) strips the --shim-plan flag and hands the user's original argv to runShimPlan, which classifies it and emits the fixed 3-keyword protocol without cobra parsing the action. Tested in shim_plan_test.go.
Exit Code Conventions
Defined centrally in main.go::translateExit:
| Code | Meaning |
|---|---|
| 0 | Success (incl. a declined hop rm <name> consent prompt — a benign no-op) |
| 1 | Application error (no match, missing tool, file already exists, write error, child resolution error, etc.); also errSilent (caller already wrote stderr) |
| 2 | Usage error (cd binary form, tool-form binary form, shell-init missing/unsupported shell, plural-selection guard, hop rm --stale combined with a name) |
| 3 | No TTY for an interactive fzf selection (errNoTTY), OR consent refused on hop rm <name> when no TTY and no --yes (errConsentRequired) — both print an actionable stderr message and exit 3 (distinct from 130) |
| 130 | User cancelled — fzf Esc / Ctrl-C (errFzfCancelled) |
The --shim-plan classifier bypasses cobra entirely and uses os.Exit directly with the classification exit code (0 for a successful plan, 2 for the plural-selection guard, 1 for resolution errors, 130 for fzf cancellation).
Design Decisions
- The
cdverb at $2 is shell-only; the binary errors with a hint. A binary cannot change its parent shell'scwd; the function wrapper (emitted byhop shell-init zsh) does. The binary's role is to print a hint pointing at the shim install andhop <name> where, so users discover the shell integration. Generalizes to: every form that needs the shim errors in the binary; every form the binary can fulfill works in both layers. - Bare-name dispatch (
hop <name>1 arg) is shorthand forhop <name> cd(Option B2). Both are shell-only — the binary errors with a hint. This enforces the invariant that anyhop <subform>either errors in the binary or works in both layers — never two different effects sharing one syntax. The pre-flip behavior (binary printed the path; shim cd'd) was the asymmetry this change eliminates. fzfis invoked lazily, not preflighted. Subcommands that don't need fzf (hop ls,hop shell-init zsh,hop config *, exact-match resolutions) work without it installed. This matters for minimal environments and CI.--shim-planbypasses cobra rather than usingcobra.Command{DisableFlagParsing: true}. Pre-Execute argv inspection is a single small function (extractShimPlan); the alternative would entangle every flag-parsing path with action-token-aware logic. Unit tests cover the classification without spawning the binary (shim_plan_test.go). This replaced the former-Rflag +extractDashRargv split (gyo0): tool-form is now native grammar dispatched via the shim'sRUN_IN_PARENTplan, not a binary-Rexec.- Match algorithm is substring-on-
Nameonly. Not Path, not URL, not Group. Simple, predictable, matches the bash original. Group disambiguation is a display-time concern only (buildPickerLinesadds[<group>]suffix when names collide across groups). - The
whereverb is the explicit path-printer. Used ashop <name> where(top-level repo-verb form) andhop config where(config namespace). The top-levelwheresubcommand (v0.x'shop where <name>) was removed —hop <name> whereis the replacement; the verb survives, the subcommand position does not. Both answer "where does this resolve to?" The v0.0.1 names (path,config path) lacked voice-fit with the new binary name and were renamed without aliases (no migration path; the rename was a clean break for v0.x). hop clone <url>infers form from argument shape.looksLikeURL(contains://OR (@AND:)) splits URL form from name form. This keepscloneto one verb rather thanclone-url/clone-name. URLs of registered repos still go through name form viahop clone <name>— there's no ambiguity because the URL form requires an actual URL shape.- Auto-registration on
hop clone <url>is opt-out, not opt-in. The default behavior for an ad-hoc URL clone is "I want this in my registry";--no-addis the escape valve. This matches the dominant use case (try a new repo → keep it). The YAML write is comment-preserving (viainternal/yamled) so registration doesn't trash hand-curated comments. hop updateis a top-level subcommand, nothop config updateor a flag. Per Constitution Principle VI, new top-level subcommands need explicit justification. Self-update is a binary-state operation, not config-state — it doesn't fit underconfig, and overloading a flag on the root (e.g.hop --update) muddles the bare-form's "print path" semantics. It also matches the convention every Homebrew-installed CLI uses (fab-kit update,gh extension upgrade). The implementation lives ininternal/updateand routes all subprocess invocations throughinternal/procper Constitution Principle I (no directos/execoutsideinternal/proc).- Grammar is uniformly
hop <selection> <action>(gyo0).<selection>= repo /repo/worktree/ group /--all;<action>= a builtin verb (cd/where/open), a batch verb (pull/push/sync), a PATH binary, or a shell alias/function. Subcommand classification moved out of the shim into the binary's hidden--shim-planflag, which emits a fixed 3-keyword protocol (CD/RUN_IN_PARENT/PASSTHROUGH). The shim hard-codes ZERO subcommand names — the permanent fix for the stale-shim drift bug. Tool-form is native grammar: a non-verb action classifies asRUN_IN_PARENT\n<path>and the shim runs the user's literal words in the parent shell (so aliases/functions resolve — the former-Rpath could not). The trade-off: scripts/CI bypassing the shim usehop <name> wherefor path resolution and run tools themselves. - No
evalof binary output (Constitution I). The shim runs the user's already-parsed"$@", and the binary emits only the fixed vocabulary plus a path used as a quotedcdoperand. There is no re-parsing of binary stdout as code — no shell-injection surface.eval-ing binary output was explicitly rejected for this reason. openkeeps theWT_CD_FILEtemp-file side-channel, unified into the PASSTHROUGH arm. wt's app menu is interactive (full stdio mid-flow) and the "Open here" cd-target arrives only after wt exits — this does not fit the classify-then-act 3-keyword shape, soopenis classifiedPASSTHROUGH. The shim's_hop_passthroughexportsWT_CD_FILEon every passthrough;wt open(andclone <url>) write their cd-target there and the shim cds afterward. This collapses the former three handoffs (where=stdout, open=WT_CD_FILE, clone=conditional-stdout) into one channel without a 4th protocol keyword.pull/push/syncare action tokens, not cobra subcommands (gyo0).hop <name> pull(reoriented fromhop pull <name>). Plural fan-out viahop --all pull/hop <group> pull(replacinghop pull --all). They classify asPASSTHROUGH(the binary owns the resolution + per-repo summary + exit-code policy viarunBatchVerb→resolveTargets/runBatch). A plural selection refuses non-batch actions (the interactive guard):cd/open/tool-form across N repos is nonsensical, so onlypull/push/syncare permitted; everything else errors exit 2.