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!(
77 "TMUX_SESSION={}{}-{}\n",
78 compose.global.supervisor.tmux_prefix, h.project, h.agent
79 ));
80 s
81}
82
83fn render_mcp(compose: &Compose, h: AgentHandle<'_>, team_mcp_bin: &str) -> String {
84 let mailbox = compose.root.join(&compose.global.broker.path);
85 let v = serde_json::json!({
86 "mcpServers": {
87 "team": {
88 "command": team_mcp_bin,
89 "args": [
90 "--agent-id", format!("{}:{}", h.project, h.agent),
91 "--mailbox", mailbox.display().to_string(),
92 ],
93 "env": {}
94 }
95 }
96 });
97 serde_json::to_string_pretty(&v).expect("json")
98}
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103 use crate::compose::*;
104 use std::collections::BTreeMap;
105 use std::path::PathBuf;
106
107 fn fixture() -> Compose {
108 let mut managers = BTreeMap::new();
109 managers.insert(
110 "mgr".into(),
111 Agent {
112 runtime: "claude-code".into(),
113 model: Some("claude-opus-4-7".into()),
114 role_prompt: Some(PathBuf::from("roles/mgr.md")),
115 permission_mode: Some("auto".into()),
116 autonomy: "low_risk_only".into(),
117 can_dm: vec![],
118 can_broadcast: vec![],
119 reports_to: None,
120 on_rate_limit: None,
121 effort: None,
122 interfaces: None,
123 },
124 );
125 Compose {
126 root: PathBuf::from("/teamctl"),
127 global: Global {
128 version: 2,
129 broker: Broker {
130 r#type: "sqlite".into(),
131 path: PathBuf::from("state/mailbox.db"),
132 },
133 supervisor: SupervisorCfg {
134 r#type: "tmux".into(),
135 tmux_prefix: "a-".into(),
136 drain_timeout_secs: 10,
137 },
138 budget: Default::default(),
139 hitl: Default::default(),
140 rate_limits: Default::default(),
141 interfaces: vec![],
142 projects: vec![],
143 },
144 projects: vec![Project {
145 version: 2,
146 project: ProjectMeta {
147 id: "hello".into(),
148 name: "Hello".into(),
149 cwd: PathBuf::from("/teamctl/examples/hello-team"),
150 },
151 channels: vec![],
152 managers,
153 workers: Default::default(),
154 }],
155 }
156 }
157
158 #[test]
159 fn env_contains_agent_id_and_mailbox() {
160 let c = fixture();
161 let h = c.agents().next().unwrap();
162 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
163 assert!(env.contains("AGENT_ID=hello:mgr"));
164 assert!(env.contains("TEAMCTL_MAILBOX=/teamctl/state/mailbox.db"));
165 assert!(env.contains("TMUX_SESSION=a-hello-mgr"));
166 }
167
168 #[test]
169 fn env_omits_effort_when_unset() {
170 let c = fixture();
171 let h = c.agents().next().unwrap();
172 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
173 assert!(!env.contains("EFFORT="), "env was: {env}");
174 }
175
176 #[test]
177 fn env_emits_effort_when_set() {
178 let mut c = fixture();
179 c.projects[0].managers.get_mut("mgr").unwrap().effort = Some(EffortLevel::Max);
180 let h = c.agents().next().unwrap();
181 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
182 assert!(env.contains("EFFORT=max\n"), "env was: {env}");
183 }
184
185 #[test]
186 fn mcp_json_parses_back() {
187 let c = fixture();
188 let h = c.agents().next().unwrap();
189 let (_, mcp) = render_agent(&c, h, "/usr/local/bin/team-mcp");
190 let v: serde_json::Value = serde_json::from_str(&mcp).unwrap();
191 assert_eq!(
192 v["mcpServers"]["team"]["command"],
193 "/usr/local/bin/team-mcp"
194 );
195 assert_eq!(
196 v["mcpServers"]["team"]["args"][1].as_str().unwrap(),
197 "hello:mgr"
198 );
199 }
200}