Add environment variable support for containers
Config accepts an env block as a HashMap, merged across layers
with higher-precedence winning. Variables are passed through
to the Docker compose environment field.

Assisted-by: GLM-5.1 via pi
change xopzvnlvkxvnskvyttyrwsqtrnszsrtp
commit 14b1885c351e2977f86b8462d20e0f8833e5ca5d
author Alpha Chen <alpha@kejadlen.dev>
date
parent tmprlmoy
diff --git a/src/config.rs b/src/config.rs
index 33cca80..d95b9d3 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -1,3 +1,4 @@
+use std::collections::{HashMap, HashSet};
 use std::fmt;
 use std::path::{Path, PathBuf};
 
@@ -10,6 +11,8 @@ pub struct Config {
     pub mounts: Vec<Mount>,
     #[serde(default)]
     pub pi: Vec<PiEntry>,
+    #[serde(default)]
+    pub env: HashMap<String, String>,
 }
 
 #[derive(Debug, PartialEq, Serialize, Deserialize)]
@@ -58,6 +61,7 @@ pub struct ConfigLayer {
     pub path: Option<PathBuf>,
     pub mounts: Vec<ResolvedMount>,
     pub pi: Vec<PiEntry>,
+    pub env: HashMap<String, String>,
 }
 
 /// All configuration layers, ordered from lowest to highest precedence.
@@ -76,7 +80,7 @@ impl ScopedConfig {
     /// the same container target path, the higher-precedence layer wins.
     /// Each mount is tagged with the scope it came from.
     pub fn merged_mounts(&self) -> Vec<(Scope, &ResolvedMount)> {
-        let mut seen = std::collections::HashSet::new();
+        let mut seen = HashSet::new();
         let mut result = Vec::new();
         // Iterate in reverse (highest precedence first) so higher layers win,
         // then reverse the result to preserve low-to-high ordering.
@@ -95,7 +99,7 @@ impl ScopedConfig {
     ///
     /// Higher-precedence layers override entries with the same target.
     pub fn merged_pi(&self) -> Vec<&PiEntry> {
-        let mut seen = std::collections::HashSet::new();
+        let mut seen = HashSet::new();
         let mut result = Vec::new();
         for layer in self.layers.iter().rev() {
             for entry in layer.pi.iter().rev() {
@@ -108,6 +112,17 @@ impl ScopedConfig {
         result.reverse();
         result
     }
+
+    /// Merge environment variables from all layers, de-duplicated by name.
+    ///
+    /// Higher-precedence layers override variables with the same name.
+    pub fn merged_env(&self) -> HashMap<&str, &str> {
+        self.layers
+            .iter()
+            .flat_map(|l| l.env.iter())
+            .map(|(k, v)| (k.as_str(), v.as_str()))
+            .collect()
+    }
 }
 
 /// A mount with tilde-expanded paths ready for Docker.
@@ -145,6 +160,7 @@ impl Config {
                 path: Some(user_path),
                 mounts: config.resolve_mounts(),
                 pi: config.pi,
+                env: config.env,
             });
         }
 
@@ -159,6 +175,7 @@ impl Config {
                 path: Some(project_path),
                 mounts: config.resolve_mounts(),
                 pi: config.pi,
+                env: config.env,
             });
         }
 
@@ -168,6 +185,7 @@ impl Config {
             path: None,
             mounts: builtin_mounts,
             pi: vec![],
+            env: HashMap::new(),
         });
 
         Ok(ScopedConfig { layers })
@@ -517,6 +535,7 @@ mod tests {
                 },
             ],
             pi: vec![],
+            env: HashMap::new(),
         };
         let resolved = config.resolve_mounts();
         assert_eq!(resolved.len(), 1);
@@ -543,6 +562,7 @@ mod tests {
                         writable: false,
                     }],
                     pi: vec![],
+                    env: HashMap::new(),
                 },
                 ConfigLayer {
                     scope: Scope::Project,
@@ -553,6 +573,7 @@ mod tests {
                         writable: true,
                     }],
                     pi: vec![],
