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 heartbeat_path(root: &Path, project: &str, agent: &str) -> PathBuf {
87 root.join("state/heartbeats")
88 .join(format!("{project}-{agent}"))
89}
90
91pub fn render_agent(
93 compose: &Compose,
94 handle: AgentHandle<'_>,
95 team_mcp_bin: &str,
96) -> (String, String) {
97 let env = render_env(compose, handle);
98 let mcp = render_mcp(compose, handle, team_mcp_bin);
99 (env, mcp)
100}
101
102pub fn render_claude_settings(compose: &Compose, h: AgentHandle<'_>) -> Option<String> {
122 if h.spec.runtime != "claude-code" {
123 if !h.spec.hooks.is_empty() {
127 tracing::warn!(
128 target: "team-core::render",
129 "agent `{}:{}` declares {} hook(s) but runtime `{}` does not support hooks (claude-code only); ignoring",
130 h.project,
131 h.agent,
132 h.spec.hooks.len(),
133 h.spec.runtime
134 );
135 }
136 return None;
137 }
138 let mut v = serde_json::json!({
143 "enableAllProjectMcpServers": true,
159 "hooks": {
160 "PreToolUse": [
161 {
162 "matcher": "AskUserQuestion|EnterPlanMode|ExitPlanMode",
163 "hooks": [
164 {
165 "type": "command",
166 "command": "echo '{\"hookSpecificOutput\":{\"permissionDecision\":\"deny\"},\"systemMessage\":\"Interactive prompts are disabled for teamctl agents. Use the `team` MCP tools to ask people or check in.\"}'"
167 }
168 ]
169 }
170 ]
171 }
172 });
173
174 let hooks_obj = v["hooks"].as_object_mut().expect("hooks is a json object");
180
181 {
198 let path = heartbeat_path(&compose.root, h.project, h.agent)
199 .display()
200 .to_string();
201 let marker =
205 crate::supervisor::shlex::try_quote(&path).expect("heartbeat marker path is NUL-free");
206 let touch = format!("touch {marker}");
207 let clear = format!("rm -f {marker}");
208 for (event, command) in [
209 ("PreToolUse", &touch),
210 ("UserPromptSubmit", &touch),
211 ("Stop", &clear),
212 ("StopFailure", &clear),
213 ] {
214 hooks_obj
215 .entry(event.to_string())
216 .or_insert_with(|| serde_json::Value::Array(Vec::new()))
217 .as_array_mut()
218 .expect("hook event maps to a json array")
219 .push(serde_json::json!({
220 "hooks": [ { "type": "command", "command": command } ]
221 }));
222 }
223 }
224
225 for hook in &h.spec.hooks {
226 let command = compose.root.join(&hook.command);
227 let mut entry = serde_json::json!({
228 "hooks": [
229 {
230 "type": "command",
231 "command": command.display().to_string()
232 }
233 ]
234 });
235 if let Some(matcher) = &hook.matcher {
236 entry["matcher"] = serde_json::Value::String(matcher.clone());
237 }
238 hooks_obj
239 .entry(hook.event.clone())
240 .or_insert_with(|| serde_json::Value::Array(Vec::new()))
241 .as_array_mut()
242 .expect("hook event maps to a json array")
243 .push(entry);
244 }
245
246 Some(serde_json::to_string_pretty(&v).expect("json"))
247}
248
249pub fn render_subagents(compose: &Compose, h: AgentHandle<'_>) -> io::Result<Option<String>> {
263 if h.spec.subagents.is_empty() {
264 return Ok(None);
265 }
266 if h.spec.runtime != "claude-code" {
267 tracing::warn!(
268 target: "team-core::render",
269 "agent `{}:{}` declares {} sub-agent(s) but runtime `{}` does not support sub-agents (claude-code only); ignoring",
270 h.project,
271 h.agent,
272 h.spec.subagents.len(),
273 h.spec.runtime
274 );
275 return Ok(None);
276 }
277
278 let mut map = serde_json::Map::new();
279 for rel in &h.spec.subagents {
280 let abs = compose.root.join(rel);
281 let raw = std::fs::read_to_string(&abs).map_err(|e| {
282 io::Error::new(
283 e.kind(),
284 format!("read sub-agent source {}: {e}", abs.display()),
285 )
286 })?;
287 let (fm, body) = parse_subagent(&raw).map_err(|e| {
288 io::Error::new(
289 io::ErrorKind::InvalidData,
290 format!("parse sub-agent {}: {e}", abs.display()),
291 )
292 })?;
293 let name = fm.name.filter(|n| !n.trim().is_empty()).unwrap_or_else(|| {
296 rel.file_stem()
297 .map(|s| s.to_string_lossy().into_owned())
298 .unwrap_or_default()
299 });
300 let mut entry = serde_json::json!({
301 "description": fm.description,
302 "prompt": body,
303 });
304 if let Some(tools) = fm.tools {
305 let list = tools.into_list();
306 if !list.is_empty() {
307 entry["tools"] = serde_json::json!(list);
308 }
309 }
310 if let Some(model) = fm.model.filter(|m| !m.trim().is_empty()) {
311 entry["model"] = serde_json::Value::String(model);
312 }
313 map.insert(name, entry);
314 }
315 Ok(Some(
316 serde_json::to_string_pretty(&serde_json::Value::Object(map)).expect("json"),
317 ))
318}
319
320pub fn write_subagents_json(compose: &Compose, h: AgentHandle<'_>) -> io::Result<()> {
327 let dest = subagents_json_path(&compose.root, h.project, h.agent);
328 match render_subagents(compose, h)? {
329 Some(json) => {
330 if let Some(parent) = dest.parent() {
331 std::fs::create_dir_all(parent)?;
332 }
333 std::fs::write(&dest, json)
334 }
335 None => match std::fs::remove_file(&dest) {
336 Ok(()) => Ok(()),
337 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
338 Err(e) => Err(e),
339 },
340 }
341}
342
343pub fn write_agent_skills(compose: &Compose, h: AgentHandle<'_>) -> io::Result<()> {
362 let scope = agent_scope_dir(&compose.root, h.project, h.agent);
363 let skills_dir = scope.join(".claude/skills");
364
365 if h.spec.runtime != "claude-code" || h.spec.skills.is_empty() {
366 if h.spec.runtime != "claude-code" && !h.spec.skills.is_empty() {
367 tracing::warn!(
371 target: "team-core::render",
372 "agent `{}:{}` declares {} skill(s) but runtime `{}` does not support skills (claude-code only); ignoring",
373 h.project,
374 h.agent,
375 h.spec.skills.len(),
376 h.spec.runtime
377 );
378 }
379 return remove_scope_dir(&scope);
382 }
383
384 clear_skills_dir(&skills_dir)?;
388 std::fs::create_dir_all(&skills_dir)?;
389 for rel in &h.spec.skills {
390 let Some(name) = rel.file_name() else {
393 continue; };
395 let link = skills_dir.join(name);
396 if std::fs::symlink_metadata(&link).is_ok() {
399 std::fs::remove_file(&link)?;
400 }
401 std::os::unix::fs::symlink(compose.root.join(rel), &link)?;
402 }
403 Ok(())
404}
405
406fn remove_scope_dir(scope: &Path) -> io::Result<()> {
411 clear_skills_dir(&scope.join(".claude/skills"))?;
412 match std::fs::remove_dir_all(scope) {
413 Ok(()) => Ok(()),
414 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
415 Err(e) => Err(e),
416 }
417}
418
419fn clear_skills_dir(skills_dir: &Path) -> io::Result<()> {
423 let entries = match std::fs::read_dir(skills_dir) {
424 Ok(e) => e,
425 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
426 Err(e) => return Err(e),
427 };
428 for entry in entries {
429 let entry = entry?;
430 let path = entry.path();
431 let meta = std::fs::symlink_metadata(&path)?;
432 if meta.file_type().is_symlink() || meta.is_file() {
433 std::fs::remove_file(&path)?;
434 } else {
435 std::fs::remove_dir_all(&path)?;
438 }
439 }
440 Ok(())
441}
442
443#[derive(serde::Deserialize)]
446struct SubagentFrontmatter {
447 #[serde(default)]
448 name: Option<String>,
449 description: String,
450 #[serde(default)]
451 tools: Option<Tools>,
452 #[serde(default)]
453 model: Option<String>,
454}
455
456#[derive(serde::Deserialize)]
460#[serde(untagged)]
461enum Tools {
462 List(Vec<String>),
463 Csv(String),
464}
465
466impl Tools {
467 fn into_list(self) -> Vec<String> {
468 let raw = match self {
469 Tools::List(v) => v,
470 Tools::Csv(s) => s.split(',').map(str::to_string).collect(),
471 };
472 raw.into_iter()
473 .map(|t| t.trim().to_string())
474 .filter(|t| !t.is_empty())
475 .collect()
476 }
477}
478
479fn parse_subagent(raw: &str) -> Result<(SubagentFrontmatter, String), String> {
483 let after_open = raw
484 .strip_prefix("---")
485 .ok_or("missing opening `---` frontmatter delimiter")?;
486 let (yaml, body) = after_open
487 .split_once("\n---")
488 .ok_or("missing closing `---` frontmatter delimiter")?;
489 let fm: SubagentFrontmatter =
490 serde_yaml::from_str(yaml.trim()).map_err(|e| format!("invalid frontmatter YAML: {e}"))?;
491 let body = body.trim_start_matches(['\r', '\n']).trim_end().to_string();
492 Ok((fm, body))
493}
494
495fn render_env(compose: &Compose, h: AgentHandle<'_>) -> String {
496 let project = compose
497 .projects
498 .iter()
499 .find(|p| p.project.id == h.project)
500 .expect("agent belongs to a loaded project");
501 let mailbox = compose.root.join(&compose.global.broker.path);
502 let mcp = mcp_path(&compose.root, h.project, h.agent);
503 let prompt = system_prompt_path(compose, h)
504 .map(|p| p.display().to_string())
505 .unwrap_or_default();
506
507 let mut s = String::new();
508 s.push_str(&format!("AGENT_ID={}:{}\n", h.project, h.agent));
509 s.push_str(&format!("PROJECT_ID={}\n", h.project));
510 s.push_str(&format!("RUNTIME={}\n", h.spec.runtime));
511 if let Some(m) = &h.spec.model {
512 s.push_str(&format!("MODEL={m}\n"));
513 }
514 if let Some(pm) = &h.spec.permission_mode {
515 s.push_str(&format!("PERMISSION_MODE={pm}\n"));
516 }
517 if let Some(effort) = h.spec.effort {
521 s.push_str(&format!("EFFORT={}\n", effort.as_str()));
522 }
523 s.push_str(&format!("TEAMCTL_MAILBOX={}\n", mailbox.display()));
524 s.push_str(&format!("MCP_CONFIG={}\n", mcp.display()));
525 s.push_str(&format!("SYSTEM_PROMPT_PATH={prompt}\n"));
526 s.push_str(&format!(
527 "CLAUDE_PROJECT_DIR={}\n",
528 project.project.cwd.display()
529 ));
530 s.push_str(&format!("TEAMCTL_ROOT={}\n", compose.root.display()));
538 s.push_str(&format!(
539 "TMUX_SESSION={}{}-{}\n",
540 compose.global.supervisor.tmux_prefix, h.project, h.agent
541 ));
542 if h.spec.runtime == "claude-code" {
548 let session_id = crate::session::derive_session_id(h.project, h.agent);
549 let session_name = crate::session::session_name(h.project, h.agent);
550 s.push_str(&format!("CLAUDE_SESSION_ID={session_id}\n"));
551 s.push_str(&format!("CLAUDE_SESSION_NAME={session_name}\n"));
552 let settings = claude_settings_path(&compose.root, h.project, h.agent);
557 s.push_str(&format!("CLAUDE_SETTINGS={}\n", settings.display()));
558 let subagents = subagents_json_path(&compose.root, h.project, h.agent);
563 s.push_str(&format!("CLAUDE_AGENTS_JSON={}\n", subagents.display()));
564 let scope = agent_scope_dir(&compose.root, h.project, h.agent);
569 s.push_str(&format!("CLAUDE_AGENT_SCOPE={}\n", scope.display()));
570 }
571 s
572}
573
574pub fn system_prompt_path(compose: &Compose, h: AgentHandle<'_>) -> Option<PathBuf> {
584 match h.spec.role_prompt.as_ref()? {
585 RolePrompt::Single(p) => Some(compose.root.join(p)),
586 RolePrompt::Multiple(_) => Some(role_prompt_concat_path(&compose.root, h.project, h.agent)),
587 }
588}
589
590pub fn write_role_prompt_concat(compose: &Compose, h: AgentHandle<'_>) -> io::Result<()> {
601 let Some(RolePrompt::Multiple(paths)) = h.spec.role_prompt.as_ref() else {
602 return Ok(());
603 };
604
605 let mut buf = String::new();
606 for (idx, rel) in paths.iter().enumerate() {
607 if idx > 0 {
608 buf.push_str(ROLE_PROMPT_SEPARATOR);
609 }
610 let abs = compose.root.join(rel);
611 let bytes = std::fs::read(&abs).map_err(|e| {
612 io::Error::new(
613 e.kind(),
614 format!("read role_prompt source {}: {e}", abs.display()),
615 )
616 })?;
617 buf.push_str(&String::from_utf8_lossy(&bytes));
620 }
621
622 let dest = role_prompt_concat_path(&compose.root, h.project, h.agent);
623 if let Some(parent) = dest.parent() {
624 std::fs::create_dir_all(parent)?;
625 }
626 std::fs::write(&dest, buf)
627}
628
629fn render_mcp(compose: &Compose, h: AgentHandle<'_>, team_mcp_bin: &str) -> String {
630 let mailbox = compose.root.join(&compose.global.broker.path);
631 let mut v = serde_json::json!({
632 "mcpServers": {
633 "team": {
634 "command": team_mcp_bin,
635 "args": [
636 "--agent-id", format!("{}:{}", h.project, h.agent),
637 "--mailbox", mailbox.display().to_string(),
638 "--tmux-prefix", compose.global.supervisor.tmux_prefix.clone(),
646 "--compose-root", compose.root.display().to_string(),
652 ],
653 "env": {}
654 }
655 }
656 });
657
658 if !h.spec.mcps.is_empty() {
667 let runtimes = crate::runtimes::load_all(&compose.root).unwrap_or_default();
668 let supports_mcp = runtimes
672 .get(h.spec.runtime.as_str())
673 .map(|r| r.supports_mcp)
674 .unwrap_or(true);
675 if supports_mcp {
676 let servers = v["mcpServers"]
677 .as_object_mut()
678 .expect("mcpServers is a json object");
679 for (name, server) in &h.spec.mcps {
680 if name == "team" {
681 continue; }
683 servers.insert(
684 name.clone(),
685 serde_json::to_value(server).expect("serialize McpServer"),
686 );
687 }
688 } else {
689 tracing::warn!(
690 target: "team-core::render",
691 "agent `{}:{}` declares {} MCP server(s) but runtime `{}` does not set `supports_mcp`; ignoring",
692 h.project,
693 h.agent,
694 h.spec.mcps.len(),
695 h.spec.runtime
696 );
697 }
698 }
699
700 serde_json::to_string_pretty(&v).expect("json")
701}
702
703#[cfg(test)]
704mod tests {
705 use super::*;
706 use crate::compose::*;
707 use std::collections::BTreeMap;
708 use std::path::PathBuf;
709
710 fn fixture() -> Compose {
711 let mut managers = BTreeMap::new();
712 managers.insert(
713 "mgr".into(),
714 Agent {
715 runtime: "claude-code".into(),
716 model: Some("claude-opus-4-8".into()),
717 role_prompt: Some(RolePrompt::Single(PathBuf::from("roles/mgr.md"))),
718 permission_mode: Some("auto".into()),
719 autonomy: "low_risk_only".into(),
720 can_dm: vec![],
721 can_broadcast: vec![],
722 reports_to: None,
723 on_rate_limit: None,
724 effort: None,
725 interfaces: None,
726 display_name: None,
727 hooks: vec![],
728 mcps: Default::default(),
729 subagents: vec![],
730 skills: vec![],
731 },
732 );
733 Compose {
734 root: PathBuf::from("/teamctl"),
735 global: Global {
736 version: crate::compose::SchemaVersion::new("2.0.0"),
737 broker: Broker {
738 r#type: "sqlite".into(),
739 path: PathBuf::from("state/mailbox.db"),
740 },
741 supervisor: SupervisorCfg {
742 r#type: "tmux".into(),
743 tmux_prefix: "a-".into(),
744 drain_timeout_secs: 10,
745 },
746 budget: Default::default(),
747 hitl: Default::default(),
748 rate_limits: Default::default(),
749 interfaces: vec![],
750 projects: vec![],
751 attachments: Default::default(),
752 },
753 projects: vec![Project {
754 version: 2,
755 project: ProjectMeta {
756 id: "hello".into(),
757 name: "Hello".into(),
758 cwd: PathBuf::from("/teamctl/examples/hello-team"),
759 },
760 channels: vec![],
761 managers,
762 workers: Default::default(),
763 interfaces: None,
764 }],
765 }
766 }
767
768 #[test]
769 fn env_contains_agent_id_and_mailbox() {
770 let c = fixture();
771 let h = c.agents().next().unwrap();
772 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
773 assert!(env.contains("AGENT_ID=hello:mgr"));
774 assert!(env.contains("TEAMCTL_MAILBOX=/teamctl/state/mailbox.db"));
775 assert!(env.contains("TMUX_SESSION=a-hello-mgr"));
776 }
777
778 #[test]
779 fn env_emits_claude_session_id_and_name_for_claude_code_runtime() {
780 let c = fixture();
784 let h = c.agents().next().unwrap();
785 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
786 let expected_id = crate::session::derive_session_id(h.project, h.agent);
787 assert!(
788 env.contains(&format!("CLAUDE_SESSION_ID={expected_id}\n")),
789 "env was: {env}"
790 );
791 assert!(
792 env.contains("CLAUDE_SESSION_NAME=teamctl:hello:mgr\n"),
793 "env was: {env}"
794 );
795 }
796
797 #[test]
798 fn env_omits_claude_session_vars_for_non_claude_runtimes() {
799 let mut c = fixture();
804 c.projects[0].managers.get_mut("mgr").unwrap().runtime = "codex".into();
805 let h = c.agents().next().unwrap();
806 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
807 assert!(
808 !env.contains("CLAUDE_SESSION_ID="),
809 "non-claude runtime must not get session id: {env}"
810 );
811 assert!(
812 !env.contains("CLAUDE_SESSION_NAME="),
813 "non-claude runtime must not get session name: {env}"
814 );
815 }
816
817 #[test]
818 fn env_pins_teamctl_root_to_compose_root() {
819 let c = fixture();
825 let h = c.agents().next().unwrap();
826 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
827 assert!(env.contains("TEAMCTL_ROOT=/teamctl\n"), "env was: {env}");
828 }
829
830 #[test]
831 fn env_omits_effort_when_unset() {
832 let c = fixture();
833 let h = c.agents().next().unwrap();
834 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
835 assert!(!env.contains("EFFORT="), "env was: {env}");
836 }
837
838 #[test]
839 fn env_emits_effort_when_set() {
840 let mut c = fixture();
841 c.projects[0].managers.get_mut("mgr").unwrap().effort = Some(EffortLevel::Max);
842 let h = c.agents().next().unwrap();
843 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
844 assert!(env.contains("EFFORT=max\n"), "env was: {env}");
845 }
846
847 #[test]
848 fn mcp_json_parses_back() {
849 let c = fixture();
850 let h = c.agents().next().unwrap();
851 let (_, mcp) = render_agent(&c, h, "/usr/local/bin/team-mcp");
852 let v: serde_json::Value = serde_json::from_str(&mcp).unwrap();
853 assert_eq!(
854 v["mcpServers"]["team"]["command"],
855 "/usr/local/bin/team-mcp"
856 );
857 assert_eq!(
858 v["mcpServers"]["team"]["args"][1].as_str().unwrap(),
859 "hello:mgr"
860 );
861 }
862
863 #[test]
864 fn mcp_json_threads_tmux_prefix_from_compose() {
865 let c = fixture();
871 let h = c.agents().next().unwrap();
872 let (_, mcp) = render_agent(&c, h, "/usr/local/bin/team-mcp");
873 let v: serde_json::Value = serde_json::from_str(&mcp).unwrap();
874 let args: Vec<&str> = v["mcpServers"]["team"]["args"]
875 .as_array()
876 .unwrap()
877 .iter()
878 .map(|a| a.as_str().unwrap())
879 .collect();
880 let i = args.iter().position(|a| *a == "--tmux-prefix").expect(
881 "render_mcp must emit --tmux-prefix so compact_self resolves the caller's pane",
882 );
883 assert_eq!(
884 args[i + 1],
885 "a-",
886 "prefix must come from compose, not the default"
887 );
888 }
889
890 fn server(command: &str, args: &[&str]) -> McpServer {
892 McpServer {
893 command: command.into(),
894 args: args.iter().map(|s| s.to_string()).collect(),
895 env: Default::default(),
896 }
897 }
898
899 #[test]
900 fn mcp_json_includes_declared_servers_alongside_team() {
901 let mut c = fixture();
905 let mut mcps = BTreeMap::new();
906 let mut gh = server("npx", &["-y", "@modelcontextprotocol/server-github"]);
907 gh.env
908 .insert("GITHUB_TOKEN".into(), "${GITHUB_TOKEN}".into());
909 mcps.insert("github".into(), gh);
910 c.projects[0].managers.get_mut("mgr").unwrap().mcps = mcps;
911
912 let h = c.agents().next().unwrap();
913 let (_, mcp) = render_agent(&c, h, "/usr/local/bin/team-mcp");
914 let v: serde_json::Value = serde_json::from_str(&mcp).unwrap();
915
916 assert_eq!(
918 v["mcpServers"]["team"]["command"],
919 "/usr/local/bin/team-mcp"
920 );
921 assert_eq!(v["mcpServers"]["github"]["command"], "npx");
923 assert_eq!(v["mcpServers"]["github"]["args"][0], "-y");
924 assert_eq!(
925 v["mcpServers"]["github"]["env"]["GITHUB_TOKEN"], "${GITHUB_TOKEN}",
926 "env values must pass through verbatim — the runtime expands ${{VAR}}"
927 );
928 assert_eq!(v["mcpServers"].as_object().unwrap().len(), 2);
929 }
930
931 #[test]
932 fn mcp_json_team_server_is_non_clobberable() {
933 let mut c = fixture();
937 let mut mcps = BTreeMap::new();
938 mcps.insert("team".into(), server("evil-team", &[]));
939 mcps.insert("github".into(), server("npx", &[]));
940 c.projects[0].managers.get_mut("mgr").unwrap().mcps = mcps;
941
942 let h = c.agents().next().unwrap();
943 let (_, mcp) = render_agent(&c, h, "/usr/local/bin/team-mcp");
944 let v: serde_json::Value = serde_json::from_str(&mcp).unwrap();
945
946 assert_eq!(
947 v["mcpServers"]["team"]["command"], "/usr/local/bin/team-mcp",
948 "built-in team server must not be clobbered by a declared `team`"
949 );
950 assert!(v["mcpServers"]["github"].is_object());
951 assert_eq!(
952 v["mcpServers"].as_object().unwrap().len(),
953 2,
954 "the declared `team` is dropped, not added as a third entry"
955 );
956 }
957
958 #[test]
959 fn mcp_json_unchanged_when_no_servers_declared() {
960 let c = fixture();
963 let h = c.agents().next().unwrap();
964 let (_, mcp) = render_agent(&c, h, "/usr/local/bin/team-mcp");
965 let v: serde_json::Value = serde_json::from_str(&mcp).unwrap();
966 let servers = v["mcpServers"].as_object().unwrap();
967 assert_eq!(servers.len(), 1);
968 assert!(servers.contains_key("team"));
969 }
970
971 #[test]
972 fn mcp_json_skips_declared_servers_on_runtime_without_mcp_support() {
973 let tmp = tempfile::tempdir().unwrap();
977 std::fs::create_dir_all(tmp.path().join("runtimes")).unwrap();
978 std::fs::write(
979 tmp.path().join("runtimes/codex.yaml"),
980 "binary: codex\nsupports_mcp: false\n",
981 )
982 .unwrap();
983
984 let mut c = fixture();
985 c.root = tmp.path().to_path_buf();
986 {
987 let m = c.projects[0].managers.get_mut("mgr").unwrap();
988 m.runtime = "codex".into();
989 let mut mcps = BTreeMap::new();
990 mcps.insert("github".into(), server("npx", &[]));
991 m.mcps = mcps;
992 }
993
994 let h = c.agents().next().unwrap();
995 let (_, mcp) = render_agent(&c, h, "/usr/local/bin/team-mcp");
996 let v: serde_json::Value = serde_json::from_str(&mcp).unwrap();
997 let servers = v["mcpServers"].as_object().unwrap();
998 assert!(servers.contains_key("team"), "team bus stays unconditional");
999 assert!(
1000 !servers.contains_key("github"),
1001 "declared server skipped when runtime lacks supports_mcp"
1002 );
1003 assert_eq!(servers.len(), 1);
1004 }
1005
1006 #[test]
1007 fn env_points_at_source_for_single_role_prompt() {
1008 let c = fixture();
1009 let h = c.agents().next().unwrap();
1010 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
1011 assert!(
1012 env.contains("SYSTEM_PROMPT_PATH=/teamctl/roles/mgr.md\n"),
1013 "env was: {env}"
1014 );
1015 }
1016
1017 #[test]
1018 fn env_points_at_concat_path_for_multi_role_prompt() {
1019 let mut c = fixture();
1020 c.projects[0].managers.get_mut("mgr").unwrap().role_prompt =
1021 Some(RolePrompt::Multiple(vec![
1022 PathBuf::from("roles/_base.md"),
1023 PathBuf::from("roles/mgr.md"),
1024 ]));
1025 let h = c.agents().next().unwrap();
1026 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
1027 assert!(
1028 env.contains("SYSTEM_PROMPT_PATH=/teamctl/state/role_prompts/hello-mgr.md\n"),
1029 "env was: {env}"
1030 );
1031 }
1032
1033 #[test]
1034 fn write_role_prompt_concat_is_noop_for_single() {
1035 let dir = tempfile::tempdir().unwrap();
1036 let mut c = fixture();
1037 c.root = dir.path().to_path_buf();
1038 let h = c.agents().next().unwrap();
1039 write_role_prompt_concat(&c, h).unwrap();
1040 assert!(
1041 !role_prompt_concat_path(&c.root, h.project, h.agent).exists(),
1042 "single-form role_prompt should not produce a concat file"
1043 );
1044 }
1045
1046 #[test]
1047 fn write_role_prompt_concat_joins_in_declared_order() {
1048 let dir = tempfile::tempdir().unwrap();
1049 let root = dir.path();
1050 std::fs::create_dir_all(root.join("roles")).unwrap();
1051 std::fs::write(root.join("roles/_base.md"), "BASE").unwrap();
1052 std::fs::write(root.join("roles/mgr.md"), "MGR").unwrap();
1053
1054 let mut c = fixture();
1055 c.root = root.to_path_buf();
1056 c.projects[0].managers.get_mut("mgr").unwrap().role_prompt =
1057 Some(RolePrompt::Multiple(vec![
1058 PathBuf::from("roles/_base.md"),
1059 PathBuf::from("roles/mgr.md"),
1060 ]));
1061 let h = c.agents().next().unwrap();
1062 write_role_prompt_concat(&c, h).unwrap();
1063
1064 let dest = role_prompt_concat_path(root, h.project, h.agent);
1065 let got = std::fs::read_to_string(&dest).unwrap();
1066 assert_eq!(got, "BASE\n\n—\n\nMGR");
1067 }
1068
1069 #[test]
1070 fn write_role_prompt_concat_reflects_source_edits() {
1071 let dir = tempfile::tempdir().unwrap();
1074 let root = dir.path();
1075 std::fs::create_dir_all(root.join("roles")).unwrap();
1076 std::fs::write(root.join("roles/_base.md"), "v1").unwrap();
1077 std::fs::write(root.join("roles/mgr.md"), "MGR").unwrap();
1078
1079 let mut c = fixture();
1080 c.root = root.to_path_buf();
1081 c.projects[0].managers.get_mut("mgr").unwrap().role_prompt =
1082 Some(RolePrompt::Multiple(vec![
1083 PathBuf::from("roles/_base.md"),
1084 PathBuf::from("roles/mgr.md"),
1085 ]));
1086 let h = c.agents().next().unwrap();
1087 write_role_prompt_concat(&c, h).unwrap();
1088
1089 std::fs::write(root.join("roles/_base.md"), "v2").unwrap();
1090 let h = c.agents().next().unwrap();
1091 write_role_prompt_concat(&c, h).unwrap();
1092
1093 let dest = role_prompt_concat_path(root, h.project, h.agent);
1094 let got = std::fs::read_to_string(&dest).unwrap();
1095 assert_eq!(got, "v2\n\n—\n\nMGR");
1096 }
1097
1098 #[test]
1099 fn claude_settings_present_for_claude_code() {
1100 let c = fixture();
1104 let h = c.agents().next().unwrap();
1105 let s = render_claude_settings(&c, h).expect("claude-code agent must get settings");
1106 let v: serde_json::Value = serde_json::from_str(&s).unwrap();
1107 let pre = &v["hooks"]["PreToolUse"][0];
1108 assert_eq!(
1109 pre["matcher"].as_str().unwrap(),
1110 "AskUserQuestion|EnterPlanMode|ExitPlanMode"
1111 );
1112 let cmd = pre["hooks"][0]["command"].as_str().unwrap();
1113 assert!(
1114 cmd.contains(r#""permissionDecision":"deny""#),
1115 "deny verdict missing from hook command: {cmd}"
1116 );
1117 assert!(
1118 cmd.contains("Interactive prompts are disabled"),
1119 "systemMessage missing from hook command: {cmd}"
1120 );
1121 }
1122
1123 #[test]
1124 fn claude_settings_pre_trust_all_project_mcp_servers() {
1125 let c = fixture();
1132 let h = c.agents().next().unwrap();
1133 let s = render_claude_settings(&c, h).expect("claude-code agent must get settings");
1134 let v: serde_json::Value = serde_json::from_str(&s).unwrap();
1135 assert_eq!(
1136 v["enableAllProjectMcpServers"],
1137 serde_json::Value::Bool(true),
1138 "headless settings must pre-trust project MCP servers: {s}"
1139 );
1140 }
1141
1142 #[test]
1143 fn claude_settings_absent_for_non_claude_runtimes() {
1144 let mut c = fixture();
1147 c.projects[0].managers.get_mut("mgr").unwrap().runtime = "codex".into();
1148 let h = c.agents().next().unwrap();
1149 assert!(render_claude_settings(&c, h).is_none());
1150 }
1151
1152 #[test]
1153 fn declared_hook_merges_alongside_deny_hook() {
1154 let mut c = fixture();
1159 c.projects[0].managers.get_mut("mgr").unwrap().hooks = vec![HookSpec {
1160 event: "PreToolUse".into(),
1161 matcher: Some("Bash".into()),
1162 command: PathBuf::from("hooks/guard.sh"),
1163 }];
1164 let h = c.agents().next().unwrap();
1165 let s = render_claude_settings(&c, h).expect("claude-code agent must get settings");
1166 let v: serde_json::Value = serde_json::from_str(&s).unwrap();
1167 let pre = v["hooks"]["PreToolUse"].as_array().unwrap();
1168 assert_eq!(
1169 pre.len(),
1170 3,
1171 "deny hook + #428 heartbeat touch + declared hook expected"
1172 );
1173 assert_eq!(
1175 pre[0]["matcher"].as_str().unwrap(),
1176 "AskUserQuestion|EnterPlanMode|ExitPlanMode"
1177 );
1178 assert!(pre[0]["hooks"][0]["command"]
1179 .as_str()
1180 .unwrap()
1181 .contains(r#""permissionDecision":"deny""#));
1182 assert!(
1184 pre[1].get("matcher").is_none(),
1185 "heartbeat touch must be match-all: {}",
1186 pre[1]
1187 );
1188 assert_eq!(pre[2]["matcher"].as_str().unwrap(), "Bash");
1190 assert_eq!(pre[2]["hooks"][0]["type"].as_str().unwrap(), "command");
1191 assert_eq!(
1192 pre[2]["hooks"][0]["command"].as_str().unwrap(),
1193 "/teamctl/hooks/guard.sh"
1194 );
1195 }
1196
1197 #[test]
1198 fn default_hooks_are_deny_plus_heartbeat_buckets() {
1199 let c = fixture();
1206 let h = c.agents().next().unwrap();
1207 let v: serde_json::Value =
1208 serde_json::from_str(&render_claude_settings(&c, h).unwrap()).unwrap();
1209 let hooks = v["hooks"].as_object().unwrap();
1210 let keys: std::collections::BTreeSet<&str> = hooks.keys().map(String::as_str).collect();
1211 assert_eq!(
1212 keys,
1213 ["PreToolUse", "Stop", "StopFailure", "UserPromptSubmit"]
1214 .into_iter()
1215 .collect::<std::collections::BTreeSet<_>>(),
1216 "exact set of built-in default hook buckets expected with no declared hooks"
1217 );
1218 assert_eq!(
1220 hooks["PreToolUse"].as_array().unwrap().len(),
1221 2,
1222 "deny hook + heartbeat touch expected"
1223 );
1224 for ev in ["UserPromptSubmit", "Stop", "StopFailure"] {
1226 assert_eq!(
1227 hooks[ev].as_array().unwrap().len(),
1228 1,
1229 "{ev} should hold exactly one built-in entry"
1230 );
1231 }
1232 }
1233
1234 #[test]
1235 fn heartbeat_hooks_touch_and_clear_the_marker() {
1236 let c = fixture();
1240 let h = c.agents().next().unwrap();
1241 let path = heartbeat_path(&c.root, h.project, h.agent)
1242 .display()
1243 .to_string();
1244 let q = crate::supervisor::shlex::try_quote(&path).unwrap();
1248 let v: serde_json::Value =
1249 serde_json::from_str(&render_claude_settings(&c, h).unwrap()).unwrap();
1250 let hooks = &v["hooks"];
1251
1252 let touch_entry = &hooks["PreToolUse"].as_array().unwrap()[1];
1254 assert!(
1255 touch_entry.get("matcher").is_none(),
1256 "heartbeat must be match-all (no matcher): {touch_entry}"
1257 );
1258 assert_eq!(touch_entry["hooks"][0]["type"].as_str().unwrap(), "command");
1259 assert_eq!(
1260 touch_entry["hooks"][0]["command"].as_str().unwrap(),
1261 format!("touch {q}"),
1262 "PreToolUse should touch the quoted marker"
1263 );
1264
1265 assert_eq!(
1267 hooks["UserPromptSubmit"].as_array().unwrap()[0]["hooks"][0]["command"]
1268 .as_str()
1269 .unwrap(),
1270 format!("touch {q}"),
1271 "UserPromptSubmit should touch the quoted marker"
1272 );
1273
1274 for ev in ["Stop", "StopFailure"] {
1276 let entry = &hooks[ev].as_array().unwrap()[0];
1277 assert!(
1278 entry.get("matcher").is_none(),
1279 "{ev} must be match-all (no matcher)"
1280 );
1281 assert_eq!(
1282 entry["hooks"][0]["command"].as_str().unwrap(),
1283 format!("rm -f {q}"),
1284 "{ev} should rm the quoted marker"
1285 );
1286 }
1287 }
1288
1289 #[test]
1290 fn declared_hook_without_matcher_opens_new_event_bucket() {
1291 let mut c = fixture();
1296 c.projects[0].managers.get_mut("mgr").unwrap().hooks = vec![HookSpec {
1297 event: "PostToolUse".into(),
1298 matcher: None,
1299 command: PathBuf::from("hooks/log.sh"),
1300 }];
1301 let h = c.agents().next().unwrap();
1302 let v: serde_json::Value =
1303 serde_json::from_str(&render_claude_settings(&c, h).unwrap()).unwrap();
1304 assert_eq!(
1305 v["hooks"]["PreToolUse"].as_array().unwrap().len(),
1306 2,
1307 "PreToolUse keeps its deny + #428 heartbeat built-ins"
1308 );
1309 let post = &v["hooks"]["PostToolUse"].as_array().unwrap()[0];
1310 assert!(
1311 post.get("matcher").is_none(),
1312 "matcher must be omitted when unset: {post}"
1313 );
1314 assert_eq!(
1315 post["hooks"][0]["command"].as_str().unwrap(),
1316 "/teamctl/hooks/log.sh"
1317 );
1318 }
1319
1320 #[test]
1321 fn declared_hooks_noop_on_non_claude_runtime() {
1322 let mut c = fixture();
1325 {
1326 let m = c.projects[0].managers.get_mut("mgr").unwrap();
1327 m.runtime = "codex".into();
1328 m.hooks = vec![HookSpec {
1329 event: "PreToolUse".into(),
1330 matcher: Some("Bash".into()),
1331 command: PathBuf::from("hooks/guard.sh"),
1332 }];
1333 }
1334 let h = c.agents().next().unwrap();
1335 assert!(
1336 render_claude_settings(&c, h).is_none(),
1337 "hooks must not render on non-claude runtimes"
1338 );
1339 }
1340
1341 #[test]
1342 fn env_emits_claude_settings_path_for_claude_code() {
1343 let c = fixture();
1346 let h = c.agents().next().unwrap();
1347 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
1348 assert!(
1349 env.contains("CLAUDE_SETTINGS=/teamctl/state/claude/hello-mgr.json\n"),
1350 "env was: {env}"
1351 );
1352 }
1353
1354 #[test]
1355 fn env_omits_claude_settings_for_non_claude_runtimes() {
1356 let mut c = fixture();
1360 c.projects[0].managers.get_mut("mgr").unwrap().runtime = "codex".into();
1361 let h = c.agents().next().unwrap();
1362 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
1363 assert!(
1364 !env.contains("CLAUDE_SETTINGS="),
1365 "non-claude runtime must not get settings path: {env}"
1366 );
1367 }
1368
1369 #[test]
1370 fn write_role_prompt_concat_errors_on_missing_source() {
1371 let dir = tempfile::tempdir().unwrap();
1372 let mut c = fixture();
1373 c.root = dir.path().to_path_buf();
1374 c.projects[0].managers.get_mut("mgr").unwrap().role_prompt = Some(RolePrompt::Multiple(
1375 vec![PathBuf::from("roles/missing.md")],
1376 ));
1377 let h = c.agents().next().unwrap();
1378 let err = write_role_prompt_concat(&c, h).unwrap_err();
1379 assert!(err.to_string().contains("missing.md"), "err was: {err}");
1380 }
1381
1382 fn write_file(root: &std::path::Path, rel: &str, contents: &str) {
1385 let abs = root.join(rel);
1386 std::fs::create_dir_all(abs.parent().unwrap()).unwrap();
1387 std::fs::write(abs, contents).unwrap();
1388 }
1389
1390 fn rooted(write: impl FnOnce(&std::path::Path)) -> (tempfile::TempDir, Compose) {
1391 let dir = tempfile::tempdir().unwrap();
1392 let mut c = fixture();
1393 c.root = dir.path().to_path_buf();
1394 write(dir.path());
1395 (dir, c)
1396 }
1397
1398 #[test]
1399 fn render_subagents_builds_agents_json_from_frontmatter() {
1400 let (_d, mut c) = rooted(|root| {
1401 write_file(
1402 root,
1403 "agents/security-auditor.md",
1404 "---\nname: security-auditor\ndescription: Audits diffs for vulns.\n\
1405 tools: Read, Grep\nmodel: claude-sonnet-4-6\n---\n\
1406 You are a security auditor.\nFlag risky patterns.\n",
1407 );
1408 });
1409 c.projects[0].managers.get_mut("mgr").unwrap().subagents =
1410 vec![PathBuf::from("agents/security-auditor.md")];
1411 let h = c.agents().next().unwrap();
1412 let json = render_subagents(&c, h).unwrap().expect("some json");
1413 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
1414 let entry = &v["security-auditor"];
1415 assert_eq!(entry["description"], "Audits diffs for vulns.");
1416 assert_eq!(
1417 entry["prompt"],
1418 "You are a security auditor.\nFlag risky patterns."
1419 );
1420 assert_eq!(entry["tools"], serde_json::json!(["Read", "Grep"]));
1421 assert_eq!(entry["model"], "claude-sonnet-4-6");
1422 }
1423
1424 #[test]
1425 fn render_subagents_name_falls_back_to_file_stem() {
1426 let (_d, mut c) = rooted(|root| {
1427 write_file(
1428 root,
1429 "agents/repo-cartographer.md",
1430 "---\ndescription: Maps the repo.\n---\nMap it.\n",
1431 );
1432 });
1433 c.projects[0].managers.get_mut("mgr").unwrap().subagents =
1434 vec![PathBuf::from("agents/repo-cartographer.md")];
1435 let h = c.agents().next().unwrap();
1436 let json = render_subagents(&c, h).unwrap().unwrap();
1437 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
1438 assert!(
1439 v.get("repo-cartographer").is_some(),
1440 "stem-derived name missing: {json}"
1441 );
1442 assert!(v["repo-cartographer"].get("tools").is_none());
1444 assert!(v["repo-cartographer"].get("model").is_none());
1445 }
1446
1447 #[test]
1448 fn render_subagents_supports_yaml_list_tools() {
1449 let (_d, mut c) = rooted(|root| {
1450 write_file(
1451 root,
1452 "agents/x.md",
1453 "---\nname: x\ndescription: d\ntools: [Read, Bash]\n---\nbody\n",
1454 );
1455 });
1456 c.projects[0].managers.get_mut("mgr").unwrap().subagents =
1457 vec![PathBuf::from("agents/x.md")];
1458 let h = c.agents().next().unwrap();
1459 let json = render_subagents(&c, h).unwrap().unwrap();
1460 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
1461 assert_eq!(v["x"]["tools"], serde_json::json!(["Read", "Bash"]));
1462 }
1463
1464 #[test]
1465 fn render_subagents_isolates_per_agent() {
1466 let (_d, mut c) = rooted(|root| {
1469 write_file(
1470 root,
1471 "agents/a.md",
1472 "---\nname: a\ndescription: da\n---\nba\n",
1473 );
1474 write_file(
1475 root,
1476 "agents/b.md",
1477 "---\nname: b\ndescription: db\n---\nbb\n",
1478 );
1479 });
1480 let worker = c.projects[0].managers["mgr"].clone();
1481 c.projects[0].workers.insert("dev".into(), worker);
1482 c.projects[0].managers.get_mut("mgr").unwrap().subagents =
1483 vec![PathBuf::from("agents/a.md")];
1484 c.projects[0].workers.get_mut("dev").unwrap().subagents =
1485 vec![PathBuf::from("agents/b.md")];
1486
1487 for h in c.agents() {
1488 let v: serde_json::Value =
1489 serde_json::from_str(&render_subagents(&c, h).unwrap().unwrap()).unwrap();
1490 match h.agent {
1491 "mgr" => {
1492 assert!(v.get("a").is_some() && v.get("b").is_none());
1493 }
1494 "dev" => {
1495 assert!(v.get("b").is_some() && v.get("a").is_none());
1496 }
1497 other => panic!("unexpected agent {other}"),
1498 }
1499 }
1500 }
1501
1502 #[test]
1503 fn render_subagents_none_when_empty() {
1504 let c = fixture();
1505 let h = c.agents().next().unwrap();
1506 assert!(render_subagents(&c, h).unwrap().is_none());
1507 }
1508
1509 #[test]
1510 fn render_subagents_ignored_on_non_claude_runtime() {
1511 let (_d, mut c) = rooted(|root| {
1512 write_file(
1513 root,
1514 "agents/x.md",
1515 "---\nname: x\ndescription: d\n---\nb\n",
1516 );
1517 });
1518 {
1519 let a = c.projects[0].managers.get_mut("mgr").unwrap();
1520 a.runtime = "codex".into();
1521 a.subagents = vec![PathBuf::from("agents/x.md")];
1522 }
1523 let h = c.agents().next().unwrap();
1524 assert!(render_subagents(&c, h).unwrap().is_none());
1526 }
1527
1528 #[test]
1529 fn render_subagents_errors_on_missing_source() {
1530 let (_d, mut c) = rooted(|_| {});
1531 c.projects[0].managers.get_mut("mgr").unwrap().subagents =
1532 vec![PathBuf::from("agents/nope.md")];
1533 let h = c.agents().next().unwrap();
1534 let err = render_subagents(&c, h).unwrap_err();
1535 assert!(err.to_string().contains("nope.md"), "err was: {err}");
1536 }
1537
1538 #[test]
1539 fn render_subagents_errors_on_unterminated_frontmatter() {
1540 let (_d, mut c) = rooted(|root| {
1541 write_file(
1542 root,
1543 "agents/bad.md",
1544 "---\nname: x\ndescription: d\nno close\n",
1545 );
1546 });
1547 c.projects[0].managers.get_mut("mgr").unwrap().subagents =
1548 vec![PathBuf::from("agents/bad.md")];
1549 let h = c.agents().next().unwrap();
1550 assert!(render_subagents(&c, h).is_err());
1551 }
1552
1553 #[test]
1554 fn env_emits_claude_agents_json_for_claude_code() {
1555 let c = fixture();
1556 let h = c.agents().next().unwrap();
1557 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
1558 assert!(env.contains("CLAUDE_AGENTS_JSON=/teamctl/state/claude/hello-mgr.agents.json"));
1559 }
1560
1561 #[test]
1562 fn write_subagents_json_writes_then_clears_stale() {
1563 let (_d, mut c) = rooted(|root| {
1564 write_file(
1565 root,
1566 "agents/x.md",
1567 "---\nname: x\ndescription: d\n---\nbody\n",
1568 );
1569 });
1570 let dest = subagents_json_path(&c.root, "hello", "mgr");
1571
1572 c.projects[0].managers.get_mut("mgr").unwrap().subagents =
1574 vec![PathBuf::from("agents/x.md")];
1575 let h = c.agents().next().unwrap();
1576 write_subagents_json(&c, h).unwrap();
1577 assert!(dest.exists(), "agents json should be written");
1578
1579 c.projects[0].managers.get_mut("mgr").unwrap().subagents = vec![];
1581 let h = c.agents().next().unwrap();
1582 write_subagents_json(&c, h).unwrap();
1583 assert!(!dest.exists(), "stale agents json should be removed");
1584 }
1585
1586 #[test]
1587 fn write_agent_skills_materializes_symlinks() {
1588 let (_d, mut c) = rooted(|root| {
1589 write_file(root, "skills/pr-review/SKILL.md", "# PR review skill\n");
1590 });
1591 c.projects[0].managers.get_mut("mgr").unwrap().skills =
1592 vec![PathBuf::from("skills/pr-review")];
1593 let h = c.agents().next().unwrap();
1594 write_agent_skills(&c, h).unwrap();
1595
1596 let link = agent_scope_dir(&c.root, "hello", "mgr").join(".claude/skills/pr-review");
1597 let meta = std::fs::symlink_metadata(&link).expect("link should exist");
1598 assert!(meta.file_type().is_symlink(), "entry must be a symlink");
1599 assert_eq!(
1601 std::fs::canonicalize(&link).unwrap(),
1602 std::fs::canonicalize(c.root.join("skills/pr-review")).unwrap()
1603 );
1604 }
1605
1606 #[test]
1607 fn write_agent_skills_clear_stale_preserves_source() {
1608 let (_d, mut c) = rooted(|root| {
1611 write_file(root, "skills/foo/SKILL.md", "# foo\n");
1612 });
1613 let source = c.root.join("skills/foo");
1614 let source_md = source.join("SKILL.md");
1615
1616 c.projects[0].managers.get_mut("mgr").unwrap().skills = vec![PathBuf::from("skills/foo")];
1618 let h = c.agents().next().unwrap();
1619 write_agent_skills(&c, h).unwrap();
1620 let scope = agent_scope_dir(&c.root, "hello", "mgr");
1621 assert!(scope.join(".claude/skills/foo").exists());
1622
1623 c.projects[0].managers.get_mut("mgr").unwrap().skills = vec![];
1625 let h = c.agents().next().unwrap();
1626 write_agent_skills(&c, h).unwrap();
1627 assert!(!scope.exists(), "stale scope dir should be removed");
1628 assert!(source.is_dir(), "source skill dir must survive the clear");
1629 assert!(
1630 source_md.is_file(),
1631 "source SKILL.md must survive the clear"
1632 );
1633 }
1634
1635 #[test]
1636 fn write_agent_skills_isolates_per_agent() {
1637 let (_d, mut c) = rooted(|root| {
1640 write_file(root, "skills/a/SKILL.md", "# a\n");
1641 write_file(root, "skills/b/SKILL.md", "# b\n");
1642 });
1643 let worker = c.projects[0].managers["mgr"].clone();
1644 c.projects[0].workers.insert("dev".into(), worker);
1645 c.projects[0].managers.get_mut("mgr").unwrap().skills = vec![PathBuf::from("skills/a")];
1646 c.projects[0].workers.get_mut("dev").unwrap().skills = vec![PathBuf::from("skills/b")];
1647
1648 for h in c.agents() {
1649 write_agent_skills(&c, h).unwrap();
1650 }
1651 let mgr_skills = agent_scope_dir(&c.root, "hello", "mgr").join(".claude/skills");
1652 let dev_skills = agent_scope_dir(&c.root, "hello", "dev").join(".claude/skills");
1653 assert!(mgr_skills.join("a").exists() && !mgr_skills.join("b").exists());
1654 assert!(dev_skills.join("b").exists() && !dev_skills.join("a").exists());
1655 }
1656
1657 #[test]
1658 fn write_agent_skills_ignored_on_non_claude_runtime() {
1659 let (_d, mut c) = rooted(|root| {
1660 write_file(root, "skills/x/SKILL.md", "# x\n");
1661 });
1662 {
1663 let a = c.projects[0].managers.get_mut("mgr").unwrap();
1664 a.runtime = "codex".into();
1665 a.skills = vec![PathBuf::from("skills/x")];
1666 }
1667 let h = c.agents().next().unwrap();
1668 write_agent_skills(&c, h).unwrap();
1671 assert!(!agent_scope_dir(&c.root, "hello", "mgr").exists());
1672 }
1673
1674 #[test]
1675 fn env_emits_claude_agent_scope_for_claude_code() {
1676 let c = fixture();
1677 let h = c.agents().next().unwrap();
1678 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
1679 assert!(env.contains("CLAUDE_AGENT_SCOPE=/teamctl/state/agent-scope/hello-mgr"));
1680 }
1681
1682 #[test]
1683 fn env_omits_claude_agent_scope_for_non_claude_runtimes() {
1684 let mut c = fixture();
1685 c.projects[0].managers.get_mut("mgr").unwrap().runtime = "codex".into();
1686 let h = c.agents().next().unwrap();
1687 let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
1688 assert!(
1689 !env.contains("CLAUDE_AGENT_SCOPE="),
1690 "non-claude runtime must not get the agent scope: {env}"
1691 );
1692 }
1693}