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 subagents_json_path(root: &Path, project: &str, agent: &str) -> PathBuf {
55 root.join("state/claude")
56 .join(format!("{project}-{agent}.agents.json"))
57}
58
59pub fn agent_scope_dir(root: &Path, project: &str, agent: &str) -> PathBuf {
67 root.join("state/agent-scope")
68 .join(format!("{project}-{agent}"))
69}
70
71pub fn role_prompt_concat_path(root: &Path, project: &str, agent: &str) -> PathBuf {
75 root.join("state/role_prompts")
76 .join(format!("{project}-{agent}.md"))
77}
78
79pub fn render_agent(
81 compose: &Compose,
82 handle: AgentHandle<'_>,
83 team_mcp_bin: &str,
84) -> (String, String) {
85 let env = render_env(compose, handle);
86 let mcp = render_mcp(compose, handle, team_mcp_bin);
87 (env, mcp)
88}
89
90pub fn render_claude_settings(compose: &Compose, h: AgentHandle<'_>) -> Option<String> {
110 if h.spec.runtime != "claude-code" {
111 if !h.spec.hooks.is_empty() {
115 tracing::warn!(
116 target: "team-core::render",
117 "agent `{}:{}` declares {} hook(s) but runtime `{}` does not support hooks (claude-code only); ignoring",
118 h.project,
119 h.agent,
120 h.spec.hooks.len(),
121 h.spec.runtime
122 );
123 }
124 return None;
125 }
126 let mut v = serde_json::json!({
131 "hooks": {
132 "PreToolUse": [
133 {
134 "matcher": "AskUserQuestion|EnterPlanMode|ExitPlanMode",
135 "hooks": [
136 {
137 "type": "command",
138 "command": "echo '{\"hookSpecificOutput\":{\"permissionDecision\":\"deny\"},\"systemMessage\":\"Interactive prompts are disabled for teamctl agents. Use the `team` MCP tools to ask people or check in.\"}'"
139 }
140 ]
141 }
142 ]
143 }
144 });
145
146 let hooks_obj = v["hooks"].as_object_mut().expect("hooks is a json object");
152 for hook in &h.spec.hooks {
153 let command = compose.root.join(&hook.command);
154 let mut entry = serde_json::json!({
155 "hooks": [
156 {
157 "type": "command",
158 "command": command.display().to_string()
159 }
160 ]
161 });
162 if let Some(matcher) = &hook.matcher {
163 entry["matcher"] = serde_json::Value::String(matcher.clone());
164 }
165 hooks_obj
166 .entry(hook.event.clone())
167 .or_insert_with(|| serde_json::Value::Array(Vec::new()))
168 .as_array_mut()
169 .expect("hook event maps to a json array")
170 .push(entry);
171 }
172
173 Some(serde_json::to_string_pretty(&v).expect("json"))
174}
175
176pub fn render_subagents(compose: &Compose, h: AgentHandle<'_>) -> io::Result<Option<String>> {
190 if h.spec.subagents.is_empty() {
191 return Ok(None);
192 }
193 if h.spec.runtime != "claude-code" {
194 tracing::warn!(
195 target: "team-core::render",
196 "agent `{}:{}` declares {} sub-agent(s) but runtime `{}` does not support sub-agents (claude-code only); ignoring",
197 h.project,
198 h.agent,
199 h.spec.subagents.len(),
200 h.spec.runtime
201 );
202 return Ok(None);
203 }
204
205 let mut map = serde_json::Map::new();
206 for rel in &h.spec.subagents {
207 let abs = compose.root.join(rel);
208 let raw = std::fs::read_to_string(&abs).map_err(|e| {
209 io::Error::new(
210 e.kind(),
211 format!("read sub-agent source {}: {e}", abs.display()),
212 )
213 })?;
214 let (fm, body) = parse_subagent(&raw).map_err(|e| {
215 io::Error::new(
216 io::ErrorKind::InvalidData,
217 format!("parse sub-agent {}: {e}", abs.display()),
218 )
219 })?;
220 let name = fm.name.filter(|n| !n.trim().is_empty()).unwrap_or_else(|| {
223 rel.file_stem()
224 .map(|s| s.to_string_lossy().into_owned())
225 .unwrap_or_default()
226 });
227 let mut entry = serde_json::json!({
228 "description": fm.description,
229 "prompt": body,
230 });
231 if let Some(tools) = fm.tools {
232 let list = tools.into_list();
233 if !list.is_empty() {
234 entry["tools"] = serde_json::json!(list);
235 }
236 }
237 if let Some(model) = fm.model.filter(|m| !m.trim().is_empty()) {
238 entry["model"] = serde_json::Value::String(model);
239 }
240 map.insert(name, entry);
241 }
242 Ok(Some(
243 serde_json::to_string_pretty(&serde_json::Value::Object(map)).expect("json"),
244 ))
245}
246
247pub fn write_subagents_json(compose: &Compose, h: AgentHandle<'_>) -> io::Result<()> {
254 let dest = subagents_json_path(&compose.root, h.project, h.agent);
255 match render_subagents(compose, h)? {
256 Some(json) => {
257 if let Some(parent) = dest.parent() {
258 std::fs::create_dir_all(parent)?;
259 }
260 std::fs::write(&dest, json)
261 }
262 None => match std::fs::remove_file(&dest) {
263 Ok(()) => Ok(()),
264 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
265 Err(e) => Err(e),
266 },
267 }
268}
269
270pub fn write_agent_skills(compose: &Compose, h: AgentHandle<'_>) -> io::Result<()> {
289 let scope = agent_scope_dir(&compose.root, h.project, h.agent);
290 let skills_dir = scope.join(".claude/skills");
291
292 if h.spec.runtime != "claude-code" || h.spec.skills.is_empty() {
293 if h.spec.runtime != "claude-code" && !h.spec.skills.is_empty() {
294 tracing::warn!(
298 target: "team-core::render",
299 "agent `{}:{}` declares {} skill(s) but runtime `{}` does not support skills (claude-code only); ignoring",
300 h.project,
301 h.agent,
302 h.spec.skills.len(),
303 h.spec.runtime
304 );
305 }
306 return remove_scope_dir(&scope);
309 }
310
311 clear_skills_dir(&skills_dir)?;
315 std::fs::create_dir_all(&skills_dir)?;
316 for rel in &h.spec.skills {
317 let Some(name) = rel.file_name() else {
320 continue; };
322 let link = skills_dir.join(name);
323 if std::fs::symlink_metadata(&link).is_ok() {
326 std::fs::remove_file(&link)?;
327 }
328 std::os::unix::fs::symlink(compose.root.join(rel), &link)?;
329 }
330 Ok(())
331}
332
333fn remove_scope_dir(scope: &Path) -> io::Result<()> {
338 clear_skills_dir(&scope.join(".claude/skills"))?;
339 match std::fs::remove_dir_all(scope) {
340 Ok(()) => Ok(()),
341 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
342 Err(e) => Err(e),
343 }
344}
345
346fn clear_skills_dir(skills_dir: &Path) -> io::Result<()> {
350 let entries = match std::fs::read_dir(skills_dir) {
351 Ok(e) => e,
352 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
353 Err(e) => return Err(e),
354 };
355 for entry in entries {
356 let entry = entry?;
357 let path = entry.path();
358 let meta = std::fs::symlink_metadata(&path)?;
359 if meta.file_type().is_symlink() || meta.is_file() {
360 std::fs::remove_file(&path)?;
361 } else {
362 std::fs::remove_dir_all(&path)?;
365 }
366 }
367 Ok(())
368}
369
370#[derive(serde::Deserialize)]
373struct SubagentFrontmatter {
374 #[serde(default)]
375 name: Option<String>,
376 description: String,
377 #[serde(default)]
378 tools: Option<Tools>,
379 #[serde(default)]
380 model: Option<String>,
381}
382
383#[derive(serde::Deserialize)]
387#[serde(untagged)]
388enum Tools {
389 List(Vec<String>),
390 Csv(String),
391}
392
393impl Tools {
394 fn into_list(self) -> Vec<String> {
395 let raw = match self {
396 Tools::List(v) => v,
397 Tools::Csv(s) => s.split(',').map(str::to_string).collect(),
398 };
399 raw.into_iter()
400 .map(|t| t.trim().to_string())
401 .filter(|t| !t.is_empty())
402 .collect()
403 }
404}
405
406fn parse_subagent(raw: &str) -> Result<(SubagentFrontmatter, String), String> {
410 let after_open = raw
411 .strip_prefix("---")
412 .ok_or("missing opening `---` frontmatter delimiter")?;
413 let (yaml, body) = after_open
414 .split_once("\n---")
415 .ok_or("missing closing `---` frontmatter delimiter")?;
416 let fm: SubagentFrontmatter =
417 serde_yaml::from_str(yaml.trim()).map_err(|e| format!("invalid frontmatter YAML: {e}"))?;
418 let body = body.trim_start_matches(['\r', '\n']).trim_end().to_string();
419 Ok((fm, body))
420}
421
422fn render_env(compose: &Compose, h: AgentHandle<'_>) -> String {
423 let project = compose
424 .projects
425 .iter()
426 .find(|p| p.project.id == h.project)
427 .expect("agent belongs to a loaded project");
428 let mailbox = compose.root.join(&compose.global.broker.path);
429 let mcp = mcp_path(&compose.root, h.project, h.agent);
430 let prompt = system_prompt_path(compose, h)
431 .map(|p| p.display().to_string())
432 .unwrap_or_default();
433
434 let mut s = String::new();
435 s.push_str(&format!("AGENT_ID={}:{}\n", h.project, h.agent));
436 s.push_str(&format!("PROJECT_ID={}\n", h.project));
437 s.push_str(&format!("RUNTIME={}\n", h.spec.runtime));
438 if let Some(m) = &h.spec.model {
439 s.push_str(&format!("MODEL={m}\n"));
440 }
441 if let Some(pm) = &h.spec.permission_mode {
442 s.push_str(&format!("PERMISSION_MODE={pm}\n"));
443 }
444 if let Some(effort) = h.spec.effort {
448 s.push_str(&format!("EFFORT={}\n", effort.as_str()));
449 }
450 s.push_str(&format!("TEAMCTL_MAILBOX={}\n", mailbox.display()));
451 s.push_str(&format!("MCP_CONFIG={}\n", mcp.display()));
452 s.push_str(&format!("SYSTEM_PROMPT_PATH={prompt}\n"));
453 s.push_str(&format!(
454 "CLAUDE_PROJECT_DIR={}\n",
455 project.project.cwd.display()
456 ));
457 s.push_str(&format!("TEAMCTL_ROOT={}\n", compose.root.display()));
465 s.push_str(&format!(
466 "TMUX_SESSION={}{}-{}\n",
467 compose.global.supervisor.tmux_prefix, h.project, h.agent
468 ));
469 if h.spec.runtime == "claude-code" {
475 let session_id = crate::session::derive_session_id(h.project, h.agent);
476 let session_name = crate::session::session_name(h.project, h.agent);
477 s.push_str(&format!("CLAUDE_SESSION_ID={session_id}\n"));
478 s.push_str(&format!("CLAUDE_SESSION_NAME={session_name}\n"));
479 let settings = claude_settings_path(&compose.root, h.project, h.agent);
484 s.push_str(&format!("CLAUDE_SETTINGS={}\n", settings.display()));
485 let subagents = subagents_json_path(&compose.root, h.project, h.agent);
490 s.push_str(&format!("CLAUDE_AGENTS_JSON={}\n", subagents.display()));
491 let scope = agent_scope_dir(&compose.root, h.project, h.agent);
496 s.push_str(&format!("CLAUDE_AGENT_SCOPE={}\n", scope.display()));
497 }
498 s
499}
500
501pub fn system_prompt_path(compose: &Compose, h: AgentHandle<'_>) -> Option<PathBuf> {
511 match h.spec.role_prompt.as_ref()? {
512 RolePrompt::Single(p) => Some(compose.root.join(p)),
513 RolePrompt::Multiple(_) => Some(role_prompt_concat_path(&compose.root, h.project, h.agent)),
514 }
515}
516
517pub fn write_role_prompt_concat(compose: &Compose, h: AgentHandle<'_>) -> io::Result<()> {
528 let Some(RolePrompt::Multiple(paths)) = h.spec.role_prompt.as_ref() else {
529 return Ok(());
530 };
531
532 let mut buf = String::new();
533 for (idx, rel) in paths.iter().enumerate() {
534 if idx > 0 {
535 buf.push_str(ROLE_PROMPT_SEPARATOR);
536 }
537 let abs = compose.root.join(rel);
538 let bytes = std::fs::read(&abs).map_err(|e| {
539 io::Error::new(
540 e.kind(),
541 format!("read role_prompt source {}: {e}", abs.display()),
542 )
543 })?;
544 buf.push_str(&String::from_utf8_lossy(&bytes));
547 }
548
549 let dest = role_prompt_concat_path(&compose.root, h.project, h.agent);
550 if let Some(parent) = dest.parent() {
551 std::fs::create_dir_all(parent)?;
552 }
553 std::fs::write(&dest, buf)
554}
555
556fn render_mcp(compose: &Compose, h: AgentHandle<'_>, team_mcp_bin: &str) -> String {
557 let mailbox = compose.root.join(&compose.global.broker.path);
558 let mut v = serde_json::json!({
559 "mcpServers": {
560 "team": {
561 "command": team_mcp_bin,
562 "args": [
563 "--agent-id", format!("{}:{}", h.project, h.agent),
564 "--mailbox", mailbox.display().to_string(),
565 "--tmux-prefix", compose.global.supervisor.tmux_prefix.clone(),
573 "--compose-root", compose.root.display().to_string(),
579 ],
580 "env": {}
581 }
582 }
583 });
584
585 if !h.spec.mcps.is_empty() {
594 let runtimes = crate::runtimes::load_all(&compose.root).unwrap_or_default();
595 let supports_mcp = runtimes
599 .get(h.spec.runtime.as_str())
600 .map(|r| r.supports_mcp)
601 .unwrap_or(true);
602 if supports_mcp {
603 let servers = v["mcpServers"]
604 .as_object_mut()
605 .expect("mcpServers is a json object");
606 for (name, server) in &h.spec.mcps {
607 if name == "team" {
608 continue; }
610 servers.insert(
611 name.clone(),
612 serde_json::to_value(server).expect("serialize McpServer"),
613 );
614 }
615 } else {
616 tracing::warn!(
617 target: "team-core::render",
618 "agent `{}:{}` declares {} MCP server(s) but runtime `{}` does not set `supports_mcp`; ignoring",
619 h.project,
620 h.agent,
621 h.spec.mcps.len(),
622 h.spec.runtime
623 );
624 }
625 }
626
627 serde_json::to_string_pretty(&v).expect("json")
628}
629
630#[cfg(test)]
631mod tests {
632 use super::*;
633 use crate::compose::*;
634 use std::collections::BTreeMap;
635 use std::path::PathBuf;
636
637 fn fixture() -> Compose {
638 let mut managers = BTreeMap::new();
639 managers.insert(
640 "mgr".into(),
641 Agent {
642 runtime: "claude-code".into(),
643 model: Some("claude-opus-4-8".into()),
644 role_prompt: Some(RolePrompt::Single(PathBuf::from("roles/mgr.md"))),
645 permission_mode: Some("auto".into()),
646 autonomy: "low_risk_only".into(),
647 can_dm: vec![],
648 can_broadcast: vec![],
649 reports_to: None,
650 on_rate_limit: None,
651 effort: None,
652 interfaces: None,
653 display_name: None,
654 hooks: vec![],
655 mcps: Default::default(),
656 subagents: vec![],
657 skills: vec![],
658 },
659 );
660 Compose {
661 root: PathBuf::from("/teamctl"),
662 global: Global {
663 version: crate::compose::SchemaVersion::new("2.0.0"),
664 broker: Broker {
665 r#type: "sqlite".into(),
666 path: PathBuf::from("state/mailbox.db"),
667 },
668 supervisor: SupervisorCfg {
669 r#type: "tmux".into(),
670 tmux_prefix: "a-".into(),
671 drain_timeout_secs: 10,
672 },
673 budget: Default::default(),
674 hitl: Default::default(),
675 rate_limits: Default::default(),
676 interfaces: vec![],
677 projects: vec![],
678 attachments: Default::default(),
679 },
680 projects: vec![Project {
681 version: 2,
682 project: ProjectMeta {
683 id: "hello".into(),
684 name: "Hello".into(),
685 cwd: PathBuf::from("/teamctl/examples/hello-team"),
686 },
687 channels: vec![],
688 managers,
689 workers: Default::default(),
690 interfaces: None,
691 }],
692 }
693 }
694
695 #[test]
696 fn env_contains_agent_id_and_mailbox() {
697 let c = fixture();
698 let h = c.agents().next().unwrap();
699 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
700 assert!(env.contains("AGENT_ID=hello:mgr"));
701 assert!(env.contains("TEAMCTL_MAILBOX=/teamctl/state/mailbox.db"));
702 assert!(env.contains("TMUX_SESSION=a-hello-mgr"));
703 }
704
705 #[test]
706 fn env_emits_claude_session_id_and_name_for_claude_code_runtime() {
707 let c = fixture();
711 let h = c.agents().next().unwrap();
712 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
713 let expected_id = crate::session::derive_session_id(h.project, h.agent);
714 assert!(
715 env.contains(&format!("CLAUDE_SESSION_ID={expected_id}\n")),
716 "env was: {env}"
717 );
718 assert!(
719 env.contains("CLAUDE_SESSION_NAME=teamctl:hello:mgr\n"),
720 "env was: {env}"
721 );
722 }
723
724 #[test]
725 fn env_omits_claude_session_vars_for_non_claude_runtimes() {
726 let mut c = fixture();
731 c.projects[0].managers.get_mut("mgr").unwrap().runtime = "codex".into();
732 let h = c.agents().next().unwrap();
733 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
734 assert!(
735 !env.contains("CLAUDE_SESSION_ID="),
736 "non-claude runtime must not get session id: {env}"
737 );
738 assert!(
739 !env.contains("CLAUDE_SESSION_NAME="),
740 "non-claude runtime must not get session name: {env}"
741 );
742 }
743
744 #[test]
745 fn env_pins_teamctl_root_to_compose_root() {
746 let c = fixture();
752 let h = c.agents().next().unwrap();
753 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
754 assert!(env.contains("TEAMCTL_ROOT=/teamctl\n"), "env was: {env}");
755 }
756
757 #[test]
758 fn env_omits_effort_when_unset() {
759 let c = fixture();
760 let h = c.agents().next().unwrap();
761 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
762 assert!(!env.contains("EFFORT="), "env was: {env}");
763 }
764
765 #[test]
766 fn env_emits_effort_when_set() {
767 let mut c = fixture();
768 c.projects[0].managers.get_mut("mgr").unwrap().effort = Some(EffortLevel::Max);
769 let h = c.agents().next().unwrap();
770 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
771 assert!(env.contains("EFFORT=max\n"), "env was: {env}");
772 }
773
774 #[test]
775 fn mcp_json_parses_back() {
776 let c = fixture();
777 let h = c.agents().next().unwrap();
778 let (_, mcp) = render_agent(&c, h, "/usr/local/bin/team-mcp");
779 let v: serde_json::Value = serde_json::from_str(&mcp).unwrap();
780 assert_eq!(
781 v["mcpServers"]["team"]["command"],
782 "/usr/local/bin/team-mcp"
783 );
784 assert_eq!(
785 v["mcpServers"]["team"]["args"][1].as_str().unwrap(),
786 "hello:mgr"
787 );
788 }
789
790 #[test]
791 fn mcp_json_threads_tmux_prefix_from_compose() {
792 let c = fixture();
798 let h = c.agents().next().unwrap();
799 let (_, mcp) = render_agent(&c, h, "/usr/local/bin/team-mcp");
800 let v: serde_json::Value = serde_json::from_str(&mcp).unwrap();
801 let args: Vec<&str> = v["mcpServers"]["team"]["args"]
802 .as_array()
803 .unwrap()
804 .iter()
805 .map(|a| a.as_str().unwrap())
806 .collect();
807 let i = args.iter().position(|a| *a == "--tmux-prefix").expect(
808 "render_mcp must emit --tmux-prefix so compact_self resolves the caller's pane",
809 );
810 assert_eq!(
811 args[i + 1],
812 "a-",
813 "prefix must come from compose, not the default"
814 );
815 }
816
817 fn server(command: &str, args: &[&str]) -> McpServer {
819 McpServer {
820 command: command.into(),
821 args: args.iter().map(|s| s.to_string()).collect(),
822 env: Default::default(),
823 }
824 }
825
826 #[test]
827 fn mcp_json_includes_declared_servers_alongside_team() {
828 let mut c = fixture();
832 let mut mcps = BTreeMap::new();
833 let mut gh = server("npx", &["-y", "@modelcontextprotocol/server-github"]);
834 gh.env
835 .insert("GITHUB_TOKEN".into(), "${GITHUB_TOKEN}".into());
836 mcps.insert("github".into(), gh);
837 c.projects[0].managers.get_mut("mgr").unwrap().mcps = mcps;
838
839 let h = c.agents().next().unwrap();
840 let (_, mcp) = render_agent(&c, h, "/usr/local/bin/team-mcp");
841 let v: serde_json::Value = serde_json::from_str(&mcp).unwrap();
842
843 assert_eq!(
845 v["mcpServers"]["team"]["command"],
846 "/usr/local/bin/team-mcp"
847 );
848 assert_eq!(v["mcpServers"]["github"]["command"], "npx");
850 assert_eq!(v["mcpServers"]["github"]["args"][0], "-y");
851 assert_eq!(
852 v["mcpServers"]["github"]["env"]["GITHUB_TOKEN"], "${GITHUB_TOKEN}",
853 "env values must pass through verbatim — the runtime expands ${{VAR}}"
854 );
855 assert_eq!(v["mcpServers"].as_object().unwrap().len(), 2);
856 }
857
858 #[test]
859 fn mcp_json_team_server_is_non_clobberable() {
860 let mut c = fixture();
864 let mut mcps = BTreeMap::new();
865 mcps.insert("team".into(), server("evil-team", &[]));
866 mcps.insert("github".into(), server("npx", &[]));
867 c.projects[0].managers.get_mut("mgr").unwrap().mcps = mcps;
868
869 let h = c.agents().next().unwrap();
870 let (_, mcp) = render_agent(&c, h, "/usr/local/bin/team-mcp");
871 let v: serde_json::Value = serde_json::from_str(&mcp).unwrap();
872
873 assert_eq!(
874 v["mcpServers"]["team"]["command"], "/usr/local/bin/team-mcp",
875 "built-in team server must not be clobbered by a declared `team`"
876 );
877 assert!(v["mcpServers"]["github"].is_object());
878 assert_eq!(
879 v["mcpServers"].as_object().unwrap().len(),
880 2,
881 "the declared `team` is dropped, not added as a third entry"
882 );
883 }
884
885 #[test]
886 fn mcp_json_unchanged_when_no_servers_declared() {
887 let c = fixture();
890 let h = c.agents().next().unwrap();
891 let (_, mcp) = render_agent(&c, h, "/usr/local/bin/team-mcp");
892 let v: serde_json::Value = serde_json::from_str(&mcp).unwrap();
893 let servers = v["mcpServers"].as_object().unwrap();
894 assert_eq!(servers.len(), 1);
895 assert!(servers.contains_key("team"));
896 }
897
898 #[test]
899 fn mcp_json_skips_declared_servers_on_runtime_without_mcp_support() {
900 let tmp = tempfile::tempdir().unwrap();
904 std::fs::create_dir_all(tmp.path().join("runtimes")).unwrap();
905 std::fs::write(
906 tmp.path().join("runtimes/codex.yaml"),
907 "binary: codex\nsupports_mcp: false\n",
908 )
909 .unwrap();
910
911 let mut c = fixture();
912 c.root = tmp.path().to_path_buf();
913 {
914 let m = c.projects[0].managers.get_mut("mgr").unwrap();
915 m.runtime = "codex".into();
916 let mut mcps = BTreeMap::new();
917 mcps.insert("github".into(), server("npx", &[]));
918 m.mcps = mcps;
919 }
920
921 let h = c.agents().next().unwrap();
922 let (_, mcp) = render_agent(&c, h, "/usr/local/bin/team-mcp");
923 let v: serde_json::Value = serde_json::from_str(&mcp).unwrap();
924 let servers = v["mcpServers"].as_object().unwrap();
925 assert!(servers.contains_key("team"), "team bus stays unconditional");
926 assert!(
927 !servers.contains_key("github"),
928 "declared server skipped when runtime lacks supports_mcp"
929 );
930 assert_eq!(servers.len(), 1);
931 }
932
933 #[test]
934 fn env_points_at_source_for_single_role_prompt() {
935 let c = fixture();
936 let h = c.agents().next().unwrap();
937 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
938 assert!(
939 env.contains("SYSTEM_PROMPT_PATH=/teamctl/roles/mgr.md\n"),
940 "env was: {env}"
941 );
942 }
943
944 #[test]
945 fn env_points_at_concat_path_for_multi_role_prompt() {
946 let mut c = fixture();
947 c.projects[0].managers.get_mut("mgr").unwrap().role_prompt =
948 Some(RolePrompt::Multiple(vec![
949 PathBuf::from("roles/_base.md"),
950 PathBuf::from("roles/mgr.md"),
951 ]));
952 let h = c.agents().next().unwrap();
953 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
954 assert!(
955 env.contains("SYSTEM_PROMPT_PATH=/teamctl/state/role_prompts/hello-mgr.md\n"),
956 "env was: {env}"
957 );
958 }
959
960 #[test]
961 fn write_role_prompt_concat_is_noop_for_single() {
962 let dir = tempfile::tempdir().unwrap();
963 let mut c = fixture();
964 c.root = dir.path().to_path_buf();
965 let h = c.agents().next().unwrap();
966 write_role_prompt_concat(&c, h).unwrap();
967 assert!(
968 !role_prompt_concat_path(&c.root, h.project, h.agent).exists(),
969 "single-form role_prompt should not produce a concat file"
970 );
971 }
972
973 #[test]
974 fn write_role_prompt_concat_joins_in_declared_order() {
975 let dir = tempfile::tempdir().unwrap();
976 let root = dir.path();
977 std::fs::create_dir_all(root.join("roles")).unwrap();
978 std::fs::write(root.join("roles/_base.md"), "BASE").unwrap();
979 std::fs::write(root.join("roles/mgr.md"), "MGR").unwrap();
980
981 let mut c = fixture();
982 c.root = root.to_path_buf();
983 c.projects[0].managers.get_mut("mgr").unwrap().role_prompt =
984 Some(RolePrompt::Multiple(vec![
985 PathBuf::from("roles/_base.md"),
986 PathBuf::from("roles/mgr.md"),
987 ]));
988 let h = c.agents().next().unwrap();
989 write_role_prompt_concat(&c, h).unwrap();
990
991 let dest = role_prompt_concat_path(root, h.project, h.agent);
992 let got = std::fs::read_to_string(&dest).unwrap();
993 assert_eq!(got, "BASE\n\n—\n\nMGR");
994 }
995
996 #[test]
997 fn write_role_prompt_concat_reflects_source_edits() {
998 let dir = tempfile::tempdir().unwrap();
1001 let root = dir.path();
1002 std::fs::create_dir_all(root.join("roles")).unwrap();
1003 std::fs::write(root.join("roles/_base.md"), "v1").unwrap();
1004 std::fs::write(root.join("roles/mgr.md"), "MGR").unwrap();
1005
1006 let mut c = fixture();
1007 c.root = root.to_path_buf();
1008 c.projects[0].managers.get_mut("mgr").unwrap().role_prompt =
1009 Some(RolePrompt::Multiple(vec![
1010 PathBuf::from("roles/_base.md"),
1011 PathBuf::from("roles/mgr.md"),
1012 ]));
1013 let h = c.agents().next().unwrap();
1014 write_role_prompt_concat(&c, h).unwrap();
1015
1016 std::fs::write(root.join("roles/_base.md"), "v2").unwrap();
1017 let h = c.agents().next().unwrap();
1018 write_role_prompt_concat(&c, h).unwrap();
1019
1020 let dest = role_prompt_concat_path(root, h.project, h.agent);
1021 let got = std::fs::read_to_string(&dest).unwrap();
1022 assert_eq!(got, "v2\n\n—\n\nMGR");
1023 }
1024
1025 #[test]
1026 fn claude_settings_present_for_claude_code() {
1027 let c = fixture();
1031 let h = c.agents().next().unwrap();
1032 let s = render_claude_settings(&c, h).expect("claude-code agent must get settings");
1033 let v: serde_json::Value = serde_json::from_str(&s).unwrap();
1034 let pre = &v["hooks"]["PreToolUse"][0];
1035 assert_eq!(
1036 pre["matcher"].as_str().unwrap(),
1037 "AskUserQuestion|EnterPlanMode|ExitPlanMode"
1038 );
1039 let cmd = pre["hooks"][0]["command"].as_str().unwrap();
1040 assert!(
1041 cmd.contains(r#""permissionDecision":"deny""#),
1042 "deny verdict missing from hook command: {cmd}"
1043 );
1044 assert!(
1045 cmd.contains("Interactive prompts are disabled"),
1046 "systemMessage missing from hook command: {cmd}"
1047 );
1048 }
1049
1050 #[test]
1051 fn claude_settings_absent_for_non_claude_runtimes() {
1052 let mut c = fixture();
1055 c.projects[0].managers.get_mut("mgr").unwrap().runtime = "codex".into();
1056 let h = c.agents().next().unwrap();
1057 assert!(render_claude_settings(&c, h).is_none());
1058 }
1059
1060 #[test]
1061 fn declared_hook_merges_alongside_deny_hook() {
1062 let mut c = fixture();
1066 c.projects[0].managers.get_mut("mgr").unwrap().hooks = vec![HookSpec {
1067 event: "PreToolUse".into(),
1068 matcher: Some("Bash".into()),
1069 command: PathBuf::from("hooks/guard.sh"),
1070 }];
1071 let h = c.agents().next().unwrap();
1072 let s = render_claude_settings(&c, h).expect("claude-code agent must get settings");
1073 let v: serde_json::Value = serde_json::from_str(&s).unwrap();
1074 let pre = v["hooks"]["PreToolUse"].as_array().unwrap();
1075 assert_eq!(pre.len(), 2, "deny hook + declared hook expected");
1076 assert_eq!(
1078 pre[0]["matcher"].as_str().unwrap(),
1079 "AskUserQuestion|EnterPlanMode|ExitPlanMode"
1080 );
1081 assert!(pre[0]["hooks"][0]["command"]
1082 .as_str()
1083 .unwrap()
1084 .contains(r#""permissionDecision":"deny""#));
1085 assert_eq!(pre[1]["matcher"].as_str().unwrap(), "Bash");
1087 assert_eq!(pre[1]["hooks"][0]["type"].as_str().unwrap(), "command");
1088 assert_eq!(
1089 pre[1]["hooks"][0]["command"].as_str().unwrap(),
1090 "/teamctl/hooks/guard.sh"
1091 );
1092 }
1093
1094 #[test]
1095 fn no_declared_hooks_leaves_settings_unchanged() {
1096 let c = fixture();
1099 let h = c.agents().next().unwrap();
1100 let v: serde_json::Value =
1101 serde_json::from_str(&render_claude_settings(&c, h).unwrap()).unwrap();
1102 let hooks = v["hooks"].as_object().unwrap();
1103 assert_eq!(
1104 hooks.len(),
1105 1,
1106 "only the built-in PreToolUse bucket expected"
1107 );
1108 assert_eq!(
1109 hooks["PreToolUse"].as_array().unwrap().len(),
1110 1,
1111 "only the deny hook expected"
1112 );
1113 }
1114
1115 #[test]
1116 fn declared_hook_without_matcher_opens_new_event_bucket() {
1117 let mut c = fixture();
1121 c.projects[0].managers.get_mut("mgr").unwrap().hooks = vec![HookSpec {
1122 event: "PostToolUse".into(),
1123 matcher: None,
1124 command: PathBuf::from("hooks/log.sh"),
1125 }];
1126 let h = c.agents().next().unwrap();
1127 let v: serde_json::Value =
1128 serde_json::from_str(&render_claude_settings(&c, h).unwrap()).unwrap();
1129 assert_eq!(v["hooks"]["PreToolUse"].as_array().unwrap().len(), 1);
1130 let post = &v["hooks"]["PostToolUse"].as_array().unwrap()[0];
1131 assert!(
1132 post.get("matcher").is_none(),
1133 "matcher must be omitted when unset: {post}"
1134 );
1135 assert_eq!(
1136 post["hooks"][0]["command"].as_str().unwrap(),
1137 "/teamctl/hooks/log.sh"
1138 );
1139 }
1140
1141 #[test]
1142 fn declared_hooks_noop_on_non_claude_runtime() {
1143 let mut c = fixture();
1146 {
1147 let m = c.projects[0].managers.get_mut("mgr").unwrap();
1148 m.runtime = "codex".into();
1149 m.hooks = vec![HookSpec {
1150 event: "PreToolUse".into(),
1151 matcher: Some("Bash".into()),
1152 command: PathBuf::from("hooks/guard.sh"),
1153 }];
1154 }
1155 let h = c.agents().next().unwrap();
1156 assert!(
1157 render_claude_settings(&c, h).is_none(),
1158 "hooks must not render on non-claude runtimes"
1159 );
1160 }
1161
1162 #[test]
1163 fn env_emits_claude_settings_path_for_claude_code() {
1164 let c = fixture();
1167 let h = c.agents().next().unwrap();
1168 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
1169 assert!(
1170 env.contains("CLAUDE_SETTINGS=/teamctl/state/claude/hello-mgr.json\n"),
1171 "env was: {env}"
1172 );
1173 }
1174
1175 #[test]
1176 fn env_omits_claude_settings_for_non_claude_runtimes() {
1177 let mut c = fixture();
1181 c.projects[0].managers.get_mut("mgr").unwrap().runtime = "codex".into();
1182 let h = c.agents().next().unwrap();
1183 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
1184 assert!(
1185 !env.contains("CLAUDE_SETTINGS="),
1186 "non-claude runtime must not get settings path: {env}"
1187 );
1188 }
1189
1190 #[test]
1191 fn write_role_prompt_concat_errors_on_missing_source() {
1192 let dir = tempfile::tempdir().unwrap();
1193 let mut c = fixture();
1194 c.root = dir.path().to_path_buf();
1195 c.projects[0].managers.get_mut("mgr").unwrap().role_prompt = Some(RolePrompt::Multiple(
1196 vec![PathBuf::from("roles/missing.md")],
1197 ));
1198 let h = c.agents().next().unwrap();
1199 let err = write_role_prompt_concat(&c, h).unwrap_err();
1200 assert!(err.to_string().contains("missing.md"), "err was: {err}");
1201 }
1202
1203 fn write_file(root: &std::path::Path, rel: &str, contents: &str) {
1206 let abs = root.join(rel);
1207 std::fs::create_dir_all(abs.parent().unwrap()).unwrap();
1208 std::fs::write(abs, contents).unwrap();
1209 }
1210
1211 fn rooted(write: impl FnOnce(&std::path::Path)) -> (tempfile::TempDir, Compose) {
1212 let dir = tempfile::tempdir().unwrap();
1213 let mut c = fixture();
1214 c.root = dir.path().to_path_buf();
1215 write(dir.path());
1216 (dir, c)
1217 }
1218
1219 #[test]
1220 fn render_subagents_builds_agents_json_from_frontmatter() {
1221 let (_d, mut c) = rooted(|root| {
1222 write_file(
1223 root,
1224 "agents/security-auditor.md",
1225 "---\nname: security-auditor\ndescription: Audits diffs for vulns.\n\
1226 tools: Read, Grep\nmodel: claude-sonnet-4-6\n---\n\
1227 You are a security auditor.\nFlag risky patterns.\n",
1228 );
1229 });
1230 c.projects[0].managers.get_mut("mgr").unwrap().subagents =
1231 vec![PathBuf::from("agents/security-auditor.md")];
1232 let h = c.agents().next().unwrap();
1233 let json = render_subagents(&c, h).unwrap().expect("some json");
1234 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
1235 let entry = &v["security-auditor"];
1236 assert_eq!(entry["description"], "Audits diffs for vulns.");
1237 assert_eq!(
1238 entry["prompt"],
1239 "You are a security auditor.\nFlag risky patterns."
1240 );
1241 assert_eq!(entry["tools"], serde_json::json!(["Read", "Grep"]));
1242 assert_eq!(entry["model"], "claude-sonnet-4-6");
1243 }
1244
1245 #[test]
1246 fn render_subagents_name_falls_back_to_file_stem() {
1247 let (_d, mut c) = rooted(|root| {
1248 write_file(
1249 root,
1250 "agents/repo-cartographer.md",
1251 "---\ndescription: Maps the repo.\n---\nMap it.\n",
1252 );
1253 });
1254 c.projects[0].managers.get_mut("mgr").unwrap().subagents =
1255 vec![PathBuf::from("agents/repo-cartographer.md")];
1256 let h = c.agents().next().unwrap();
1257 let json = render_subagents(&c, h).unwrap().unwrap();
1258 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
1259 assert!(
1260 v.get("repo-cartographer").is_some(),
1261 "stem-derived name missing: {json}"
1262 );
1263 assert!(v["repo-cartographer"].get("tools").is_none());
1265 assert!(v["repo-cartographer"].get("model").is_none());
1266 }
1267
1268 #[test]
1269 fn render_subagents_supports_yaml_list_tools() {
1270 let (_d, mut c) = rooted(|root| {
1271 write_file(
1272 root,
1273 "agents/x.md",
1274 "---\nname: x\ndescription: d\ntools: [Read, Bash]\n---\nbody\n",
1275 );
1276 });
1277 c.projects[0].managers.get_mut("mgr").unwrap().subagents =
1278 vec![PathBuf::from("agents/x.md")];
1279 let h = c.agents().next().unwrap();
1280 let json = render_subagents(&c, h).unwrap().unwrap();
1281 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
1282 assert_eq!(v["x"]["tools"], serde_json::json!(["Read", "Bash"]));
1283 }
1284
1285 #[test]
1286 fn render_subagents_isolates_per_agent() {
1287 let (_d, mut c) = rooted(|root| {
1290 write_file(
1291 root,
1292 "agents/a.md",
1293 "---\nname: a\ndescription: da\n---\nba\n",
1294 );
1295 write_file(
1296 root,
1297 "agents/b.md",
1298 "---\nname: b\ndescription: db\n---\nbb\n",
1299 );
1300 });
1301 let worker = c.projects[0].managers["mgr"].clone();
1302 c.projects[0].workers.insert("dev".into(), worker);
1303 c.projects[0].managers.get_mut("mgr").unwrap().subagents =
1304 vec![PathBuf::from("agents/a.md")];
1305 c.projects[0].workers.get_mut("dev").unwrap().subagents =
1306 vec![PathBuf::from("agents/b.md")];
1307
1308 for h in c.agents() {
1309 let v: serde_json::Value =
1310 serde_json::from_str(&render_subagents(&c, h).unwrap().unwrap()).unwrap();
1311 match h.agent {
1312 "mgr" => {
1313 assert!(v.get("a").is_some() && v.get("b").is_none());
1314 }
1315 "dev" => {
1316 assert!(v.get("b").is_some() && v.get("a").is_none());
1317 }
1318 other => panic!("unexpected agent {other}"),
1319 }
1320 }
1321 }
1322
1323 #[test]
1324 fn render_subagents_none_when_empty() {
1325 let c = fixture();
1326 let h = c.agents().next().unwrap();
1327 assert!(render_subagents(&c, h).unwrap().is_none());
1328 }
1329
1330 #[test]
1331 fn render_subagents_ignored_on_non_claude_runtime() {
1332 let (_d, mut c) = rooted(|root| {
1333 write_file(
1334 root,
1335 "agents/x.md",
1336 "---\nname: x\ndescription: d\n---\nb\n",
1337 );
1338 });
1339 {
1340 let a = c.projects[0].managers.get_mut("mgr").unwrap();
1341 a.runtime = "codex".into();
1342 a.subagents = vec![PathBuf::from("agents/x.md")];
1343 }
1344 let h = c.agents().next().unwrap();
1345 assert!(render_subagents(&c, h).unwrap().is_none());
1347 }
1348
1349 #[test]
1350 fn render_subagents_errors_on_missing_source() {
1351 let (_d, mut c) = rooted(|_| {});
1352 c.projects[0].managers.get_mut("mgr").unwrap().subagents =
1353 vec![PathBuf::from("agents/nope.md")];
1354 let h = c.agents().next().unwrap();
1355 let err = render_subagents(&c, h).unwrap_err();
1356 assert!(err.to_string().contains("nope.md"), "err was: {err}");
1357 }
1358
1359 #[test]
1360 fn render_subagents_errors_on_unterminated_frontmatter() {
1361 let (_d, mut c) = rooted(|root| {
1362 write_file(
1363 root,
1364 "agents/bad.md",
1365 "---\nname: x\ndescription: d\nno close\n",
1366 );
1367 });
1368 c.projects[0].managers.get_mut("mgr").unwrap().subagents =
1369 vec![PathBuf::from("agents/bad.md")];
1370 let h = c.agents().next().unwrap();
1371 assert!(render_subagents(&c, h).is_err());
1372 }
1373
1374 #[test]
1375 fn env_emits_claude_agents_json_for_claude_code() {
1376 let c = fixture();
1377 let h = c.agents().next().unwrap();
1378 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
1379 assert!(env.contains("CLAUDE_AGENTS_JSON=/teamctl/state/claude/hello-mgr.agents.json"));
1380 }
1381
1382 #[test]
1383 fn write_subagents_json_writes_then_clears_stale() {
1384 let (_d, mut c) = rooted(|root| {
1385 write_file(
1386 root,
1387 "agents/x.md",
1388 "---\nname: x\ndescription: d\n---\nbody\n",
1389 );
1390 });
1391 let dest = subagents_json_path(&c.root, "hello", "mgr");
1392
1393 c.projects[0].managers.get_mut("mgr").unwrap().subagents =
1395 vec![PathBuf::from("agents/x.md")];
1396 let h = c.agents().next().unwrap();
1397 write_subagents_json(&c, h).unwrap();
1398 assert!(dest.exists(), "agents json should be written");
1399
1400 c.projects[0].managers.get_mut("mgr").unwrap().subagents = vec![];
1402 let h = c.agents().next().unwrap();
1403 write_subagents_json(&c, h).unwrap();
1404 assert!(!dest.exists(), "stale agents json should be removed");
1405 }
1406
1407 #[test]
1408 fn write_agent_skills_materializes_symlinks() {
1409 let (_d, mut c) = rooted(|root| {
1410 write_file(root, "skills/pr-review/SKILL.md", "# PR review skill\n");
1411 });
1412 c.projects[0].managers.get_mut("mgr").unwrap().skills =
1413 vec![PathBuf::from("skills/pr-review")];
1414 let h = c.agents().next().unwrap();
1415 write_agent_skills(&c, h).unwrap();
1416
1417 let link = agent_scope_dir(&c.root, "hello", "mgr").join(".claude/skills/pr-review");
1418 let meta = std::fs::symlink_metadata(&link).expect("link should exist");
1419 assert!(meta.file_type().is_symlink(), "entry must be a symlink");
1420 assert_eq!(
1422 std::fs::canonicalize(&link).unwrap(),
1423 std::fs::canonicalize(c.root.join("skills/pr-review")).unwrap()
1424 );
1425 }
1426
1427 #[test]
1428 fn write_agent_skills_clear_stale_preserves_source() {
1429 let (_d, mut c) = rooted(|root| {
1432 write_file(root, "skills/foo/SKILL.md", "# foo\n");
1433 });
1434 let source = c.root.join("skills/foo");
1435 let source_md = source.join("SKILL.md");
1436
1437 c.projects[0].managers.get_mut("mgr").unwrap().skills = vec![PathBuf::from("skills/foo")];
1439 let h = c.agents().next().unwrap();
1440 write_agent_skills(&c, h).unwrap();
1441 let scope = agent_scope_dir(&c.root, "hello", "mgr");
1442 assert!(scope.join(".claude/skills/foo").exists());
1443
1444 c.projects[0].managers.get_mut("mgr").unwrap().skills = vec![];
1446 let h = c.agents().next().unwrap();
1447 write_agent_skills(&c, h).unwrap();
1448 assert!(!scope.exists(), "stale scope dir should be removed");
1449 assert!(source.is_dir(), "source skill dir must survive the clear");
1450 assert!(
1451 source_md.is_file(),
1452 "source SKILL.md must survive the clear"
1453 );
1454 }
1455
1456 #[test]
1457 fn write_agent_skills_isolates_per_agent() {
1458 let (_d, mut c) = rooted(|root| {
1461 write_file(root, "skills/a/SKILL.md", "# a\n");
1462 write_file(root, "skills/b/SKILL.md", "# b\n");
1463 });
1464 let worker = c.projects[0].managers["mgr"].clone();
1465 c.projects[0].workers.insert("dev".into(), worker);
1466 c.projects[0].managers.get_mut("mgr").unwrap().skills = vec![PathBuf::from("skills/a")];
1467 c.projects[0].workers.get_mut("dev").unwrap().skills = vec![PathBuf::from("skills/b")];
1468
1469 for h in c.agents() {
1470 write_agent_skills(&c, h).unwrap();
1471 }
1472 let mgr_skills = agent_scope_dir(&c.root, "hello", "mgr").join(".claude/skills");
1473 let dev_skills = agent_scope_dir(&c.root, "hello", "dev").join(".claude/skills");
1474 assert!(mgr_skills.join("a").exists() && !mgr_skills.join("b").exists());
1475 assert!(dev_skills.join("b").exists() && !dev_skills.join("a").exists());
1476 }
1477
1478 #[test]
1479 fn write_agent_skills_ignored_on_non_claude_runtime() {
1480 let (_d, mut c) = rooted(|root| {
1481 write_file(root, "skills/x/SKILL.md", "# x\n");
1482 });
1483 {
1484 let a = c.projects[0].managers.get_mut("mgr").unwrap();
1485 a.runtime = "codex".into();
1486 a.skills = vec![PathBuf::from("skills/x")];
1487 }
1488 let h = c.agents().next().unwrap();
1489 write_agent_skills(&c, h).unwrap();
1492 assert!(!agent_scope_dir(&c.root, "hello", "mgr").exists());
1493 }
1494
1495 #[test]
1496 fn env_emits_claude_agent_scope_for_claude_code() {
1497 let c = fixture();
1498 let h = c.agents().next().unwrap();
1499 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
1500 assert!(env.contains("CLAUDE_AGENT_SCOPE=/teamctl/state/agent-scope/hello-mgr"));
1501 }
1502
1503 #[test]
1504 fn env_omits_claude_agent_scope_for_non_claude_runtimes() {
1505 let mut c = fixture();
1506 c.projects[0].managers.get_mut("mgr").unwrap().runtime = "codex".into();
1507 let h = c.agents().next().unwrap();
1508 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
1509 assert!(
1510 !env.contains("CLAUDE_AGENT_SCOPE="),
1511 "non-claude runtime must not get the agent scope: {env}"
1512 );
1513 }
1514}