+                    env: HashMap::new(),
                 },
             ],
         };
@@ -574,6 +595,7 @@ mod tests {
                     writable: false,
                 }],
                 pi: vec![],
+                env: HashMap::new(),
             }],
         };
         let merged = config.merged_mounts();
@@ -594,6 +616,7 @@ mod tests {
                         writable: false,
                     }],
                     pi: vec![],
+                    env: HashMap::new(),
                 },
                 ConfigLayer {
                     scope: Scope::Project,
@@ -604,6 +627,7 @@ mod tests {
                         writable: true,
                     }],
                     pi: vec![],
+                    env: HashMap::new(),
                 },
                 ConfigLayer {
                     scope: Scope::Builtin,
@@ -614,6 +638,7 @@ mod tests {
                         writable: true,
                     }],
                     pi: vec![],
+                    env: HashMap::new(),
                 },
             ],
         };
@@ -644,6 +669,7 @@ mod tests {
                         },
                     ],
                     pi: vec![],
+                    env: HashMap::new(),
                 },
                 ConfigLayer {
                     scope: Scope::Project,
@@ -655,6 +681,7 @@ mod tests {
                         writable: true,
                     }],
                     pi: vec![],
+                    env: HashMap::new(),
                 },
             ],
         };
@@ -975,6 +1002,7 @@ mod tests {
                         source: "~/.dotfiles/AGENTS.md".into(),
                         target: None,
                     }],
+                    env: HashMap::new(),
                 },
                 ConfigLayer {
                     scope: Scope::Project,
@@ -984,12 +1012,14 @@ mod tests {
                         source: "/project/skills".into(),
                         target: None,
                     }],
+                    env: HashMap::new(),
                 },
                 ConfigLayer {
                     scope: Scope::Builtin,
                     path: None,
                     mounts: vec![],
                     pi: vec![],
+                    env: HashMap::new(),
                 },
             ],
         };
@@ -1011,6 +1041,7 @@ mod tests {
                         source: "~/.dotfiles/AGENTS.md".into(),
                         target: None,
                     }],
+                    env: HashMap::new(),
                 },
                 ConfigLayer {
                     scope: Scope::Project,
@@ -1020,6 +1051,7 @@ mod tests {
                         source: "/project/AGENTS.md".into(),
                         target: None,
                     }],
+                    env: HashMap::new(),
                 },
             ],
         };
@@ -1041,6 +1073,7 @@ mod tests {
                         source: "~/.dotfiles/ai/skills".into(),
                         target: None,
                     }],
+                    env: HashMap::new(),
                 },
                 ConfigLayer {
                     scope: Scope::Project,
@@ -1050,6 +1083,7 @@ mod tests {
                         source: "/project/my-custom-skills".into(),
                         target: Some("skills".into()),
                     }],
+                    env: HashMap::new(),
                 },
             ],
         };
@@ -1058,4 +1092,60 @@ mod tests {
         // Project wins — explicit target "skills" matches user's basename "skills".
         assert_eq!(merged[0].source, "/project/my-custom-skills");
     }
