1use std::path::{Path, PathBuf};
11
12use crate::compose::{AgentHandle, Compose};
13
14pub fn env_path(root: &Path, project: &str, agent: &str) -> PathBuf {
16 root.join("state/envs")
17 .join(format!("{project}-{agent}.env"))
18}
19
20pub fn mcp_path(root: &Path, project: &str, agent: &str) -> PathBuf {
22 root.join("state/mcp")
23 .join(format!("{project}-{agent}.json"))
24}
25
26pub fn render_agent(
28 compose: &Compose,
29 handle: AgentHandle<'_>,
30 team_mcp_bin: &str,
31) -> (String, String) {
32 let env = render_env(compose, handle);
33 let mcp = render_mcp(compose, handle, team_mcp_bin);
34 (env, mcp)
35}
36
37fn render_env(compose: &Compose, h: AgentHandle<'_>) -> String {
38 let project = compose
39 .projects
40 .iter()
41 .find(|p| p.project.id == h.project)
42 .expect("agent belongs to a loaded project");
43 let mailbox = compose.root.join(&compose.global.broker.path);
44 let mcp = mcp_path(&compose.root, h.project, h.agent);
45 let prompt = h
46 .spec
47 .role_prompt
48 .as_ref()
49 .map(|p| compose.root.join(p))
50 .map(|p| p.display().to_string())
51 .unwrap_or_default();
52
53 let mut s = String::new();
54 s.push_str(&format!("AGENT_ID={}:{}\n", h.project, h.agent));
55 s.push_str(&format!("PROJECT_ID={}\n", h.project));
56 s.push_str(&format!("RUNTIME={}\n", h.spec.runtime));
57 if let Some(m) = &h.spec.model {
58 s.push_str(&format!("MODEL={m}\n"));
59 }
60 if let Some(pm) = &h.spec.permission_mode {
61 s.push_str(&format!("PERMISSION_MODE={pm}\n"));
62 }
63 if let Some(effort) = h.spec.effort {
67 s.push_str(&format!("EFFORT={}\n", effort.as_str()));
68 }
69 s.push_str(&format!("TEAMCTL_MAILBOX={}\n", mailbox.display()));
70 s.push_str(&format!("MCP_CONFIG={}\n", mcp.display()));
71 s.push_str(&format!("SYSTEM_PROMPT_PATH={prompt}\n"));
72 s.push_str(&format!(
73 "CLAUDE_PROJECT_DIR={}\n",
74 project.project.cwd.display()
75 ));
76 s.push_str(&format!("TEAMCTL_ROOT={}\n", compose.root.display()));
84 s.push_str(&format!(
85 "TMUX_SESSION={}{}-{}\n",
86 compose.global.supervisor.tmux_prefix, h.project, h.agent
87 ));
88 s
89}
90
91fn render_mcp(compose: &Compose, h: AgentHandle<'_>, team_mcp_bin: &str) -> String {
92 let mailbox = compose.root.join(&compose.global.broker.path);
93 let v = serde_json::json!({
94 "mcpServers": {
95 "team": {
96 "command": team_mcp_bin,
97 "args": [
98 "--agent-id", format!("{}:{}", h.project, h.agent),
99 "--mailbox", mailbox.display().to_string(),
100 ],
101 "env": {}
102 }
103 }
104 });
105 serde_json::to_string_pretty(&v).expect("json")
106}
107
108#[cfg(test)]
109mod tests {
110 use super::*;
111 use crate::compose::*;
112 use std::collections::BTreeMap;
113 use std::path::PathBuf;
114
115 fn fixture() -> Compose {
116 let mut managers = BTreeMap::new();
117 managers.insert(
118 "mgr".into(),
119 Agent {
120 runtime: "claude-code".into(),
121 model: Some("claude-opus-4-7".into()),
122 role_prompt: Some(PathBuf::from("roles/mgr.md")),
123 permission_mode: Some("auto".into()),
124 autonomy: "low_risk_only".into(),
125 can_dm: vec![],
126 can_broadcast: vec![],
127 reports_to: None,
128 on_rate_limit: None,
129 effort: None,
130 interfaces: None,
131 },
132 );
133 Compose {
134 root: PathBuf::from("/teamctl"),
135 global: Global {
136 version: 2,
137 broker: Broker {
138 r#type: "sqlite".into(),
139 path: PathBuf::from("state/mailbox.db"),
140 },
141 supervisor: SupervisorCfg {
142 r#type: "tmux".into(),
143 tmux_prefix: "a-".into(),
144 drain_timeout_secs: 10,
145 },
146 budget: Default::default(),
147 hitl: Default::default(),
148 rate_limits: Default::default(),
149 interfaces: vec![],
150 projects: vec![],
151 },
152 projects: vec![Project {
153 version: 2,
154 project: ProjectMeta {
155 id: "hello".into(),
156 name: "Hello".into(),
157 cwd: PathBuf::from("/teamctl/examples/hello-team"),
158 },
159 channels: vec![],
160 managers,
161 workers: Default::default(),
162 }],
163 }
164 }
165
166 #[test]
167 fn env_contains_agent_id_and_mailbox() {
168 let c = fixture();
169 let h = c.agents().next().unwrap();
170 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
171 assert!(env.contains("AGENT_ID=hello:mgr"));
172 assert!(env.contains("TEAMCTL_MAILBOX=/teamctl/state/mailbox.db"));
173 assert!(env.contains("TMUX_SESSION=a-hello-mgr"));
174 }
175
176 #[test]
177 fn env_pins_teamctl_root_to_compose_root() {
178 let c = fixture();
184 let h = c.agents().next().unwrap();
185 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
186 assert!(env.contains("TEAMCTL_ROOT=/teamctl\n"), "env was: {env}");
187 }
188
189 #[test]
190 fn env_omits_effort_when_unset() {
191 let c = fixture();
192 let h = c.agents().next().unwrap();
193 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
194 assert!(!env.contains("EFFORT="), "env was: {env}");
195 }
196
197 #[test]
198 fn env_emits_effort_when_set() {
199 let mut c = fixture();
200 c.projects[0].managers.get_mut("mgr").unwrap().effort = Some(EffortLevel::Max);
201 let h = c.agents().next().unwrap();
202 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
203 assert!(env.contains("EFFORT=max\n"), "env was: {env}");
204 }
205
206 #[test]
207 fn mcp_json_parses_back() {
208 let c = fixture();
209 let h = c.agents().next().unwrap();
210 let (_, mcp) = render_agent(&c, h, "/usr/local/bin/team-mcp");
211 let v: serde_json::Value = serde_json::from_str(&mcp).unwrap();
212 assert_eq!(
213 v["mcpServers"]["team"]["command"],
214 "/usr/local/bin/team-mcp"
215 );
216 assert_eq!(
217 v["mcpServers"]["team"]["args"][1].as_str().unwrap(),
218 "hello:mgr"
219 );
220 }
221}