cdp: jumping to project directories by label, not by guess

· Valentin Mihai · ~4 min read

  • bash
  • cli
  • developer-tools
  • tmux
TL;DR

Frequency-ranked directory jumpers guess where you want to go. cdp lets you declare it, in an SSH-style config file — and then goes one step further, because the thing you actually do all day is not "go to the project", it is "go to the project and start the thing".

github.com/mihai-valentin/cdp · bash, MIT

the actual annoyance

Switching between something like thirty project directories a day, each with its own ritual to start work. One needs git pull && pnpm dev. One needs a tmux session with a dev server in one pane and a tunnel in another. One needs you to descend into .docker/ and pick a Node version first.

So the cd is the boring half. The interesting half is everything you type immediately after it, every single time, from memory.

why not zoxide

zoxide, autojump and fasd are excellent and they solve a different problem. They rank directories by how often you visit them and let you get there with a fragment of the name. That is inference: the tool watches you and guesses.

Two things follow from that. Your most-visited directory is not necessarily the one you want a short name for. And a frequency index has nowhere to hang the other half of the problem — there is no place in "I visited /home/me/work/acme-api 94 times" to record that arriving there usually means starting three processes.

So cdp takes the opposite position: labels are authored, not inferred. You write them down once, they never drift, and the config file becomes somewhere to put the rest of the ritual.

the config is the feature

The format is deliberately SSH-config-shaped — indentation-based blocks, a keyword per line — because every developer already knows how to read one:

Project myproject
    Path /home/user/myproject
    Macro deploy
        Run cd ./static-pages
        Run ./scripts/publish.sh
    Macro logs
        Run tail -f /var/log/myproject.log

Which gives you cdp myproject to jump, and cdp myproject deploy to jump and then run the deploy steps in order. Tmux blocks go further and describe a whole pane layout to build on arrival.

One decision in there is worth calling out because it looks like a bug and isn't: macros run in your interactive shell, not a subshell. So export FOO=bar inside a macro changes your shell. That is the point — a macro is meant to be the keystrokes you would have typed, and keystrokes you type affect your session. Sandboxing them would make them useless for exactly the setup work they exist to automate.

the constraint that shapes the whole tool

A child process cannot change its parent's working directory. So no binary, in any language, can implement cd for your shell. This is why zoxide, nvm, direnv and everything else in this category hand you a shell snippet to eval:

eval "$(~/.local/bin/cdp init bash)"

That emits a shell function, which runs inside your shell and can therefore move it. The function stays thin and delegates everything else to subprocesses, which keeps the part that must be sourced into your shell small enough to audit.

Given that a shell function is unavoidable, writing the rest in Go or Rust would buy a binary you still could not call directly. So: bash, with no runtime dependencies beyond coreutils and flock. Nothing to install, nothing to keep up to date, and the whole thing stays readable — which matters more than usual for code that runs in every shell you open.

the honest trade-off

You have to write the config. A frequency tracker works the moment you install it; cdp does nothing until you tell it about a project. For five directories that is a bad deal. For thirty, each with its own startup ritual, the config pays for itself in about a day — and unlike an inferred index, it is something you can read, diff and copy to another machine.