Repository: a-scie/jump
Pull request #93 (2023-04-14 17:17:22)

Support `{scie.env.*}` referring to cmd env values.
This is needed by https://github.com/a-scie/lift for interpreter group
selection support and is otherwise a reasonable thing to expect to have
worked.

jsirois: My apologies in advance. This is rather large. That said, the concept is small and the bulk is tests.

Patch:
diff --git a/CHANGES.md b/CHANGES.md
index f7def83..33b7373 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,5 +1,11 @@
 # Release Notes
 
+## 0.11.0
+
+Support is added for `{scie.env.*}` placeholders referring to environment variables defined in the
+lift command environment in addition to the existing support for referring to environment variables
+defined in the ambient environment.
+
 ## 0.10.0
 
 In addition to the `SCIE` environment variable being exposed to scies, `SCIE_ARGV0` is now exposed
diff --git a/Cargo.lock b/Cargo.lock
index 4da07a1..564699b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -10,42 +10,51 @@ checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe"
 
 [[package]]
 name = "anstream"
-version = "0.2.6"
+version = "0.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "342258dd14006105c2b75ab1bd7543a03bdf0cfc94383303ac212a04939dff6f"
+checksum = "9e579a7752471abc2a8268df8b20005e3eadd975f585398f17efcfd8d4927371"
 dependencies = [
  "anstyle",
  "anstyle-parse",
+ "anstyle-query",
  "anstyle-wincon",
- "concolor-override",
- "concolor-query",
+ "colorchoice",
  "is-terminal",
  "utf8parse",
 ]
 
 [[package]]
 name = "anstyle"
-version = "0.3.5"
+version = "1.0.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "23ea9e81bd02e310c216d080f6223c179012256e5151c41db88d12c88a1684d2"
+checksum = "41ed9a86bf92ae6580e0a31281f65a1b1d867c0cc68d5346e2ae128dddfa6a7d"
 
 [[package]]
 name = "anstyle-parse"
-version = "0.1.1"
+version = "0.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a7d1bb534e9efed14f3e5f44e7dd1a4f709384023a4165199a4241e18dff0116"
+checksum = "e765fd216e48e067936442276d1d57399e37bce53c264d6fefbe298080cb57ee"
 dependencies = [
  "utf8parse",
 ]
 
+[[package]]
+name = "anstyle-query"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ca11d4be1bab0c8bc8734a9aa7bf4ee8316d462a08c6ac5052f888fef5b494b"
+dependencies = [
+ "windows-sys 0.48.0",
+]
+
 [[package]]
 name = "anstyle-wincon"
-version = "0.2.0"
+version = "1.0.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c3127af6145b149f3287bb9a0d10ad9c5692dba8c53ad48285e5bec4063834fa"
+checksum = "4bcd8291a340dd8ac70e18878bc4501dd7b4ff970cfa21c207d36ece51ea88fd"
 dependencies = [
  "anstyle",
- "windows-sys 0.45.0",
+ "windows-sys 0.48.0",
 ]
 
 [[package]]
@@ -125,9 +134,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
 
 [[package]]
 name = "clap"
