1use std::io;
18use std::path::{Path, PathBuf};
19
20use crate::compose::{AgentHandle, Compose, RolePrompt};
21
22const ROLE_PROMPT_SEPARATOR: &str = "\n\n—\n\n";
26
27pub fn env_path(root: &Path, project: &str, agent: &str) -> PathBuf {
29 root.join("state/envs")
30 .join(format!("{project}-{agent}.env"))
31}
32
33pub fn mcp_path(root: &Path, project: &str, agent: &str) -> PathBuf {
35 root.join("state/mcp")
36 .join(format!("{project}-{agent}.json"))
37}
38
39pub fn claude_settings_path(root: &Path, project: &str, agent: &str) -> PathBuf {
46 root.join("state/claude")
47 .join(format!("{project}-{agent}.json"))
48}
49
50pub fn role_prompt_concat_path(root: &Path, project: &str, agent: &str) -> PathBuf {
54 root.join("state/role_prompts")
55 .join(format!("{project}-{agent}.md"))
56}
57
58pub fn render_agent(
60 compose: &Compose,
61 handle: AgentHandle<'_>,
62 team_mcp_bin: &str,
63) -> (String, String) {
64 let env = render_env(compose, handle);
65 let mcp = render_mcp(compose, handle, team_mcp_bin);
66 (env, mcp)
67}
68
69pub fn render_claude_settings(compose: &Compose, h: AgentHandle<'_>) -> Option<String> {
83 let _ = compose;
84 if h.spec.runtime != "claude-code" {
85 return None;
86 }
87 let v = serde_json::json!({
91 "hooks": {
92 "PreToolUse": [
93 {
94 "matcher": "AskUserQuestion|EnterPlanMode|ExitPlanMode",
95 "hooks": [
96 {
97 "type": "command",
98 "command": "echo '{\"hookSpecificOutput\":{\"permissionDecision\":\"deny\"},\"systemMessage\":\"Interactive prompts are disabled for teamctl agents. Use the `team` MCP tools to ask people or check in.\"}'"
99 }
100 ]
101 }
102 ]
103 }
104 });
105 Some(serde_json::to_string_pretty(&v).expect("json"))
106}
107
108fn render_env(compose: &Compose, h: AgentHandle<'_>) -> String {
109 let project = compose
110 .projects
111 .iter()
112 .find(|p| p.project.id == h.project)
113 .expect("agent belongs to a loaded project");
114 let mailbox = compose.root.join(&compose.global.broker.path);
115 let mcp = mcp_path(&compose.root, h.project, h.agent);
116 let prompt = system_prompt_path(compose, h)
117 .map(|p| p.display().to_string())
118 .unwrap_or_default();
119
120 let mut s = String::new();
121 s.push_str(&format!("AGENT_ID={}:{}\n", h.project, h.agent));
122 s.push_str(&format!("PROJECT_ID={}\n", h.project));
123 s.push_str(&format!("RUNTIME={}\n", h.spec.runtime));
124 if let Some(m) = &h.spec.model {
125 s.push_str(&format!("MODEL={m}\n"));
126 }
127 if let Some(pm) = &h.spec.permission_mode {
128 s.push_str(&format!("PERMISSION_MODE={pm}\n"));
129 }
130 if let Some(effort) = h.spec.effort {
134 s.push_str(&format!("EFFORT={}\n", effort.as_str()));
135 }
136 s.push_str(&format!("TEAMCTL_MAILBOX={}\n", mailbox.display()));
137 s.push_str(&format!("MCP_CONFIG={}\n", mcp.display()));
138 s.push_str(&format!("SYSTEM_PROMPT_PATH={prompt}\n"));
139 s.push_str(&format!(
140 "CLAUDE_PROJECT_DIR={}\n",
141 project.project.cwd.display()
142 ));
143 s.push_str(&format!("TEAMCTL_ROOT={}\n", compose.root.display()));
151 s.push_str(&format!(
152 "TMUX_SESSION={}{}-{}\n",
153 compose.global.supervisor.tmux_prefix, h.project, h.agent
154 ));
155 if h.spec.runtime == "claude-code" {
161 let session_id = crate::session::derive_session_id(h.project, h.agent);
162 let session_name = crate::session::session_name(h.project, h.agent);
163 s.push_str(&format!("CLAUDE_SESSION_ID={session_id}\n"));
164 s.push_str(&format!("CLAUDE_SESSION_NAME={session_name}\n"));
165 let settings = claude_settings_path(&compose.root, h.project, h.agent);
170 s.push_str(&format!("CLAUDE_SETTINGS={}\n", settings.display()));
171 }
172 s
173}
174
175pub fn system_prompt_path(compose: &Compose, h: AgentHandle<'_>) -> Option<PathBuf> {
185 match h.spec.role_prompt.as_ref()? {
186 RolePrompt::Single(p) => Some(compose.root.join(p)),
187 RolePrompt::Multiple(_) => Some(role_prompt_concat_path(&compose.root, h.project, h.agent)),
188 }
189}
190
191pub fn write_role_prompt_concat(compose: &Compose, h: AgentHandle<'_>) -> io::Result<()> {
202 let Some(RolePrompt::Multiple(paths)) = h.spec.role_prompt.as_ref() else {
203 return Ok(());
204 };
205
206 let mut buf = String::new();
207 for (idx, rel) in paths.iter().enumerate() {
208 if idx > 0 {
209 buf.push_str(ROLE_PROMPT_SEPARATOR);
210 }
211 let abs = compose.root.join(rel);
212 let bytes = std::fs::read(&abs).map_err(|e| {
213 io::Error::new(
214 e.kind(),
215 format!("read role_prompt source {}: {e}", abs.display()),
216 )
217 })?;
218 buf.push_str(&String::from_utf8_lossy(&bytes));
221 }
222
223 let dest = role_prompt_concat_path(&compose.root, h.project, h.agent);
224 if let Some(parent) = dest.parent() {
225 std::fs::create_dir_all(parent)?;
226 }
227 std::fs::write(&dest, buf)
228}
229
230fn render_mcp(compose: &Compose, h: AgentHandle<'_>, team_mcp_bin: &str) -> String {
231 let mailbox = compose.root.join(&compose.global.broker.path);
232 let v = serde_json::json!({
233 "mcpServers": {
234 "team": {
235 "command": team_mcp_bin,
236 "args": [
237 "--agent-id", format!("{}:{}", h.project, h.agent),
238 "--mailbox", mailbox.display().to_string(),
239 "--tmux-prefix", compose.global.supervisor.tmux_prefix.clone(),
247 "--compose-root", compose.root.display().to_string(),
253 ],
254 "env": {}
255 }
256 }
257 });
258 serde_json::to_string_pretty(&v).expect("json")
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264 use crate::compose::*;
265 use std::collections::BTreeMap;
266 use std::path::PathBuf;
267
268 fn fixture() -> Compose {
269 let mut managers = BTreeMap::new();
270 managers.insert(
271 "mgr".into(),
272 Agent {
273 runtime: "claude-code".into(),
274 model: Some("claude-opus-4-7".into()),
275 role_prompt: Some(RolePrompt::Single(PathBuf::from("roles/mgr.md"))),
276 permission_mode: Some("auto".into()),
277 autonomy: "low_risk_only".into(),
278 can_dm: vec![],
279 can_broadcast: vec![],
280 reports_to: None,
281 on_rate_limit: None,
282 effort: None,
283 interfaces: None,
284 display_name: None,
285 },
286 );
287 Compose {
288 root: PathBuf::from("/teamctl"),
289 global: Global {
290 version: 2,
291 broker: Broker {
292 r#type: "sqlite".into(),
293 path: PathBuf::from("state/mailbox.db"),
294 },
295 supervisor: SupervisorCfg {
296 r#type: "tmux".into(),
297 tmux_prefix: "a-".into(),
298 drain_timeout_secs: 10,
299 },
300 budget: Default::default(),
301 hitl: Default::default(),
302 rate_limits: Default::default(),
303 interfaces: vec![],
304 projects: vec![],
305 attachments: Default::default(),
306 },
307 projects: vec![Project {
308 version: 2,
309 project: ProjectMeta {
310 id: "hello".into(),
311 name: "Hello".into(),
312 cwd: PathBuf::from("/teamctl/examples/hello-team"),
313 },
314 channels: vec![],
315 managers,
316 workers: Default::default(),
317 }],
318 }
319 }
320
321 #[test]
322 fn env_contains_agent_id_and_mailbox() {
323 let c = fixture();
324 let h = c.agents().next().unwrap();
325 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
326 assert!(env.contains("AGENT_ID=hello:mgr"));
327 assert!(env.contains("TEAMCTL_MAILBOX=/teamctl/state/mailbox.db"));
328 assert!(env.contains("TMUX_SESSION=a-hello-mgr"));
329 }
330
331 #[test]
332 fn env_emits_claude_session_id_and_name_for_claude_code_runtime() {
333 let c = fixture();
337 let h = c.agents().next().unwrap();
338 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
339 let expected_id = crate::session::derive_session_id(h.project, h.agent);
340 assert!(
341 env.contains(&format!("CLAUDE_SESSION_ID={expected_id}\n")),
342 "env was: {env}"
343 );
344 assert!(
345 env.contains("CLAUDE_SESSION_NAME=teamctl:hello:mgr\n"),
346 "env was: {env}"
347 );
348 }
349
350 #[test]
351 fn env_omits_claude_session_vars_for_non_claude_runtimes() {
352 let mut c = fixture();
357 c.projects[0].managers.get_mut("mgr").unwrap().runtime = "codex".into();
358 let h = c.agents().next().unwrap();
359 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
360 assert!(
361 !env.contains("CLAUDE_SESSION_ID="),
362 "non-claude runtime must not get session id: {env}"
363 );
364 assert!(
365 !env.contains("CLAUDE_SESSION_NAME="),
366 "non-claude runtime must not get session name: {env}"
367 );
368 }
369
370 #[test]
371 fn env_pins_teamctl_root_to_compose_root() {
372 let c = fixture();
378 let h = c.agents().next().unwrap();
379 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
380 assert!(env.contains("TEAMCTL_ROOT=/teamctl\n"), "env was: {env}");
381 }
382
383 #[test]
384 fn env_omits_effort_when_unset() {
385 let c = fixture();
386 let h = c.agents().next().unwrap();
387 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
388 assert!(!env.contains("EFFORT="), "env was: {env}");
389 }
390
391 #[test]
392 fn env_emits_effort_when_set() {
393 let mut c = fixture();
394 c.projects[0].managers.get_mut("mgr").unwrap().effort = Some(EffortLevel::Max);
395 let h = c.agents().next().unwrap();
396 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
397 assert!(env.contains("EFFORT=max\n"), "env was: {env}");
398 }
399
400 #[test]
401 fn mcp_json_parses_back() {
402 let c = fixture();
403 let h = c.agents().next().unwrap();
404 let (_, mcp) = render_agent(&c, h, "/usr/local/bin/team-mcp");
405 let v: serde_json::Value = serde_json::from_str(&mcp).unwrap();
406 assert_eq!(
407 v["mcpServers"]["team"]["command"],
408 "/usr/local/bin/team-mcp"
409 );
410 assert_eq!(
411 v["mcpServers"]["team"]["args"][1].as_str().unwrap(),
412 "hello:mgr"
413 );
414 }
415
416 #[test]
417 fn mcp_json_threads_tmux_prefix_from_compose() {
418 let c = fixture();
424 let h = c.agents().next().unwrap();
425 let (_, mcp) = render_agent(&c, h, "/usr/local/bin/team-mcp");
426 let v: serde_json::Value = serde_json::from_str(&mcp).unwrap();
427 let args: Vec<&str> = v["mcpServers"]["team"]["args"]
428 .as_array()
429 .unwrap()
430 .iter()
431 .map(|a| a.as_str().unwrap())
432 .collect();
433 let i = args.iter().position(|a| *a == "--tmux-prefix").expect(
434 "render_mcp must emit --tmux-prefix so compact_self resolves the caller's pane",
435 );
436 assert_eq!(
437 args[i + 1],
438 "a-",
439 "prefix must come from compose, not the default"
440 );
441 }
442
443 #[test]
444 fn env_points_at_source_for_single_role_prompt() {
445 let c = fixture();
446 let h = c.agents().next().unwrap();
447 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
448 assert!(
449 env.contains("SYSTEM_PROMPT_PATH=/teamctl/roles/mgr.md\n"),
450 "env was: {env}"
451 );
452 }
453
454 #[test]
455 fn env_points_at_concat_path_for_multi_role_prompt() {
456 let mut c = fixture();
457 c.projects[0].managers.get_mut("mgr").unwrap().role_prompt =
458 Some(RolePrompt::Multiple(vec![
459 PathBuf::from("roles/_base.md"),
460 PathBuf::from("roles/mgr.md"),
461 ]));
462 let h = c.agents().next().unwrap();
463 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
464 assert!(
465 env.contains("SYSTEM_PROMPT_PATH=/teamctl/state/role_prompts/hello-mgr.md\n"),
466 "env was: {env}"
467 );
468 }
469
470 #[test]
471 fn write_role_prompt_concat_is_noop_for_single() {
472 let dir = tempfile::tempdir().unwrap();
473 let mut c = fixture();
474 c.root = dir.path().to_path_buf();
475 let h = c.agents().next().unwrap();
476 write_role_prompt_concat(&c, h).unwrap();
477 assert!(
478 !role_prompt_concat_path(&c.root, h.project, h.agent).exists(),
479 "single-form role_prompt should not produce a concat file"
480 );
481 }
482
483 #[test]
484 fn write_role_prompt_concat_joins_in_declared_order() {
485 let dir = tempfile::tempdir().unwrap();
486 let root = dir.path();
487 std::fs::create_dir_all(root.join("roles")).unwrap();
488 std::fs::write(root.join("roles/_base.md"), "BASE").unwrap();
489 std::fs::write(root.join("roles/mgr.md"), "MGR").unwrap();
490
491 let mut c = fixture();
492 c.root = root.to_path_buf();
493 c.projects[0].managers.get_mut("mgr").unwrap().role_prompt =
494 Some(RolePrompt::Multiple(vec![
495 PathBuf::from("roles/_base.md"),
496 PathBuf::from("roles/mgr.md"),
497 ]));
498 let h = c.agents().next().unwrap();
499 write_role_prompt_concat(&c, h).unwrap();
500
501 let dest = role_prompt_concat_path(root, h.project, h.agent);
502 let got = std::fs::read_to_string(&dest).unwrap();
503 assert_eq!(got, "BASE\n\n—\n\nMGR");
504 }
505
506 #[test]
507 fn write_role_prompt_concat_reflects_source_edits() {
508 let dir = tempfile::tempdir().unwrap();
511 let root = dir.path();
512 std::fs::create_dir_all(root.join("roles")).unwrap();
513 std::fs::write(root.join("roles/_base.md"), "v1").unwrap();
514 std::fs::write(root.join("roles/mgr.md"), "MGR").unwrap();
515
516 let mut c = fixture();
517 c.root = root.to_path_buf();
518 c.projects[0].managers.get_mut("mgr").unwrap().role_prompt =
519 Some(RolePrompt::Multiple(vec![
520 PathBuf::from("roles/_base.md"),
521 PathBuf::from("roles/mgr.md"),
522 ]));
523 let h = c.agents().next().unwrap();
524 write_role_prompt_concat(&c, h).unwrap();
525
526 std::fs::write(root.join("roles/_base.md"), "v2").unwrap();
527 let h = c.agents().next().unwrap();
528 write_role_prompt_concat(&c, h).unwrap();
529
530 let dest = role_prompt_concat_path(root, h.project, h.agent);
531 let got = std::fs::read_to_string(&dest).unwrap();
532 assert_eq!(got, "v2\n\n—\n\nMGR");
533 }
534
535 #[test]
536 fn claude_settings_present_for_claude_code() {
537 let c = fixture();
541 let h = c.agents().next().unwrap();
542 let s = render_claude_settings(&c, h).expect("claude-code agent must get settings");
543 let v: serde_json::Value = serde_json::from_str(&s).unwrap();
544 let pre = &v["hooks"]["PreToolUse"][0];
545 assert_eq!(
546 pre["matcher"].as_str().unwrap(),
547 "AskUserQuestion|EnterPlanMode|ExitPlanMode"
548 );
549 let cmd = pre["hooks"][0]["command"].as_str().unwrap();
550 assert!(
551 cmd.contains(r#""permissionDecision":"deny""#),
552 "deny verdict missing from hook command: {cmd}"
553 );
554 assert!(
555 cmd.contains("Interactive prompts are disabled"),
556 "systemMessage missing from hook command: {cmd}"
557 );
558 }
559
560 #[test]
561 fn claude_settings_absent_for_non_claude_runtimes() {
562 let mut c = fixture();
565 c.projects[0].managers.get_mut("mgr").unwrap().runtime = "codex".into();
566 let h = c.agents().next().unwrap();
567 assert!(render_claude_settings(&c, h).is_none());
568 }
569
570 #[test]
571 fn env_emits_claude_settings_path_for_claude_code() {
572 let c = fixture();
575 let h = c.agents().next().unwrap();
576 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
577 assert!(
578 env.contains("CLAUDE_SETTINGS=/teamctl/state/claude/hello-mgr.json\n"),
579 "env was: {env}"
580 );
581 }
582
583 #[test]
584 fn env_omits_claude_settings_for_non_claude_runtimes() {
585 let mut c = fixture();
589 c.projects[0].managers.get_mut("mgr").unwrap().runtime = "codex".into();
590 let h = c.agents().next().unwrap();
591 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
592 assert!(
593 !env.contains("CLAUDE_SETTINGS="),
594 "non-claude runtime must not get settings path: {env}"
595 );
596 }
597
598 #[test]
599 fn write_role_prompt_concat_errors_on_missing_source() {
600 let dir = tempfile::tempdir().unwrap();
601 let mut c = fixture();
602 c.root = dir.path().to_path_buf();
603 c.projects[0].managers.get_mut("mgr").unwrap().role_prompt = Some(RolePrompt::Multiple(
604 vec![PathBuf::from("roles/missing.md")],
605 ));
606 let h = c.agents().next().unwrap();
607 let err = write_role_prompt_concat(&c, h).unwrap_err();
608 assert!(err.to_string().contains("missing.md"), "err was: {err}");
609 }
610}