use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::error::{Error, Result};
pub fn teams_dir() -> PathBuf {
if let Some(custom) = std::env::var_os("RECURSIVE_TEAMS_DIR") {
return PathBuf::from(custom);
}
if let Some(home) = dirs::home_dir() {
return home.join(".claude").join("teams");
}
PathBuf::from(".claude").join("teams")
}
pub fn team_file_path(team_name: &str) -> PathBuf {
teams_dir().join(format!("{team_name}.json"))
}
pub fn validate_team_name(name: &str) -> Result<()> {
if name.is_empty()
|| name.contains('/')
|| name.contains('\\')
|| name.contains("..")
|| name.starts_with('.')
{
return Err(Error::BadToolArgs {
name: "<team>".into(),
message: format!("invalid team name: '{name}'"),
});
}
Ok(())
}
pub fn ensure_teams_dir() -> std::io::Result<PathBuf> {
let dir = teams_dir();
std::fs::create_dir_all(&dir)?;
Ok(dir)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TeammateStatus {
Active,
Idle,
Stopped,
Error,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TeamMember {
pub name: String,
#[serde(default)]
pub agent_type: String,
#[serde(default = "default_status")]
pub status: TeammateStatus,
#[serde(default)]
pub model: String,
}
fn default_status() -> TeammateStatus {
TeammateStatus::Idle
}
impl TeamMember {
pub fn new(name: impl Into<String>, agent_type: impl Into<String>) -> Self {
Self {
name: name.into(),
agent_type: agent_type.into(),
status: default_status(),
model: String::new(),
}
}
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = model.into();
self
}
pub fn with_status(mut self, status: TeammateStatus) -> Self {
self.status = status;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TeamFile {
pub name: String,
pub created_at: DateTime<Utc>,
pub members: BTreeMap<String, TeamMember>,
}
impl TeamFile {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
created_at: Utc::now(),
members: BTreeMap::new(),
}
}
pub fn add_member(&mut self, member: TeamMember) {
self.members.insert(member.name.clone(), member);
}
pub fn remove_member(&mut self, name: &str) -> bool {
self.members.remove(name).is_some()
}
pub fn get_member(&self, name: &str) -> Option<&TeamMember> {
self.members.get(name)
}
pub fn get_member_mut(&mut self, name: &str) -> Option<&mut TeamMember> {
self.members.get_mut(name)
}
pub fn member_count(&self) -> usize {
self.members.len()
}
pub fn member_names(&self) -> Vec<&str> {
self.members.keys().map(|s| s.as_str()).collect()
}
}
#[derive(Clone, Default)]
pub struct TeamRegistry {
inner: Arc<RwLock<TeamRegistryInner>>,
}
#[derive(Default)]
struct TeamRegistryInner {
teams: BTreeMap<String, TeamFile>,
}
impl TeamRegistry {
pub fn new() -> Self {
Self::default()
}
pub async fn register_team(&self, team: TeamFile) -> Option<TeamFile> {
self.inner
.write()
.await
.teams
.insert(team.name.clone(), team)
}
pub async fn load(team_name: &str) -> Result<Self> {
validate_team_name(team_name)?;
let path = team_file_path(team_name);
let bytes = std::fs::read(&path).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
Error::NotFound(format!("team '{team_name}'"))
} else {
Error::Internal {
context: "team::load".to_string(),
message: format!("read team file: {e}"),
}
}
})?;
let team: TeamFile = serde_json::from_slice(&bytes)?;
let mut inner = TeamRegistryInner::default();
inner.teams.insert(team.name.clone(), team);
Ok(Self {
inner: Arc::new(RwLock::new(inner)),
})
}
pub async fn create(team_name: &str) -> Result<Self> {
validate_team_name(team_name)?;
let team = TeamFile::new(team_name);
Self::save_team(&team)?;
let mut inner = TeamRegistryInner::default();
inner.teams.insert(team.name.clone(), team);
Ok(Self {
inner: Arc::new(RwLock::new(inner)),
})
}
pub async fn save(&self) -> Result<()> {
let inner = self.inner.read().await;
for team in inner.teams.values() {
Self::save_team(team)?;
}
Ok(())
}
pub(crate) fn save_team(team: &TeamFile) -> Result<()> {
ensure_teams_dir()?;
let path = team_file_path(&team.name);
let json = serde_json::to_string_pretty(team)?;
crate::atomic::atomic_write(&path, json.as_bytes())?;
Ok(())
}
pub async fn get(&self, team_name: &str) -> Option<TeamFile> {
self.inner.read().await.teams.get(team_name).cloned()
}
pub async fn list_teams(&self) -> Vec<String> {
self.inner.read().await.teams.keys().cloned().collect()
}
pub fn list_on_disk() -> std::io::Result<Vec<String>> {
let dir = teams_dir();
if !dir.exists() {
return Ok(Vec::new());
}
let mut out = Vec::new();
for entry in std::fs::read_dir(&dir)? {
let entry = entry?;
let path = entry.path();
if !path.is_file() {
continue;
}
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
if stem.starts_with(".tmp-") {
continue;
}
out.push(stem.to_string());
}
}
out.sort();
Ok(out)
}
pub async fn delete(team_name: &str) -> Result<bool> {
validate_team_name(team_name)?;
let path = team_file_path(team_name);
match std::fs::remove_file(&path) {
Ok(()) => Ok(true),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(Error::Internal {
context: "team::delete".to_string(),
message: format!("delete team file: {e}"),
}),
}
}
pub async fn add_member(&self, team_name: &str, member: TeamMember) -> Result<()> {
validate_team_name(team_name)?;
let mut inner = self.inner.write().await;
let team = inner
.teams
.get_mut(team_name)
.ok_or_else(|| Error::NotFound(format!("team '{team_name}'")))?;
team.add_member(member);
Self::save_team(team)?;
Ok(())
}
pub async fn remove_member(&self, team_name: &str, member_name: &str) -> Result<bool> {
validate_team_name(team_name)?;
let mut inner = self.inner.write().await;
let team = inner
.teams
.get_mut(team_name)
.ok_or_else(|| Error::NotFound(format!("team '{team_name}'")))?;
let removed = team.remove_member(member_name);
if removed {
Self::save_team(team)?;
}
Ok(removed)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn with_temp_teams_dir() -> crate::test_util::PinnedTeamsDir {
crate::test_util::PinnedTeamsDir::new()
}
#[test]
fn team_file_new_is_empty() {
let tf = TeamFile::new("alpha");
assert_eq!(tf.name, "alpha");
assert_eq!(tf.member_count(), 0);
assert!(tf.member_names().is_empty());
assert!(tf.created_at <= Utc::now());
}
#[test]
fn add_and_remove_member() {
let mut tf = TeamFile::new("alpha");
let m = TeamMember::new("researcher", "general");
tf.add_member(m.clone());
assert_eq!(tf.member_count(), 1);
assert_eq!(tf.get_member("researcher"), Some(&m));
assert!(tf.remove_member("researcher"));
assert_eq!(tf.member_count(), 0);
assert!(
!tf.remove_member("researcher"),
"double-remove returns false"
);
}
#[test]
fn team_member_with_model_and_status() {
let m = TeamMember::new("coder", "general")
.with_model("claude-opus-4-7")
.with_status(TeammateStatus::Active);
assert_eq!(m.model, "claude-opus-4-7");
assert_eq!(m.status, TeammateStatus::Active);
}
#[test]
fn team_file_round_trip_json() {
let mut tf = TeamFile::new("alpha");
tf.add_member(TeamMember::new("researcher", "general"));
tf.add_member(
TeamMember::new("coder", "general")
.with_model("claude-opus-4-7")
.with_status(TeammateStatus::Active),
);
let json = serde_json::to_string(&tf).unwrap();
let back: TeamFile = serde_json::from_str(&json).unwrap();
assert_eq!(tf, back);
}
#[tokio::test]
async fn registry_create_save_load() {
let _g = with_temp_teams_dir();
let reg = TeamRegistry::create("alpha").await.unwrap();
assert_eq!(reg.list_teams().await, vec!["alpha".to_string()]);
assert!(team_file_path("alpha").exists());
let reg2 = TeamRegistry::load("alpha").await.unwrap();
let tf = reg2.get("alpha").await.unwrap();
assert_eq!(tf.name, "alpha");
assert_eq!(tf.member_count(), 0);
}
#[tokio::test]
async fn registry_add_remove_member_persists() {
let _g = with_temp_teams_dir();
let reg = TeamRegistry::create("beta").await.unwrap();
reg.add_member("beta", TeamMember::new("r", "general"))
.await
.unwrap();
reg.add_member(
"beta",
TeamMember::new("c", "general").with_model("claude-opus-4-7"),
)
.await
.unwrap();
let reg2 = TeamRegistry::load("beta").await.unwrap();
let tf = reg2.get("beta").await.unwrap();
assert_eq!(tf.member_count(), 2);
assert!(tf.get_member("r").is_some());
assert!(tf.get_member("c").is_some());
assert_eq!(tf.get_member("c").unwrap().model, "claude-opus-4-7");
assert!(reg.remove_member("beta", "r").await.unwrap());
let reg3 = TeamRegistry::load("beta").await.unwrap();
assert_eq!(reg3.get("beta").await.unwrap().member_count(), 1);
}
#[tokio::test]
async fn registry_delete_removes_file() {
let _g = with_temp_teams_dir();
let _ = TeamRegistry::create("gamma").await.unwrap();
assert!(team_file_path("gamma").exists());
assert!(TeamRegistry::delete("gamma").await.unwrap());
assert!(!team_file_path("gamma").exists());
assert!(!TeamRegistry::delete("gamma").await.unwrap());
}
#[tokio::test]
async fn registry_load_missing_team_errors() {
let _g = with_temp_teams_dir();
let res = TeamRegistry::load("nonexistent").await;
assert!(matches!(res, Err(Error::NotFound(_))));
}
#[test]
fn atomic_write_replaces_existing() {
let g = with_temp_teams_dir();
let dir = g.path();
let p = dir.join("alpha.json");
crate::atomic::atomic_write(&p, b"{\"name\":\"alpha\"}").unwrap();
let s1 = std::fs::read_to_string(&p).unwrap();
assert_eq!(s1, "{\"name\":\"alpha\"}");
crate::atomic::atomic_write(&p, b"{\"name\":\"alpha\",\"v\":2}").unwrap();
let s2 = std::fs::read_to_string(&p).unwrap();
assert_eq!(s2, "{\"name\":\"alpha\",\"v\":2}");
for entry in std::fs::read_dir(dir).unwrap() {
let entry = entry.unwrap();
assert!(
!entry
.file_name()
.to_str()
.unwrap()
.starts_with(".tmp-team-"),
"found leftover temp file: {:?}",
entry.file_name()
);
}
}
#[test]
fn teammate_status_default_is_idle() {
let j = r#"{"name":"x","agent_type":"general","model":""}"#;
let m: TeamMember = serde_json::from_str(j).unwrap();
assert_eq!(m.status, TeammateStatus::Idle);
}
#[test]
fn validate_team_name_accepts_valid_names() {
assert!(validate_team_name("alpha").is_ok());
assert!(validate_team_name("my-team-123").is_ok());
assert!(validate_team_name("team_v2").is_ok());
}
#[test]
fn validate_team_name_rejects_empty() {
assert!(
validate_team_name("").is_err(),
"empty name must be rejected"
);
}
#[test]
fn validate_team_name_rejects_slash() {
assert!(
validate_team_name("etc/passwd").is_err(),
"/ must be rejected"
);
}
#[test]
fn validate_team_name_rejects_backslash() {
assert!(
validate_team_name("etc\\passwd").is_err(),
r"\ must be rejected"
);
}
#[test]
fn validate_team_name_rejects_dotdot() {
assert!(validate_team_name("..").is_err(), ".. must be rejected");
assert!(
validate_team_name("../escape").is_err(),
"../escape must be rejected"
);
}
#[test]
fn validate_team_name_rejects_leading_dot() {
assert!(
validate_team_name(".hidden").is_err(),
".hidden must be rejected"
);
}
#[test]
fn team_file_get_member_mut_modifies_in_place() {
let mut tf = TeamFile::new("test");
tf.add_member(TeamMember::new("dev", "general"));
{
let m = tf.get_member_mut("dev").expect("must find 'dev'");
m.model = "gpt-4o".to_string();
}
assert_eq!(tf.get_member("dev").unwrap().model, "gpt-4o");
}
#[test]
fn team_file_get_member_mut_returns_none_for_unknown() {
let mut tf = TeamFile::new("test");
assert!(tf.get_member_mut("ghost").is_none());
}
#[test]
fn team_file_path_contains_team_name() {
let _g = with_temp_teams_dir();
let path = team_file_path("my-team");
assert!(
path.to_string_lossy().contains("my-team.json"),
"path must contain 'my-team.json', got: {path:?}"
);
}
#[test]
fn teams_dir_honors_env_var() {
let _lock = crate::test_util::env_lock();
let custom = "/tmp/custom-teams-dir";
std::env::set_var("RECURSIVE_TEAMS_DIR", custom);
let dir = teams_dir();
std::env::remove_var("RECURSIVE_TEAMS_DIR");
assert_eq!(dir, std::path::PathBuf::from(custom));
}
}