Chapter 8: The shell-hook Command¶
After installing packages into .env/, you need to be able to use them.
That means getting lua, luarocks, and any other installed binaries onto
your PATH, and setting any environment variables that packages declare.
Design¶
$() runs shot shell-hook and captures its text output. eval then executes that text as shell commands in the current session. This is how shell-hook can modify your current shell's PATH and other variables.
You can pass an optional --shell flag to override the detected shell
dialect and --prefix to override the environment location.
Concepts¶
Why activation is necessary¶
A lot of programs read data from environment variables. Beyond just PATH, packages may need PKG_CONFIG_PATH, LD_LIBRARY_PATH, compiler flags, and other variables to function correctly. Activation is the mechanism that sets all of these up.
Different package managers handle this in different ways, some examples are:
- conda: a shell function
evals generated shell code.- Prepends
bin/toPATH - Sets
CONDA_PREFIX/CONDA_SHLVL - Sources any scripts packages ship in
activate.d/
- Prepends
- pixi: conda-compatible activation with three entry points.
pixi run: prependsbin/toPATH, setsCONDA_PREFIXandPIXI_*variables, runs the full activation sequence includingactivate.d/scriptspixi shell-hook: prints an eval-able scriptpixi shell: starts an activated shell as a subprocess
- uv: lighter-weight, no activation scripts.
uv tool installsymlinks executables into~/.local/bin- Virtual-environment activation sets
PATHandVIRTUAL_ENV uv runsets env vars on the subprocess directly
- Nix:
nix developstarts a new shell withPATHrebuilt entirely from/nix/storepaths.- Sets build variables like
NIX_CFLAGS_COMPILEandPKG_CONFIG_PATH - Runs the derivation's
shellHook
- Sets build variables like
A child process cannot modify the environment of its parent. This is a basic rule of Unix and Windows process isolation: when a program exits, any environment variable changes it made die with it. So when you run shot shell-hook, it can't just set
PATH for you, it has to print a script that you evaluate in your shell.
Shell dialects and nesting¶
For people to have a native experience they probably want to keep using their shell of choice, which is why popular projects often include multiple shell dialects for their scripts. Several things to take into account:
- Different shells have different syntax for setting variables, exporting
them, and sourcing scripts. Bash uses
export FOO=bar; fish usesset -gx FOO bar; PowerShell uses$env:FOO = "bar". - Nested activations: what if you activate environment A, then activate
environment B inside it? You want
PATHto contain B's bins, then A's, then the originalPATH. - Packages with activation scripts: some packages install scripts in
etc/conda/activate.d/that need to be sourced. A CUDA package might setLD_LIBRARY_PATH; an OpenBLAS package might setOPENBLAS_NUM_THREADS.
conda tracks nesting depth with CONDA_SHLVL and the current prefix with
CONDA_PREFIX. rattler implements the same protocol.
Implementation¶
The Environment struct¶
Both shot shell-hook and shot run need to work with an installed environment.
Rather than duplicating prefix-handling and activation logic, we extract it
into a dedicated Environment struct in src/environment.rs:
<<environment-imports>>
<<environment-struct>>
<<environment-impl>> [1, 2, 3]
<<environment-parse-shell>>
We bring in the activation and shell types from rattler:
use std::collections::HashMap;
use std::path::PathBuf;
use std::str::FromStr;
use miette::IntoDiagnostic;
use rattler_conda_types::Platform;
use rattler_shell::activation::{ActivationVariables, Activator};
use rattler_shell::shell::{Bash, ShellEnum};
use crate::project::Project;
The struct holds the prefix path and target platform:
/// An installed conda environment that can be activated.
pub struct Environment {
pub prefix: PathBuf,
#[allow(dead_code)]
pub platform: Platform,
}
from_project creates an environment from a discovered project, using the
default .env/ prefix unless overridden. with_prefix is used when the
caller already knows the exact path (e.g. for a temporary build prefix):
#[allow(dead_code)]
impl Environment {
/// Create an environment from a project, with an optional prefix override.
pub fn from_project(
project: &Project,
prefix_override: Option<PathBuf>,
) -> miette::Result<Self> {
let prefix = prefix_override.unwrap_or_else(|| project.default_prefix());
let prefix = std::path::absolute(prefix).into_diagnostic()?;
Ok(Self {
prefix,
platform: Platform::current(),
})
}
/// Create an environment pointing at an arbitrary prefix.
pub fn with_prefix(prefix: PathBuf) -> miette::Result<Self> {
let prefix = std::path::absolute(prefix).into_diagnostic()?;
Ok(Self {
prefix,
platform: Platform::current(),
})
}
Before activating or running commands, we check that shot install has
actually created the prefix. This gives a clear error instead of a confusing
"file not found" later:
/// Bail if the prefix directory does not exist.
pub fn ensure_exists(&self) -> miette::Result<()> {
if !self.prefix.exists() {
miette::bail!(
"Environment not found at `{}`. Run `shot install` first.",
self.prefix.display()
);
}
Ok(())
}
activate_script is the core of shot shell-hook. It detects the user's
shell, builds an Activator from the prefix, and returns the activation
script as a string that eval can execute:
/// Generate the shell activation script as a string.
pub fn activate_script(&self, shell_name: Option<&str>) -> miette::Result<String> {
let shell = parse_shell(shell_name)?;
let activator =
Activator::from_path(&self.prefix, shell, self.platform).into_diagnostic()?;
let vars = ActivationVariables::from_env().into_diagnostic()?;
let result = activator.activation(vars).into_diagnostic()?;
result.script.contents().into_diagnostic()
}
}
A small helper resolves the shell dialect from an explicit name or the environment:
fn parse_shell(name: Option<&str>) -> miette::Result<ShellEnum> {
match name {
Some(n) => ShellEnum::from_str(n)
.map_err(|_| miette::miette!("Unknown shell `{n}`. Try: bash, zsh, fish")),
None => Ok(ShellEnum::from_env().unwrap_or_else(|| Bash.into())),
}
}
The shell-hook command¶
With Environment in place, the shell-hook command becomes very thin:
use clap::Parser;
use crate::environment::Environment;
use crate::project::Project;
#[derive(Debug, Parser)]
pub struct Args {
/// Shell dialect to emit. Auto-detected from $SHELL if not set.
///
/// Supported values: bash, zsh, fish, xonsh, powershell, cmd, nushell
#[clap(long)]
pub shell: Option<String>,
/// Override the prefix path.
#[clap(long)]
pub prefix: Option<std::path::PathBuf>,
}
pub fn execute(args: Args) -> miette::Result<()> {
let project = Project::discover()?;
let env = Environment::from_project(&project, args.prefix)?;
env.ensure_exists()?;
let script = env.activate_script(args.shell.as_deref())?;
print!("{script}");
Ok(())
}
Notice that execute is not async. Generating an activation script is
purely synchronous: no network, no disk I/O beyond reading a few small files
in the prefix.
ShellEnum: a type-safe shell dialect¶
rattler_shell::shell::ShellEnum is an enum with a variant for each supported
shell:
pub enum ShellEnum {
Bash(Bash),
Zsh(Zsh),
Fish(Fish),
Xonsh(Xonsh),
PowerShell(PowerShell),
CmdExe(CmdExe),
NuShell(NuShell),
}
Each variant wraps a unit struct that implements the Shell trait. The trait
defines methods like set_env_var, export_env_var, source_script, etc. The
Activator calls these methods generically; it doesn't need to know which
shell we're generating for.
Detecting the shell from $SHELL¶
from_env() reads $SHELL (on Unix) or the default shell (on Windows) and
returns Option<ShellEnum>. If the shell isn't recognized, we fall back to
Bash, which has the widest compatibility.
Bash.into() converts the Bash struct into ShellEnum::Bash(Bash).
Activator::from_path¶
This reads the prefix and discovers:
- Paths to prepend to
PATH: typically<prefix>/binon Unix,<prefix>/binand<prefix>/Scriptson Windows. - Activation scripts: files in
<prefix>/etc/conda/activate.d/that match the current shell's extension (.sh,.fish,.bat, ...). - Extra environment variables: from
<prefix>/conda-meta/stateand<prefix>/etc/conda/env_vars.d/.
As a concrete example, the glib package ships a small activation script that
looks like this:
export GSETTINGS_SCHEMA_DIR_CONDA_BACKUP="${GSETTINGS_SCHEMA_DIR:-}"
export GSETTINGS_SCHEMA_DIR="$CONDA_PREFIX/share/glib-2.0/schemas"
This script sets GSETTINGS_SCHEMA_DIR to point at the prefix's schema
directory. The first line backs up any existing value so deactivation can
restore it.
Compiler packages like clang_osx-arm64 ship much larger activation scripts
that set CC, CFLAGS, LDFLAGS, CMAKE_ARGS, and other build variables.
Install a compiler into your environment, activate it, and the shell is ready
to compile.
ActivationVariables¶
This reads the current shell state:
CONDA_PREFIX, the currently-activated prefix (if any)CONDA_SHLVL, the nesting depthPATH, the current PATH
The activator uses these to correctly compute the transition: deactivate the current environment (if any), then activate the new one. The resulting script handles both the "no active env" case and the "replacing an existing env" case.
What the generated script looks like¶
For Bash, shot shell-hook might print something like:
export PATH="/home/user/my-app/.env/bin:${PATH}"
export CONDA_SHLVL=1
export CONDA_ENV_SHLVL_1_CONDA_PREFIX=''
export CONDA_PREFIX=/home/user/my-app/.env
You evaluate this, and from that point on lua, luarocks, etc. are on your
PATH.
Exercises¶
Show Activation Environment Variables
Add a --show-env flag to shot shell-hook that prints the environment variables activation would set, instead of the activation script. Use Environment::activation_env() and compare against std::env::vars() to show only changed variables.
- Acceptance criteria
-
shot shell-hook --show-envprints lines likePATH=/path/to/env/bin:...- Only variables that differ from the current environment are shown
- Variables are sorted alphabetically
- Count of modified variables printed at the end
Generate Dotenv File from Activation
Add shot shell-hook --dotenv [path] that writes the activation environment to a dotenv file. This lets other tools (Docker, systemd, IDE run configs) consume the environment without shell-specific activation. Use the Activator to compute the full environment, diff against the current env, and write only the changed variables.
- Acceptance criteria
-
shot shell-hook --dotenvwritesmoonshot.envin the project root (not.env, which is the conda prefix directory)shot shell-hook --dotenv /tmp/my.envwrites to the specified path- File format:
KEY=VALUEper line, values quoted if they contain spaces - Only activation-added/changed variables are included (not the full inherited environment)
Stacked Environment Activation
Implement shot shell-hook --stack /other/env that generates an activation script layering a second environment on top of the currently active one. Construct ActivationVariables from the already-activated environment state, then run the Activator for the stacked prefix. The result should have both envs on PATH in the correct order.
- Acceptance criteria
-
eval $(shot shell-hook)theneval $(shot shell-hook --stack /other/env)puts both envs on PATH- Stacked env's
bin/appears before the base env'sbin/ CONDA_PREFIXreflects the top-of-stack environment- A
MOONSHOT_STACK_DEPTHenv var tracks nesting level
Summary¶
- Shell activation generates a script that the user evaluates to modify their shell's environment.
- rattler_shell handles multi-shell compatibility (Bash, Fish, PowerShell, ...).
Activator::from_pathreads activation metadata from the prefix.ActivationVariablescaptures current state for correct nested-activation handling.
In the next chapter we implement shot run, a way to run a command inside the
activated environment without permanently modifying the shell.