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 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> {
let _ = compose;
if h.spec.runtime != "claude-code" {
return None;
}
let 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.\"}'"
}
]
}
]
}
});
Some(serde_json::to_string_pretty(&v).expect("json"))
}
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()));
}
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 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": {}
}
}
});
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,
},
);
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"
);
}
#[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 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}");
}
}