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-8".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: crate::compose::SchemaVersion::new("2.0.0"),
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 interfaces: None,
318 }],
319 }
320 }
321
322 #[test]
323 fn env_contains_agent_id_and_mailbox() {
324 let c = fixture();
325 let h = c.agents().next().unwrap();
326 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
327 assert!(env.contains("AGENT_ID=hello:mgr"));
328 assert!(env.contains("TEAMCTL_MAILBOX=/teamctl/state/mailbox.db"));
329 assert!(env.contains("TMUX_SESSION=a-hello-mgr"));
330 }
331
332 #[test]
333 fn env_emits_claude_session_id_and_name_for_claude_code_runtime() {
334 let c = fixture();
338 let h = c.agents().next().unwrap();
339 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
340 let expected_id = crate::session::derive_session_id(h.project, h.agent);
341 assert!(
342 env.contains(&format!("CLAUDE_SESSION_ID={expected_id}\n")),
343 "env was: {env}"
344 );
345 assert!(
346 env.contains("CLAUDE_SESSION_NAME=teamctl:hello:mgr\n"),
347 "env was: {env}"
348 );
349 }
350
351 #[test]
352 fn env_omits_claude_session_vars_for_non_claude_runtimes() {
353 let mut c = fixture();
358 c.projects[0].managers.get_mut("mgr").unwrap().runtime = "codex".into();
359 let h = c.agents().next().unwrap();
360 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
361 assert!(
362 !env.contains("CLAUDE_SESSION_ID="),
363 "non-claude runtime must not get session id: {env}"
364 );
365 assert!(
366 !env.contains("CLAUDE_SESSION_NAME="),
367 "non-claude runtime must not get session name: {env}"
368 );
369 }
370
371 #[test]
372 fn env_pins_teamctl_root_to_compose_root() {
373 let c = fixture();
379 let h = c.agents().next().unwrap();
380 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
381 assert!(env.contains("TEAMCTL_ROOT=/teamctl\n"), "env was: {env}");
382 }
383
384 #[test]
385 fn env_omits_effort_when_unset() {
386 let c = fixture();
387 let h = c.agents().next().unwrap();
388 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
389 assert!(!env.contains("EFFORT="), "env was: {env}");
390 }
391
392 #[test]
393 fn env_emits_effort_when_set() {
394 let mut c = fixture();
395 c.projects[0].managers.get_mut("mgr").unwrap().effort = Some(EffortLevel::Max);
396 let h = c.agents().next().unwrap();
397 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
398 assert!(env.contains("EFFORT=max\n"), "env was: {env}");
399 }
400
401 #[test]
402 fn mcp_json_parses_back() {
403 let c = fixture();
404 let h = c.agents().next().unwrap();
405 let (_, mcp) = render_agent(&c, h, "/usr/local/bin/team-mcp");
406 let v: serde_json::Value = serde_json::from_str(&mcp).unwrap();
407 assert_eq!(
408 v["mcpServers"]["team"]["command"],
409 "/usr/local/bin/team-mcp"
410 );
411 assert_eq!(
412 v["mcpServers"]["team"]["args"][1].as_str().unwrap(),
413 "hello:mgr"
414 );
415 }
416
417 #[test]
418 fn mcp_json_threads_tmux_prefix_from_compose() {
419 let c = fixture();
425 let h = c.agents().next().unwrap();
426 let (_, mcp) = render_agent(&c, h, "/usr/local/bin/team-mcp");
427 let v: serde_json::Value = serde_json::from_str(&mcp).unwrap();
428 let args: Vec<&str> = v["mcpServers"]["team"]["args"]
429 .as_array()
430 .unwrap()
431 .iter()
432 .map(|a| a.as_str().unwrap())
433 .collect();
434 let i = args.iter().position(|a| *a == "--tmux-prefix").expect(
435 "render_mcp must emit --tmux-prefix so compact_self resolves the caller's pane",
436 );
437 assert_eq!(
438 args[i + 1],
439 "a-",
440 "prefix must come from compose, not the default"
441 );
442 }
443
444 #[test]
445 fn env_points_at_source_for_single_role_prompt() {
446 let c = fixture();
447 let h = c.agents().next().unwrap();
448 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
449 assert!(
450 env.contains("SYSTEM_PROMPT_PATH=/teamctl/roles/mgr.md\n"),
451 "env was: {env}"
452 );
453 }
454
455 #[test]
456 fn env_points_at_concat_path_for_multi_role_prompt() {
457 let mut c = fixture();
458 c.projects[0].managers.get_mut("mgr").unwrap().role_prompt =
459 Some(RolePrompt::Multiple(vec![
460 PathBuf::from("roles/_base.md"),
461 PathBuf::from("roles/mgr.md"),
462 ]));
463 let h = c.agents().next().unwrap();
464 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
465 assert!(
466 env.contains("SYSTEM_PROMPT_PATH=/teamctl/state/role_prompts/hello-mgr.md\n"),
467 "env was: {env}"
468 );
469 }
470
471 #[test]
472 fn write_role_prompt_concat_is_noop_for_single() {
473 let dir = tempfile::tempdir().unwrap();
474 let mut c = fixture();
475 c.root = dir.path().to_path_buf();
476 let h = c.agents().next().unwrap();
477 write_role_prompt_concat(&c, h).unwrap();
478 assert!(
479 !role_prompt_concat_path(&c.root, h.project, h.agent).exists(),
480 "single-form role_prompt should not produce a concat file"
481 );
482 }
483
484 #[test]
485 fn write_role_prompt_concat_joins_in_declared_order() {
486 let dir = tempfile::tempdir().unwrap();
487 let root = dir.path();
488 std::fs::create_dir_all(root.join("roles")).unwrap();
489 std::fs::write(root.join("roles/_base.md"), "BASE").unwrap();
490 std::fs::write(root.join("roles/mgr.md"), "MGR").unwrap();
491
492 let mut c = fixture();
493 c.root = root.to_path_buf();
494 c.projects[0].managers.get_mut("mgr").unwrap().role_prompt =
495 Some(RolePrompt::Multiple(vec![
496 PathBuf::from("roles/_base.md"),
497 PathBuf::from("roles/mgr.md"),
498 ]));
499 let h = c.agents().next().unwrap();
500 write_role_prompt_concat(&c, h).unwrap();
501
502 let dest = role_prompt_concat_path(root, h.project, h.agent);
503 let got = std::fs::read_to_string(&dest).unwrap();
504 assert_eq!(got, "BASE\n\n—\n\nMGR");
505 }
506
507 #[test]
508 fn write_role_prompt_concat_reflects_source_edits() {
509 let dir = tempfile::tempdir().unwrap();
512 let root = dir.path();
513 std::fs::create_dir_all(root.join("roles")).unwrap();
514 std::fs::write(root.join("roles/_base.md"), "v1").unwrap();
515 std::fs::write(root.join("roles/mgr.md"), "MGR").unwrap();
516
517 let mut c = fixture();
518 c.root = root.to_path_buf();
519 c.projects[0].managers.get_mut("mgr").unwrap().role_prompt =
520 Some(RolePrompt::Multiple(vec![
521 PathBuf::from("roles/_base.md"),
522 PathBuf::from("roles/mgr.md"),
523 ]));
524 let h = c.agents().next().unwrap();
525 write_role_prompt_concat(&c, h).unwrap();
526
527 std::fs::write(root.join("roles/_base.md"), "v2").unwrap();
528 let h = c.agents().next().unwrap();
529 write_role_prompt_concat(&c, h).unwrap();
530
531 let dest = role_prompt_concat_path(root, h.project, h.agent);
532 let got = std::fs::read_to_string(&dest).unwrap();
533 assert_eq!(got, "v2\n\n—\n\nMGR");
534 }
535
536 #[test]
537 fn claude_settings_present_for_claude_code() {
538 let c = fixture();
542 let h = c.agents().next().unwrap();
543 let s = render_claude_settings(&c, h).expect("claude-code agent must get settings");
544 let v: serde_json::Value = serde_json::from_str(&s).unwrap();
545 let pre = &v["hooks"]["PreToolUse"][0];
546 assert_eq!(
547 pre["matcher"].as_str().unwrap(),
548 "AskUserQuestion|EnterPlanMode|ExitPlanMode"
549 );
550 let cmd = pre["hooks"][0]["command"].as_str().unwrap();
551 assert!(
552 cmd.contains(r#""permissionDecision":"deny""#),
553 "deny verdict missing from hook command: {cmd}"
554 );
555 assert!(
556 cmd.contains("Interactive prompts are disabled"),
557 "systemMessage missing from hook command: {cmd}"
558 );
559 }
560
561 #[test]
562 fn claude_settings_absent_for_non_claude_runtimes() {
563 let mut c = fixture();
566 c.projects[0].managers.get_mut("mgr").unwrap().runtime = "codex".into();
567 let h = c.agents().next().unwrap();
568 assert!(render_claude_settings(&c, h).is_none());
569 }
570
571 #[test]
572 fn env_emits_claude_settings_path_for_claude_code() {
573 let c = fixture();
576 let h = c.agents().next().unwrap();
577 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
578 assert!(
579 env.contains("CLAUDE_SETTINGS=/teamctl/state/claude/hello-mgr.json\n"),
580 "env was: {env}"
581 );
582 }
583
584 #[test]
585 fn env_omits_claude_settings_for_non_claude_runtimes() {
586 let mut c = fixture();
590 c.projects[0].managers.get_mut("mgr").unwrap().runtime = "codex".into();
591 let h = c.agents().next().unwrap();
592 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
593 assert!(
594 !env.contains("CLAUDE_SETTINGS="),
595 "non-claude runtime must not get settings path: {env}"
596 );
597 }
598
599 #[test]
600 fn write_role_prompt_concat_errors_on_missing_source() {
601 let dir = tempfile::tempdir().unwrap();
602 let mut c = fixture();
603 c.root = dir.path().to_path_buf();
604 c.projects[0].managers.get_mut("mgr").unwrap().role_prompt = Some(RolePrompt::Multiple(
605 vec![PathBuf::from("roles/missing.md")],
606 ));
607 let h = c.agents().next().unwrap();
608 let err = write_role_prompt_concat(&c, h).unwrap_err();
609 assert!(err.to_string().contains("missing.md"), "err was: {err}");
610 }
611}