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 telegram_inbox: true,
117 reports_to_user: true,
118 autonomy: "low_risk_only".into(),
119 can_dm: vec![],
120 can_broadcast: vec![],
121 reports_to: None,
122 on_rate_limit: None,
123 effort: None,
124 },
125 );
126 Compose {
127 root: PathBuf::from("/teamctl"),
128 global: Global {
129 version: 2,
130 broker: Broker {
131 r#type: "sqlite".into(),
132 path: PathBuf::from("state/mailbox.db"),
133 },
134 supervisor: SupervisorCfg {
135 r#type: "tmux".into(),
136 tmux_prefix: "a-".into(),
137 drain_timeout_secs: 10,
138 },
139 budget: Default::default(),
140 hitl: Default::default(),
141 rate_limits: Default::default(),
142 interfaces: vec![],
143 projects: vec![],
144 },
145 projects: vec![Project {
146 version: 2,
147 project: ProjectMeta {
148 id: "hello".into(),
149 name: "Hello".into(),
150 cwd: PathBuf::from("/teamctl/examples/hello-team"),
151 },
152 channels: vec![],
153 managers,
154 workers: Default::default(),
155 }],
156 }
157 }
158
159 #[test]
160 fn env_contains_agent_id_and_mailbox() {
161 let c = fixture();
162 let h = c.agents().next().unwrap();
163 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
164 assert!(env.contains("AGENT_ID=hello:mgr"));
165 assert!(env.contains("TEAMCTL_MAILBOX=/teamctl/state/mailbox.db"));
166 assert!(env.contains("TMUX_SESSION=a-hello-mgr"));
167 }
168
169 #[test]
170 fn env_omits_effort_when_unset() {
171 let c = fixture();
172 let h = c.agents().next().unwrap();
173 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
174 assert!(!env.contains("EFFORT="), "env was: {env}");
175 }
176
177 #[test]
178 fn env_emits_effort_when_set() {
179 let mut c = fixture();
180 c.projects[0].managers.get_mut("mgr").unwrap().effort = Some(EffortLevel::Max);
181 let h = c.agents().next().unwrap();
182 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
183 assert!(env.contains("EFFORT=max\n"), "env was: {env}");
184 }
185
186 #[test]
187 fn mcp_json_parses_back() {
188 let c = fixture();
189 let h = c.agents().next().unwrap();
190 let (_, mcp) = render_agent(&c, h, "/usr/local/bin/team-mcp");
191 let v: serde_json::Value = serde_json::from_str(&mcp).unwrap();
192 assert_eq!(
193 v["mcpServers"]["team"]["command"],
194 "/usr/local/bin/team-mcp"
195 );
196 assert_eq!(
197 v["mcpServers"]["team"]["args"][1].as_str().unwrap(),
198 "hello:mgr"
199 );
200 }
201}