1use std::io;
28use std::path::{Path, PathBuf};
29
30use crate::compose::{AgentHandle, Compose, RolePrompt};
31
32const ROLE_PROMPT_SEPARATOR: &str = "\n\n—\n\n";
36
37pub fn env_path(root: &Path, project: &str, agent: &str) -> PathBuf {
39 root.join("state/envs")
40 .join(format!("{project}-{agent}.env"))
41}
42
43pub fn mcp_path(root: &Path, project: &str, agent: &str) -> PathBuf {
45 root.join("state/mcp")
46 .join(format!("{project}-{agent}.json"))
47}
48
49pub fn claude_settings_path(root: &Path, project: &str, agent: &str) -> PathBuf {
56 root.join("state/claude")
57 .join(format!("{project}-{agent}.json"))
58}
59
60pub fn subagents_json_path(root: &Path, project: &str, agent: &str) -> PathBuf {
65 root.join("state/claude")
66 .join(format!("{project}-{agent}.agents.json"))
67}
68
69pub fn agent_scope_dir(root: &Path, project: &str, agent: &str) -> PathBuf {
77 root.join("state/agent-scope")
78 .join(format!("{project}-{agent}"))
79}
80
81pub fn role_prompt_concat_path(root: &Path, project: &str, agent: &str) -> PathBuf {
85 root.join("state/role_prompts")
86 .join(format!("{project}-{agent}.md"))
87}
88
89pub fn heartbeat_path(root: &Path, project: &str, agent: &str) -> PathBuf {
97 root.join("state/heartbeats")
98 .join(format!("{project}-{agent}"))
99}
100
101pub fn lastseen_path(root: &Path, project: &str, agent: &str) -> PathBuf {
111 root.join("state/heartbeats")
112 .join(format!("{project}-{agent}.lastseen"))
113}
114
115pub fn boot_script_path(root: &Path) -> PathBuf {
122 root.join("bin/boot.sh")
123}
124
125pub fn codex_home_dir(root: &Path, project: &str, agent: &str) -> PathBuf {
133 root.join("state/codex-home")
134 .join(format!("{project}-{agent}"))
135}
136
137pub fn opencode_home_dir(root: &Path, project: &str, agent: &str) -> PathBuf {
147 root.join("state/opencode-home")
148 .join(format!("{project}-{agent}"))
149}
150
151pub fn render_agent(
153 compose: &Compose,
154 handle: AgentHandle<'_>,
155 team_mcp_bin: &str,
156) -> (String, String) {
157 let env = render_env(compose, handle);
158 let mcp = render_mcp(compose, handle, team_mcp_bin);
159 (env, mcp)
160}
161
162pub fn render_claude_settings(compose: &Compose, h: AgentHandle<'_>) -> Option<String> {
182 if h.spec.runtime != "claude-code" {
183 if !h.spec.hooks.is_empty() {
187 tracing::warn!(
188 target: "team-core::render",
189 "agent `{}:{}` declares {} hook(s) but runtime `{}` does not support hooks (claude-code only); ignoring",
190 h.project,
191 h.agent,
192 h.spec.hooks.len(),
193 h.spec.runtime
194 );
195 }
196 if h.spec.ultracode {
200 tracing::warn!(
201 target: "team-core::render",
202 "agent `{}:{}` sets ultracode but runtime `{}` does not support it (claude-code only); ignoring",
203 h.project,
204 h.agent,
205 h.spec.runtime
206 );
207 }
208 return None;
209 }
210 let mut v = serde_json::json!({
215 "enableAllProjectMcpServers": true,
231 "hooks": {
232 "PreToolUse": [
233 {
234 "matcher": "AskUserQuestion|EnterPlanMode|ExitPlanMode",
235 "hooks": [
236 {
237 "type": "command",
238 "command": "echo '{\"hookSpecificOutput\":{\"permissionDecision\":\"deny\"},\"systemMessage\":\"Interactive prompts are disabled for teamctl agents. Use the `team` MCP tools to ask people or check in.\"}'"
239 }
240 ]
241 }
242 ]
243 }
244 });
245
246 let hooks_obj = v["hooks"].as_object_mut().expect("hooks is a json object");
252
253 {
270 let path = heartbeat_path(&compose.root, h.project, h.agent)
271 .display()
272 .to_string();
273 let marker =
277 crate::supervisor::shlex::try_quote(&path).expect("heartbeat marker path is NUL-free");
278 let lastseen_p = lastseen_path(&compose.root, h.project, h.agent)
287 .display()
288 .to_string();
289 let lastseen = crate::supervisor::shlex::try_quote(&lastseen_p)
290 .expect("lastseen marker path is NUL-free");
291 let touch = format!("touch {marker}");
292 let clear = format!("touch {lastseen} && rm -f {marker}");
293 for (event, command) in [
294 ("PreToolUse", &touch),
295 ("UserPromptSubmit", &touch),
296 ("Stop", &clear),
297 ("StopFailure", &clear),
298 ] {
299 hooks_obj
300 .entry(event.to_string())
301 .or_insert_with(|| serde_json::Value::Array(Vec::new()))
302 .as_array_mut()
303 .expect("hook event maps to a json array")
304 .push(serde_json::json!({
305 "hooks": [ { "type": "command", "command": command } ]
306 }));
307 }
308 }
309
310 {
326 let path = boot_script_path(&compose.root).display().to_string();
327 let boot =
328 crate::supervisor::shlex::try_quote(&path).expect("boot script path is NUL-free");
329 let lastseen_p = lastseen_path(&compose.root, h.project, h.agent)
337 .display()
338 .to_string();
339 let lastseen = crate::supervisor::shlex::try_quote(&lastseen_p)
340 .expect("lastseen marker path is NUL-free");
341 let marker_p = heartbeat_path(&compose.root, h.project, h.agent)
342 .display()
343 .to_string();
344 let marker = crate::supervisor::shlex::try_quote(&marker_p)
345 .expect("heartbeat marker path is NUL-free");
346 let command = format!("{boot} {lastseen} {marker}");
347 hooks_obj
348 .entry("SessionStart".to_string())
349 .or_insert_with(|| serde_json::Value::Array(Vec::new()))
350 .as_array_mut()
351 .expect("hook event maps to a json array")
352 .push(serde_json::json!({
353 "hooks": [ { "type": "command", "command": command, "timeout": 5 } ]
354 }));
355 }
356
357 {
376 let root = crate::supervisor::shlex::try_quote(&compose.root.display().to_string())
377 .expect("compose root is NUL-free");
378 let agent_id = format!("{}:{}", h.project, h.agent);
379 let agent_id =
380 crate::supervisor::shlex::try_quote(&agent_id).expect("agent id is NUL-free");
381 let command = format!(
382 "command -v teamctl >/dev/null 2>&1 && teamctl --root {root} rl-hit {agent_id} || true"
383 );
384 hooks_obj
385 .entry("StopFailure".to_string())
386 .or_insert_with(|| serde_json::Value::Array(Vec::new()))
387 .as_array_mut()
388 .expect("hook event maps to a json array")
389 .push(serde_json::json!({
390 "matcher": "rate_limit",
391 "hooks": [ { "type": "command", "command": command } ]
392 }));
393 }
394
395 {
413 let root = crate::supervisor::shlex::try_quote(&compose.root.display().to_string())
414 .expect("compose root is NUL-free");
415 let agent_id = format!("{}:{}", h.project, h.agent);
416 let agent_id =
417 crate::supervisor::shlex::try_quote(&agent_id).expect("agent id is NUL-free");
418 let command = format!(
419 "command -v teamctl >/dev/null 2>&1 && teamctl --root {root} budget-record {agent_id} || true"
420 );
421 hooks_obj
422 .entry("Stop".to_string())
423 .or_insert_with(|| serde_json::Value::Array(Vec::new()))
424 .as_array_mut()
425 .expect("hook event maps to a json array")
426 .push(serde_json::json!({
427 "hooks": [ { "type": "command", "command": command } ]
428 }));
429 }
430
431 for hook in &h.spec.hooks {
432 let command = compose.root.join(&hook.command);
433 let mut entry = serde_json::json!({
434 "hooks": [
435 {
436 "type": "command",
437 "command": command.display().to_string()
438 }
439 ]
440 });
441 if let Some(matcher) = &hook.matcher {
442 entry["matcher"] = serde_json::Value::String(matcher.clone());
443 }
444 hooks_obj
445 .entry(hook.event.clone())
446 .or_insert_with(|| serde_json::Value::Array(Vec::new()))
447 .as_array_mut()
448 .expect("hook event maps to a json array")
449 .push(entry);
450 }
451
452 if h.spec.ultracode {
460 v["ultracode"] = serde_json::Value::Bool(true);
461 }
462
463 Some(serde_json::to_string_pretty(&v).expect("json"))
464}
465
466pub fn render_subagents(compose: &Compose, h: AgentHandle<'_>) -> io::Result<Option<String>> {
480 if h.spec.subagents.is_empty() {
481 return Ok(None);
482 }
483 if h.spec.runtime != "claude-code" {
484 tracing::warn!(
485 target: "team-core::render",
486 "agent `{}:{}` declares {} sub-agent(s) but runtime `{}` does not support sub-agents (claude-code only); ignoring",
487 h.project,
488 h.agent,
489 h.spec.subagents.len(),
490 h.spec.runtime
491 );
492 return Ok(None);
493 }
494
495 let mut map = serde_json::Map::new();
496 for rel in &h.spec.subagents {
497 let abs = compose.root.join(rel);
498 let raw = std::fs::read_to_string(&abs).map_err(|e| {
499 io::Error::new(
500 e.kind(),
501 format!("read sub-agent source {}: {e}", abs.display()),
502 )
503 })?;
504 let (fm, body) = parse_subagent(&raw).map_err(|e| {
505 io::Error::new(
506 io::ErrorKind::InvalidData,
507 format!("parse sub-agent {}: {e}", abs.display()),
508 )
509 })?;
510 let name = fm.name.filter(|n| !n.trim().is_empty()).unwrap_or_else(|| {
513 rel.file_stem()
514 .map(|s| s.to_string_lossy().into_owned())
515 .unwrap_or_default()
516 });
517 let mut entry = serde_json::json!({
518 "description": fm.description,
519 "prompt": body,
520 });
521 if let Some(tools) = fm.tools {
522 let list = tools.into_list();
523 if !list.is_empty() {
524 entry["tools"] = serde_json::json!(list);
525 }
526 }
527 if let Some(model) = fm.model.filter(|m| !m.trim().is_empty()) {
528 entry["model"] = serde_json::Value::String(model);
529 }
530 map.insert(name, entry);
531 }
532 Ok(Some(
533 serde_json::to_string_pretty(&serde_json::Value::Object(map)).expect("json"),
534 ))
535}
536
537pub fn write_subagents_json(compose: &Compose, h: AgentHandle<'_>) -> io::Result<()> {
544 let dest = subagents_json_path(&compose.root, h.project, h.agent);
545 match render_subagents(compose, h)? {
546 Some(json) => {
547 if let Some(parent) = dest.parent() {
548 std::fs::create_dir_all(parent)?;
549 }
550 std::fs::write(&dest, json)
551 }
552 None => match std::fs::remove_file(&dest) {
553 Ok(()) => Ok(()),
554 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
555 Err(e) => Err(e),
556 },
557 }
558}
559
560pub fn write_agent_skills(compose: &Compose, h: AgentHandle<'_>) -> io::Result<()> {
579 let scope = agent_scope_dir(&compose.root, h.project, h.agent);
580 let skills_dir = scope.join(".claude/skills");
581
582 if h.spec.runtime != "claude-code" || h.spec.skills.is_empty() {
583 if h.spec.runtime != "claude-code" && !h.spec.skills.is_empty() {
584 tracing::warn!(
588 target: "team-core::render",
589 "agent `{}:{}` declares {} skill(s) but runtime `{}` does not support skills (claude-code only); ignoring",
590 h.project,
591 h.agent,
592 h.spec.skills.len(),
593 h.spec.runtime
594 );
595 }
596 return remove_scope_dir(&scope);
599 }
600
601 clear_skills_dir(&skills_dir)?;
605 std::fs::create_dir_all(&skills_dir)?;
606 for rel in &h.spec.skills {
607 let Some(name) = rel.file_name() else {
610 continue; };
612 let link = skills_dir.join(name);
613 if std::fs::symlink_metadata(&link).is_ok() {
616 std::fs::remove_file(&link)?;
617 }
618 std::os::unix::fs::symlink(compose.root.join(rel), &link)?;
619 }
620 Ok(())
621}
622
623fn remove_scope_dir(scope: &Path) -> io::Result<()> {
628 clear_skills_dir(&scope.join(".claude/skills"))?;
629 match std::fs::remove_dir_all(scope) {
630 Ok(()) => Ok(()),
631 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
632 Err(e) => Err(e),
633 }
634}
635
636fn clear_skills_dir(skills_dir: &Path) -> io::Result<()> {
640 let entries = match std::fs::read_dir(skills_dir) {
641 Ok(e) => e,
642 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
643 Err(e) => return Err(e),
644 };
645 for entry in entries {
646 let entry = entry?;
647 let path = entry.path();
648 let meta = std::fs::symlink_metadata(&path)?;
649 if meta.file_type().is_symlink() || meta.is_file() {
650 std::fs::remove_file(&path)?;
651 } else {
652 std::fs::remove_dir_all(&path)?;
655 }
656 }
657 Ok(())
658}
659
660#[derive(serde::Deserialize)]
663struct SubagentFrontmatter {
664 #[serde(default)]
665 name: Option<String>,
666 description: String,
667 #[serde(default)]
668 tools: Option<Tools>,
669 #[serde(default)]
670 model: Option<String>,
671}
672
673#[derive(serde::Deserialize)]
677#[serde(untagged)]
678enum Tools {
679 List(Vec<String>),
680 Csv(String),
681}
682
683impl Tools {
684 fn into_list(self) -> Vec<String> {
685 let raw = match self {
686 Tools::List(v) => v,
687 Tools::Csv(s) => s.split(',').map(str::to_string).collect(),
688 };
689 raw.into_iter()
690 .map(|t| t.trim().to_string())
691 .filter(|t| !t.is_empty())
692 .collect()
693 }
694}
695
696fn parse_subagent(raw: &str) -> Result<(SubagentFrontmatter, String), String> {
700 let after_open = raw
701 .strip_prefix("---")
702 .ok_or("missing opening `---` frontmatter delimiter")?;
703 let (yaml, body) = after_open
704 .split_once("\n---")
705 .ok_or("missing closing `---` frontmatter delimiter")?;
706 let fm: SubagentFrontmatter =
707 serde_yaml::from_str(yaml.trim()).map_err(|e| format!("invalid frontmatter YAML: {e}"))?;
708 let body = body.trim_start_matches(['\r', '\n']).trim_end().to_string();
709 Ok((fm, body))
710}
711
712fn render_env(compose: &Compose, h: AgentHandle<'_>) -> String {
713 let project = compose
714 .projects
715 .iter()
716 .find(|p| p.project.id == h.project)
717 .expect("agent belongs to a loaded project");
718 let mailbox = compose.root.join(&compose.global.broker.path);
719 let mcp = mcp_path(&compose.root, h.project, h.agent);
720 let prompt = system_prompt_path(compose, h)
721 .map(|p| p.display().to_string())
722 .unwrap_or_default();
723
724 let mut s = String::new();
725 s.push_str(&format!("AGENT_ID={}:{}\n", h.project, h.agent));
726 s.push_str(&format!("PROJECT_ID={}\n", h.project));
727 s.push_str(&format!("RUNTIME={}\n", h.spec.runtime));
728 if let Some(m) = &h.spec.model {
729 s.push_str(&format!("MODEL={m}\n"));
730 }
731 if let Some(pm) = &h.spec.permission_mode {
732 s.push_str(&format!("PERMISSION_MODE={pm}\n"));
733 }
734 if let Some(effort) = h.spec.effort {
738 s.push_str(&format!("EFFORT={}\n", effort.as_str()));
739 }
740 s.push_str(&format!("TEAMCTL_MAILBOX={}\n", mailbox.display()));
741 s.push_str(&format!("MCP_CONFIG={}\n", mcp.display()));
742 s.push_str(&format!("SYSTEM_PROMPT_PATH={prompt}\n"));
743 s.push_str(&format!(
744 "CLAUDE_PROJECT_DIR={}\n",
745 project.project.cwd.display()
746 ));
747 s.push_str(&format!("TEAMCTL_ROOT={}\n", compose.root.display()));
755 s.push_str(&format!(
756 "TMUX_SESSION={}{}-{}\n",
757 compose.global.supervisor.tmux_prefix, h.project, h.agent
758 ));
759 if h.spec.runtime == "claude-code" {
765 let session_id = crate::session::derive_session_id(h.project, h.agent);
766 let session_name = crate::session::session_name(h.project, h.agent);
767 s.push_str(&format!("CLAUDE_SESSION_ID={session_id}\n"));
768 s.push_str(&format!("CLAUDE_SESSION_NAME={session_name}\n"));
769 let settings = claude_settings_path(&compose.root, h.project, h.agent);
774 s.push_str(&format!("CLAUDE_SETTINGS={}\n", settings.display()));
775 let subagents = subagents_json_path(&compose.root, h.project, h.agent);
780 s.push_str(&format!("CLAUDE_AGENTS_JSON={}\n", subagents.display()));
781 let scope = agent_scope_dir(&compose.root, h.project, h.agent);
786 s.push_str(&format!("CLAUDE_AGENT_SCOPE={}\n", scope.display()));
787 }
788 if h.spec.runtime == "codex" {
794 let home = codex_home_dir(&compose.root, h.project, h.agent);
795 s.push_str(&format!("CODEX_HOME={}\n", home.display()));
796 }
797 if h.spec.runtime == "opencode" {
805 let home = opencode_home_dir(&compose.root, h.project, h.agent);
806 s.push_str(&format!(
807 "OPENCODE_DB={}\n",
808 home.join("agent.db").display()
809 ));
810 s.push_str(&format!(
811 "OPENCODE_CONFIG={}\n",
812 home.join("opencode.json").display()
813 ));
814 }
815 s
816}
817
818pub fn system_prompt_path(compose: &Compose, h: AgentHandle<'_>) -> Option<PathBuf> {
828 match h.spec.role_prompt.as_ref()? {
829 RolePrompt::Single(p) => Some(compose.root.join(p)),
830 RolePrompt::Multiple(_) => Some(role_prompt_concat_path(&compose.root, h.project, h.agent)),
831 }
832}
833
834pub fn write_role_prompt_concat(compose: &Compose, h: AgentHandle<'_>) -> io::Result<()> {
845 let Some(RolePrompt::Multiple(paths)) = h.spec.role_prompt.as_ref() else {
846 return Ok(());
847 };
848
849 let mut buf = String::new();
850 for (idx, rel) in paths.iter().enumerate() {
851 if idx > 0 {
852 buf.push_str(ROLE_PROMPT_SEPARATOR);
853 }
854 let abs = compose.root.join(rel);
855 let bytes = std::fs::read(&abs).map_err(|e| {
856 io::Error::new(
857 e.kind(),
858 format!("read role_prompt source {}: {e}", abs.display()),
859 )
860 })?;
861 buf.push_str(&String::from_utf8_lossy(&bytes));
864 }
865
866 let dest = role_prompt_concat_path(&compose.root, h.project, h.agent);
867 if let Some(parent) = dest.parent() {
868 std::fs::create_dir_all(parent)?;
869 }
870 std::fs::write(&dest, buf)
871}
872
873fn team_server_args(compose: &Compose, h: AgentHandle<'_>) -> Vec<String> {
878 let mailbox = compose.root.join(&compose.global.broker.path);
879 vec![
880 "--agent-id".into(),
881 format!("{}:{}", h.project, h.agent),
882 "--mailbox".into(),
883 mailbox.display().to_string(),
884 "--tmux-prefix".into(),
892 compose.global.supervisor.tmux_prefix.clone(),
893 "--compose-root".into(),
899 compose.root.display().to_string(),
900 ]
901}
902
903fn runtime_supports_mcp(compose: &Compose, h: AgentHandle<'_>) -> bool {
907 let runtimes = crate::runtimes::load_all(&compose.root).unwrap_or_default();
908 runtimes
909 .get(h.spec.runtime.as_str())
910 .map(|r| r.supports_mcp)
911 .unwrap_or(true)
912}
913
914fn render_mcp(compose: &Compose, h: AgentHandle<'_>, team_mcp_bin: &str) -> String {
915 let mut v = serde_json::json!({
916 "mcpServers": {
917 "team": {
918 "command": team_mcp_bin,
919 "args": team_server_args(compose, h),
920 "env": {}
921 }
922 }
923 });
924
925 if !h.spec.mcps.is_empty() {
937 if runtime_supports_mcp(compose, h) {
938 let servers = v["mcpServers"]
939 .as_object_mut()
940 .expect("mcpServers is a json object");
941 for (name, server) in &h.spec.mcps {
942 if name == "team" {
943 continue; }
945 servers.insert(
946 name.clone(),
947 serde_json::to_value(server).expect("serialize McpServer"),
948 );
949 }
950 } else {
951 tracing::warn!(
952 target: "team-core::render",
953 "agent `{}:{}` declares {} MCP server(s) but runtime `{}` does not set `supports_mcp`; ignoring",
954 h.project,
955 h.agent,
956 h.spec.mcps.len(),
957 h.spec.runtime
958 );
959 }
960 }
961
962 serde_json::to_string_pretty(&v).expect("json")
963}
964
965const CODEX_PREAPPROVED_TEAM_TOOLS: &[&str] = &[
974 "broadcast",
975 "compact_self",
976 "dm",
977 "inbox_ack",
978 "inbox_peek",
979 "inbox_read",
980 "inbox_watch",
981 "list_team",
982 "org_chart",
983 "react_to_user",
984 "read_attachment",
985 "reply_to_user",
986 "request_approval",
987 "show_typing",
988 "whoami",
989];
990
991pub fn render_codex_config(
1013 compose: &Compose,
1014 h: AgentHandle<'_>,
1015 team_mcp_bin: &str,
1016) -> Option<String> {
1017 if h.spec.runtime != "codex" {
1018 return None;
1019 }
1020 let mut s = String::from(
1021 "# teamctl-managed: rewritten on every `teamctl up` / `reload`.\n\
1022 # Customize MCP servers through the compose file's `mcps:` field.\n",
1023 );
1024 push_mcp_server_table(
1025 &mut s,
1026 "team",
1027 team_mcp_bin,
1028 &team_server_args(compose, h),
1029 &Default::default(),
1030 );
1031 for tool in CODEX_PREAPPROVED_TEAM_TOOLS {
1032 s.push_str(&format!(
1033 "\n[mcp_servers.team.tools.{tool}]\napproval_mode = \"approve\"\n"
1034 ));
1035 }
1036 if !h.spec.mcps.is_empty() && runtime_supports_mcp(compose, h) {
1037 for (name, server) in &h.spec.mcps {
1038 if name == "team" {
1039 continue; }
1041 push_mcp_server_table(&mut s, name, &server.command, &server.args, &server.env);
1042 }
1043 }
1044 let project = compose
1050 .projects
1051 .iter()
1052 .find(|p| p.project.id == h.project)
1053 .expect("agent belongs to a loaded project");
1054 let cwd = compose.root.join(&project.project.cwd);
1055 let cwd = std::fs::canonicalize(&cwd).unwrap_or(cwd);
1056 s.push_str(&format!(
1057 "\n[projects.{}]\ntrust_level = \"trusted\"\n",
1058 toml_str(&cwd.display().to_string())
1059 ));
1060 s.push_str("\n[sandbox_workspace_write]\nnetwork_access = true\n");
1071 Some(s)
1072}
1073
1074pub fn write_codex_config(
1080 compose: &Compose,
1081 h: AgentHandle<'_>,
1082 team_mcp_bin: &str,
1083) -> io::Result<()> {
1084 let dest = codex_home_dir(&compose.root, h.project, h.agent).join("config.toml");
1085 match render_codex_config(compose, h, team_mcp_bin) {
1086 Some(toml) => {
1087 if let Some(parent) = dest.parent() {
1088 std::fs::create_dir_all(parent)?;
1089 }
1090 std::fs::write(&dest, toml)
1091 }
1092 None => match std::fs::remove_file(&dest) {
1093 Ok(()) => Ok(()),
1094 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
1095 Err(e) => Err(e),
1096 },
1097 }
1098}
1099
1100pub fn render_opencode_config(
1122 compose: &Compose,
1123 h: AgentHandle<'_>,
1124 team_mcp_bin: &str,
1125) -> Option<String> {
1126 if h.spec.runtime != "opencode" {
1127 return None;
1128 }
1129 let mut mcp = serde_json::Map::new();
1130 mcp.insert(
1131 "team".into(),
1132 opencode_mcp_server(
1133 team_mcp_bin,
1134 &team_server_args(compose, h),
1135 &Default::default(),
1136 ),
1137 );
1138 if !h.spec.mcps.is_empty() && runtime_supports_mcp(compose, h) {
1139 for (name, server) in &h.spec.mcps {
1140 if name == "team" {
1141 continue; }
1143 mcp.insert(
1144 name.clone(),
1145 opencode_mcp_server(&server.command, &server.args, &server.env),
1146 );
1147 }
1148 }
1149 let mut v = serde_json::json!({
1150 "mcp": mcp,
1151 "autoupdate": false,
1152 "share": "disabled",
1153 });
1154 if let Some(prompt) = system_prompt_path(compose, h) {
1155 v["instructions"] = serde_json::json!([prompt.display().to_string()]);
1156 }
1157 Some(serde_json::to_string_pretty(&v).expect("json"))
1158}
1159
1160fn opencode_mcp_server(
1168 command: &str,
1169 args: &[String],
1170 env: &std::collections::BTreeMap<String, String>,
1171) -> serde_json::Value {
1172 let mut argv = vec![command.to_string()];
1173 argv.extend(args.iter().cloned());
1174 let mut server = serde_json::json!({
1175 "type": "local",
1176 "command": argv,
1177 "enabled": true,
1178 });
1179 if !env.is_empty() {
1180 server["environment"] = serde_json::to_value(env).expect("json");
1181 }
1182 server
1183}
1184
1185pub fn write_opencode_config(
1191 compose: &Compose,
1192 h: AgentHandle<'_>,
1193 team_mcp_bin: &str,
1194) -> io::Result<()> {
1195 let dest = opencode_home_dir(&compose.root, h.project, h.agent).join("opencode.json");
1196 match render_opencode_config(compose, h, team_mcp_bin) {
1197 Some(json) => {
1198 if let Some(parent) = dest.parent() {
1199 std::fs::create_dir_all(parent)?;
1200 }
1201 std::fs::write(&dest, json)
1202 }
1203 None => match std::fs::remove_file(&dest) {
1204 Ok(()) => Ok(()),
1205 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
1206 Err(e) => Err(e),
1207 },
1208 }
1209}
1210
1211fn push_mcp_server_table(
1214 buf: &mut String,
1215 name: &str,
1216 command: &str,
1217 args: &[String],
1218 env: &std::collections::BTreeMap<String, String>,
1219) {
1220 buf.push_str(&format!("\n[mcp_servers.{}]\n", toml_key(name)));
1221 buf.push_str(&format!("command = {}\n", toml_str(command)));
1222 let args: Vec<String> = args.iter().map(|a| toml_str(a)).collect();
1223 buf.push_str(&format!("args = [{}]\n", args.join(", ")));
1224 if !env.is_empty() {
1225 buf.push_str(&format!("\n[mcp_servers.{}.env]\n", toml_key(name)));
1226 for (k, v) in env {
1227 buf.push_str(&format!("{} = {}\n", toml_key(k), toml_str(v)));
1228 }
1229 }
1230}
1231
1232fn toml_str(s: &str) -> String {
1237 let mut out = String::with_capacity(s.len() + 2);
1238 out.push('"');
1239 for c in s.chars() {
1240 match c {
1241 '"' => out.push_str("\\\""),
1242 '\\' => out.push_str("\\\\"),
1243 '\n' => out.push_str("\\n"),
1244 '\r' => out.push_str("\\r"),
1245 '\t' => out.push_str("\\t"),
1246 c if (c as u32) < 0x20 || c == '\u{7f}' => {
1247 out.push_str(&format!("\\u{:04X}", c as u32));
1248 }
1249 c => out.push(c),
1250 }
1251 }
1252 out.push('"');
1253 out
1254}
1255
1256fn toml_key(k: &str) -> String {
1260 let bare = !k.is_empty()
1261 && k.chars()
1262 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
1263 if bare {
1264 k.to_string()
1265 } else {
1266 toml_str(k)
1267 }
1268}
1269
1270#[cfg(test)]
1271mod tests {
1272 use super::*;
1273 use crate::compose::*;
1274 use std::collections::BTreeMap;
1275 use std::path::PathBuf;
1276
1277 fn fixture() -> Compose {
1278 let mut managers = BTreeMap::new();
1279 managers.insert(
1280 "mgr".into(),
1281 Agent {
1282 runtime: "claude-code".into(),
1283 model: Some("claude-opus-4-8".into()),
1284 role_prompt: Some(RolePrompt::Single(PathBuf::from("roles/mgr.md"))),
1285 permission_mode: Some("auto".into()),
1286 autonomy: "low_risk_only".into(),
1287 can_dm: vec![],
1288 can_broadcast: vec![],
1289 reports_to: None,
1290 on_rate_limit: None,
1291 effort: None,
1292 ultracode: false,
1293 interfaces: None,
1294 display_name: None,
1295 hooks: vec![],
1296 mcps: Default::default(),
1297 subagents: vec![],
1298 skills: vec![],
1299 },
1300 );
1301 Compose {
1302 root: PathBuf::from("/teamctl"),
1303 global: Global {
1304 version: crate::compose::SchemaVersion::new("2.0.0"),
1305 broker: Broker {
1306 r#type: "sqlite".into(),
1307 path: PathBuf::from("state/mailbox.db"),
1308 },
1309 supervisor: SupervisorCfg {
1310 r#type: "tmux".into(),
1311 tmux_prefix: "a-".into(),
1312 drain_timeout_secs: 10,
1313 },
1314 budget: Default::default(),
1315 hitl: Default::default(),
1316 rate_limits: Default::default(),
1317 interfaces: vec![],
1318 projects: vec![],
1319 attachments: Default::default(),
1320 },
1321 projects: vec![Project {
1322 version: 2,
1323 project: ProjectMeta {
1324 id: "hello".into(),
1325 name: "Hello".into(),
1326 cwd: PathBuf::from("/teamctl/examples/hello-team"),
1327 },
1328 channels: vec![],
1329 managers,
1330 workers: Default::default(),
1331 interfaces: None,
1332 }],
1333 }
1334 }
1335
1336 #[test]
1337 fn env_contains_agent_id_and_mailbox() {
1338 let c = fixture();
1339 let h = c.agents().next().unwrap();
1340 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
1341 assert!(env.contains("AGENT_ID=hello:mgr"));
1342 assert!(env.contains("TEAMCTL_MAILBOX=/teamctl/state/mailbox.db"));
1343 assert!(env.contains("TMUX_SESSION=a-hello-mgr"));
1344 }
1345
1346 #[test]
1347 fn env_emits_claude_session_id_and_name_for_claude_code_runtime() {
1348 let c = fixture();
1352 let h = c.agents().next().unwrap();
1353 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
1354 let expected_id = crate::session::derive_session_id(h.project, h.agent);
1355 assert!(
1356 env.contains(&format!("CLAUDE_SESSION_ID={expected_id}\n")),
1357 "env was: {env}"
1358 );
1359 assert!(
1360 env.contains("CLAUDE_SESSION_NAME=teamctl:hello:mgr\n"),
1361 "env was: {env}"
1362 );
1363 }
1364
1365 #[test]
1366 fn env_omits_claude_session_vars_for_non_claude_runtimes() {
1367 let mut c = fixture();
1372 c.projects[0].managers.get_mut("mgr").unwrap().runtime = "codex".into();
1373 let h = c.agents().next().unwrap();
1374 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
1375 assert!(
1376 !env.contains("CLAUDE_SESSION_ID="),
1377 "non-claude runtime must not get session id: {env}"
1378 );
1379 assert!(
1380 !env.contains("CLAUDE_SESSION_NAME="),
1381 "non-claude runtime must not get session name: {env}"
1382 );
1383 }
1384
1385 #[test]
1386 fn env_pins_teamctl_root_to_compose_root() {
1387 let c = fixture();
1393 let h = c.agents().next().unwrap();
1394 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
1395 assert!(env.contains("TEAMCTL_ROOT=/teamctl\n"), "env was: {env}");
1396 }
1397
1398 #[test]
1399 fn env_omits_effort_when_unset() {
1400 let c = fixture();
1401 let h = c.agents().next().unwrap();
1402 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
1403 assert!(!env.contains("EFFORT="), "env was: {env}");
1404 }
1405
1406 #[test]
1407 fn env_emits_effort_when_set() {
1408 let mut c = fixture();
1409 c.projects[0].managers.get_mut("mgr").unwrap().effort = Some(EffortLevel::Max);
1410 let h = c.agents().next().unwrap();
1411 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
1412 assert!(env.contains("EFFORT=max\n"), "env was: {env}");
1413 }
1414
1415 #[test]
1416 fn mcp_json_parses_back() {
1417 let c = fixture();
1418 let h = c.agents().next().unwrap();
1419 let (_, mcp) = render_agent(&c, h, "/usr/local/bin/team-mcp");
1420 let v: serde_json::Value = serde_json::from_str(&mcp).unwrap();
1421 assert_eq!(
1422 v["mcpServers"]["team"]["command"],
1423 "/usr/local/bin/team-mcp"
1424 );
1425 assert_eq!(
1426 v["mcpServers"]["team"]["args"][1].as_str().unwrap(),
1427 "hello:mgr"
1428 );
1429 }
1430
1431 #[test]
1432 fn mcp_json_threads_tmux_prefix_from_compose() {
1433 let c = fixture();
1439 let h = c.agents().next().unwrap();
1440 let (_, mcp) = render_agent(&c, h, "/usr/local/bin/team-mcp");
1441 let v: serde_json::Value = serde_json::from_str(&mcp).unwrap();
1442 let args: Vec<&str> = v["mcpServers"]["team"]["args"]
1443 .as_array()
1444 .unwrap()
1445 .iter()
1446 .map(|a| a.as_str().unwrap())
1447 .collect();
1448 let i = args.iter().position(|a| *a == "--tmux-prefix").expect(
1449 "render_mcp must emit --tmux-prefix so compact_self resolves the caller's pane",
1450 );
1451 assert_eq!(
1452 args[i + 1],
1453 "a-",
1454 "prefix must come from compose, not the default"
1455 );
1456 }
1457
1458 fn server(command: &str, args: &[&str]) -> McpServer {
1460 McpServer {
1461 command: command.into(),
1462 args: args.iter().map(|s| s.to_string()).collect(),
1463 env: Default::default(),
1464 }
1465 }
1466
1467 #[test]
1468 fn mcp_json_includes_declared_servers_alongside_team() {
1469 let mut c = fixture();
1473 let mut mcps = BTreeMap::new();
1474 let mut gh = server("npx", &["-y", "@modelcontextprotocol/server-github"]);
1475 gh.env
1476 .insert("GITHUB_TOKEN".into(), "${GITHUB_TOKEN}".into());
1477 mcps.insert("github".into(), gh);
1478 c.projects[0].managers.get_mut("mgr").unwrap().mcps = mcps;
1479
1480 let h = c.agents().next().unwrap();
1481 let (_, mcp) = render_agent(&c, h, "/usr/local/bin/team-mcp");
1482 let v: serde_json::Value = serde_json::from_str(&mcp).unwrap();
1483
1484 assert_eq!(
1486 v["mcpServers"]["team"]["command"],
1487 "/usr/local/bin/team-mcp"
1488 );
1489 assert_eq!(v["mcpServers"]["github"]["command"], "npx");
1491 assert_eq!(v["mcpServers"]["github"]["args"][0], "-y");
1492 assert_eq!(
1493 v["mcpServers"]["github"]["env"]["GITHUB_TOKEN"], "${GITHUB_TOKEN}",
1494 "env values must pass through verbatim — claude expands ${{VAR}} at launch"
1495 );
1496 assert_eq!(v["mcpServers"].as_object().unwrap().len(), 2);
1497 }
1498
1499 #[test]
1500 fn mcp_json_team_server_is_non_clobberable() {
1501 let mut c = fixture();
1505 let mut mcps = BTreeMap::new();
1506 mcps.insert("team".into(), server("evil-team", &[]));
1507 mcps.insert("github".into(), server("npx", &[]));
1508 c.projects[0].managers.get_mut("mgr").unwrap().mcps = mcps;
1509
1510 let h = c.agents().next().unwrap();
1511 let (_, mcp) = render_agent(&c, h, "/usr/local/bin/team-mcp");
1512 let v: serde_json::Value = serde_json::from_str(&mcp).unwrap();
1513
1514 assert_eq!(
1515 v["mcpServers"]["team"]["command"], "/usr/local/bin/team-mcp",
1516 "built-in team server must not be clobbered by a declared `team`"
1517 );
1518 assert!(v["mcpServers"]["github"].is_object());
1519 assert_eq!(
1520 v["mcpServers"].as_object().unwrap().len(),
1521 2,
1522 "the declared `team` is dropped, not added as a third entry"
1523 );
1524 }
1525
1526 #[test]
1527 fn mcp_json_unchanged_when_no_servers_declared() {
1528 let c = fixture();
1531 let h = c.agents().next().unwrap();
1532 let (_, mcp) = render_agent(&c, h, "/usr/local/bin/team-mcp");
1533 let v: serde_json::Value = serde_json::from_str(&mcp).unwrap();
1534 let servers = v["mcpServers"].as_object().unwrap();
1535 assert_eq!(servers.len(), 1);
1536 assert!(servers.contains_key("team"));
1537 }
1538
1539 #[test]
1540 fn mcp_json_skips_declared_servers_on_runtime_without_mcp_support() {
1541 let tmp = tempfile::tempdir().unwrap();
1545 std::fs::create_dir_all(tmp.path().join("runtimes")).unwrap();
1546 std::fs::write(
1547 tmp.path().join("runtimes/codex.yaml"),
1548 "binary: codex\nsupports_mcp: false\n",
1549 )
1550 .unwrap();
1551
1552 let mut c = fixture();
1553 c.root = tmp.path().to_path_buf();
1554 {
1555 let m = c.projects[0].managers.get_mut("mgr").unwrap();
1556 m.runtime = "codex".into();
1557 let mut mcps = BTreeMap::new();
1558 mcps.insert("github".into(), server("npx", &[]));
1559 m.mcps = mcps;
1560 }
1561
1562 let h = c.agents().next().unwrap();
1563 let (_, mcp) = render_agent(&c, h, "/usr/local/bin/team-mcp");
1564 let v: serde_json::Value = serde_json::from_str(&mcp).unwrap();
1565 let servers = v["mcpServers"].as_object().unwrap();
1566 assert!(servers.contains_key("team"), "team bus stays unconditional");
1567 assert!(
1568 !servers.contains_key("github"),
1569 "declared server skipped when runtime lacks supports_mcp"
1570 );
1571 assert_eq!(servers.len(), 1);
1572 }
1573
1574 #[test]
1575 fn env_emits_codex_home_for_codex_runtime() {
1576 let mut c = fixture();
1580 c.projects[0].managers.get_mut("mgr").unwrap().runtime = "codex".into();
1581 let h = c.agents().next().unwrap();
1582 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
1583 assert!(
1584 env.contains("CODEX_HOME=/teamctl/state/codex-home/hello-mgr\n"),
1585 "env was: {env}"
1586 );
1587 }
1588
1589 #[test]
1590 fn env_omits_codex_home_for_non_codex_runtimes() {
1591 for runtime in ["claude-code", "gemini"] {
1595 let mut c = fixture();
1596 c.projects[0].managers.get_mut("mgr").unwrap().runtime = runtime.into();
1597 let h = c.agents().next().unwrap();
1598 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
1599 assert!(
1600 !env.contains("CODEX_HOME="),
1601 "{runtime} must not get CODEX_HOME: {env}"
1602 );
1603 }
1604 }
1605
1606 #[test]
1607 fn codex_config_present_with_team_server_for_codex_runtime() {
1608 let mut c = fixture();
1609 c.projects[0].managers.get_mut("mgr").unwrap().runtime = "codex".into();
1610 let h = c.agents().next().unwrap();
1611 let toml = render_codex_config(&c, h, "/usr/local/bin/team-mcp")
1612 .expect("codex agent must get a config.toml");
1613 assert!(toml.contains("[mcp_servers.team]"), "toml was: {toml}");
1614 assert!(
1615 toml.contains("command = \"/usr/local/bin/team-mcp\""),
1616 "toml was: {toml}"
1617 );
1618 assert!(
1621 toml.contains("\"--agent-id\", \"hello:mgr\""),
1622 "toml was: {toml}"
1623 );
1624 assert!(
1625 toml.contains("\"--tmux-prefix\", \"a-\""),
1626 "toml was: {toml}"
1627 );
1628 }
1629
1630 #[test]
1631 fn codex_config_absent_for_non_codex_runtimes() {
1632 let c = fixture();
1635 let h = c.agents().next().unwrap();
1636 assert!(render_codex_config(&c, h, "/usr/local/bin/team-mcp").is_none());
1637 }
1638
1639 #[test]
1640 fn codex_config_preapproves_team_tools_and_trusts_cwd() {
1641 let mut c = fixture();
1648 c.projects[0].managers.get_mut("mgr").unwrap().runtime = "codex".into();
1649 let h = c.agents().next().unwrap();
1650 let toml = render_codex_config(&c, h, "/usr/local/bin/team-mcp").unwrap();
1651 for tool in super::CODEX_PREAPPROVED_TEAM_TOOLS {
1652 assert!(
1653 toml.contains(&format!("[mcp_servers.team.tools.{tool}]")),
1654 "missing pre-approval table for {tool}: {toml}"
1655 );
1656 }
1657 assert!(
1658 toml.contains("approval_mode = \"approve\""),
1659 "toml was: {toml}"
1660 );
1661 assert!(toml.contains("[projects."), "toml was: {toml}");
1662 assert!(
1663 toml.contains("trust_level = \"trusted\""),
1664 "toml was: {toml}"
1665 );
1666 assert!(
1669 toml.contains("[sandbox_workspace_write]\nnetwork_access = true"),
1670 "toml must enable sandbox network: {toml}"
1671 );
1672 }
1673
1674 #[test]
1675 fn codex_config_includes_declared_servers() {
1676 let mut c = fixture();
1683 {
1684 let m = c.projects[0].managers.get_mut("mgr").unwrap();
1685 m.runtime = "codex".into();
1686 let mut gh = server("npx", &["-y", "@modelcontextprotocol/server-github"]);
1687 gh.env
1688 .insert("GITHUB_TOKEN".into(), "${GITHUB_TOKEN}".into());
1689 let mut mcps = BTreeMap::new();
1690 mcps.insert("github".into(), gh);
1691 m.mcps = mcps;
1692 }
1693 let h = c.agents().next().unwrap();
1694 let toml = render_codex_config(&c, h, "/usr/local/bin/team-mcp").unwrap();
1695 assert!(toml.contains("[mcp_servers.team]"), "toml was: {toml}");
1696 assert!(toml.contains("[mcp_servers.github]"), "toml was: {toml}");
1697 assert!(toml.contains("command = \"npx\""), "toml was: {toml}");
1698 assert!(
1699 toml.contains("args = [\"-y\", \"@modelcontextprotocol/server-github\"]"),
1700 "toml was: {toml}"
1701 );
1702 assert!(
1703 toml.contains("[mcp_servers.github.env]\nGITHUB_TOKEN = \"${GITHUB_TOKEN}\""),
1704 "env must land in a sub-table, verbatim — codex won't expand it: {toml}"
1705 );
1706 }
1707
1708 #[test]
1709 fn codex_config_escapes_toml_strings() {
1710 let mut c = fixture();
1713 {
1714 let m = c.projects[0].managers.get_mut("mgr").unwrap();
1715 m.runtime = "codex".into();
1716 let mut srv = server("run", &[]);
1717 srv.env
1718 .insert("TRICKY".into(), "say \"hi\" C:\\path".into());
1719 let mut mcps = BTreeMap::new();
1720 mcps.insert("x".into(), srv);
1721 m.mcps = mcps;
1722 }
1723 let h = c.agents().next().unwrap();
1724 let toml = render_codex_config(&c, h, "/usr/local/bin/team-mcp").unwrap();
1725 assert!(
1726 toml.contains("TRICKY = \"say \\\"hi\\\" C:\\\\path\""),
1727 "toml was: {toml}"
1728 );
1729 }
1730
1731 #[test]
1732 fn write_codex_config_writes_then_clears_stale() {
1733 let dir = tempfile::tempdir().unwrap();
1734 let mut c = fixture();
1735 c.root = dir.path().to_path_buf();
1736 let dest = codex_home_dir(&c.root, "hello", "mgr").join("config.toml");
1737
1738 c.projects[0].managers.get_mut("mgr").unwrap().runtime = "codex".into();
1740 let h = c.agents().next().unwrap();
1741 write_codex_config(&c, h, "/usr/local/bin/team-mcp").unwrap();
1742 assert!(dest.exists(), "codex config.toml should be written");
1743
1744 c.projects[0].managers.get_mut("mgr").unwrap().runtime = "claude-code".into();
1747 let h = c.agents().next().unwrap();
1748 write_codex_config(&c, h, "/usr/local/bin/team-mcp").unwrap();
1749 assert!(!dest.exists(), "stale codex config.toml should be removed");
1750 }
1751
1752 #[test]
1753 fn env_emits_opencode_vars_for_opencode_runtime() {
1754 let mut c = fixture();
1759 c.projects[0].managers.get_mut("mgr").unwrap().runtime = "opencode".into();
1760 let h = c.agents().next().unwrap();
1761 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
1762 assert!(
1763 env.contains("OPENCODE_DB=/teamctl/state/opencode-home/hello-mgr/agent.db\n"),
1764 "env was: {env}"
1765 );
1766 assert!(
1767 env.contains("OPENCODE_CONFIG=/teamctl/state/opencode-home/hello-mgr/opencode.json\n"),
1768 "env was: {env}"
1769 );
1770 }
1771
1772 #[test]
1773 fn env_omits_opencode_vars_for_non_opencode_runtimes() {
1774 for runtime in ["claude-code", "codex", "gemini"] {
1778 let mut c = fixture();
1779 c.projects[0].managers.get_mut("mgr").unwrap().runtime = runtime.into();
1780 let h = c.agents().next().unwrap();
1781 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
1782 assert!(
1783 !env.contains("OPENCODE_DB=") && !env.contains("OPENCODE_CONFIG="),
1784 "{runtime} must not get opencode vars: {env}"
1785 );
1786 }
1787 }
1788
1789 #[test]
1790 fn opencode_config_present_with_team_server_for_opencode_runtime() {
1791 let mut c = fixture();
1797 c.projects[0].managers.get_mut("mgr").unwrap().runtime = "opencode".into();
1798 let h = c.agents().next().unwrap();
1799 let json = render_opencode_config(&c, h, "/usr/local/bin/team-mcp")
1800 .expect("opencode agent must get an opencode.json");
1801 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
1802 let team = &v["mcp"]["team"];
1803 assert_eq!(team["type"], "local");
1804 assert_eq!(team["enabled"], true);
1805 assert_eq!(team["command"][0], "/usr/local/bin/team-mcp");
1808 assert_eq!(team["command"][1], "--agent-id");
1809 assert_eq!(team["command"][2], "hello:mgr");
1810 let argv: Vec<&str> = team["command"]
1811 .as_array()
1812 .unwrap()
1813 .iter()
1814 .map(|a| a.as_str().unwrap())
1815 .collect();
1816 let i = argv.iter().position(|a| *a == "--tmux-prefix").unwrap();
1817 assert_eq!(argv[i + 1], "a-");
1818 assert_eq!(v["autoupdate"], false);
1820 assert_eq!(v["share"], "disabled");
1821 assert_eq!(
1823 v["instructions"],
1824 serde_json::json!(["/teamctl/roles/mgr.md"])
1825 );
1826 }
1827
1828 #[test]
1829 fn opencode_config_absent_for_non_opencode_runtimes() {
1830 let c = fixture();
1833 let h = c.agents().next().unwrap();
1834 assert!(render_opencode_config(&c, h, "/usr/local/bin/team-mcp").is_none());
1835 }
1836
1837 #[test]
1838 fn opencode_config_omits_instructions_without_role_prompt() {
1839 let mut c = fixture();
1843 {
1844 let m = c.projects[0].managers.get_mut("mgr").unwrap();
1845 m.runtime = "opencode".into();
1846 m.role_prompt = None;
1847 }
1848 let h = c.agents().next().unwrap();
1849 let json = render_opencode_config(&c, h, "/usr/local/bin/team-mcp").unwrap();
1850 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
1851 assert!(v.get("instructions").is_none(), "json was: {json}");
1852 }
1853
1854 #[test]
1855 fn opencode_config_includes_declared_servers() {
1856 let mut c = fixture();
1860 {
1861 let m = c.projects[0].managers.get_mut("mgr").unwrap();
1862 m.runtime = "opencode".into();
1863 let mut gh = server("npx", &["-y", "@modelcontextprotocol/server-github"]);
1864 gh.env
1865 .insert("GITHUB_TOKEN".into(), "${GITHUB_TOKEN}".into());
1866 let mut mcps = BTreeMap::new();
1867 mcps.insert("github".into(), gh);
1868 m.mcps = mcps;
1869 }
1870 let h = c.agents().next().unwrap();
1871 let json = render_opencode_config(&c, h, "/usr/local/bin/team-mcp").unwrap();
1872 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
1873 let gh = &v["mcp"]["github"];
1874 assert_eq!(gh["type"], "local");
1875 assert_eq!(gh["enabled"], true);
1876 assert_eq!(
1877 gh["command"],
1878 serde_json::json!(["npx", "-y", "@modelcontextprotocol/server-github"])
1879 );
1880 assert_eq!(
1881 gh["environment"]["GITHUB_TOKEN"], "${GITHUB_TOKEN}",
1882 "env values must pass through verbatim under `environment`"
1883 );
1884 assert_eq!(v["mcp"].as_object().unwrap().len(), 2);
1885 }
1886
1887 #[test]
1888 fn opencode_config_team_server_is_non_clobberable() {
1889 let mut c = fixture();
1892 {
1893 let m = c.projects[0].managers.get_mut("mgr").unwrap();
1894 m.runtime = "opencode".into();
1895 let mut mcps = BTreeMap::new();
1896 mcps.insert("team".into(), server("evil-team", &[]));
1897 m.mcps = mcps;
1898 }
1899 let h = c.agents().next().unwrap();
1900 let json = render_opencode_config(&c, h, "/usr/local/bin/team-mcp").unwrap();
1901 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
1902 assert_eq!(
1903 v["mcp"]["team"]["command"][0], "/usr/local/bin/team-mcp",
1904 "built-in team server must not be clobbered by a declared `team`"
1905 );
1906 assert_eq!(v["mcp"].as_object().unwrap().len(), 1);
1907 }
1908
1909 #[test]
1910 fn write_opencode_config_writes_then_clears_stale() {
1911 let dir = tempfile::tempdir().unwrap();
1912 let mut c = fixture();
1913 c.root = dir.path().to_path_buf();
1914 let dest = opencode_home_dir(&c.root, "hello", "mgr").join("opencode.json");
1915
1916 c.projects[0].managers.get_mut("mgr").unwrap().runtime = "opencode".into();
1918 let h = c.agents().next().unwrap();
1919 write_opencode_config(&c, h, "/usr/local/bin/team-mcp").unwrap();
1920 assert!(dest.exists(), "opencode.json should be written");
1921
1922 c.projects[0].managers.get_mut("mgr").unwrap().runtime = "claude-code".into();
1925 let h = c.agents().next().unwrap();
1926 write_opencode_config(&c, h, "/usr/local/bin/team-mcp").unwrap();
1927 assert!(!dest.exists(), "stale opencode.json should be removed");
1928 }
1929
1930 #[test]
1931 fn env_points_at_source_for_single_role_prompt() {
1932 let c = fixture();
1933 let h = c.agents().next().unwrap();
1934 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
1935 assert!(
1936 env.contains("SYSTEM_PROMPT_PATH=/teamctl/roles/mgr.md\n"),
1937 "env was: {env}"
1938 );
1939 }
1940
1941 #[test]
1942 fn env_points_at_concat_path_for_multi_role_prompt() {
1943 let mut c = fixture();
1944 c.projects[0].managers.get_mut("mgr").unwrap().role_prompt =
1945 Some(RolePrompt::Multiple(vec![
1946 PathBuf::from("roles/_base.md"),
1947 PathBuf::from("roles/mgr.md"),
1948 ]));
1949 let h = c.agents().next().unwrap();
1950 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
1951 assert!(
1952 env.contains("SYSTEM_PROMPT_PATH=/teamctl/state/role_prompts/hello-mgr.md\n"),
1953 "env was: {env}"
1954 );
1955 }
1956
1957 #[test]
1958 fn write_role_prompt_concat_is_noop_for_single() {
1959 let dir = tempfile::tempdir().unwrap();
1960 let mut c = fixture();
1961 c.root = dir.path().to_path_buf();
1962 let h = c.agents().next().unwrap();
1963 write_role_prompt_concat(&c, h).unwrap();
1964 assert!(
1965 !role_prompt_concat_path(&c.root, h.project, h.agent).exists(),
1966 "single-form role_prompt should not produce a concat file"
1967 );
1968 }
1969
1970 #[test]
1971 fn write_role_prompt_concat_joins_in_declared_order() {
1972 let dir = tempfile::tempdir().unwrap();
1973 let root = dir.path();
1974 std::fs::create_dir_all(root.join("roles")).unwrap();
1975 std::fs::write(root.join("roles/_base.md"), "BASE").unwrap();
1976 std::fs::write(root.join("roles/mgr.md"), "MGR").unwrap();
1977
1978 let mut c = fixture();
1979 c.root = root.to_path_buf();
1980 c.projects[0].managers.get_mut("mgr").unwrap().role_prompt =
1981 Some(RolePrompt::Multiple(vec![
1982 PathBuf::from("roles/_base.md"),
1983 PathBuf::from("roles/mgr.md"),
1984 ]));
1985 let h = c.agents().next().unwrap();
1986 write_role_prompt_concat(&c, h).unwrap();
1987
1988 let dest = role_prompt_concat_path(root, h.project, h.agent);
1989 let got = std::fs::read_to_string(&dest).unwrap();
1990 assert_eq!(got, "BASE\n\n—\n\nMGR");
1991 }
1992
1993 #[test]
1994 fn write_role_prompt_concat_reflects_source_edits() {
1995 let dir = tempfile::tempdir().unwrap();
1998 let root = dir.path();
1999 std::fs::create_dir_all(root.join("roles")).unwrap();
2000 std::fs::write(root.join("roles/_base.md"), "v1").unwrap();
2001 std::fs::write(root.join("roles/mgr.md"), "MGR").unwrap();
2002
2003 let mut c = fixture();
2004 c.root = root.to_path_buf();
2005 c.projects[0].managers.get_mut("mgr").unwrap().role_prompt =
2006 Some(RolePrompt::Multiple(vec![
2007 PathBuf::from("roles/_base.md"),
2008 PathBuf::from("roles/mgr.md"),
2009 ]));
2010 let h = c.agents().next().unwrap();
2011 write_role_prompt_concat(&c, h).unwrap();
2012
2013 std::fs::write(root.join("roles/_base.md"), "v2").unwrap();
2014 let h = c.agents().next().unwrap();
2015 write_role_prompt_concat(&c, h).unwrap();
2016
2017 let dest = role_prompt_concat_path(root, h.project, h.agent);
2018 let got = std::fs::read_to_string(&dest).unwrap();
2019 assert_eq!(got, "v2\n\n—\n\nMGR");
2020 }
2021
2022 #[test]
2023 fn claude_settings_present_for_claude_code() {
2024 let c = fixture();
2028 let h = c.agents().next().unwrap();
2029 let s = render_claude_settings(&c, h).expect("claude-code agent must get settings");
2030 let v: serde_json::Value = serde_json::from_str(&s).unwrap();
2031 let pre = &v["hooks"]["PreToolUse"][0];
2032 assert_eq!(
2033 pre["matcher"].as_str().unwrap(),
2034 "AskUserQuestion|EnterPlanMode|ExitPlanMode"
2035 );
2036 let cmd = pre["hooks"][0]["command"].as_str().unwrap();
2037 assert!(
2038 cmd.contains(r#""permissionDecision":"deny""#),
2039 "deny verdict missing from hook command: {cmd}"
2040 );
2041 assert!(
2042 cmd.contains("Interactive prompts are disabled"),
2043 "systemMessage missing from hook command: {cmd}"
2044 );
2045 }
2046
2047 #[test]
2048 fn claude_settings_pre_trust_all_project_mcp_servers() {
2049 let c = fixture();
2056 let h = c.agents().next().unwrap();
2057 let s = render_claude_settings(&c, h).expect("claude-code agent must get settings");
2058 let v: serde_json::Value = serde_json::from_str(&s).unwrap();
2059 assert_eq!(
2060 v["enableAllProjectMcpServers"],
2061 serde_json::Value::Bool(true),
2062 "headless settings must pre-trust project MCP servers: {s}"
2063 );
2064 }
2065
2066 #[test]
2067 fn claude_settings_absent_for_non_claude_runtimes() {
2068 let mut c = fixture();
2071 c.projects[0].managers.get_mut("mgr").unwrap().runtime = "codex".into();
2072 let h = c.agents().next().unwrap();
2073 assert!(render_claude_settings(&c, h).is_none());
2074 }
2075
2076 #[test]
2077 fn claude_settings_absent_when_non_claude_agent_opts_into_ultracode() {
2078 let mut c = fixture();
2084 {
2085 let m = c.projects[0].managers.get_mut("mgr").unwrap();
2086 m.runtime = "codex".into();
2087 m.ultracode = true;
2088 }
2089 let h = c.agents().next().unwrap();
2090 assert!(render_claude_settings(&c, h).is_none());
2091 }
2092
2093 #[test]
2094 fn declared_hook_merges_alongside_deny_hook() {
2095 let mut c = fixture();
2100 c.projects[0].managers.get_mut("mgr").unwrap().hooks = vec![HookSpec {
2101 event: "PreToolUse".into(),
2102 matcher: Some("Bash".into()),
2103 command: PathBuf::from("hooks/guard.sh"),
2104 }];
2105 let h = c.agents().next().unwrap();
2106 let s = render_claude_settings(&c, h).expect("claude-code agent must get settings");
2107 let v: serde_json::Value = serde_json::from_str(&s).unwrap();
2108 let pre = v["hooks"]["PreToolUse"].as_array().unwrap();
2109 assert_eq!(
2110 pre.len(),
2111 3,
2112 "deny hook + #428 heartbeat touch + declared hook expected"
2113 );
2114 assert_eq!(
2116 pre[0]["matcher"].as_str().unwrap(),
2117 "AskUserQuestion|EnterPlanMode|ExitPlanMode"
2118 );
2119 assert!(pre[0]["hooks"][0]["command"]
2120 .as_str()
2121 .unwrap()
2122 .contains(r#""permissionDecision":"deny""#));
2123 assert!(
2125 pre[1].get("matcher").is_none(),
2126 "heartbeat touch must be match-all: {}",
2127 pre[1]
2128 );
2129 assert_eq!(pre[2]["matcher"].as_str().unwrap(), "Bash");
2131 assert_eq!(pre[2]["hooks"][0]["type"].as_str().unwrap(), "command");
2132 assert_eq!(
2133 pre[2]["hooks"][0]["command"].as_str().unwrap(),
2134 "/teamctl/hooks/guard.sh"
2135 );
2136 }
2137
2138 #[test]
2139 fn claude_settings_emits_ultracode_when_set() {
2140 let mut c = fixture();
2144 c.projects[0].managers.get_mut("mgr").unwrap().ultracode = true;
2145 let h = c.agents().next().unwrap();
2146 let v: serde_json::Value =
2147 serde_json::from_str(&render_claude_settings(&c, h).unwrap()).unwrap();
2148 assert_eq!(v["ultracode"], true);
2149 }
2150
2151 #[test]
2152 fn claude_settings_omits_ultracode_when_unset() {
2153 let c = fixture();
2157 let h = c.agents().next().unwrap();
2158 let v: serde_json::Value =
2159 serde_json::from_str(&render_claude_settings(&c, h).unwrap()).unwrap();
2160 assert!(v.get("ultracode").is_none());
2161 }
2162
2163 #[test]
2164 fn default_hooks_are_deny_plus_heartbeat_buckets() {
2165 let c = fixture();
2173 let h = c.agents().next().unwrap();
2174 let v: serde_json::Value =
2175 serde_json::from_str(&render_claude_settings(&c, h).unwrap()).unwrap();
2176 let hooks = v["hooks"].as_object().unwrap();
2177 let keys: std::collections::BTreeSet<&str> = hooks.keys().map(String::as_str).collect();
2178 assert_eq!(
2179 keys,
2180 [
2181 "PreToolUse",
2182 "SessionStart",
2183 "Stop",
2184 "StopFailure",
2185 "UserPromptSubmit"
2186 ]
2187 .into_iter()
2188 .collect::<std::collections::BTreeSet<_>>(),
2189 "exact set of built-in default hook buckets expected with no declared hooks"
2190 );
2191 assert_eq!(
2193 hooks["PreToolUse"].as_array().unwrap().len(),
2194 2,
2195 "deny hook + heartbeat touch expected"
2196 );
2197 let stop_failure = hooks["StopFailure"].as_array().unwrap();
2201 assert_eq!(
2202 stop_failure.len(),
2203 2,
2204 "heartbeat clear + rate-limit marker expected"
2205 );
2206 assert!(
2207 stop_failure[0].get("matcher").is_none(),
2208 "StopFailure slot 0 should be the match-all heartbeat clear"
2209 );
2210 let clear_cmd = stop_failure[0]["hooks"][0]["command"].as_str().unwrap();
2213 assert!(
2214 clear_cmd.starts_with("touch ") && clear_cmd.contains(" && rm -f "),
2215 "StopFailure slot 0 should touch lastseen then rm the marker: {clear_cmd}"
2216 );
2217 assert_eq!(
2218 stop_failure[1]["matcher"].as_str().unwrap(),
2219 "rate_limit",
2220 "StopFailure slot 1 should scope to rate-limit stops"
2221 );
2222 assert!(stop_failure[1]["hooks"][0]["command"]
2223 .as_str()
2224 .unwrap()
2225 .contains("rl-hit"));
2226 let stop = hooks["Stop"].as_array().unwrap();
2230 assert_eq!(
2231 stop.len(),
2232 2,
2233 "heartbeat clear + budget cost writer expected"
2234 );
2235 assert!(
2236 stop[0].get("matcher").is_none(),
2237 "Stop slot 0 should be the match-all heartbeat clear"
2238 );
2239 let stop_clear = stop[0]["hooks"][0]["command"].as_str().unwrap();
2240 assert!(
2241 stop_clear.starts_with("touch ") && stop_clear.contains(" && rm -f "),
2242 "Stop slot 0 should touch lastseen then rm the marker: {stop_clear}"
2243 );
2244 assert!(
2245 stop[1].get("matcher").is_none(),
2246 "Stop slot 1 (budget writer) should be match-all"
2247 );
2248 assert!(stop[1]["hooks"][0]["command"]
2249 .as_str()
2250 .unwrap()
2251 .contains("budget-record"));
2252 for ev in ["UserPromptSubmit", "SessionStart"] {
2254 assert_eq!(
2255 hooks[ev].as_array().unwrap().len(),
2256 1,
2257 "{ev} should hold exactly one built-in entry"
2258 );
2259 }
2260 }
2261
2262 #[test]
2263 fn stop_failure_rate_limit_hook_records_a_hit() {
2264 let c = fixture();
2269 let h = c.agents().next().unwrap();
2270 let v: serde_json::Value =
2271 serde_json::from_str(&render_claude_settings(&c, h).unwrap()).unwrap();
2272 let stop_failure = v["hooks"]["StopFailure"].as_array().unwrap();
2273 let command = stop_failure[1]["hooks"][0]["command"].as_str().unwrap();
2274
2275 assert!(
2277 command.starts_with("command -v teamctl >/dev/null"),
2278 "rl-hit command must lead with the PATH guard: {command}"
2279 );
2280 assert!(
2282 command.contains("--root"),
2283 "rl-hit command must pass the compose --root: {command}"
2284 );
2285 assert!(
2289 command.contains("rl-hit"),
2290 "rl-hit subcommand missing: {command}"
2291 );
2292 let agent_id = format!("{}:{}", h.project, h.agent);
2293 assert!(
2294 command.contains(&agent_id),
2295 "rl-hit command must target the agent id {agent_id}: {command}"
2296 );
2297 assert!(
2300 command.ends_with("|| true"),
2301 "rl-hit command must end with the fire-and-forget guard: {command}"
2302 );
2303 }
2304
2305 #[test]
2306 fn stop_budget_record_hook_records_cost() {
2307 let c = fixture();
2313 let h = c.agents().next().unwrap();
2314 let v: serde_json::Value =
2315 serde_json::from_str(&render_claude_settings(&c, h).unwrap()).unwrap();
2316 let stop = v["hooks"]["Stop"].as_array().unwrap();
2317 let command = stop[1]["hooks"][0]["command"].as_str().unwrap();
2318
2319 assert!(
2321 command.starts_with("command -v teamctl >/dev/null"),
2322 "budget-record command must lead with the PATH guard: {command}"
2323 );
2324 assert!(
2326 command.contains("--root"),
2327 "budget-record command must pass the compose --root: {command}"
2328 );
2329 assert!(
2333 command.contains("budget-record"),
2334 "budget-record subcommand missing: {command}"
2335 );
2336 let agent_id = format!("{}:{}", h.project, h.agent);
2337 assert!(
2338 command.contains(&agent_id),
2339 "budget-record command must target the agent id {agent_id}: {command}"
2340 );
2341 assert!(
2344 command.ends_with("|| true"),
2345 "budget-record command must end with the fire-and-forget guard: {command}"
2346 );
2347 }
2348
2349 #[test]
2350 fn heartbeat_hooks_touch_and_clear_the_marker() {
2351 let c = fixture();
2355 let h = c.agents().next().unwrap();
2356 let path = heartbeat_path(&c.root, h.project, h.agent)
2357 .display()
2358 .to_string();
2359 let q = crate::supervisor::shlex::try_quote(&path).unwrap();
2363 let ls_path = lastseen_path(&c.root, h.project, h.agent)
2365 .display()
2366 .to_string();
2367 let ls = crate::supervisor::shlex::try_quote(&ls_path).unwrap();
2368 let v: serde_json::Value =
2369 serde_json::from_str(&render_claude_settings(&c, h).unwrap()).unwrap();
2370 let hooks = &v["hooks"];
2371
2372 let touch_entry = &hooks["PreToolUse"].as_array().unwrap()[1];
2374 assert!(
2375 touch_entry.get("matcher").is_none(),
2376 "heartbeat must be match-all (no matcher): {touch_entry}"
2377 );
2378 assert_eq!(touch_entry["hooks"][0]["type"].as_str().unwrap(), "command");
2379 assert_eq!(
2380 touch_entry["hooks"][0]["command"].as_str().unwrap(),
2381 format!("touch {q}"),
2382 "PreToolUse should touch the quoted marker"
2383 );
2384
2385 assert_eq!(
2387 hooks["UserPromptSubmit"].as_array().unwrap()[0]["hooks"][0]["command"]
2388 .as_str()
2389 .unwrap(),
2390 format!("touch {q}"),
2391 "UserPromptSubmit should touch the quoted marker"
2392 );
2393
2394 for ev in ["Stop", "StopFailure"] {
2399 let entry = &hooks[ev].as_array().unwrap()[0];
2400 assert!(
2401 entry.get("matcher").is_none(),
2402 "{ev} must be match-all (no matcher)"
2403 );
2404 assert_eq!(
2405 entry["hooks"][0]["command"].as_str().unwrap(),
2406 format!("touch {ls} && rm -f {q}"),
2407 "{ev} should touch the quoted lastseen then rm the quoted marker"
2408 );
2409 }
2410 }
2411
2412 #[test]
2413 fn session_start_hook_runs_the_boot_script() {
2414 let c = fixture();
2423 let h = c.agents().next().unwrap();
2424 let path = boot_script_path(&c.root).display().to_string();
2425 let q = crate::supervisor::shlex::try_quote(&path).unwrap();
2426 let ls_path = lastseen_path(&c.root, h.project, h.agent)
2427 .display()
2428 .to_string();
2429 let ls = crate::supervisor::shlex::try_quote(&ls_path).unwrap();
2430 let marker_path = heartbeat_path(&c.root, h.project, h.agent)
2431 .display()
2432 .to_string();
2433 let marker = crate::supervisor::shlex::try_quote(&marker_path).unwrap();
2434 let v: serde_json::Value =
2435 serde_json::from_str(&render_claude_settings(&c, h).unwrap()).unwrap();
2436 let bucket = v["hooks"]["SessionStart"].as_array().unwrap();
2437 assert_eq!(
2438 bucket.len(),
2439 1,
2440 "exactly one SessionStart built-in expected"
2441 );
2442 let entry = &bucket[0];
2443 assert!(
2444 entry.get("matcher").is_none(),
2445 "SessionStart must be match-all (fire on every source): {entry}"
2446 );
2447 let inner = &entry["hooks"][0];
2448 assert_eq!(inner["type"].as_str().unwrap(), "command");
2449 assert_eq!(
2450 inner["command"].as_str().unwrap(),
2451 format!("{q} {ls} {marker}"),
2452 "SessionStart should run the quoted boot.sh path with lastseen + marker argv"
2453 );
2454 assert_eq!(inner["timeout"].as_i64().unwrap(), 5, "5s timeout expected");
2455 }
2456
2457 #[test]
2458 fn lastseen_path_is_a_dotlastseen_sibling_of_the_marker() {
2459 let root = std::path::Path::new("/srv/.team");
2465 let marker = heartbeat_path(root, "proj", "ada");
2466 let lastseen = lastseen_path(root, "proj", "ada");
2467 assert_eq!(lastseen, root.join("state/heartbeats/proj-ada.lastseen"));
2468 assert_eq!(
2469 lastseen.parent(),
2470 marker.parent(),
2471 "lastseen must sit in the same heartbeats dir as the marker"
2472 );
2473 assert_ne!(
2474 lastseen, marker,
2475 "lastseen must not collide with the marker"
2476 );
2477 }
2478
2479 #[test]
2480 fn declared_hook_without_matcher_opens_new_event_bucket() {
2481 let mut c = fixture();
2486 c.projects[0].managers.get_mut("mgr").unwrap().hooks = vec![HookSpec {
2487 event: "PostToolUse".into(),
2488 matcher: None,
2489 command: PathBuf::from("hooks/log.sh"),
2490 }];
2491 let h = c.agents().next().unwrap();
2492 let v: serde_json::Value =
2493 serde_json::from_str(&render_claude_settings(&c, h).unwrap()).unwrap();
2494 assert_eq!(
2495 v["hooks"]["PreToolUse"].as_array().unwrap().len(),
2496 2,
2497 "PreToolUse keeps its deny + #428 heartbeat built-ins"
2498 );
2499 let post = &v["hooks"]["PostToolUse"].as_array().unwrap()[0];
2500 assert!(
2501 post.get("matcher").is_none(),
2502 "matcher must be omitted when unset: {post}"
2503 );
2504 assert_eq!(
2505 post["hooks"][0]["command"].as_str().unwrap(),
2506 "/teamctl/hooks/log.sh"
2507 );
2508 }
2509
2510 #[test]
2511 fn declared_hooks_noop_on_non_claude_runtime() {
2512 let mut c = fixture();
2515 {
2516 let m = c.projects[0].managers.get_mut("mgr").unwrap();
2517 m.runtime = "codex".into();
2518 m.hooks = vec![HookSpec {
2519 event: "PreToolUse".into(),
2520 matcher: Some("Bash".into()),
2521 command: PathBuf::from("hooks/guard.sh"),
2522 }];
2523 }
2524 let h = c.agents().next().unwrap();
2525 assert!(
2526 render_claude_settings(&c, h).is_none(),
2527 "hooks must not render on non-claude runtimes"
2528 );
2529 }
2530
2531 #[test]
2532 fn env_emits_claude_settings_path_for_claude_code() {
2533 let c = fixture();
2536 let h = c.agents().next().unwrap();
2537 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
2538 assert!(
2539 env.contains("CLAUDE_SETTINGS=/teamctl/state/claude/hello-mgr.json\n"),
2540 "env was: {env}"
2541 );
2542 }
2543
2544 #[test]
2545 fn env_omits_claude_settings_for_non_claude_runtimes() {
2546 let mut c = fixture();
2550 c.projects[0].managers.get_mut("mgr").unwrap().runtime = "codex".into();
2551 let h = c.agents().next().unwrap();
2552 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
2553 assert!(
2554 !env.contains("CLAUDE_SETTINGS="),
2555 "non-claude runtime must not get settings path: {env}"
2556 );
2557 }
2558
2559 #[test]
2560 fn write_role_prompt_concat_errors_on_missing_source() {
2561 let dir = tempfile::tempdir().unwrap();
2562 let mut c = fixture();
2563 c.root = dir.path().to_path_buf();
2564 c.projects[0].managers.get_mut("mgr").unwrap().role_prompt = Some(RolePrompt::Multiple(
2565 vec![PathBuf::from("roles/missing.md")],
2566 ));
2567 let h = c.agents().next().unwrap();
2568 let err = write_role_prompt_concat(&c, h).unwrap_err();
2569 assert!(err.to_string().contains("missing.md"), "err was: {err}");
2570 }
2571
2572 fn write_file(root: &std::path::Path, rel: &str, contents: &str) {
2575 let abs = root.join(rel);
2576 std::fs::create_dir_all(abs.parent().unwrap()).unwrap();
2577 std::fs::write(abs, contents).unwrap();
2578 }
2579
2580 fn rooted(write: impl FnOnce(&std::path::Path)) -> (tempfile::TempDir, Compose) {
2581 let dir = tempfile::tempdir().unwrap();
2582 let mut c = fixture();
2583 c.root = dir.path().to_path_buf();
2584 write(dir.path());
2585 (dir, c)
2586 }
2587
2588 #[test]
2589 fn render_subagents_builds_agents_json_from_frontmatter() {
2590 let (_d, mut c) = rooted(|root| {
2591 write_file(
2592 root,
2593 "agents/security-auditor.md",
2594 "---\nname: security-auditor\ndescription: Audits diffs for vulns.\n\
2595 tools: Read, Grep\nmodel: claude-sonnet-4-6\n---\n\
2596 You are a security auditor.\nFlag risky patterns.\n",
2597 );
2598 });
2599 c.projects[0].managers.get_mut("mgr").unwrap().subagents =
2600 vec![PathBuf::from("agents/security-auditor.md")];
2601 let h = c.agents().next().unwrap();
2602 let json = render_subagents(&c, h).unwrap().expect("some json");
2603 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
2604 let entry = &v["security-auditor"];
2605 assert_eq!(entry["description"], "Audits diffs for vulns.");
2606 assert_eq!(
2607 entry["prompt"],
2608 "You are a security auditor.\nFlag risky patterns."
2609 );
2610 assert_eq!(entry["tools"], serde_json::json!(["Read", "Grep"]));
2611 assert_eq!(entry["model"], "claude-sonnet-4-6");
2612 }
2613
2614 #[test]
2615 fn render_subagents_name_falls_back_to_file_stem() {
2616 let (_d, mut c) = rooted(|root| {
2617 write_file(
2618 root,
2619 "agents/repo-cartographer.md",
2620 "---\ndescription: Maps the repo.\n---\nMap it.\n",
2621 );
2622 });
2623 c.projects[0].managers.get_mut("mgr").unwrap().subagents =
2624 vec![PathBuf::from("agents/repo-cartographer.md")];
2625 let h = c.agents().next().unwrap();
2626 let json = render_subagents(&c, h).unwrap().unwrap();
2627 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
2628 assert!(
2629 v.get("repo-cartographer").is_some(),
2630 "stem-derived name missing: {json}"
2631 );
2632 assert!(v["repo-cartographer"].get("tools").is_none());
2634 assert!(v["repo-cartographer"].get("model").is_none());
2635 }
2636
2637 #[test]
2638 fn render_subagents_supports_yaml_list_tools() {
2639 let (_d, mut c) = rooted(|root| {
2640 write_file(
2641 root,
2642 "agents/x.md",
2643 "---\nname: x\ndescription: d\ntools: [Read, Bash]\n---\nbody\n",
2644 );
2645 });
2646 c.projects[0].managers.get_mut("mgr").unwrap().subagents =
2647 vec![PathBuf::from("agents/x.md")];
2648 let h = c.agents().next().unwrap();
2649 let json = render_subagents(&c, h).unwrap().unwrap();
2650 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
2651 assert_eq!(v["x"]["tools"], serde_json::json!(["Read", "Bash"]));
2652 }
2653
2654 #[test]
2655 fn render_subagents_isolates_per_agent() {
2656 let (_d, mut c) = rooted(|root| {
2659 write_file(
2660 root,
2661 "agents/a.md",
2662 "---\nname: a\ndescription: da\n---\nba\n",
2663 );
2664 write_file(
2665 root,
2666 "agents/b.md",
2667 "---\nname: b\ndescription: db\n---\nbb\n",
2668 );
2669 });
2670 let worker = c.projects[0].managers["mgr"].clone();
2671 c.projects[0].workers.insert("dev".into(), worker);
2672 c.projects[0].managers.get_mut("mgr").unwrap().subagents =
2673 vec![PathBuf::from("agents/a.md")];
2674 c.projects[0].workers.get_mut("dev").unwrap().subagents =
2675 vec![PathBuf::from("agents/b.md")];
2676
2677 for h in c.agents() {
2678 let v: serde_json::Value =
2679 serde_json::from_str(&render_subagents(&c, h).unwrap().unwrap()).unwrap();
2680 match h.agent {
2681 "mgr" => {
2682 assert!(v.get("a").is_some() && v.get("b").is_none());
2683 }
2684 "dev" => {
2685 assert!(v.get("b").is_some() && v.get("a").is_none());
2686 }
2687 other => panic!("unexpected agent {other}"),
2688 }
2689 }
2690 }
2691
2692 #[test]
2693 fn render_subagents_none_when_empty() {
2694 let c = fixture();
2695 let h = c.agents().next().unwrap();
2696 assert!(render_subagents(&c, h).unwrap().is_none());
2697 }
2698
2699 #[test]
2700 fn render_subagents_ignored_on_non_claude_runtime() {
2701 let (_d, mut c) = rooted(|root| {
2702 write_file(
2703 root,
2704 "agents/x.md",
2705 "---\nname: x\ndescription: d\n---\nb\n",
2706 );
2707 });
2708 {
2709 let a = c.projects[0].managers.get_mut("mgr").unwrap();
2710 a.runtime = "codex".into();
2711 a.subagents = vec![PathBuf::from("agents/x.md")];
2712 }
2713 let h = c.agents().next().unwrap();
2714 assert!(render_subagents(&c, h).unwrap().is_none());
2716 }
2717
2718 #[test]
2719 fn render_subagents_errors_on_missing_source() {
2720 let (_d, mut c) = rooted(|_| {});
2721 c.projects[0].managers.get_mut("mgr").unwrap().subagents =
2722 vec![PathBuf::from("agents/nope.md")];
2723 let h = c.agents().next().unwrap();
2724 let err = render_subagents(&c, h).unwrap_err();
2725 assert!(err.to_string().contains("nope.md"), "err was: {err}");
2726 }
2727
2728 #[test]
2729 fn render_subagents_errors_on_unterminated_frontmatter() {
2730 let (_d, mut c) = rooted(|root| {
2731 write_file(
2732 root,
2733 "agents/bad.md",
2734 "---\nname: x\ndescription: d\nno close\n",
2735 );
2736 });
2737 c.projects[0].managers.get_mut("mgr").unwrap().subagents =
2738 vec![PathBuf::from("agents/bad.md")];
2739 let h = c.agents().next().unwrap();
2740 assert!(render_subagents(&c, h).is_err());
2741 }
2742
2743 #[test]
2744 fn env_emits_claude_agents_json_for_claude_code() {
2745 let c = fixture();
2746 let h = c.agents().next().unwrap();
2747 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
2748 assert!(env.contains("CLAUDE_AGENTS_JSON=/teamctl/state/claude/hello-mgr.agents.json"));
2749 }
2750
2751 #[test]
2752 fn write_subagents_json_writes_then_clears_stale() {
2753 let (_d, mut c) = rooted(|root| {
2754 write_file(
2755 root,
2756 "agents/x.md",
2757 "---\nname: x\ndescription: d\n---\nbody\n",
2758 );
2759 });
2760 let dest = subagents_json_path(&c.root, "hello", "mgr");
2761
2762 c.projects[0].managers.get_mut("mgr").unwrap().subagents =
2764 vec![PathBuf::from("agents/x.md")];
2765 let h = c.agents().next().unwrap();
2766 write_subagents_json(&c, h).unwrap();
2767 assert!(dest.exists(), "agents json should be written");
2768
2769 c.projects[0].managers.get_mut("mgr").unwrap().subagents = vec![];
2771 let h = c.agents().next().unwrap();
2772 write_subagents_json(&c, h).unwrap();
2773 assert!(!dest.exists(), "stale agents json should be removed");
2774 }
2775
2776 #[test]
2777 fn write_agent_skills_materializes_symlinks() {
2778 let (_d, mut c) = rooted(|root| {
2779 write_file(root, "skills/pr-review/SKILL.md", "# PR review skill\n");
2780 });
2781 c.projects[0].managers.get_mut("mgr").unwrap().skills =
2782 vec![PathBuf::from("skills/pr-review")];
2783 let h = c.agents().next().unwrap();
2784 write_agent_skills(&c, h).unwrap();
2785
2786 let link = agent_scope_dir(&c.root, "hello", "mgr").join(".claude/skills/pr-review");
2787 let meta = std::fs::symlink_metadata(&link).expect("link should exist");
2788 assert!(meta.file_type().is_symlink(), "entry must be a symlink");
2789 assert_eq!(
2791 std::fs::canonicalize(&link).unwrap(),
2792 std::fs::canonicalize(c.root.join("skills/pr-review")).unwrap()
2793 );
2794 }
2795
2796 #[test]
2797 fn write_agent_skills_clear_stale_preserves_source() {
2798 let (_d, mut c) = rooted(|root| {
2801 write_file(root, "skills/foo/SKILL.md", "# foo\n");
2802 });
2803 let source = c.root.join("skills/foo");
2804 let source_md = source.join("SKILL.md");
2805
2806 c.projects[0].managers.get_mut("mgr").unwrap().skills = vec![PathBuf::from("skills/foo")];
2808 let h = c.agents().next().unwrap();
2809 write_agent_skills(&c, h).unwrap();
2810 let scope = agent_scope_dir(&c.root, "hello", "mgr");
2811 assert!(scope.join(".claude/skills/foo").exists());
2812
2813 c.projects[0].managers.get_mut("mgr").unwrap().skills = vec![];
2815 let h = c.agents().next().unwrap();
2816 write_agent_skills(&c, h).unwrap();
2817 assert!(!scope.exists(), "stale scope dir should be removed");
2818 assert!(source.is_dir(), "source skill dir must survive the clear");
2819 assert!(
2820 source_md.is_file(),
2821 "source SKILL.md must survive the clear"
2822 );
2823 }
2824
2825 #[test]
2826 fn write_agent_skills_isolates_per_agent() {
2827 let (_d, mut c) = rooted(|root| {
2830 write_file(root, "skills/a/SKILL.md", "# a\n");
2831 write_file(root, "skills/b/SKILL.md", "# b\n");
2832 });
2833 let worker = c.projects[0].managers["mgr"].clone();
2834 c.projects[0].workers.insert("dev".into(), worker);
2835 c.projects[0].managers.get_mut("mgr").unwrap().skills = vec![PathBuf::from("skills/a")];
2836 c.projects[0].workers.get_mut("dev").unwrap().skills = vec![PathBuf::from("skills/b")];
2837
2838 for h in c.agents() {
2839 write_agent_skills(&c, h).unwrap();
2840 }
2841 let mgr_skills = agent_scope_dir(&c.root, "hello", "mgr").join(".claude/skills");
2842 let dev_skills = agent_scope_dir(&c.root, "hello", "dev").join(".claude/skills");
2843 assert!(mgr_skills.join("a").exists() && !mgr_skills.join("b").exists());
2844 assert!(dev_skills.join("b").exists() && !dev_skills.join("a").exists());
2845 }
2846
2847 #[test]
2848 fn write_agent_skills_ignored_on_non_claude_runtime() {
2849 let (_d, mut c) = rooted(|root| {
2850 write_file(root, "skills/x/SKILL.md", "# x\n");
2851 });
2852 {
2853 let a = c.projects[0].managers.get_mut("mgr").unwrap();
2854 a.runtime = "codex".into();
2855 a.skills = vec![PathBuf::from("skills/x")];
2856 }
2857 let h = c.agents().next().unwrap();
2858 write_agent_skills(&c, h).unwrap();
2861 assert!(!agent_scope_dir(&c.root, "hello", "mgr").exists());
2862 }
2863
2864 #[test]
2865 fn env_emits_claude_agent_scope_for_claude_code() {
2866 let c = fixture();
2867 let h = c.agents().next().unwrap();
2868 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
2869 assert!(env.contains("CLAUDE_AGENT_SCOPE=/teamctl/state/agent-scope/hello-mgr"));
2870 }
2871
2872 #[test]
2873 fn env_omits_claude_agent_scope_for_non_claude_runtimes() {
2874 let mut c = fixture();
2875 c.projects[0].managers.get_mut("mgr").unwrap().runtime = "codex".into();
2876 let h = c.agents().next().unwrap();
2877 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
2878 assert!(
2879 !env.contains("CLAUDE_AGENT_SCOPE="),
2880 "non-claude runtime must not get the agent scope: {env}"
2881 );
2882 }
2883}