-version = "4.2.1"
+version = "4.2.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "046ae530c528f252094e4a77886ee1374437744b2bff1497aa898bbddbbb29b3"
+checksum = "9b802d85aaf3a1cdb02b224ba472ebdea62014fccfcb269b95a4d76443b5ee5a"
 dependencies = [
  "clap_builder",
  "clap_derive",
@@ -136,9 +145,9 @@ dependencies = [
 
 [[package]]
 name = "clap_builder"
-version = "4.2.1"
+version = "4.2.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "223163f58c9a40c3b0a43e1c4b50a9ce09f007ea2cb1ec258a687945b4b7929f"
+checksum = "14a1a858f532119338887a4b8e1af9c60de8249cd7bafd68036a489e261e37b6"
 dependencies = [
  "anstream",
  "anstyle",
@@ -166,19 +175,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "8a2dd5a6fe8c6e3502f568a6353e5273bbb15193ad9a89e457b9970798efbea1"
 
 [[package]]
-name = "concolor-override"
+name = "colorchoice"
 version = "1.0.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a855d4a1978dc52fb0536a04d384c2c0c1aa273597f08b77c8c4d3b2eec6037f"
-
-[[package]]
-name = "concolor-query"
-version = "0.3.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "88d11d52c3d7ca2e6d0040212be9e4dbbcd78b6447f535b6b561f449427944cf"
-dependencies = [
- "windows-sys 0.45.0",
-]
+checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7"
 
 [[package]]
 name = "cpufeatures"
@@ -363,6 +363,12 @@ dependencies = [
  "wasi",
 ]
 
+[[package]]
+name = "hashbrown"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
+
 [[package]]
 name = "heck"
 version = "0.4.0"
@@ -375,6 +381,17 @@ version = "0.3.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "fed44880c466736ef9a5c5b5facefb5ed0785676d0c02d612db14e54f0d84286"
 
+[[package]]
+name = "indexmap"
+version = "1.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
+dependencies = [
+ "autocfg",
+ "hashbrown",
+ "serde",
+]
+
 [[package]]
 name = "instant"
 version = "0.1.12"
@@ -438,7 +455,7 @@ dependencies = [
 
 [[package]]
 name = "jump"
-version = "0.10.0"
+version = "0.11.0"
 dependencies = [
  "bstr",
  "byteorder",
@@ -447,6 +464,7 @@ dependencies = [
  "dotenv",
  "fd-lock",
  "flate2",
+ "indexmap",
  "itertools",
  "log",
  "logging_timer",
@@ -751,7 +769,7 @@ dependencies = [
 
 [[package]]
 name = "scie-jump"
-version = "0.10.0"
+version = "0.11.0"
 dependencies = [
  "bstr",
  "env_logger",
diff --git a/Cargo.toml b/Cargo.toml
index 8e23a7d..c60366c 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -5,7 +5,7 @@ members = [
 
 [package]
 name = "scie-jump"
-version = "0.10.0"
+version = "0.11.0"
 description = "The self contained interpreted executable launcher."
 authors = [
     "John Sirois <john.sirois@gmail.com>",
diff --git a/docs/packaging.md b/docs/packaging.md
index b3ed136..f8a5d8d 100644
--- a/docs/packaging.md
+++ b/docs/packaging.md
@@ -357,10 +357,12 @@ Further placeholders you can use in command "exe", "args" and "env" values inclu
 
 + `{scie.base}`: The value of the active `SCIE_BASE`.
 + `{scie.env.<env var name>[=<default env var value>]}`: This expands to the value of the env var
-  named. If the env var is not in the ambient runtime environment and no default env var value is
-  specified it expands to the empty string (""). If a default env var value is specified, it is
-  used. The default env var value specified can itself be a placeholder, in which case that is
-  expanded (recursively) to obtain the default value. For example,
+  named. If the env var is not in the ambient runtime environment or the defined command environment
+  and no default env var value is specified it expands to the empty string (""). If a default env 
+  var value is specified, it is used. The env var name can itself be composed of environment
+  placeholders, in which case they are expanded (recursively) before evaluating the env var value.
+  Likewise, the default env var value specified can also be composed of environment placeholders, in
+  which case they too are expanded (recursively) to obtain the default value. For example,
   `{scie.env.FOO={scie.env.BAR=42}}` would evaluate to "bar" if the "FOO" env var was not set but
   the "BAR" env var was set to "bar" and it would evaluate to "42" if neither the "FOO" nor "BAR"
   env vars were set.
diff --git a/examples/python/lift.linux-x86_64.json b/examples/python/lift.linux-x86_64.json
index fe651e2..6e831da 100644
--- a/examples/python/lift.linux-x86_64.json
+++ b/examples/python/lift.linux-x86_64.json
@@ -8,33 +8,30 @@
           "": {
             "description": "The Pants build tool.",
             "env": {
-              "=PATH": "{cpython}/python/bin:{scie.env.PATH}",
               "PEX_.*": null
             },
-            "exe": "{scie.bindings.venv}/venv/bin/python3.9",
-            "args": [
-              "{scie.bindings.venv}/venv/pex"
-            ]
+            "exe": "{scie.bindings.venv}/venv-3.{scie.env.PYTHON_MINOR=9}/pex"
           },
           "repl": {
             "description": "A Python repl with Pants (minus plugins) available for inspection.",
             "env": {
-              "=PATH": "{cpython}/python/bin:{scie.env.PATH}",
               "=PYTHONPATH": null
             },
-            "exe": "{scie.bindings.venv}/venv/bin/python3.9"
+            "exe": "{scie.bindings.venv}/venv-3.{scie.env.PYTHON_MINOR=9}/bin/python"
           }
         },
         "bindings": {
           "venv": {
             "description": "Installs Pants in a venv and pre-compiles .pyc.",
             "env": {
-              "=PATH": "{cpython}/python/bin:{scie.env.PATH}",
               "PEX_TOOLS": "1",
               "PEX_ROOT": "{scie.bindings}/pex_root",
-              "PEX_.*": null
+              "PEX_.*": null,
+              "=__PYTHON_3_8": "{cpython38}/python/bin/python3.8",
+              "=__PYTHON_3_9": "{cpython39}/python/bin/python3.9",
+              "=__PYTHON": "{scie.env.__PYTHON_3_{scie.env.PYTHON_MINOR=9}}"
             },
-            "exe": "{cpython}/python/bin/python3.9",
+            "exe": "{scie.env.__PYTHON}",
             "args": [
               "{pants.2.14.0.pex}",
               "venv",
@@ -43,15 +40,19 @@
               "--compile",
               "--rm",
               "all",
-              "{scie.bindings}/venv"
+              "{scie.bindings}/venv-3.{scie.env.PYTHON_MINOR=9}"
             ]
           }
         }
       },
       "files": [
+        {
+          "name": "cpython-3.8.14+20221002-x86_64-unknown-linux-gnu-install_only.tar.gz",
+          "key": "cpython38"
+        },
         {
           "name": "cpython-3.9.14+20221002-x86_64-unknown-linux-gnu-install_only.tar.gz",
-          "key": "cpython"
+          "key": "cpython39"
         },
         {
           "name": "pants.2.14.0.pex"
@@ -60,6 +61,7 @@
     }
   },
   "fetch": [
+    "https://github.com/indygreg/python-build-standalone/releases/download/20221002/cpython-3.8.14+20221002-x86_64-unknown-linux-gnu-install_only.tar.gz",
     "https://github.com/indygreg/python-build-standalone/releases/download/20221002/cpython-3.9.14+20221002-x86_64-unknown-linux-gnu-install_only.tar.gz"
   ]
 }
diff --git a/examples/python/lift.macos-x86_64.json b/examples/python/lift.macos-x86_64.json
index 0bac356..a3beb3c 100644
--- a/examples/python/lift.macos-x86_64.json
+++ b/examples/python/lift.macos-x86_64.json
@@ -8,33 +8,30 @@
           "": {
             "description": "The Pants build tool.",
             "env": {
-              "=PATH": "{cpython}/python/bin:{scie.env.PATH}",
               "PEX_.*": null
             },
-            "exe": "{scie.bindings.venv}/venv/bin/python3.9",
-            "args": [
-              "{scie.bindings.venv}/venv/pex"
-            ]
+            "exe": "{scie.bindings.venv}/venv-3.{scie.env.PYTHON_MINOR=9}/pex"
           },
           "repl": {
             "description": "A Python repl with Pants (minus plugins) available for inspection.",
             "env": {
-              "=PATH": "{cpython}/python/bin:{scie.env.PATH}",
               "=PYTHONPATH": null
             },
-            "exe": "{scie.bindings.venv}/venv/bin/python3.9"
+            "exe": "{scie.bindings.venv}/venv-3.{scie.env.PYTHON_MINOR=9}/bin/python"
           }
         },
         "bindings": {
           "venv": {
             "description": "Installs Pants in a venv and pre-compiles .pyc.",
             "env": {
-              "=PATH": "{cpython}/python/bin:{scie.env.PATH}",
               "PEX_TOOLS": "1",
               "PEX_ROOT": "{scie.bindings}/pex_root",
-              "PEX_.*": null
+              "PEX_.*": null,
+              "=__PYTHON_3_8": "{cpython38}/python/bin/python3.8",
+              "=__PYTHON_3_9": "{cpython39}/python/bin/python3.9",
+              "=__PYTHON": "{scie.env.__PYTHON_3_{scie.env.PYTHON_MINOR=9}}"
             },
-            "exe": "{cpython}/python/bin/python3.9",
+            "exe": "{scie.env.__PYTHON}",
             "args": [
               "{pants.2.14.0.pex}",
               "venv",
@@ -43,26 +40,28 @@
               "--compile",
               "--rm",
               "all",
-              "{scie.bindings}/venv"
+              "{scie.bindings}/venv-3.{scie.env.PYTHON_MINOR=9}"
             ]
           }
         }
       },
       "files": [
+        {
+          "name": "cpython-3.8.14+20221002-x86_64-apple-darwin-install_only.tar.gz",
+          "key": "cpython38"
+        },
         {
           "name": "cpython-3.9.14+20221002-x86_64-apple-darwin-install_only.tar.gz",
-          "key": "cpython",
-          "hash": "b7d3a1f4b57e9350571ccee49c82f503133de0d113a2dbaebc8ccf108fb3fe1b",
-          "size": 17138819
+          "key": "cpython39"
         },
         {
-          "name": "pants.2.14.0.pex",
-          "type": "zip"
+          "name": "pants.2.14.0.pex"
         }
       ]
     }
   },
   "fetch": [
+    "https://github.com/indygreg/python-build-standalone/releases/download/20221002/cpython-3.8.14+20221002-x86_64-apple-darwin-install_only.tar.gz",
     "https://github.com/indygreg/python-build-standalone/releases/download/20221002/cpython-3.9.14+20221002-x86_64-apple-darwin-install_only.tar.gz"
   ]
 }
diff --git a/jump/Cargo.toml b/jump/Cargo.toml
index 168b275..47b356e 100644
--- a/jump/Cargo.toml
+++ b/jump/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "jump"
-version = "0.10.0"
+version = "0.11.0"
 description = "The bulk of the scie-jump binary logic."
 authors = [
     "John Sirois <john.sirois@gmail.com>",
@@ -16,6 +16,7 @@ dirs = "4.0"
 dotenv = "0.15"
 fd-lock = "3.0"
 flate2 = "1.0"  # For gz support.
+indexmap = { version = "1.9", features = ["serde"] }
 itertools = "0.10"
 log = { workspace = true }
 logging_timer = { workspace = true }
diff --git a/jump/src/cmd_env.rs b/jump/src/cmd_env.rs
new file mode 100644
index 0000000..2c4e66b
--- /dev/null
+++ b/jump/src/cmd_env.rs
@@ -0,0 +1,400 @@
+// Copyright 2023 Science project contributors.
+// Licensed under the Apache License, Version 2.0 (see LICENSE).
+
+use std::env;
+
+use indexmap::IndexMap;
+
+use crate::config::{Cmd, EnvVar};
+use crate::placeholders;
+use crate::placeholders::{Item, Placeholder, ScieBindingEnv};
+
+#[derive(Debug)]
+#[cfg_attr(test, derive(Eq, PartialEq))]
+pub(crate) struct ParsedEnv {
+    pub(crate) name: String,
+    pub(crate) default: Option<String>,
+}
+
+pub(crate) fn parse_scie_env_placeholder(env_var: &str) -> Result<ParsedEnv, String> {
+    match env_var.splitn(2, '=').collect::<Vec<_>>()[..] {
+        [name] => Ok(ParsedEnv {
+            name: name.to_string(),
+            default: None,
+        }),
+        [name, default] => Ok(ParsedEnv {
+            name: name.to_string(),
+            default: Some(default.to_string()),
+        }),
+        _ => Err(
+            "Expected {{scie.env.<name>}} <name> placeholder to be a non-empty string".to_string(),
+        ),
+    }
+}
+
+struct EnvParser {
+    env: IndexMap<String, String>,
+    key_stack: Vec<String>,
+    parsed: IndexMap<String, String>,
+}
+
+impl EnvParser {
+    fn new(env_vars: &IndexMap<EnvVar, Option<String>>) -> Self {
+        let mut env = IndexMap::new();
+        for (key, value) in env_vars.iter() {
+            if let Some(val) = value {
+                match key {
+                    EnvVar::Default(name) => {
+                        if env::var_os(name).is_none() {
+                            env.insert(name.to_owned(), val.to_owned());
+                        }
+                    }
+                    EnvVar::Replace(name) => {
+                        env.insert(name.to_owned(), val.to_owned());
+                    }
+                }
+            }
+        }
+        Self {
+            env,
+            key_stack: vec![],
+            parsed: IndexMap::new(),
+        }
+    }
+
+    fn reify_env(&mut self, value: &str) -> Result<String, String> {
+        let mut reified = String::with_capacity(value.len());
+        let parsed = placeholders::parse(value)?;
+        for item in &parsed.items {
+            match item {
+                Item::LeftBrace => reified.push('{'),
+                Item::Text(text) => reified.push_str(text),
+                Item::Placeholder(Placeholder::Env(name)) => {
+                    reified.push_str(&self.reify_env_var(name)?)
+                }
+                Item::Placeholder(Placeholder::FileHash(name)) => {
+                    reified.push_str(&format!("{{scie.files:hash.{name}}}"))
+                }
+                Item::Placeholder(Placeholder::FileName(name)) => {
+                    reified.push_str(&format!("{{scie.files.{name}}}"))
+                }
+                Item::Placeholder(Placeholder::Scie) => reified.push_str("{scie}"),
+                Item::Placeholder(Placeholder::ScieBase) => reified.push_str("{scie.base}"),
+                Item::Placeholder(Placeholder::ScieBindings) => reified.push_str("{scie.bindings}"),
+                Item::Placeholder(Placeholder::ScieBindingCmd(cmd)) => {
+                    reified.push_str(&format!("{{scie.bindings.{cmd}}}"))
+                }
+                Item::Placeholder(Placeholder::ScieBindingEnv(ScieBindingEnv { binding, env })) => {
+                    reified.push_str(&format!("{{scie.bindings.{binding}:{env}}}"))
+                }
+                Item::Placeholder(Placeholder::ScieLift) => reified.push_str("{scie.lift}"),
+                Item::Placeholder(Placeholder::SciePlatform) => reified.push_str("{scie.platform}"),
+                Item::Placeholder(Placeholder::SciePlatformArch) => {
+                    reified.push_str("{scie.platform.arch}")
+                }
+                Item::Placeholder(Placeholder::SciePlatformOs) => {
+                    reified.push_str("{scie.platform.os}")
+                }
+            }
+        }
+        Ok(reified)
+    }
+
+    fn reify_env_var(&mut self, name: &str) -> Result<String, String> {
+        let reified_env_name = self.reify_env(name)?;
+        let parsed_env = parse_scie_env_placeholder(&reified_env_name)?;
+        let env_val = if self.key_stack.contains(&parsed_env.name) {
+            // If we're already calculating a Cmd env var value for `key`, we can only
+            // pull references to that `key` needed to compute the value from the
+            // ambient environment.
+            if let Ok(val) = env::var(&parsed_env.name) {
+                val
+            } else {
+                parsed_env.default.unwrap_or_default()
+            }
+        } else if let Some(val) = self
+            .env
+            .get(&parsed_env.name)
+            .map(|v| v.to_owned())
+            .or_else(|| env::var(&parsed_env.name).ok())
+        {
+            self.parse_env_var(&parsed_env.name, &val)?
+        } else {
+            parsed_env.default.unwrap_or_default()
+        };
+        self.reify_env(&env_val)
+    }
+
+    fn parse_env_var(&mut self, key: &str, value: &str) -> Result<String, String> {
+        self.parse_entry(key, value)?;
+        self.parsed
+            .get(key)
+            .ok_or_else(|| format!("We just parsed an entry for {key}={value} above without error"))
+            .map(String::to_owned)
+    }
+
+    fn parse_entry(&mut self, key: &str, value: &str) -> Result<(), String> {
+        if !self.parsed.contains_key(key) {
+            self.key_stack.push(key.to_string());
+            let parsed_value = self.reify_env(value)?;
+            self.key_stack.pop();
+            self.parsed.insert(key.to_string(), parsed_value);
+        }
+        Ok(())
+    }
+
+    fn parse_env(mut self) -> Result<IndexMap<String, String>, String> {
+        for (key, value) in self.env.clone() {
+            self.parse_entry(&key, &value)?;
+        }
+        self.parsed.retain(|k, _| self.env.contains_key(k));
+        Ok(self.parsed)
+    }
+}
+
+pub(crate) fn prepare_env(cmd: &Cmd) -> Result<IndexMap<String, String>, String> {
+    EnvParser::new(&cmd.env).parse_env()
+}
+
+#[cfg(test)]
+mod tests {
+    use std::env;
+
+    use indexmap::IndexMap;
+
+    use crate::cmd_env::{parse_scie_env_placeholder, EnvParser, ParsedEnv};
+    use crate::config::EnvVar;
+
+    #[test]
+    fn parse_env_placeholder() {
+        assert_eq!(
+            ParsedEnv {
+                name: "FOO".to_string(),
+                default: None
+            },
+            parse_scie_env_placeholder("FOO").unwrap()
+        );
+        assert_eq!(
+            ParsedEnv {
+                name: "FOO".to_string(),
+                default: Some("bar".to_string())
+            },
+            parse_scie_env_placeholder("FOO=bar").unwrap()
+        );
+        assert_eq!(
+            ParsedEnv {
+                name: "FOO".to_string(),
+                default: Some("bar=baz".to_string())
+            },
+            parse_scie_env_placeholder("FOO=bar=baz").unwrap()
+        );
+    }
+
+    #[test]
+    fn self_recurse() {
+        let env_parser = EnvParser::new(
+            &[(
+                EnvVar::Replace("PATH".to_string()),
+                Some("foo:{scie.env.PATH}".to_string()),
+            )]
+            .into_iter()
+            .collect::<IndexMap<_, _>>(),
+        );
+
+        let expected = [(
+            "PATH".to_string(),
+            format!("foo:{path}", path = env::var("PATH").unwrap()),
+        )]
+        .into_iter()
+        .collect::<IndexMap<_, _>>();
+
+        assert_eq!(expected, env_parser.parse_env().unwrap());
+    }
+
+    #[test]
+    fn multi_step_recurse() {
+        let env_parser = EnvParser::new(
+            &[
+                (
+                    EnvVar::Replace("PATH".to_string()),
+                    Some("foo:{scie.env.X}".to_string()),
+                ),
+                (
+                    EnvVar::Replace("X".to_string()),
+                    Some("{scie.env.PATH}:bar".to_string()),
+                ),
+            ]
+            .into_iter()
+            .collect::<IndexMap<_, _>>(),
+        );
+
+        let expected_path = env::var("PATH").unwrap();
+        let expected = [
+            ("PATH".to_string(), format!("foo:{expected_path}:bar")),
+            ("X".to_string(), format!("{expected_path}:bar")),
+        ]
+        .into_iter()
+        .collect::<IndexMap<_, _>>();
+
+        assert_eq!(expected, env_parser.parse_env().unwrap());
+    }
+
+    #[test]
+    fn test_dynamic_env_var_name() {
+        let cmd_env = &[
+            (
+                EnvVar::Replace("__PYTHON_3_8".to_string()),
+                Some("{cpython38}/python/bin/python3.8".to_string()),
+            ),
+            (
+                EnvVar::Replace("__PYTHON_3_9".to_string()),
+                Some("{cpython39}/python/bin/python3.9".to_string()),
+            ),
+            (
+                EnvVar::Replace("__PYTHON".to_string()),
+                Some("{scie.env.__PYTHON_3_{scie.env.__PYTHON_MINOR=9}}".to_string()),
+            ),
+        ]
+        .into_iter()
+        .collect::<IndexMap<_, _>>();
+
+        let expected = [
+            (
+                "__PYTHON_3_8".to_string(),
+                "{scie.files.cpython38}/python/bin/python3.8".to_string(),
+            ),
+            (
+                "__PYTHON_3_9".to_string(),
+                "{scie.files.cpython39}/python/bin/python3.9".to_string(),
+            ),
+            (
+                "__PYTHON".to_string(),
+                "{scie.files.cpython39}/python/bin/python3.9".to_string(),
+            ),
+        ]
+        .into_iter()
+        .collect::<IndexMap<_, _>>();
+
+        assert_eq!(expected, EnvParser::new(cmd_env).parse_env().unwrap());
+
+        env::set_var("__PYTHON_MINOR", "8");
+        let expected = [
+            (
+                "__PYTHON_3_8".to_string(),
+                "{scie.files.cpython38}/python/bin/python3.8".to_string(),
+            ),
+            (
+                "__PYTHON_3_9".to_string(),
+                "{scie.files.cpython39}/python/bin/python3.9".to_string(),
+            ),
+            (
+                "__PYTHON".to_string(),
+                "{scie.files.cpython38}/python/bin/python3.8".to_string(),
+            ),
+        ]
+        .into_iter()
+        .collect::<IndexMap<_, _>>();
+
+        assert_eq!(expected, EnvParser::new(cmd_env).parse_env().unwrap());
+    }
+
+    #[test]
+    fn test_dynamic_env_var_default() {
+        let cmd_env = [(
+            EnvVar::Replace("FOO".to_string()),
+            Some("{scie.env.BAR={scie.env.BAZ=spam}}".to_string()),
+        )]
+        .into_iter()
+        .collect::<IndexMap<_, _>>();
+
+        let expected = [("FOO".to_string(), "spam".to_string())]
+            .into_iter()
+            .collect::<IndexMap<_, _>>();
+
+        assert_eq!(expected, EnvParser::new(&cmd_env).parse_env().unwrap());
+
+        env::set_var("BAZ", "eggs");
+        let expected = [("FOO".to_string(), "eggs".to_string())]
+            .into_iter()
+            .collect::<IndexMap<_, _>>();
+
+        assert_eq!(expected, EnvParser::new(&cmd_env).parse_env().unwrap());
+        env::remove_var("BAZ");
+
+        env::set_var("BAR", "cheese");
+        let expected = [("FOO".to_string(), "cheese".to_string())]
+            .into_iter()
+            .collect::<IndexMap<_, _>>();
+
+        assert_eq!(expected, EnvParser::new(&cmd_env).parse_env().unwrap());
+        env::remove_var("BAR");
+    }
+
+    #[test]
+    fn test_excessively_dynamic_env() {
+        let cmd_env = [(
+            EnvVar::Replace("A".to_string()),
+            Some("PreA{scie.env.PreB{scie.env.B}PostB=PreC{scie.env.C=c}PostC}PostA".to_string()),
+        )]
+        .into_iter()
+        .collect::<IndexMap<_, _>>();
+
+        let expected = [("A".to_string(), "PreAPreCcPostCPostA".to_string())]
+            .into_iter()
+            .collect::<IndexMap<_, _>>();
+
+        assert_eq!(expected, EnvParser::new(&cmd_env).parse_env().unwrap());
+
+        env::set_var("C", "_Cee_");
+        let expected = [("A".to_string(), "PreAPreC_Cee_PostCPostA".to_string())]
+            .into_iter()
+            .collect::<IndexMap<_, _>>();
+
+        assert_eq!(expected, EnvParser::new(&cmd_env).parse_env().unwrap());
+        env::remove_var("C");
+
+        env::set_var("PreBPostB", "_Bee_");
+        let expected = [("A".to_string(), "PreA_Bee_PostA".to_string())]
+            .into_iter()
+            .collect::<IndexMap<_, _>>();
+
+        assert_eq!(expected, EnvParser::new(&cmd_env).parse_env().unwrap());
+        env::remove_var("PreBPostB");
+
+        env::set_var("B", "_Bee_");
+        env::set_var("PreB_Bee_PostB", "_Buzz_");
+        let expected = [("A".to_string(), "PreA_Buzz_PostA".to_string())]
+            .into_iter()
+            .collect::<IndexMap<_, _>>();
+
+        assert_eq!(expected, EnvParser::new(&cmd_env).parse_env().unwrap());
+        env::remove_var("B");
+        env::remove_var("PreB_Bee_PostB");
+    }
+
+    #[test]
+    fn test_ignored_placeholders() {
+        let env_parser = EnvParser::new(
+            &[(
+                EnvVar::Replace("PATH".to_string()),
+                Some(
+                    "{foo}:{scie.env.PATH}:{scie}:{scie.base}:{scie.files.bar}:baz{{}".to_string(),
+                ),
+            )]
+            .into_iter()
+            .collect::<IndexMap<_, _>>(),
+        );
+
+        let expected = [(
+            "PATH".to_string(),
+            format!(
+                "{{scie.files.foo}}:{path}:{{scie}}:{{scie.base}}:{{scie.files.bar}}:baz{{}}",
+                path = env::var("PATH").unwrap()
+            ),
+        )]
+        .into_iter()
+        .collect::<IndexMap<_, _>>();
+
+        assert_eq!(expected, env_parser.parse_env().unwrap());
+    }
+}
diff --git a/jump/src/config.rs b/jump/src/config.rs
index e0f7c46..e3997f1 100644
--- a/jump/src/config.rs
+++ b/jump/src/config.rs
@@ -1,11 +1,11 @@
 // Copyright 2022 Science project contributors.
 // Licensed under the Apache License, Version 2.0 (see LICENSE).
 
-use std::collections::BTreeMap;
 use std::fmt::Formatter;
 use std::io::Write;
 use std::path::PathBuf;
 
+use indexmap::IndexMap;
 use serde::de::{Error, Unexpected, Visitor};
 use serde::{Deserialize, Deserializer, Serialize, Serializer};
 use serde_json::Value;
@@ -207,8 +207,8 @@ pub struct Cmd {
     #[serde(skip_serializing_if = "Vec::is_empty")]
     pub args: Vec<String>,
     #[serde(default)]
-    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
-    pub env: BTreeMap<EnvVar, Option<String>>,
+    #[serde(skip_serializing_if = "IndexMap::is_empty")]
+    pub env: IndexMap<EnvVar, Option<String>>,
     #[serde(default)]
     #[serde(skip_serializing_if = "Option::is_none")]
     pub description: Option<String>,
@@ -225,10 +225,10 @@ pub struct Jump {
 #[derive(Clone, Debug, Serialize, Deserialize)]
 #[serde(deny_unknown_fields)]
 pub struct Boot {
-    pub commands: BTreeMap<String, Cmd>,
+    pub commands: IndexMap<String, Cmd>,
     #[serde(default)]
-    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
-    pub bindings: BTreeMap<String, Cmd>,
+    #[serde(skip_serializing_if = "IndexMap::is_empty")]
+    pub bindings: IndexMap<String, Cmd>,
 }
 
 #[derive(Debug, Serialize, Deserialize)]
@@ -296,7 +296,7 @@ impl Default for Fmt {
 #[derive(Clone, Debug, Serialize, Deserialize)]
 pub struct Other {
     #[serde(flatten)]
-    other: BTreeMap<String, Value>,
+    other: IndexMap<String, Value>,
 }
 
 #[derive(Debug, Serialize, Deserialize)]
@@ -358,7 +358,7 @@ impl Config {
 
 #[cfg(test)]
 mod tests {
-    use std::collections::BTreeMap;
+    use indexmap::IndexMap;
 
     use super::{ArchiveType, Boot, Cmd, Compression, Config, EnvVar, File, Jump, Lift};
     use crate::config::FileType;
@@ -432,7 +432,7 @@ mod tests {
                             }
                         )]
                         .into_iter()
-                        .collect::<BTreeMap<_, _>>(),
+                        .collect::<IndexMap<_, _>>(),
                         bindings: Default::default()
                     },
                     name: "test".to_string(),
diff --git a/jump/src/context.rs b/jump/src/context.rs
index 9205689..0ea6b7f 100644
--- a/jump/src/context.rs
+++ b/jump/src/context.rs
@@ -1,7 +1,7 @@
 // Copyright 2022 Science project contributors.
 // Licensed under the Apache License, Version 2.0 (see LICENSE).
 
-use std::collections::{BTreeMap, HashMap, HashSet};
+use std::collections::{HashMap, HashSet};
 use std::env;
 use std::ffi::OsStr;
 use std::fmt::{Debug, Formatter};
@@ -9,9 +9,11 @@ use std::path::{Component, Path, PathBuf};
 use std::process::Child;
 
 use bstr::ByteSlice;
+use indexmap::IndexMap;
 use logging_timer::time;
 
 use crate::atomic::{atomic_path, Target};
+use crate::cmd_env::{parse_scie_env_placeholder, prepare_env, ParsedEnv};
 use crate::config::{Cmd, Fmt};
 use crate::installer::Installer;
 use crate::lift::{File, Lift};
@@ -200,12 +202,12 @@ pub(crate) struct Context<'a> {
     lift: &'a Lift,
     base: PathBuf,
     installer: &'a Installer<'a>,
-    files_by_name: BTreeMap<&'a str, &'a File>,
+    files_by_name: HashMap<&'a str, &'a File>,
     replacements: HashSet<&'a File>,
     lift_manifest: LiftManifest,
     lift_manifest_dependants: HashSet<Process>,
     lift_manifest_installed: bool,
-    bound: HashMap<&'a str, Binding>,
+    bound: HashMap<String, Binding>,
     installed: HashSet<File>,
 }
 
@@ -221,7 +223,7 @@ impl<'a> Context<'a> {
         lift: &'a Lift,
         installer: &'a Installer,
     ) -> Result<Self, String> {
-        let mut files_by_name = BTreeMap::new();
+        let mut files_by_name = HashMap::new();
         for file in &lift.files {
             files_by_name.insert(file.name.as_str(), file);
             if let Some(key) = file.key.as_ref() {
@@ -259,13 +261,14 @@ impl<'a> Context<'a> {
     }
 
     fn prepare_process(&mut self, cmd: &'a Cmd) -> Result<Process, String> {
+        let mut env = prepare_env(cmd)?;
         let mut needs_lift_manifest = false;
-        let (exe, needs_manifest) = self.reify_string(&cmd.exe)?;
+        let (exe, needs_manifest) = self.reify_string(&env, &cmd.exe)?;
         needs_lift_manifest |= needs_manifest;
 
         let mut args = vec![];
         for arg in &cmd.args {
-            let (reified_arg, needs_manifest) = self.reify_string(arg)?;
+            let (reified_arg, needs_manifest) = self.reify_string(&env, arg)?;
             needs_lift_manifest |= needs_manifest;
             args.push(reified_arg.into());
         }
@@ -273,8 +276,18 @@ impl<'a> Context<'a> {
         for (key, value) in cmd.env.iter() {
             let final_value = match value {
                 Some(val) => {
-                    let (reified_value, needs_manifest) = self.reify_string(val)?;
+                    let (reified_value, needs_manifest) = self.reify_string(&env, val)?;
                     needs_lift_manifest |= needs_manifest;
+                    match key {
+                        config::EnvVar::Default(name) => {
+                            if !env.contains_key(name) {
+                                env.insert(name.to_owned(), reified_value.clone());
+                            }
+                        }
+                        config::EnvVar::Replace(name) => {
+                            env.insert(name.to_owned(), reified_value.clone());
+                        }
+                    }
                     Some(reified_value)
                 }
                 None => None,
@@ -432,23 +445,19 @@ impl<'a> Context<'a> {
         Ok(())
     }
 
-    fn parse_env(&mut self, env: &'a str) -> Result<(String, String, bool), String> {
-        let (parsed_env, needs_lift_manifest) = self.reify_string(env)?;
-        let (name, default) = match parsed_env.splitn(2, '=').collect::<Vec<_>>()[..] {
-            [name] => (name, ""),
-            [name, default] => (name, default),
-            _ => {
-                return Err(
-                    "Expected {{scie.env.<name>}} <name> placeholder to be a non-empty \
-                            string"
-                        .to_string(),
-                )
-            }
-        };
-        Ok((name.to_string(), default.to_string(), needs_lift_manifest))
+    fn parse_env(
+        &mut self,
+        env: &IndexMap<String, String>,
+        env_var: &str,
+    ) -> Result<(ParsedEnv, bool), String> {
+        let (parsed_env, needs_lift_manifest) = self.reify_string(env, env_var)?;
+        Ok((
+            parse_scie_env_placeholder(&parsed_env)?,
+            needs_lift_manifest,
+        ))
     }
 
-    fn bind(&mut self, name: &'a str) -> Result<HashMap<String, String>, String> {
+    fn bind(&mut self, name: &str) -> Result<HashMap<String, String>, String> {
         if let Some(binding) = self.bound.get(name) {
             binding.load_env()
         } else {
@@ -472,7 +481,7 @@ impl<'a> Context<'a> {
                 self.maybe_install_lift_manifest(&boot_binding.process)?;
                 self.installer.install(files.as_slice())
             })?;
-            self.bound.insert(name, boot_binding);
+            self.bound.insert(name.to_string(), boot_binding);
             for file_entry in files {
                 match file_entry {
                     FileEntry::Skip(_) => {}
@@ -493,7 +502,11 @@ impl<'a> Context<'a> {
         }
     }
 
-    fn reify_string(&mut self, value: &'a str) -> Result<(String, bool), String> {
+    fn reify_string(
+        &mut self,
+        env: &IndexMap<String, String>,
+        value: &str,
+    ) -> Result<(String, bool), String> {
         let mut reified = String::with_capacity(value.len());
         let mut lift_manifest_required = false;
 
@@ -503,7 +516,7 @@ impl<'a> Context<'a> {
                 Item::LeftBrace => reified.push('{'),
                 Item::Text(text) => reified.push_str(text),
                 Item::Placeholder(Placeholder::FileHash(name)) => {
-                    let (parsed_name, needs_manifest) = self.reify_string(name)?;
+                    let (parsed_name, needs_manifest) = self.reify_string(env, name)?;
                     lift_manifest_required |= needs_manifest;
                     let file = self
                         .files_by_name
@@ -514,7 +527,7 @@ impl<'a> Context<'a> {
                     reified.push_str(&file.hash);
                 }
                 Item::Placeholder(Placeholder::FileName(name)) => {
-                    let (parsed_name, needs_manifest) = self.reify_string(name)?;
+                    let (parsed_name, needs_manifest) = self.reify_string(env, name)?;
                     lift_manifest_required |= needs_manifest;
                     let file = self
                         .files_by_name
@@ -526,13 +539,20 @@ impl<'a> Context<'a> {
                     reified.push_str(path_to_str(&path)?);
                     self.replacements.insert(file);
                 }
-                Item::Placeholder(Placeholder::Env(env)) => {
-                    let (name, default, needs_manifest) = self.parse_env(env)?;
+                Item::Placeholder(Placeholder::Env(env_var)) => {
+                    let (parsed_env, needs_manifest) = self.parse_env(env, env_var)?;
                     lift_manifest_required |= needs_manifest;
-                    let env_var = env::var_os(&name).unwrap_or_else(|| default.into());
-                    let value = env_var.into_string().map_err(|value| {
-                        format!("Failed to decode env var {name} as utf-8 value: {value:?}")
-                    })?;
+                    let value = if let Some(val) = env
+                        .get(&parsed_env.name)
+                        .map(String::to_owned)
+                        .or_else(|| env::var(&parsed_env.name).ok())
+                    {
+                        let (parsed_value, needs_manifest) = self.reify_string(env, &val)?;
+                        lift_manifest_required |= needs_manifest;
+                        parsed_value
+                    } else {
+                        parsed_env.default.unwrap_or_default()
+                    };
                     reified.push_str(&value)
                 }
                 Item::Placeholder(Placeholder::Scie) => reified.push_str(path_to_str(self.scie)?),
@@ -546,14 +566,18 @@ impl<'a> Context<'a> {
                     self.bind(name)?;
                     reified.push_str(path_to_str(self.get_bindings_dir().as_path())?);
                 }
-                Item::Placeholder(Placeholder::ScieBindingEnv(ScieBindingEnv { binding, env })) => {
+                Item::Placeholder(Placeholder::ScieBindingEnv(ScieBindingEnv {
+                    binding,
+                    env: env_var,
+                })) => {
                     let binding_env = self.bind(binding)?;
-                    let (name, default, needs_manifest) = self.parse_env(env)?;
+                    let (parsed_env, needs_manifest) = self.parse_env(env, env_var)?;
                     lift_manifest_required |= needs_manifest;
                     let value = binding_env
-                        .get(name.as_str())
+                        .get(&parsed_env.name)
                         .map(String::to_owned)
-                        .unwrap_or(default);
+                        .or(parsed_env.default)
+                        .unwrap_or_default();
                     reified.push_str(&value)
                 }
                 Item::Placeholder(Placeholder::ScieLift) => {
@@ -590,12 +614,15 @@ pub(crate) fn select_command(
 
 #[cfg(test)]
 mod tests {
+    use std::env;
     use std::path::Path;
 
+    use indexmap::IndexMap;
+
     use super::Context;
-    use crate::config::{Boot, FileType};
+    use crate::config::{ArchiveType, Boot, Cmd, Compression, FileType};
     use crate::installer::Installer;
-    use crate::{File, Jump, Lift, Source};
+    use crate::{config, process, File, Jump, Lift, Process, Source};
 
     #[test]
     fn env() {
@@ -630,27 +657,42 @@ mod tests {
         let installer = Installer::new(&[]);
         let mut context = Context::new(Path::new("scie_path"), &jump, &lift, &installer).unwrap();
 
-        assert!(std::env::var_os("__DNE__").is_none());
+        assert!(env::var_os("__DNE__").is_none());
+
+        let mut env = IndexMap::new();
         assert_eq!(
             ("".to_string(), false),
-            context.reify_string("{scie.env.__DNE__}").unwrap()
+            context
+                .reify_string(&mut env, "{scie.env.__DNE__}")
+                .unwrap()
         );
+
+        env.clear();
         assert_eq!(
             ("default".to_string(), false),
-            context.reify_string("{scie.env.__DNE__=default}").unwrap()
+            context
+                .reify_string(&mut env, "{scie.env.__DNE__=default}")
+                .unwrap()
         );
 
-        std::env::set_var("__DNE__", "foo");
+        env.clear();
+        env.insert("__DNE__".to_owned(), "foo".to_owned());
         assert_eq!(
             ("foo".to_string(), false),
-            context.reify_string("{scie.env.__DNE__=default}").unwrap()
+            context
+                .reify_string(&mut env, "{scie.env.__DNE__=default}")
+                .unwrap()
         );
-        std::env::remove_var("__DNE__");
 
+        env.clear();
         assert_eq!(
             ("scie_path".to_string(), false),
-            context.reify_string("{scie.env.__DNE__={scie}}").unwrap()
+            context
+                .reify_string(&mut env, "{scie.env.__DNE__={scie}}")
+                .unwrap()
         );
+
+        env.clear();
         assert_eq!(
             (
                 tempdir
@@ -663,9 +705,11 @@ mod tests {
                 true
             ),
             context
-                .reify_string("{scie.env.__DNE__={scie.lift}}")
+                .reify_string(&mut env, "{scie.env.__DNE__={scie.lift}}")
                 .unwrap()
         );
+
+        env.clear();
         assert_eq!(
             (
                 tempdir
@@ -677,23 +721,237 @@ mod tests {
                     .to_string(),
                 false
             ),
-            context.reify_string("{scie.env.__DNE__={file}}").unwrap()
+            context
+                .reify_string(&mut env, "{scie.env.__DNE__={file}}")
+                .unwrap()
         );
 
-        assert!(std::env::var_os("__DNE2__").is_none());
+        env.clear();
         assert_eq!(
             ("42".to_string(), false),
             context
-                .reify_string("{scie.env.__DNE__={scie.env.__DNE2__=42}}")
+                .reify_string(&mut env, "{scie.env.__DNE__={scie.env.__DNE2__=42}}")
                 .unwrap()
         );
-        std::env::set_var("__DNE2__", "bar");
+
+        env.clear();
+        env.insert("__DNE2__".to_owned(), "bar".to_owned());
         assert_eq!(
             ("bar".to_string(), false),
             context
-                .reify_string("{scie.env.__DNE__={scie.env.__DNE2__=42}}")
+                .reify_string(&mut env, "{scie.env.__DNE__={scie.env.__DNE2__=42}}")
                 .unwrap()
         );
-        std::env::remove_var("__DNE2__");
+    }
+
+    #[test]
+    fn prepare_process_use_cmd_env() {
+        let jump = Jump {
+            size: 42,
+            version: "0.1.0".to_string(),
+        };
+        let tempdir = tempfile::tempdir().unwrap();
+        let lift = Lift {
+            name: "test".to_string(),
+            description: None,
+            base: Some(tempdir.path().to_path_buf()),
+            load_dotenv: true,
+            size: 137,
+            hash: "abc".to_string(),
+            boot: Boot {
+                commands: vec![(
+                    "".to_owned(),
+                    Cmd {
+                        env: vec![
+                            (
+                                config::EnvVar::Replace("SUB_SELECT-v1".to_owned()),
+                                Some("v1/exe".to_owned()),
+                            ),
+                            (
+                                config::EnvVar::Replace("SUB_SELECT-v2".to_owned()),
+                                Some("v2/binary".to_owned()),
+                            ),
+                        ]
+                        .into_iter()
+                        .collect::<IndexMap<_, _>>(),
+                        exe: "{scie.files.dist-{scie.env.SELECT=v1}}/\
+                            {scie.env.SUB_SELECT-{scie.env.SELECT=v2}}"
+                            .to_string(),
+                        args: vec![],
+                        description: None,
+                    },
+                )]
+                .into_iter()
+                .collect::<IndexMap<_, _>>(),
+                bindings: Default::default(),
+            },
+            files: vec![
+                File {
+                    name: "dist-v1".to_string(),
+                    key: None,
+                    size: 37,
+                    hash: "def".to_string(),
+                    file_type: FileType::Archive(ArchiveType::CompressedTar(Compression::Zstd)),
+                    executable: None,
+                    eager_extract: false,
+                    source: Source::Scie,
+                },
+                File {
+                    name: "dist-v2".to_string(),
+                    key: None,
+                    size: 42,
+                    hash: "ghi".to_string(),
+                    file_type: FileType::Archive(ArchiveType::Zip),
+                    executable: None,
+                    eager_extract: false,
+                    source: Source::Scie,
+                },
+            ],
+            other: None,
+        };
+        let installer = Installer::new(&[]);
+        let mut context = Context::new(Path::new("scie_path"), &jump, &lift, &installer).unwrap();
+
+        let cmd = lift.boot.commands.get("").unwrap();
+        let expected_env = process::EnvVars {
+            vars: vec![
+                process::EnvVar::Replace(("SUB_SELECT-v1".into(), "v1/exe".into())),
+                process::EnvVar::Replace(("SUB_SELECT-v2".into(), "v2/binary".into())),
+            ],
+        };
+
+        let process = context.prepare_process(&cmd).unwrap();
+        assert_eq!(
+            Process {
+                env: expected_env.clone(),
+                exe: tempdir.path().join("def").join("dist-v1/v2/binary").into(),
+                args: vec![],
+            },
+            process
+        );
+
+        env::set_var("SELECT", "v1");
+        let process = context.prepare_process(&cmd).unwrap();
+        assert_eq!(
+            Process {
+                env: expected_env.clone(),
+                exe: tempdir.path().join("def").join("dist-v1/v1/exe").into(),
+                args: vec![],
+            },
+            process
+        );
+        env::remove_var("SELECT");
+
+        env::set_var("SELECT", "v2");
+        let process = context.prepare_process(&cmd).unwrap();
+        assert_eq!(
+            Process {
+                env: expected_env,
+                exe: tempdir.path().join("ghi").join("dist-v2/v2/binary").into(),
+                args: vec![],
+            },
+            process
+        );
+        env::remove_var("SELECT")
+    }
+
+    #[test]
+    fn prepare_process_use_cmd_env_recursive() {
+        let jump = Jump {
+            size: 42,
+            version: "0.1.0".to_string(),
+        };
+        let tempdir = tempfile::tempdir().unwrap();
+        let lift = Lift {
+            name: "test".to_string(),
+            description: None,
+            base: Some(tempdir.path().to_path_buf()),
+            load_dotenv: true,
+            size: 137,
+            hash: "abc".to_string(),
+            boot: Boot {
+                commands: vec![(
+                    "".to_owned(),
+                    Cmd {
+                        env: vec![
+                            (
+                                config::EnvVar::Replace("A".to_owned()),
+                                Some("{scie.env.B}".to_owned()),
+                            ),
+                            (
+                                config::EnvVar::Replace("B".to_owned()),
+                                Some("{scie.env.C}".to_owned()),
+                            ),
+                            (
+                                config::EnvVar::Replace("C".to_owned()),
+                                Some("{scie.env.D=c}".to_owned()),
+                            ),
+                            (
+                                config::EnvVar::Replace("PATH".to_owned()),
+                                Some("{scie.env.A}:{scie.env.E=e}:{scie.env.F}".to_owned()),
+                            ),
+                            (
+                                config::EnvVar::Replace("F".to_owned()),
+                                Some("{scie.env.PATH}".to_owned()),
+                            ),
+                        ]
+                        .into_iter()
+                        .collect::<IndexMap<_, _>>(),
+                        exe: "{scie.env.A}".to_string(),
+                        args: vec![],
+                        description: None,
+                    },
+                )]
+                .into_iter()
+                .collect::<IndexMap<_, _>>(),
+                bindings: Default::default(),
+            },
+            files: vec![],
+            other: None,
+        };
+        let installer = Installer::new(&[]);
+        let mut context = Context::new(Path::new("scie_path"), &jump, &lift, &installer).unwrap();
+
+        let cmd = lift.boot.commands.get("").unwrap();
+
+        let process = context.prepare_process(&cmd).unwrap();
+        let reified_path = format!("c:e:{}", env::var("PATH").unwrap());
+        assert_eq!(
+            Process {
+                env: process::EnvVars {
+                    vars: vec![
+                        process::EnvVar::Replace(("A".into(), "c".into())),
+                        process::EnvVar::Replace(("B".into(), "c".into())),
+                        process::EnvVar::Replace(("C".into(), "c".into())),
+                        process::EnvVar::Replace(("PATH".into(), reified_path.clone().into())),
+                        process::EnvVar::Replace(("F".into(), reified_path.into())),
+                    ]
+                },
+                exe: "c".into(),
+                args: vec![],
+            },
+            process
+        );
+
+        env::set_var("D", "d");
+        let process = context.prepare_process(&cmd).unwrap();
+        let reified_path = format!("d:e:{}", env::var("PATH").unwrap());
+        assert_eq!(
+            Process {
+                env: process::EnvVars {
+                    vars: vec![
+                        process::EnvVar::Replace(("A".into(), "d".into())),
+                        process::EnvVar::Replace(("B".into(), "d".into())),
+                        process::EnvVar::Replace(("C".into(), "d".into())),
+                        process::EnvVar::Replace(("PATH".into(), reified_path.clone().into())),
+                        process::EnvVar::Replace(("F".into(), reified_path.into())),
+                    ]
+                },
+                exe: "d".into(),
+                args: vec![],
+            },
+            process
+        );
+        env::remove_var("D");
     }
 }
diff --git a/jump/src/lib.rs b/jump/src/lib.rs
index c2a05d6..f78a470 100644
--- a/jump/src/lib.rs
+++ b/jump/src/lib.rs
@@ -9,6 +9,7 @@ extern crate structure;
 
 mod archive;
 mod atomic;
+mod cmd_env;
 mod comparable_regex;
 pub mod config;
 mod context;


Test patch:
diff --git a/examples/python/test.sh b/examples/python/test.sh
index 32da643..96e07d8 100644
--- a/examples/python/test.sh
+++ b/examples/python/test.sh
@@ -40,3 +40,8 @@ setpath(["extra"]; 42)
 gc "${PWD}/lift.json" "${PWD}/pants-extra"
 "${SCIE_JUMP}"
 time RUST_LOG=debug ./pants-extra -V
+
+# Verify dynamic env var selection.
+SCIE_BOOT=repl ./pants -c 'import sys; assert (3, 9) == sys.version_info[:2]'
+PYTHON_MINOR=8 SCIE_BOOT=repl ./pants -c 'import sys; assert (3, 8) == sys.version_info[:2]'
+PYTHON_MINOR=9 SCIE_BOOT=repl ./pants -c 'import sys; assert (3, 9) == sys.version_info[:2]'