use std::io;
use std::path::{Path, PathBuf};
use crate::compose::{AgentHandle, Compose, RolePrompt};
const ROLE_PROMPT_SEPARATOR: &str = "\n\n—\n\n";
pub fn env_path(root: &Path, project: &str, agent: &str) -> PathBuf {
root.join("state/envs")
.join(format!("{project}-{agent}.env"))
}
pub fn mcp_path(root: &Path, project: &str, agent: &str) -> PathBuf {
root.join("state/mcp")
.join(format!("{project}-{agent}.json"))
}
pub fn claude_settings_path(root: &Path, project: &str, agent: &str) -> PathBuf {
root.join("state/claude")
.join(format!("{project}-{agent}.json"))
}
pub fn subagents_json_path(root: &Path, project: &str, agent: &str) -> PathBuf {
root.join("state/claude")
.join(format!("{project}-{agent}.agents.json"))
}
pub fn agent_scope_dir(root: &Path, project: &str, agent: &str) -> PathBuf {
root.join("state/agent-scope")
.join(format!("{project}-{agent}"))
}
pub fn role_prompt_concat_path(root: &Path, project: &str, agent: &str) -> PathBuf {
root.join("state/role_prompts")
.join(format!("{project}-{agent}.md"))
}
pub fn render_agent(
compose: &Compose,
handle: AgentHandle<'_>,
team_mcp_bin: &str,
) -> (String, String) {
let env = render_env(compose, handle);
let mcp = render_mcp(compose, handle, team_mcp_bin);
(env, mcp)
}
pub fn render_claude_settings(compose: &Compose, h: AgentHandle<'_>) -> Option<String> {
if h.spec.runtime != "claude-code" {
if !h.spec.hooks.is_empty() {
tracing::warn!(
target: "team-core::render",
"agent `{}:{}` declares {} hook(s) but runtime `{}` does not support hooks (claude-code only); ignoring",
h.project,
h.agent,
h.spec.hooks.len(),
h.spec.runtime
);
}
return None;
}
let mut v = serde_json::json!({
"hooks": {
"PreToolUse": [
{
"matcher": "AskUserQuestion|EnterPlanMode|ExitPlanMode",
"hooks": [
{
"type": "command",
"command": "echo '{\"hookSpecificOutput\":{\"permissionDecision\":\"deny\"},\"systemMessage\":\"Interactive prompts are disabled for teamctl agents. Use the `team` MCP tools to ask people or check in.\"}'"
}
]
}
]
}
});
let hooks_obj = v["hooks"].as_object_mut().expect("hooks is a json object");
for hook in &h.spec.hooks {
let command = compose.root.join(&hook.command);
let mut entry = serde_json::json!({
"hooks": [
{
"type": "command",
"command": command.display().to_string()
}
]
});
if let Some(matcher) = &hook.matcher {
entry["matcher"] = serde_json::Value::String(matcher.clone());
}
hooks_obj
.entry(hook.event.clone())
.or_insert_with(|| serde_json::Value::Array(Vec::new()))
.as_array_mut()
.expect("hook event maps to a json array")
.push(entry);
}
Some(serde_json::to_string_pretty(&v).expect("json"))
}
pub fn render_subagents(compose: &Compose, h: AgentHandle<'_>) -> io::Result<Option<String>> {
if h.spec.subagents.is_empty() {
return Ok(None);
}
if h.spec.runtime != "claude-code" {
tracing::warn!(
target: "team-core::render",
"agent `{}:{}` declares {} sub-agent(s) but runtime `{}` does not support sub-agents (claude-code only); ignoring",
h.project,
h.agent,
h.spec.subagents.len(),
h.spec.runtime
);
return Ok(None);
}
let mut map = serde_json::Map::new();
for rel in &h.spec.subagents {
let abs = compose.root.join(rel);
let raw = std::fs::read_to_string(&abs).map_err(|e| {
io::Error::new(
e.kind(),
format!("read sub-agent source {}: {e}", abs.display()),
)
})?;
let (fm, body) = parse_subagent(&raw).map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("parse sub-agent {}: {e}", abs.display()),
)
})?;
let name = fm.name.filter(|n| !n.trim().is_empty()).unwrap_or_else(|| {
rel.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default()
});
let mut entry = serde_json::json!({
"description": fm.description,
"prompt": body,
});
if let Some(tools) = fm.tools {
let list = tools.into_list();
if !list.is_empty() {
entry["tools"] = serde_json::json!(list);
}
}
if let Some(model) = fm.model.filter(|m| !m.trim().is_empty()) {
entry["model"] = serde_json::Value::String(model);
}
map.insert(name, entry);
}
Ok(Some(
serde_json::to_string_pretty(&serde_json::Value::Object(map)).expect("json"),
))
}
pub fn write_subagents_json(compose: &Compose, h: AgentHandle<'_>) -> io::Result<()> {
let dest = subagents_json_path(&compose.root, h.project, h.agent);
match render_subagents(compose, h)? {
Some(json) => {
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&dest, json)
}
None => match std::fs::remove_file(&dest) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
},
}
}
pub fn write_agent_skills(compose: &Compose, h: AgentHandle<'_>) -> io::Result<()> {
let scope = agent_scope_dir(&compose.root, h.project, h.agent);
let skills_dir = scope.join(".claude/skills");
if h.spec.runtime != "claude-code" || h.spec.skills.is_empty() {
if h.spec.runtime != "claude-code" && !h.spec.skills.is_empty() {
tracing::warn!(
target: "team-core::render",
"agent `{}:{}` declares {} skill(s) but runtime `{}` does not support skills (claude-code only); ignoring",
h.project,
h.agent,
h.spec.skills.len(),
h.spec.runtime
);
}
return remove_scope_dir(&scope);
}
clear_skills_dir(&skills_dir)?;
std::fs::create_dir_all(&skills_dir)?;
for rel in &h.spec.skills {
let Some(name) = rel.file_name() else {
continue; };
let link = skills_dir.join(name);
if std::fs::symlink_metadata(&link).is_ok() {
std::fs::remove_file(&link)?;
}
std::os::unix::fs::symlink(compose.root.join(rel), &link)?;
}
Ok(())
}
fn remove_scope_dir(scope: &Path) -> io::Result<()> {
clear_skills_dir(&scope.join(".claude/skills"))?;
match std::fs::remove_dir_all(scope) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}
fn clear_skills_dir(skills_dir: &Path) -> io::Result<()> {
let entries = match std::fs::read_dir(skills_dir) {
Ok(e) => e,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(e),
};
for entry in entries {
let entry = entry?;
let path = entry.path();
let meta = std::fs::symlink_metadata(&path)?;
if meta.file_type().is_symlink() || meta.is_file() {
std::fs::remove_file(&path)?;
} else {
std::fs::remove_dir_all(&path)?;
}
}
Ok(())
}
#[derive(serde::Deserialize)]
struct SubagentFrontmatter {
#[serde(default)]
name: Option<String>,
description: String,
#[serde(default)]
tools: Option<Tools>,
#[serde(default)]
model: Option<String>,
}
#[derive(serde::Deserialize)]
#[serde(untagged)]
enum Tools {
List(Vec<String>),
Csv(String),
}
impl Tools {
fn into_list(self) -> Vec<String> {
let raw = match self {
Tools::List(v) => v,
Tools::Csv(s) => s.split(',').map(str::to_string).collect(),
};
raw.into_iter()
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
.collect()
}
}
fn parse_subagent(raw: &str) -> Result<(SubagentFrontmatter, String), String> {
let after_open = raw
.strip_prefix("---")
.ok_or("missing opening `---` frontmatter delimiter")?;
let (yaml, body) = after_open
.split_once("\n---")
.ok_or("missing closing `---` frontmatter delimiter")?;
let fm: SubagentFrontmatter =
serde_yaml::from_str(yaml.trim()).map_err(|e| format!("invalid frontmatter YAML: {e}"))?;
let body = body.trim_start_matches(['\r', '\n']).trim_end().to_string();
Ok((fm, body))
}
fn render_env(compose: &Compose, h: AgentHandle<'_>) -> String {
let project = compose
.projects
.iter()
.find(|p| p.project.id == h.project)
.expect("agent belongs to a loaded project");
let mailbox = compose.root.join(&compose.global.broker.path);
let mcp = mcp_path(&compose.root, h.project, h.agent);
let prompt = system_prompt_path(compose, h)
.map(|p| p.display().to_string())
.unwrap_or_default();
let mut s = String::new();
s.push_str(&format!("AGENT_ID={}:{}\n", h.project, h.agent));
s.push_str(&format!("PROJECT_ID={}\n", h.project));
s.push_str(&format!("RUNTIME={}\n", h.spec.runtime));
if let Some(m) = &h.spec.model {
s.push_str(&format!("MODEL={m}\n"));
}
if let Some(pm) = &h.spec.permission_mode {
s.push_str(&format!("PERMISSION_MODE={pm}\n"));
}
if let Some(effort) = h.spec.effort {
s.push_str(&format!("EFFORT={}\n", effort.as_str()));
}
s.push_str(&format!("TEAMCTL_MAILBOX={}\n", mailbox.display()));
s.push_str(&format!("MCP_CONFIG={}\n", mcp.display()));
s.push_str(&format!("SYSTEM_PROMPT_PATH={prompt}\n"));
s.push_str(&format!(
"CLAUDE_PROJECT_DIR={}\n",
project.project.cwd.display()
));
s.push_str(&format!("TEAMCTL_ROOT={}\n", compose.root.display()));
s.push_str(&format!(
"TMUX_SESSION={}{}-{}\n",
compose.global.supervisor.tmux_prefix, h.project, h.agent
));
if h.spec.runtime == "claude-code" {
let session_id = crate::session::derive_session_id(h.project, h.agent);
let session_name = crate::session::session_name(h.project, h.agent);
s.push_str(&format!("CLAUDE_SESSION_ID={session_id}\n"));
s.push_str(&format!("CLAUDE_SESSION_NAME={session_name}\n"));
let settings = claude_settings_path(&compose.root, h.project, h.agent);
s.push_str(&format!("CLAUDE_SETTINGS={}\n", settings.display()));
let subagents = subagents_json_path(&compose.root, h.project, h.agent);
s.push_str(&format!("CLAUDE_AGENTS_JSON={}\n", subagents.display()));
let scope = agent_scope_dir(&compose.root, h.project, h.agent);
s.push_str(&format!("CLAUDE_AGENT_SCOPE={}\n", scope.display()));
}
s
}
pub fn system_prompt_path(compose: &Compose, h: AgentHandle<'_>) -> Option<PathBuf> {
match h.spec.role_prompt.as_ref()? {
RolePrompt::Single(p) => Some(compose.root.join(p)),
RolePrompt::Multiple(_) => Some(role_prompt_concat_path(&compose.root, h.project, h.agent)),
}
}
pub fn write_role_prompt_concat(compose: &Compose, h: AgentHandle<'_>) -> io::Result<()> {
let Some(RolePrompt::Multiple(paths)) = h.spec.role_prompt.as_ref() else {
return Ok(());
};
let mut buf = String::new();
for (idx, rel) in paths.iter().enumerate() {
if idx > 0 {
buf.push_str(ROLE_PROMPT_SEPARATOR);
}
let abs = compose.root.join(rel);
let bytes = std::fs::read(&abs).map_err(|e| {
io::Error::new(
e.kind(),
format!("read role_prompt source {}: {e}", abs.display()),
)
})?;
buf.push_str(&String::from_utf8_lossy(&bytes));
}
let dest = role_prompt_concat_path(&compose.root, h.project, h.agent);
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&dest, buf)
}
fn render_mcp(compose: &Compose, h: AgentHandle<'_>, team_mcp_bin: &str) -> String {
let mailbox = compose.root.join(&compose.global.broker.path);
let mut v = serde_json::json!({
"mcpServers": {
"team": {
"command": team_mcp_bin,
"args": [
"--agent-id", format!("{}:{}", h.project, h.agent),
"--mailbox", mailbox.display().to_string(),
"--tmux-prefix", compose.global.supervisor.tmux_prefix.clone(),
"--compose-root", compose.root.display().to_string(),
],
"env": {}
}
}
});
if !h.spec.mcps.is_empty() {
let runtimes = crate::runtimes::load_all(&compose.root).unwrap_or_default();
let supports_mcp = runtimes
.get(h.spec.runtime.as_str())
.map(|r| r.supports_mcp)
.unwrap_or(true);
if supports_mcp {
let servers = v["mcpServers"]
.as_object_mut()
.expect("mcpServers is a json object");
for (name, server) in &h.spec.mcps {
if name == "team" {
continue; }
servers.insert(
name.clone(),
serde_json::to_value(server).expect("serialize McpServer"),
);
}
} else {
tracing::warn!(
target: "team-core::render",
"agent `{}:{}` declares {} MCP server(s) but runtime `{}` does not set `supports_mcp`; ignoring",
h.project,
h.agent,
h.spec.mcps.len(),
h.spec.runtime
);
}
}
serde_json::to_string_pretty(&v).expect("json")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::compose::*;
use std::collections::BTreeMap;
use std::path::PathBuf;
fn fixture() -> Compose {
let mut managers = BTreeMap::new();
managers.insert(
"mgr".into(),
Agent {
runtime: "claude-code".into(),
model: Some("claude-opus-4-8".into()),
role_prompt: Some(RolePrompt::Single(PathBuf::from("roles/mgr.md"))),
permission_mode: Some("auto".into()),
autonomy: "low_risk_only".into(),
can_dm: vec![],
can_broadcast: vec![],
reports_to: None,
on_rate_limit: None,
effort: None,
interfaces: None,
display_name: None,
hooks: vec![],
mcps: Default::default(),
subagents: vec![],
skills: vec![],
},
);
Compose {
root: PathBuf::from("/teamctl"),
global: Global {
version: crate::compose::SchemaVersion::new("2.0.0"),
broker: Broker {
r#type: "sqlite".into(),
path: PathBuf::from("state/mailbox.db"),
},
supervisor: SupervisorCfg {
r#type: "tmux".into(),
tmux_prefix: "a-".into(),
drain_timeout_secs: 10,
},
budget: Default::default(),
hitl: Default::default(),
rate_limits: Default::default(),
interfaces: vec![],
projects: vec![],
attachments: Default::default(),
},
projects: vec![Project {
version: 2,
project: ProjectMeta {
id: "hello".into(),
name: "Hello".into(),
cwd: PathBuf::from("/teamctl/examples/hello-team"),
},
channels: vec![],
managers,
workers: Default::default(),
interfaces: None,
}],
}
}
#[test]
fn env_contains_agent_id_and_mailbox() {
let c = fixture();
let h = c.agents().next().unwrap();
let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
assert!(env.contains("AGENT_ID=hello:mgr"));
assert!(env.contains("TEAMCTL_MAILBOX=/teamctl/state/mailbox.db"));
assert!(env.contains("TMUX_SESSION=a-hello-mgr"));
}
#[test]
fn env_emits_claude_session_id_and_name_for_claude_code_runtime() {
let c = fixture();
let h = c.agents().next().unwrap();
let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
let expected_id = crate::session::derive_session_id(h.project, h.agent);
assert!(
env.contains(&format!("CLAUDE_SESSION_ID={expected_id}\n")),
"env was: {env}"
);
assert!(
env.contains("CLAUDE_SESSION_NAME=teamctl:hello:mgr\n"),
"env was: {env}"
);
}
#[test]
fn env_omits_claude_session_vars_for_non_claude_runtimes() {
let mut c = fixture();
c.projects[0].managers.get_mut("mgr").unwrap().runtime = "codex".into();
let h = c.agents().next().unwrap();
let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
assert!(
!env.contains("CLAUDE_SESSION_ID="),
"non-claude runtime must not get session id: {env}"
);
assert!(
!env.contains("CLAUDE_SESSION_NAME="),
"non-claude runtime must not get session name: {env}"
);
}
#[test]
fn env_pins_teamctl_root_to_compose_root() {
let c = fixture();
let h = c.agents().next().unwrap();
let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
assert!(env.contains("TEAMCTL_ROOT=/teamctl\n"), "env was: {env}");
}
#[test]
fn env_omits_effort_when_unset() {
let c = fixture();
let h = c.agents().next().unwrap();
let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
assert!(!env.contains("EFFORT="), "env was: {env}");
}
#[test]
fn env_emits_effort_when_set() {
let mut c = fixture();
c.projects[0].managers.get_mut("mgr").unwrap().effort = Some(EffortLevel::Max);
let h = c.agents().next().unwrap();
let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
assert!(env.contains("EFFORT=max\n"), "env was: {env}");
}
#[test]
fn mcp_json_parses_back() {
let c = fixture();
let h = c.agents().next().unwrap();
let (_, mcp) = render_agent(&c, h, "/usr/local/bin/team-mcp");
let v: serde_json::Value = serde_json::from_str(&mcp).unwrap();
assert_eq!(
v["mcpServers"]["team"]["command"],
"/usr/local/bin/team-mcp"
);
assert_eq!(
v["mcpServers"]["team"]["args"][1].as_str().unwrap(),
"hello:mgr"
);
}
#[test]
fn mcp_json_threads_tmux_prefix_from_compose() {
let c = fixture();
let h = c.agents().next().unwrap();
let (_, mcp) = render_agent(&c, h, "/usr/local/bin/team-mcp");
let v: serde_json::Value = serde_json::from_str(&mcp).unwrap();
let args: Vec<&str> = v["mcpServers"]["team"]["args"]
.as_array()
.unwrap()
.iter()
.map(|a| a.as_str().unwrap())
.collect();
let i = args.iter().position(|a| *a == "--tmux-prefix").expect(
"render_mcp must emit --tmux-prefix so compact_self resolves the caller's pane",
);
assert_eq!(
args[i + 1],
"a-",
"prefix must come from compose, not the default"
);
}
fn server(command: &str, args: &[&str]) -> McpServer {
McpServer {
command: command.into(),
args: args.iter().map(|s| s.to_string()).collect(),
env: Default::default(),
}
}
#[test]
fn mcp_json_includes_declared_servers_alongside_team() {
let mut c = fixture();
let mut mcps = BTreeMap::new();
let mut gh = server("npx", &["-y", "@modelcontextprotocol/server-github"]);
gh.env
.insert("GITHUB_TOKEN".into(), "${GITHUB_TOKEN}".into());
mcps.insert("github".into(), gh);
c.projects[0].managers.get_mut("mgr").unwrap().mcps = mcps;
let h = c.agents().next().unwrap();
let (_, mcp) = render_agent(&c, h, "/usr/local/bin/team-mcp");
let v: serde_json::Value = serde_json::from_str(&mcp).unwrap();
assert_eq!(
v["mcpServers"]["team"]["command"],
"/usr/local/bin/team-mcp"
);
assert_eq!(v["mcpServers"]["github"]["command"], "npx");
assert_eq!(v["mcpServers"]["github"]["args"][0], "-y");
assert_eq!(
v["mcpServers"]["github"]["env"]["GITHUB_TOKEN"], "${GITHUB_TOKEN}",
"env values must pass through verbatim — the runtime expands ${{VAR}}"
);
assert_eq!(v["mcpServers"].as_object().unwrap().len(), 2);
}
#[test]
fn mcp_json_team_server_is_non_clobberable() {
let mut c = fixture();
let mut mcps = BTreeMap::new();
mcps.insert("team".into(), server("evil-team", &[]));
mcps.insert("github".into(), server("npx", &[]));
c.projects[0].managers.get_mut("mgr").unwrap().mcps = mcps;
let h = c.agents().next().unwrap();
let (_, mcp) = render_agent(&c, h, "/usr/local/bin/team-mcp");
let v: serde_json::Value = serde_json::from_str(&mcp).unwrap();
assert_eq!(
v["mcpServers"]["team"]["command"], "/usr/local/bin/team-mcp",
"built-in team server must not be clobbered by a declared `team`"
);
assert!(v["mcpServers"]["github"].is_object());
assert_eq!(
v["mcpServers"].as_object().unwrap().len(),
2,
"the declared `team` is dropped, not added as a third entry"
);
}
#[test]
fn mcp_json_unchanged_when_no_servers_declared() {
let c = fixture();
let h = c.agents().next().unwrap();
let (_, mcp) = render_agent(&c, h, "/usr/local/bin/team-mcp");
let v: serde_json::Value = serde_json::from_str(&mcp).unwrap();
let servers = v["mcpServers"].as_object().unwrap();
assert_eq!(servers.len(), 1);
assert!(servers.contains_key("team"));
}
#[test]
fn mcp_json_skips_declared_servers_on_runtime_without_mcp_support() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join("runtimes")).unwrap();
std::fs::write(
tmp.path().join("runtimes/codex.yaml"),
"binary: codex\nsupports_mcp: false\n",
)
.unwrap();
let mut c = fixture();
c.root = tmp.path().to_path_buf();
{
let m = c.projects[0].managers.get_mut("mgr").unwrap();
m.runtime = "codex".into();
let mut mcps = BTreeMap::new();
mcps.insert("github".into(), server("npx", &[]));
m.mcps = mcps;
}
let h = c.agents().next().unwrap();
let (_, mcp) = render_agent(&c, h, "/usr/local/bin/team-mcp");
let v: serde_json::Value = serde_json::from_str(&mcp).unwrap();
let servers = v["mcpServers"].as_object().unwrap();
assert!(servers.contains_key("team"), "team bus stays unconditional");
assert!(
!servers.contains_key("github"),
"declared server skipped when runtime lacks supports_mcp"
);
assert_eq!(servers.len(), 1);
}
#[test]
fn env_points_at_source_for_single_role_prompt() {
let c = fixture();
let h = c.agents().next().unwrap();
let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
assert!(
env.contains("SYSTEM_PROMPT_PATH=/teamctl/roles/mgr.md\n"),
"env was: {env}"
);
}
#[test]
fn env_points_at_concat_path_for_multi_role_prompt() {
let mut c = fixture();
c.projects[0].managers.get_mut("mgr").unwrap().role_prompt =
Some(RolePrompt::Multiple(vec![
PathBuf::from("roles/_base.md"),
PathBuf::from("roles/mgr.md"),
]));
let h = c.agents().next().unwrap();
let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
assert!(
env.contains("SYSTEM_PROMPT_PATH=/teamctl/state/role_prompts/hello-mgr.md\n"),
"env was: {env}"
);
}
#[test]
fn write_role_prompt_concat_is_noop_for_single() {
let dir = tempfile::tempdir().unwrap();
let mut c = fixture();
c.root = dir.path().to_path_buf();
let h = c.agents().next().unwrap();
write_role_prompt_concat(&c, h).unwrap();
assert!(
!role_prompt_concat_path(&c.root, h.project, h.agent).exists(),
"single-form role_prompt should not produce a concat file"
);
}
#[test]
fn write_role_prompt_concat_joins_in_declared_order() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join("roles")).unwrap();
std::fs::write(root.join("roles/_base.md"), "BASE").unwrap();
std::fs::write(root.join("roles/mgr.md"), "MGR").unwrap();
let mut c = fixture();
c.root = root.to_path_buf();
c.projects[0].managers.get_mut("mgr").unwrap().role_prompt =
Some(RolePrompt::Multiple(vec![
PathBuf::from("roles/_base.md"),
PathBuf::from("roles/mgr.md"),
]));
let h = c.agents().next().unwrap();
write_role_prompt_concat(&c, h).unwrap();
let dest = role_prompt_concat_path(root, h.project, h.agent);
let got = std::fs::read_to_string(&dest).unwrap();
assert_eq!(got, "BASE\n\n—\n\nMGR");
}
#[test]
fn write_role_prompt_concat_reflects_source_edits() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join("roles")).unwrap();
std::fs::write(root.join("roles/_base.md"), "v1").unwrap();
std::fs::write(root.join("roles/mgr.md"), "MGR").unwrap();
let mut c = fixture();
c.root = root.to_path_buf();
c.projects[0].managers.get_mut("mgr").unwrap().role_prompt =
Some(RolePrompt::Multiple(vec![
PathBuf::from("roles/_base.md"),
PathBuf::from("roles/mgr.md"),
]));
let h = c.agents().next().unwrap();
write_role_prompt_concat(&c, h).unwrap();
std::fs::write(root.join("roles/_base.md"), "v2").unwrap();
let h = c.agents().next().unwrap();
write_role_prompt_concat(&c, h).unwrap();
let dest = role_prompt_concat_path(root, h.project, h.agent);
let got = std::fs::read_to_string(&dest).unwrap();
assert_eq!(got, "v2\n\n—\n\nMGR");
}
#[test]
fn claude_settings_present_for_claude_code() {
let c = fixture();
let h = c.agents().next().unwrap();
let s = render_claude_settings(&c, h).expect("claude-code agent must get settings");
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
let pre = &v["hooks"]["PreToolUse"][0];
assert_eq!(
pre["matcher"].as_str().unwrap(),
"AskUserQuestion|EnterPlanMode|ExitPlanMode"
);
let cmd = pre["hooks"][0]["command"].as_str().unwrap();
assert!(
cmd.contains(r#""permissionDecision":"deny""#),
"deny verdict missing from hook command: {cmd}"
);
assert!(
cmd.contains("Interactive prompts are disabled"),
"systemMessage missing from hook command: {cmd}"
);
}
#[test]
fn claude_settings_absent_for_non_claude_runtimes() {
let mut c = fixture();
c.projects[0].managers.get_mut("mgr").unwrap().runtime = "codex".into();
let h = c.agents().next().unwrap();
assert!(render_claude_settings(&c, h).is_none());
}
#[test]
fn declared_hook_merges_alongside_deny_hook() {
let mut c = fixture();
c.projects[0].managers.get_mut("mgr").unwrap().hooks = vec![HookSpec {
event: "PreToolUse".into(),
matcher: Some("Bash".into()),
command: PathBuf::from("hooks/guard.sh"),
}];
let h = c.agents().next().unwrap();
let s = render_claude_settings(&c, h).expect("claude-code agent must get settings");
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
let pre = v["hooks"]["PreToolUse"].as_array().unwrap();
assert_eq!(pre.len(), 2, "deny hook + declared hook expected");
assert_eq!(
pre[0]["matcher"].as_str().unwrap(),
"AskUserQuestion|EnterPlanMode|ExitPlanMode"
);
assert!(pre[0]["hooks"][0]["command"]
.as_str()
.unwrap()
.contains(r#""permissionDecision":"deny""#));
assert_eq!(pre[1]["matcher"].as_str().unwrap(), "Bash");
assert_eq!(pre[1]["hooks"][0]["type"].as_str().unwrap(), "command");
assert_eq!(
pre[1]["hooks"][0]["command"].as_str().unwrap(),
"/teamctl/hooks/guard.sh"
);
}
#[test]
fn no_declared_hooks_leaves_settings_unchanged() {
let c = fixture();
let h = c.agents().next().unwrap();
let v: serde_json::Value =
serde_json::from_str(&render_claude_settings(&c, h).unwrap()).unwrap();
let hooks = v["hooks"].as_object().unwrap();
assert_eq!(
hooks.len(),
1,
"only the built-in PreToolUse bucket expected"
);
assert_eq!(
hooks["PreToolUse"].as_array().unwrap().len(),
1,
"only the deny hook expected"
);
}
#[test]
fn declared_hook_without_matcher_opens_new_event_bucket() {
let mut c = fixture();
c.projects[0].managers.get_mut("mgr").unwrap().hooks = vec![HookSpec {
event: "PostToolUse".into(),
matcher: None,
command: PathBuf::from("hooks/log.sh"),
}];
let h = c.agents().next().unwrap();
let v: serde_json::Value =
serde_json::from_str(&render_claude_settings(&c, h).unwrap()).unwrap();
assert_eq!(v["hooks"]["PreToolUse"].as_array().unwrap().len(), 1);
let post = &v["hooks"]["PostToolUse"].as_array().unwrap()[0];
assert!(
post.get("matcher").is_none(),
"matcher must be omitted when unset: {post}"
);
assert_eq!(
post["hooks"][0]["command"].as_str().unwrap(),
"/teamctl/hooks/log.sh"
);
}
#[test]
fn declared_hooks_noop_on_non_claude_runtime() {
let mut c = fixture();
{
let m = c.projects[0].managers.get_mut("mgr").unwrap();
m.runtime = "codex".into();
m.hooks = vec![HookSpec {
event: "PreToolUse".into(),
matcher: Some("Bash".into()),
command: PathBuf::from("hooks/guard.sh"),
}];
}
let h = c.agents().next().unwrap();
assert!(
render_claude_settings(&c, h).is_none(),
"hooks must not render on non-claude runtimes"
);
}
#[test]
fn env_emits_claude_settings_path_for_claude_code() {
let c = fixture();
let h = c.agents().next().unwrap();
let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
assert!(
env.contains("CLAUDE_SETTINGS=/teamctl/state/claude/hello-mgr.json\n"),
"env was: {env}"
);
}
#[test]
fn env_omits_claude_settings_for_non_claude_runtimes() {
let mut c = fixture();
c.projects[0].managers.get_mut("mgr").unwrap().runtime = "codex".into();
let h = c.agents().next().unwrap();
let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
assert!(
!env.contains("CLAUDE_SETTINGS="),
"non-claude runtime must not get settings path: {env}"
);
}
#[test]
fn write_role_prompt_concat_errors_on_missing_source() {
let dir = tempfile::tempdir().unwrap();
let mut c = fixture();
c.root = dir.path().to_path_buf();
c.projects[0].managers.get_mut("mgr").unwrap().role_prompt = Some(RolePrompt::Multiple(
vec![PathBuf::from("roles/missing.md")],
));
let h = c.agents().next().unwrap();
let err = write_role_prompt_concat(&c, h).unwrap_err();
assert!(err.to_string().contains("missing.md"), "err was: {err}");
}
fn write_file(root: &std::path::Path, rel: &str, contents: &str) {
let abs = root.join(rel);
std::fs::create_dir_all(abs.parent().unwrap()).unwrap();
std::fs::write(abs, contents).unwrap();
}
fn rooted(write: impl FnOnce(&std::path::Path)) -> (tempfile::TempDir, Compose) {
let dir = tempfile::tempdir().unwrap();
let mut c = fixture();
c.root = dir.path().to_path_buf();
write(dir.path());
(dir, c)
}
#[test]
fn render_subagents_builds_agents_json_from_frontmatter() {
let (_d, mut c) = rooted(|root| {
write_file(
root,
"agents/security-auditor.md",
"---\nname: security-auditor\ndescription: Audits diffs for vulns.\n\
tools: Read, Grep\nmodel: claude-sonnet-4-6\n---\n\
You are a security auditor.\nFlag risky patterns.\n",
);
});
c.projects[0].managers.get_mut("mgr").unwrap().subagents =
vec![PathBuf::from("agents/security-auditor.md")];
let h = c.agents().next().unwrap();
let json = render_subagents(&c, h).unwrap().expect("some json");
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
let entry = &v["security-auditor"];
assert_eq!(entry["description"], "Audits diffs for vulns.");
assert_eq!(
entry["prompt"],
"You are a security auditor.\nFlag risky patterns."
);
assert_eq!(entry["tools"], serde_json::json!(["Read", "Grep"]));
assert_eq!(entry["model"], "claude-sonnet-4-6");
}
#[test]
fn render_subagents_name_falls_back_to_file_stem() {
let (_d, mut c) = rooted(|root| {
write_file(
root,
"agents/repo-cartographer.md",
"---\ndescription: Maps the repo.\n---\nMap it.\n",
);
});
c.projects[0].managers.get_mut("mgr").unwrap().subagents =
vec![PathBuf::from("agents/repo-cartographer.md")];
let h = c.agents().next().unwrap();
let json = render_subagents(&c, h).unwrap().unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(
v.get("repo-cartographer").is_some(),
"stem-derived name missing: {json}"
);
assert!(v["repo-cartographer"].get("tools").is_none());
assert!(v["repo-cartographer"].get("model").is_none());
}
#[test]
fn render_subagents_supports_yaml_list_tools() {
let (_d, mut c) = rooted(|root| {
write_file(
root,
"agents/x.md",
"---\nname: x\ndescription: d\ntools: [Read, Bash]\n---\nbody\n",
);
});
c.projects[0].managers.get_mut("mgr").unwrap().subagents =
vec![PathBuf::from("agents/x.md")];
let h = c.agents().next().unwrap();
let json = render_subagents(&c, h).unwrap().unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(v["x"]["tools"], serde_json::json!(["Read", "Bash"]));
}
#[test]
fn render_subagents_isolates_per_agent() {
let (_d, mut c) = rooted(|root| {
write_file(
root,
"agents/a.md",
"---\nname: a\ndescription: da\n---\nba\n",
);
write_file(
root,
"agents/b.md",
"---\nname: b\ndescription: db\n---\nbb\n",
);
});
let worker = c.projects[0].managers["mgr"].clone();
c.projects[0].workers.insert("dev".into(), worker);
c.projects[0].managers.get_mut("mgr").unwrap().subagents =
vec![PathBuf::from("agents/a.md")];
c.projects[0].workers.get_mut("dev").unwrap().subagents =
vec![PathBuf::from("agents/b.md")];
for h in c.agents() {
let v: serde_json::Value =
serde_json::from_str(&render_subagents(&c, h).unwrap().unwrap()).unwrap();
match h.agent {
"mgr" => {
assert!(v.get("a").is_some() && v.get("b").is_none());
}
"dev" => {
assert!(v.get("b").is_some() && v.get("a").is_none());
}
other => panic!("unexpected agent {other}"),
}
}
}
#[test]
fn render_subagents_none_when_empty() {
let c = fixture();
let h = c.agents().next().unwrap();
assert!(render_subagents(&c, h).unwrap().is_none());
}
#[test]
fn render_subagents_ignored_on_non_claude_runtime() {
let (_d, mut c) = rooted(|root| {
write_file(
root,
"agents/x.md",
"---\nname: x\ndescription: d\n---\nb\n",
);
});
{
let a = c.projects[0].managers.get_mut("mgr").unwrap();
a.runtime = "codex".into();
a.subagents = vec![PathBuf::from("agents/x.md")];
}
let h = c.agents().next().unwrap();
assert!(render_subagents(&c, h).unwrap().is_none());
}
#[test]
fn render_subagents_errors_on_missing_source() {
let (_d, mut c) = rooted(|_| {});
c.projects[0].managers.get_mut("mgr").unwrap().subagents =
vec![PathBuf::from("agents/nope.md")];
let h = c.agents().next().unwrap();
let err = render_subagents(&c, h).unwrap_err();
assert!(err.to_string().contains("nope.md"), "err was: {err}");
}
#[test]
fn render_subagents_errors_on_unterminated_frontmatter() {
let (_d, mut c) = rooted(|root| {
write_file(
root,
"agents/bad.md",
"---\nname: x\ndescription: d\nno close\n",
);
});
c.projects[0].managers.get_mut("mgr").unwrap().subagents =
vec![PathBuf::from("agents/bad.md")];
let h = c.agents().next().unwrap();
assert!(render_subagents(&c, h).is_err());
}
#[test]
fn env_emits_claude_agents_json_for_claude_code() {
let c = fixture();
let h = c.agents().next().unwrap();
let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
assert!(env.contains("CLAUDE_AGENTS_JSON=/teamctl/state/claude/hello-mgr.agents.json"));
}
#[test]
fn write_subagents_json_writes_then_clears_stale() {
let (_d, mut c) = rooted(|root| {
write_file(
root,
"agents/x.md",
"---\nname: x\ndescription: d\n---\nbody\n",
);
});
let dest = subagents_json_path(&c.root, "hello", "mgr");
c.projects[0].managers.get_mut("mgr").unwrap().subagents =
vec![PathBuf::from("agents/x.md")];
let h = c.agents().next().unwrap();
write_subagents_json(&c, h).unwrap();
assert!(dest.exists(), "agents json should be written");
c.projects[0].managers.get_mut("mgr").unwrap().subagents = vec![];
let h = c.agents().next().unwrap();
write_subagents_json(&c, h).unwrap();
assert!(!dest.exists(), "stale agents json should be removed");
}
#[test]
fn write_agent_skills_materializes_symlinks() {
let (_d, mut c) = rooted(|root| {
write_file(root, "skills/pr-review/SKILL.md", "# PR review skill\n");
});
c.projects[0].managers.get_mut("mgr").unwrap().skills =
vec![PathBuf::from("skills/pr-review")];
let h = c.agents().next().unwrap();
write_agent_skills(&c, h).unwrap();
let link = agent_scope_dir(&c.root, "hello", "mgr").join(".claude/skills/pr-review");
let meta = std::fs::symlink_metadata(&link).expect("link should exist");
assert!(meta.file_type().is_symlink(), "entry must be a symlink");
assert_eq!(
std::fs::canonicalize(&link).unwrap(),
std::fs::canonicalize(c.root.join("skills/pr-review")).unwrap()
);
}
#[test]
fn write_agent_skills_clear_stale_preserves_source() {
let (_d, mut c) = rooted(|root| {
write_file(root, "skills/foo/SKILL.md", "# foo\n");
});
let source = c.root.join("skills/foo");
let source_md = source.join("SKILL.md");
c.projects[0].managers.get_mut("mgr").unwrap().skills = vec![PathBuf::from("skills/foo")];
let h = c.agents().next().unwrap();
write_agent_skills(&c, h).unwrap();
let scope = agent_scope_dir(&c.root, "hello", "mgr");
assert!(scope.join(".claude/skills/foo").exists());
c.projects[0].managers.get_mut("mgr").unwrap().skills = vec![];
let h = c.agents().next().unwrap();
write_agent_skills(&c, h).unwrap();
assert!(!scope.exists(), "stale scope dir should be removed");
assert!(source.is_dir(), "source skill dir must survive the clear");
assert!(
source_md.is_file(),
"source SKILL.md must survive the clear"
);
}
#[test]
fn write_agent_skills_isolates_per_agent() {
let (_d, mut c) = rooted(|root| {
write_file(root, "skills/a/SKILL.md", "# a\n");
write_file(root, "skills/b/SKILL.md", "# b\n");
});
let worker = c.projects[0].managers["mgr"].clone();
c.projects[0].workers.insert("dev".into(), worker);
c.projects[0].managers.get_mut("mgr").unwrap().skills = vec![PathBuf::from("skills/a")];
c.projects[0].workers.get_mut("dev").unwrap().skills = vec![PathBuf::from("skills/b")];
for h in c.agents() {
write_agent_skills(&c, h).unwrap();
}
let mgr_skills = agent_scope_dir(&c.root, "hello", "mgr").join(".claude/skills");
let dev_skills = agent_scope_dir(&c.root, "hello", "dev").join(".claude/skills");
assert!(mgr_skills.join("a").exists() && !mgr_skills.join("b").exists());
assert!(dev_skills.join("b").exists() && !dev_skills.join("a").exists());
}
#[test]
fn write_agent_skills_ignored_on_non_claude_runtime() {
let (_d, mut c) = rooted(|root| {
write_file(root, "skills/x/SKILL.md", "# x\n");
});
{
let a = c.projects[0].managers.get_mut("mgr").unwrap();
a.runtime = "codex".into();
a.skills = vec![PathBuf::from("skills/x")];
}
let h = c.agents().next().unwrap();
write_agent_skills(&c, h).unwrap();
assert!(!agent_scope_dir(&c.root, "hello", "mgr").exists());
}
#[test]
fn env_emits_claude_agent_scope_for_claude_code() {
let c = fixture();
let h = c.agents().next().unwrap();
let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
assert!(env.contains("CLAUDE_AGENT_SCOPE=/teamctl/state/agent-scope/hello-mgr"));
}
#[test]
fn env_omits_claude_agent_scope_for_non_claude_runtimes() {
let mut c = fixture();
c.projects[0].managers.get_mut("mgr").unwrap().runtime = "codex".into();
let h = c.agents().next().unwrap();
let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
assert!(
!env.contains("CLAUDE_AGENT_SCOPE="),
"non-claude runtime must not get the agent scope: {env}"
);
}
}