When working across multiple projects, each project typically needs:
- Its own directory prepended to
PATH(for project-specific executables) - Its own Python virtual environment activated (for isolated dependencies)
- A clean way to undo all of the above when switching away
The naive approach is a separate enable/disable script per project. That means duplicating the same logic across every project, and every improvement must be applied N times.
projsSelector solves this with a single script (projsSelector.shDot) and a
set of symlinks. The symlink’s filename is the contract: it encodes both the
action (projEnable or projDisable) and the project name (this, other,
…) separated by _. When sourced, the script reads BASH_SOURCE[0] to
extract both parts and branches on each independently. Adding a new project is
a filesystem operation — create two symlinks and add one elif line.
The figure below shows the full flow from shell login to project environment effects — four layers read top to bottom. The colours carry meaning; scan the legend first, then the figure.
Legend:
| Element | Meaning |
|---|---|
| Cyan fill | Shell bootstrap: ~/.bashrc, projsEnabler.shDot, aliases. |
| Yellow fill | Symlink. Points to projsSelector.shDot; filename is the contract. |
| Green fill | The real script. Reads BASH_SOURCE[0] to extract action and project name. |
| Blue fill | Dispatch decision. Two independent axes: action and project. |
| Pink fill | Effect / outcome produced in the current shell session. |
| Solid edge | Direct source or invocation. |
| Dashed edge | Symlink resolution or alias definition. |
| Dotted edge | Alias invocation (triggers the source chain). |
Reading the figure, top to bottom:
- Bootstrap layer. At login,
~/.bashrcsourcesprojsEnabler.shDot, which defines two aliases:projEnable_thisandprojEnable_other. These are the only entry points a user ever types. Note that noprojDisable_xxxalias is registered here — it is created dynamically by the script at enable time. - Symlink layer. All four
proj{Enable,Disable}_{this,other}.shfiles are symlinks to the single real scriptprojsSelector.shDot. Invoking aprojEnable_xxxalias sources the corresponding symlink, which is how the chain reaches the script. - Dispatch layer. The script reads
BASH_SOURCE[0]##*/to get the symlink’s own filename and branches on two independent axes: the action part (projEnablevsprojDisable) determines what to do, and the project part (this,other, …) determines which resources to use. - Effects layer. On enable:
PATHis prepended, the project venv is activated, and aprojDisable_xxxalias is registered for later use. On disable: the venv is deactivated andPATHis restored.
projsSelector.shDot ← the single real script (source of truth) projEnable_this.sh → projsSelector.shDot projDisable_this.sh → projsSelector.shDot projEnable_other.sh → projsSelector.shDot projDisable_other.sh → projsSelector.shDot projsEnabler.shDot ← sourced from ~/.bashrc to register aliases at login pyVenv-this-sbom.pcs ← provisions the Python venv for project "this" pyVenv-other-sbom.pcs ← provisions the Python venv for project "other"
projsEnabler.shDot registers a shell alias for each project’s enable script.
Without it, you would have to type the full path to source projEnable_this.sh
each time. Add the following to ~~/.bashrc~:
if [ -f /de/example/bin/projsEnabler.shDot ] ; then
source /de/example/bin/projsEnabler.shDot
fiprojsEnabler.shDot contains:
de_example_bin="/de/example/bin"
alias projEnable_this=". ${de_example_bin}/projEnable_this.sh"
alias projEnable_other=". ${de_example_bin}/projEnable_other.sh"Note that only projEnable_xxx aliases are registered here. projDisable_xxx
is defined dynamically as a shell alias when the corresponding projEnable_xxx
runs — it is available for the lifetime of that shell session.
projEnable_thisThis sources projEnable_this.sh (a symlink to projsSelector.shDot), which:
- Prepends
/de/example/bintoPATH - Activates the Python venv at
/de/example/venv/this - Registers a
projDisable_thisalias in the current shell
projDisable_thisThis sources projDisable_this.sh (also a symlink to projsSelector.shDot),
which:
- Calls venv
deactivate(restoring the PATH snapshot taken at activate time) - Removes
/de/example/binfromPATH
Simply enable the new project — if a venv is already active, projEnable_xxx
detects it, deactivates it first, then activates the new one. You do not need
to manually disable before enabling another.
The filename of the symlink being sourced is the contract. It encodes two parts
separated by _:
projEnable_this.sh ^^^^^^^^^^ ^^^^ action project-name
At source time, BASH_SOURCE[0] gives the symlink’s own name (not the master
file’s name), so both parts are available for branching:
- The action part (
projEnablevsprojDisable) drives what to do. - The project part (
this,other, …) drives which resources to use.
This is a well-established Unix pattern — the same mechanism used by busybox, where one binary behaves differently depending on the name it was invoked under.
To add a project named xxx:
- Create the symlinks:
ln -s projsSelector.shDot projEnable_xxx.sh
ln -s projsSelector.shDot projDisable_xxx.sh- Add an
elifbranch in the project-name detection block inprojsSelector.shDot:
elif [[ "${FileBeingSourced}" == *_xxx.* ]]; then
proj_venvBinPath="/de/example/venv/xxx/bin"- Add the alias to
projsEnabler.shDot:
alias projEnable_xxx=". ${de_example_bin}/projEnable_xxx.sh"- Provision the venv (copy and adapt
pyVenv-this-sbom.pcs→pyVenv-xxx-sbom.pcs).
No changes to the dispatch logic are needed.
Every Python virtual environment created with python3 -m venv ships an
activate script and a deactivate function. projsSelector does not replace
these — it wraps them. activate / deactivate deliberately scope themselves
to the venv’s own bin/ directory and know nothing about a project’s separate
executables directory or about switching between named projects. projsSelector
adds the layer that activate / deactivate omit:
- manages the project
bin/path (/de/example/bin) separately from the venv path - provides a named-project entry point (
projEnable_this) callable from anywhere - guards against non-nestable
activateby deactivating any active venv first - wires
projDisable_xxxso the full undo is one command
activate and deactivate are used as primitives inside projsSelector —
they compose, not compete.
direnv automatically sources a .envrc file when you cd into a directory
and unloads it when you leave. It is the most widely used tool in this space.
The key difference in philosophy: direnv is location-triggered (entering a
directory), while projsSelector is explicit (you invoke projEnable_xxx
by name). direnv also requires a shell hook installed in .bashrc and a
running helper process. projsSelector is pure bash with no daemon.
virtualenvwrapper provides workon <project> style switching between named
Python environments. It is Python-specific and opinionated about where venvs
live ($WORKON_HOME). projsSelector is not Python-specific — the venv
integration is one feature, and the proj_binPath management is independent
of it.
The symlink dispatch pattern in projsSelector is the sourced-script analogue
of the busybox multicall binary: one binary (or one script) behaves differently
depending on the name it was invoked under. busybox uses argv[0];
projsSelector uses BASH_SOURCE[0]. This pattern is well-established for
executables but rarely seen applied to sourced shell scripts.
update-alternatives (Debian/Red Hat) manages symlink farms at the OS level
to select among competing implementations of a command. The filesystem idea is
the same — symlinks as dispatch — but the scope is system-wide executable
selection, not per-user project environment switching.
path_contains,path_prepend,path_remove— provided by thelpDo/lpshell library.activate/deactivate— standard Python venv scripts (created bypython3 -m venv).
