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