+
+    #[test]
+    fn kdl_env_flat_props() {
+        let kdl = r#"
+            env FOO="bar"
+        "#;
+        let parsed: Config = serde_kdl2::from_str(kdl).unwrap();
+        assert_eq!(parsed.env.get("FOO").unwrap(), "bar");
+    }
+
+    #[test]
+    fn kdl_env_multiple() {
+        let kdl = r#"
+            env {
+                API_KEY "secret123"
+                NODE_ENV "production"
+            }
+        "#;
+        let parsed: Config = serde_kdl2::from_str(kdl).unwrap();
+        assert_eq!(parsed.env.len(), 2);
+        assert_eq!(parsed.env.get("API_KEY").unwrap(), "secret123");
+        assert_eq!(parsed.env.get("NODE_ENV").unwrap(), "production");
+    }
+
+    #[test]
+    fn merged_env_deduplicates_by_name() {
+        let mut user_env = HashMap::new();
+        user_env.insert("FOO".into(), "user".into());
+        user_env.insert("BAR".into(), "user".into());
+
+        let mut project_env = HashMap::new();
+        project_env.insert("FOO".into(), "project".into());
+
+        let config = ScopedConfig {
+            layers: vec![
+                ConfigLayer {
+                    scope: Scope::User,
+                    path: None,
+                    mounts: vec![],
+                    pi: vec![],
+                    env: user_env,
+                },
+                ConfigLayer {
+                    scope: Scope::Project,
+                    path: None,
+                    mounts: vec![],
+                    pi: vec![],
+                    env: project_env,
+                },
+            ],
+        };
+        let merged = config.merged_env();
+        assert_eq!(merged.len(), 2);
+        assert_eq!(merged.get("FOO").unwrap(), &"project");
+        assert_eq!(merged.get("BAR").unwrap(), &"user");
+    }
 }
diff --git a/src/main.rs b/src/main.rs
index 517ad85..d9ad9cd 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,5 +1,6 @@
 mod config;
 
+use std::collections::HashMap;
 use std::path::{Path, PathBuf};
 use std::process::{Command, Stdio};
 
@@ -212,9 +213,19 @@ impl Ramekin {
         }
 
         let merged_pi = self.config.merged_pi();
-        if !merged_pi.is_empty() {
+        let merged_env = self.config.merged_env();
+        if !merged_pi.is_empty() || !merged_env.is_empty() {
             println!();
-            println!("Pi config");
+            println!("Agent config");
+        }
+
+        if !merged_env.is_empty() {
+            for (name, value) in &merged_env {
+                println!("  {name}={value}");
+            }
+        }
+
+        if !merged_pi.is_empty() {
             for entry in &merged_pi {
                 let resolved = entry.resolve();
                 let kind = if resolved.source.is_dir() {
@@ -306,7 +317,9 @@ impl Ramekin {
             .into_iter()
             .map(|(_, m)| m)
             .collect();
-        let compose = generate_compose(&dockerfile, &build_context, &all_mounts, pi_args);
+        let env_vars = self.config.merged_env();
+        let compose =
+            generate_compose(&dockerfile, &build_context, &all_mounts, &env_vars, pi_args);
         let compose_file = session_dir.join("compose.yml");
         fs_err::write(&compose_file, &compose).into_diagnostic()?;
 
@@ -404,6 +417,7 @@ struct AgentService {
     image: String,
     stdin_open: bool,
     tty: bool,
+    environment: Vec<String>,
     volumes: Vec<String>,
     command: Vec<String>,
 }
@@ -419,15 +433,24 @@ fn generate_compose(
     dockerfile: &Path,
     build_context: &Path,
     mounts: &[&config::ResolvedMount],
+    env_vars: &HashMap<&str, &str>,
     pi_args: &[String],
 ) -> String {
     let volumes: Vec<String> = mounts.iter().map(|m| m.to_volume_string()).collect();
 
+    let environment: Vec<String> = env_vars
+        .iter()
+        .map(|(name, value)| format!("{name}={value}"))
+        .collect();
+
     // Always pass --append-system-prompt for the ramekin container context.
     // The prompt file is written into the agent dir which is mounted at /root/.pi/agent.
     let prompt_path = "/root/.pi/agent/ramekin-prompt.md";
     let command: Vec<String> = std::iter::once("pi".to_string())
-        .chain(["--append-system-prompt".to_string(), prompt_path.to_string()])
+        .chain([
+            "--append-system-prompt".to_string(),
+            prompt_path.to_string(),
+        ])
         .chain(pi_args.iter().cloned())
         .collect();
 
@@ -441,6 +464,7 @@ fn generate_compose(
                 image: "ramekin-agent".into(),
                 stdin_open: true,
                 tty: true,
+                environment,
                 volumes,
                 command,
             },