use std::collections::BTreeMap;
use std::fs;
use std::io::{self, BufRead, IsTerminal, Write};
use std::process::Command;
use anyhow::{anyhow, bail, Context, Result};
use include_dir::{include_dir, Dir};
#[derive(Clone, Copy)]
pub struct Template {
pub key: &'static str,
pub label: &'static str,
pub blurb: &'static str,
pub dir: &'static Dir<'static>,
pub shared: &'static [(&'static str, &'static str)],
}
impl Template {
pub(crate) fn entries(&self) -> Vec<(String, &'static str)> {
let mut out: Vec<(String, &'static str)> = Vec::new();
collect_dir(self.dir, &mut out);
out.extend(self.shared.iter().map(|(p, c)| ((*p).to_string(), *c)));
out.sort_by(|a, b| {
let rank = |p: &str| usize::from(p != "team-compose.yaml");
rank(&a.0).cmp(&rank(&b.0)).then_with(|| a.0.cmp(&b.0))
});
out
}
}
fn collect_dir(dir: &'static Dir<'static>, out: &mut Vec<(String, &'static str)>) {
for file in dir.files() {
let rel = file.path().to_string_lossy().into_owned();
if rel.ends_with(".DS_Store") || rel.ends_with("Thumbs.db") {
continue;
}
let contents = file
.contents_utf8()
.unwrap_or_else(|| panic!("template file `{rel}` is not valid UTF-8"));
out.push((rel, contents));
}
for sub in dir.dirs() {
collect_dir(sub, out);
}
}
static IDEATE_DIR: Dir<'static> =
include_dir!("$CARGO_MANIFEST_DIR/assets/templates/ideate-and-build");
static ESSENTIALS_DIR: Dir<'static> =
include_dir!("$CARGO_MANIFEST_DIR/assets/templates/essentials");
static BLANK_DIR: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/assets/templates/blank");
const COMMON_GITIGNORE: &str = include_str!("../../assets/templates/_common/.gitignore");
const COMMON_ENV: &str = include_str!("../../assets/templates/_common/.env.example");
pub const GUIDED_KEY: &str = "guided";
pub const TEMPLATES: &[Template] = &[
Template {
key: "ideate-and-build",
label: "Ideate & Build",
blurb: "An Executor, a Compass ideation partner, and two engineers — think it through, then build it.",
dir: &IDEATE_DIR,
shared: &[(".gitignore", COMMON_GITIGNORE)],
},
Template {
key: "essentials",
label: "Essentials",
blurb: "A blank project + an ops bot that helps you evolve it.",
dir: &ESSENTIALS_DIR,
shared: &[(".gitignore", COMMON_GITIGNORE)],
},
Template {
key: "blank",
label: "Blank",
blurb: "Empty compose tree. Wire it up yourself.",
dir: &BLANK_DIR,
shared: &[
(".env.example", COMMON_ENV),
(".gitignore", COMMON_GITIGNORE),
],
},
];
#[derive(Clone, Copy)]
enum Choice {
Guided,
Template(&'static Template),
}
fn picker_menu() -> Vec<Choice> {
let by_key = |k: &str| {
TEMPLATES
.iter()
.find(|t| t.key == k)
.unwrap_or_else(|| panic!("missing template `{k}` in TEMPLATES"))
};
vec![
Choice::Template(by_key("ideate-and-build")),
Choice::Guided,
Choice::Template(by_key("blank")),
]
}
pub fn run(
name: Option<String>,
template: Option<String>,
project_id: Option<String>,
force: bool,
yes: bool,
) -> Result<()> {
if matches!(template.as_deref(), Some(GUIDED_KEY)) && yes {
bail!(
"`--template guided` requires interactive confirmation and is incompatible with \
`--yes`. Drop `--yes` to run the guided flow, or pick `--template essentials` for \
a non-interactive scaffold."
);
}
let choice = match template {
Some(k) if k == GUIDED_KEY => {
if !confirm("This will open Claude Code and run `/teamctl:init`. Continue?")? {
bail!("aborted");
}
exec_guided()?;
return Ok(());
}
Some(k) => Choice::Template(
TEMPLATES
.iter()
.find(|t| t.key == k)
.ok_or_else(|| anyhow!("unknown template `{k}` — known: {}", template_keys()))?,
),
None if yes => {
Choice::Template(TEMPLATES.iter().find(|t| t.key == "essentials").unwrap())
}
None => choose_template_interactive()?,
};
let tpl = match choice {
Choice::Guided => {
if !confirm("This will open Claude Code and run `/teamctl:init`. Continue?")? {
bail!("aborted");
}
exec_guided()?;
return Ok(());
}
Choice::Template(t) => t,
};
let cwd = std::env::current_dir().context("get cwd")?;
let (parent, target) = match &name {
Some(n) => {
let dir = cwd.join(n);
(dir.clone(), dir.join(".team"))
}
None => (cwd.clone(), cwd.join(".team")),
};
if target.exists() {
if force {
fs::remove_dir_all(&target)
.with_context(|| format!("--force: remove existing {}", target.display()))?;
} else {
bail!(
"{} already exists. Pass --force to overwrite, or pick a different name.",
target.display()
);
}
}
let pid = project_id.unwrap_or_else(|| {
name.as_deref()
.map(slugify)
.filter(|s| !s.is_empty())
.or_else(|| parent.file_name().and_then(|s| s.to_str()).map(slugify))
.unwrap_or_else(|| "main".into())
});
let mut subs: BTreeMap<&str, String> = BTreeMap::new();
subs.insert("project_id", pid.clone());
subs.insert("project_name", titlecase(&pid));
let files = tpl.entries();
if !yes {
let team = team_structure_lines(&files);
if !team.is_empty() {
eprintln!();
eprintln!("Team shape:");
for line in &team {
eprintln!("{line}");
}
}
eprintln!();
eprintln!("About to scaffold `.team/` at {}:", target.display());
eprintln!(" template: {} ({})", tpl.label, tpl.key);
eprintln!(" project id: {pid}");
eprintln!(" files:");
eprintln!(" .team/");
let mut tree = TreeNode::default();
for (path, _) in &files {
tree.insert(&path.split('/').collect::<Vec<_>>());
}
tree.render(" ", &mut |line| eprintln!("{line}"));
if !confirm("Proceed?")? {
bail!("aborted");
}
}
fs::create_dir_all(&target)?;
for (relpath, contents) in &files {
let dest = target.join(relpath);
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)?;
}
let rendered = substitute(contents, &subs);
fs::write(&dest, rendered)?;
}
println!();
println!("✓ {} scaffolded.", target.display());
println!();
let attach_target = super::load(&target)
.ok()
.and_then(|c| lead_attach_target(&c));
println!("Next:");
if name.is_some() {
let display = name.as_deref().unwrap_or(".");
println!(" cd {display}");
}
println!(
" {:<30}# sanity-check the team structure",
"teamctl validate"
);
if attach_target.is_some() {
println!(
" {:<30}# (optional) connect your agents to Telegram",
"teamctl bot setup"
);
}
println!(" {:<30}# start the team", "teamctl up");
if let Some(id) = attach_target {
println!(
" {:<30}# drop into a live agent session",
format!("teamctl attach {id}")
);
}
Ok(())
}
fn lead_attach_target(compose: &team_core::compose::Compose) -> Option<String> {
let agents: Vec<_> = compose.agents().collect();
let reported_to = |mgr: &team_core::compose::AgentHandle| {
agents
.iter()
.any(|w| w.project == mgr.project && w.spec.reports_to.as_deref() == Some(mgr.agent))
};
agents
.iter()
.find(|a| a.is_manager && reported_to(a))
.or_else(|| agents.iter().find(|a| a.is_manager))
.or_else(|| agents.first())
.map(|h| h.id())
}
fn team_structure_lines(files: &[(String, &'static str)]) -> Vec<String> {
let color = io::stderr().is_terminal();
let paint = |code: &str, s: &str| -> String {
if color {
format!("\x1b[{code}m{s}\x1b[0m")
} else {
s.to_string()
}
};
const GREY: &str = "90"; const PINK: &str = "38;5;218";
let by_path = |p: &str| files.iter().find(|(rel, _)| rel == p).map(|(_, c)| *c);
let project_paths: Vec<String> = by_path("team-compose.yaml")
.and_then(|c| serde_yaml::from_str::<team_core::compose::Global>(c).ok())
.map(|g| {
g.projects
.iter()
.map(|p| p.file.to_string_lossy().into_owned())
.collect()
})
.filter(|v: &Vec<String>| !v.is_empty())
.unwrap_or_else(|| {
files
.iter()
.map(|(rel, _)| rel.clone())
.filter(|rel| rel.starts_with("projects/") && rel.ends_with(".yaml"))
.collect()
});
let mut out = Vec::new();
for path in project_paths {
let Some(project) = by_path(&path)
.and_then(|c| serde_yaml::from_str::<team_core::compose::Project>(c).ok())
else {
continue;
};
if project.managers.is_empty() && project.workers.is_empty() {
continue;
}
let mut children: BTreeMap<&str, Vec<&String>> = BTreeMap::new();
let mut orphans: Vec<&String> = Vec::new();
for (wid, w) in &project.workers {
match w.reports_to.as_deref() {
Some(m) if project.managers.contains_key(m) => {
children.entry(m).or_default().push(wid)
}
_ => orphans.push(wid),
}
}
let top: Vec<(&String, &str)> = project
.managers
.keys()
.map(|id| (id, "manager"))
.chain(orphans.iter().map(|id| (*id, "worker")))
.collect();
out.push(paint(&format!("1;{PINK}"), "You"));
let row = |indent: &str, connector: &str, label: &str, descriptor: &str| {
format!(
"{}{}{} {}",
indent,
paint(GREY, connector),
paint(PINK, label),
paint(GREY, descriptor),
)
};
let last_top = top.len().saturating_sub(1);
for (i, (id, _role)) in top.iter().enumerate() {
let agent = project
.managers
.get(*id)
.or_else(|| project.workers.get(*id));
let label = agent.map_or_else(|| (*id).to_string(), |a| label_for(id, a));
let descriptor = agent.map(agent_descriptor).unwrap_or_default();
let top_last = i == last_top;
out.push(row(
"",
if top_last { "└── " } else { "├── " },
&label,
&descriptor,
));
let kids = children.get(id.as_str()).cloned().unwrap_or_default();
let cont = if top_last {
" ".to_string()
} else {
format!("{} ", paint(GREY, "│"))
};
let last_kid = kids.len().saturating_sub(1);
for (j, wid) in kids.iter().enumerate() {
let w = project.workers.get(wid.as_str());
let label = w.map_or_else(|| (*wid).to_string(), |a| label_for(wid, a));
let descriptor = w.map(agent_descriptor).unwrap_or_default();
let conn = if j == last_kid {
"└── "
} else {
"├── "
};
out.push(row(&cont, conn, &label, &descriptor));
}
}
}
out
}
fn label_for(id: &str, agent: &team_core::compose::Agent) -> String {
agent.display_name.clone().unwrap_or_else(|| id.to_string())
}
fn agent_descriptor(agent: &team_core::compose::Agent) -> String {
let mut parts = vec![runtime_label(&agent.runtime)];
if let Some(model) = &agent.model {
parts.push(model_label(model));
}
parts.push(format!(
"{}×a {}×s {}×h {}×m",
agent.subagents.len(),
agent.skills.len(),
agent.hooks.len(),
agent.mcps.len(),
));
parts.join(" · ")
}
fn runtime_label(runtime: &str) -> String {
match runtime {
"claude-code" => "Claude Code".to_string(),
other => other.to_string(),
}
}
fn model_label(model: &str) -> String {
match model {
"claude-opus-4-8" => "Opus 4.8".to_string(),
"claude-sonnet-4-6" => "Sonnet 4.6".to_string(),
"claude-haiku-4-5" | "claude-haiku-4-5-20251001" => "Haiku 4.5".to_string(),
other => other.to_string(),
}
}
fn exec_guided() -> Result<()> {
let status = Command::new("claude")
.arg("/teamctl:init")
.status()
.with_context(|| {
"failed to launch `claude` — is Claude Code installed and on PATH? See \
https://code.claude.com/docs"
})?;
if !status.success() {
bail!(
"`claude /teamctl:init` exited with status {status} — see the Claude Code output \
above for details."
);
}
Ok(())
}
fn choice_line(c: &Choice) -> (&'static str, &'static str) {
match c {
Choice::Guided => ("Guided", "LLM walks you through setup (opens Claude Code)"),
Choice::Template(t) => (t.label, t.blurb),
}
}
fn choose_template_interactive() -> Result<Choice> {
eprintln!("Pick a template:");
for (i, c) in picker_menu().iter().enumerate() {
let (label, blurb) = choice_line(c);
eprintln!(" {}) {:<16} — {}", i + 1, label, blurb);
}
eprint!("Choice [1]: ");
io::stderr().flush().ok();
let mut line = String::new();
io::stdin().lock().read_line(&mut line)?;
Ok(parse_picker_input(line.trim()))
}
fn parse_picker_input(trimmed: &str) -> Choice {
let menu = picker_menu();
if trimmed.is_empty() {
return menu[0];
}
match trimmed.parse::<usize>() {
Ok(n) if n >= 1 => menu.get(n - 1).copied().unwrap_or(menu[0]),
_ => menu[0],
}
}
fn confirm(prompt: &str) -> Result<bool> {
eprint!("{prompt} [Y/n] ");
io::stderr().flush().ok();
let mut line = String::new();
io::stdin().lock().read_line(&mut line)?;
Ok(answer_is_yes(&line))
}
fn answer_is_yes(line: &str) -> bool {
let s = line.trim().to_lowercase();
s.is_empty() || s == "y" || s == "yes"
}
fn template_keys() -> String {
let mut keys: Vec<&str> = vec![GUIDED_KEY];
keys.extend(TEMPLATES.iter().map(|t| t.key));
keys.join(", ")
}
fn substitute(s: &str, vars: &BTreeMap<&str, String>) -> String {
let mut out = s.to_string();
for (k, v) in vars {
out = out.replace(&format!("{{{{{k}}}}}"), v);
}
out
}
fn slugify(s: &str) -> String {
s.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_lowercase()
} else {
'-'
}
})
.collect::<String>()
.trim_matches('-')
.to_string()
}
fn titlecase(s: &str) -> String {
s.split('-')
.map(|p| {
let mut c = p.chars();
match c.next() {
None => String::new(),
Some(f) => f.to_ascii_uppercase().to_string() + c.as_str(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
#[derive(Default)]
struct TreeNode {
children: Vec<(String, TreeNode)>,
}
impl TreeNode {
fn insert(&mut self, parts: &[&str]) {
let Some((head, rest)) = parts.split_first() else {
return;
};
let idx = match self.children.iter().position(|(n, _)| n == head) {
Some(i) => i,
None => {
self.children
.push(((*head).to_string(), TreeNode::default()));
self.children.len() - 1
}
};
self.children[idx].1.insert(rest);
}
fn render(&self, prefix: &str, out: &mut impl FnMut(String)) {
let last_idx = self.children.len().saturating_sub(1);
for (i, (name, child)) in self.children.iter().enumerate() {
let last = i == last_idx;
let connector = if last { "└── " } else { "├── " };
let slash = if child.children.is_empty() { "" } else { "/" };
out(format!("{prefix}{connector}{name}{slash}"));
let child_prefix = format!("{prefix}{}", if last { " " } else { "│ " });
child.render(&child_prefix, out);
}
}
}
#[allow(dead_code)]
pub fn template_list_for_cli() -> Vec<&'static str> {
let mut keys: Vec<&str> = vec![GUIDED_KEY];
keys.extend(TEMPLATES.iter().map(|t| t.key));
keys
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn confirm_defaults_to_yes_on_empty_declines_on_n() {
assert!(answer_is_yes(""));
assert!(answer_is_yes("\n"));
assert!(answer_is_yes(" "));
assert!(answer_is_yes("y"));
assert!(answer_is_yes("Y\n"));
assert!(answer_is_yes("yes"));
assert!(!answer_is_yes("n"));
assert!(!answer_is_yes("no"));
assert!(!answer_is_yes("N"));
}
#[test]
fn slugify_basic() {
assert_eq!(slugify("My Repo!"), "my-repo");
assert_eq!(slugify("---weird___name"), "weird---name");
}
#[test]
fn substitute_replaces_handlebars() {
let mut m = BTreeMap::new();
m.insert("x", "Y".to_string());
assert_eq!(substitute("hi {{x}}", &m), "hi Y");
}
#[test]
fn picker_empty_input_defaults_to_ideate_and_build() {
match parse_picker_input("") {
Choice::Template(t) => assert_eq!(t.key, "ideate-and-build"),
_ => panic!("expected Choice::Template(ideate-and-build) for empty input"),
}
}
#[test]
fn picker_one_selects_ideate_and_build() {
match parse_picker_input("1") {
Choice::Template(t) => assert_eq!(t.key, "ideate-and-build"),
_ => panic!("expected Choice::Template(ideate-and-build) for input `1`"),
}
}
#[test]
fn picker_two_selects_guided() {
assert!(matches!(parse_picker_input("2"), Choice::Guided));
}
#[test]
fn picker_three_selects_blank() {
match parse_picker_input("3") {
Choice::Template(t) => assert_eq!(t.key, "blank"),
_ => panic!("expected Choice::Template(blank) for input `3`"),
}
}
#[test]
fn picker_essentials_hidden_but_flag_still_resolves() {
assert!(
TEMPLATES.iter().any(|t| t.key == "essentials"),
"essentials must remain in TEMPLATES for the --template flag"
);
assert!(
!picker_menu()
.iter()
.any(|c| matches!(c, Choice::Template(t) if t.key == "essentials")),
"essentials must NOT appear in the interactive picker menu"
);
}
#[test]
fn picker_out_of_range_falls_back_to_ideate_and_build() {
for input in ["99", "0", "hello"] {
match parse_picker_input(input) {
Choice::Template(t) => assert_eq!(
t.key, "ideate-and-build",
"input `{input}` should fall back to the flagship"
),
_ => panic!("expected Choice::Template(ideate-and-build) for input `{input}`"),
}
}
}
#[test]
fn template_keys_lists_guided_first() {
let keys = template_keys();
assert!(
keys.starts_with("guided"),
"expected `guided` to lead; got `{keys}`"
);
assert!(keys.contains("ideate-and-build"));
assert!(keys.contains("essentials"));
assert!(keys.contains("blank"));
}
fn entry_for<'a>(entries: &'a [(String, &'static str)], dest: &str) -> Option<&'a str> {
entries.iter().find(|(p, _)| p == dest).map(|(_, c)| *c)
}
#[test]
fn entries_ships_compose_and_gitignore_for_every_template() {
for tpl in TEMPLATES {
let entries = tpl.entries();
let compose = entry_for(&entries, "team-compose.yaml")
.unwrap_or_else(|| panic!("template `{}` must ship team-compose.yaml", tpl.key));
assert!(
!compose.is_empty(),
"template `{}` ships an empty team-compose.yaml",
tpl.key
);
let gitignore = entry_for(&entries, ".gitignore")
.unwrap_or_else(|| panic!("template `{}` must ship .gitignore", tpl.key));
assert!(
!gitignore.is_empty(),
"template `{}` ships an empty .gitignore",
tpl.key
);
}
}
#[test]
fn entries_walks_nested_dirs() {
let tpl = TEMPLATES
.iter()
.find(|t| t.key == "ideate-and-build")
.expect("ideate-and-build template");
let entries = tpl.entries();
for dest in [
"projects/main.yaml",
"roles/_base.md",
"subagents/implementer.md",
] {
assert!(
entry_for(&entries, dest).is_some(),
"ideate-and-build entries() must include nested `{dest}`; got {:?}",
entries.iter().map(|(p, _)| p).collect::<Vec<_>>()
);
}
}
#[test]
fn entries_includes_shared_common_overlays() {
let blank = TEMPLATES
.iter()
.find(|t| t.key == "blank")
.expect("blank template");
let blank_entries = blank.entries();
assert_eq!(
entry_for(&blank_entries, ".env.example"),
Some(COMMON_ENV),
"blank .env.example must be the shared _common/.env.example"
);
assert_eq!(
entry_for(&blank_entries, ".gitignore"),
Some(COMMON_GITIGNORE),
"blank .gitignore must be the shared _common/.gitignore"
);
let essentials = TEMPLATES
.iter()
.find(|t| t.key == "essentials")
.expect("essentials template");
assert_eq!(
entry_for(&essentials.entries(), ".gitignore"),
Some(COMMON_GITIGNORE),
"essentials .gitignore must be the shared _common/.gitignore"
);
}
#[test]
fn entries_blank_does_not_duplicate_dotfiles() {
let blank = TEMPLATES
.iter()
.find(|t| t.key == "blank")
.expect("blank template");
let entries = blank.entries();
let count = |dest: &str| entries.iter().filter(|(p, _)| p == dest).count();
assert_eq!(
count(".gitignore"),
1,
"blank must ship exactly one .gitignore"
);
assert_eq!(
count(".env.example"),
1,
"blank must ship exactly one .env.example"
);
}
#[test]
fn entries_skips_ds_store() {
for tpl in TEMPLATES {
for (path, _) in tpl.entries() {
assert!(
!path.ends_with(".DS_Store"),
"template `{}` leaked a .DS_Store entry: `{path}`",
tpl.key
);
}
}
}
#[test]
fn every_template_file_is_utf8() {
fn check(dir: &Dir<'static>, bad: &mut Vec<String>) {
for f in dir.files() {
if f.contents_utf8().is_none() {
bad.push(f.path().to_string_lossy().into_owned());
}
}
for d in dir.dirs() {
check(d, bad);
}
}
for tpl in TEMPLATES {
let mut bad = Vec::new();
check(tpl.dir, &mut bad);
assert!(
bad.is_empty(),
"template `{}` has non-UTF-8 file(s): {bad:?}",
tpl.key
);
}
}
#[test]
fn entries_lead_with_team_compose() {
for tpl in TEMPLATES {
let entries = tpl.entries();
assert_eq!(
entries[0].0, "team-compose.yaml",
"template `{}` should preview team-compose.yaml first, got `{}`",
tpl.key, entries[0].0
);
}
}
#[test]
fn picker_menu_is_in_display_order() {
let menu = picker_menu();
assert_eq!(menu.len(), 3);
assert!(matches!(menu[0], Choice::Template(t) if t.key == "ideate-and-build"));
assert!(matches!(menu[1], Choice::Guided));
assert!(matches!(menu[2], Choice::Template(t) if t.key == "blank"));
}
fn strip_ansi(s: &str) -> String {
let mut out = String::new();
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\u{1b}' {
for d in chars.by_ref() {
if d == 'm' {
break;
}
}
} else {
out.push(c);
}
}
out
}
fn tree_files(
compose: &'static str,
projects: &[(&str, &'static str)],
) -> Vec<(String, &'static str)> {
let mut files: Vec<(String, &'static str)> =
vec![("team-compose.yaml".to_string(), compose)];
files.extend(projects.iter().map(|(p, c)| ((*p).to_string(), *c)));
files
}
fn plain_tree(files: &[(String, &'static str)]) -> Vec<String> {
team_structure_lines(files)
.iter()
.map(|l| strip_ansi(l))
.collect()
}
fn parse_project(yaml: &str) -> team_core::compose::Project {
serde_yaml::from_str::<team_core::compose::Project>(yaml)
.unwrap_or_else(|e| panic!("fixture project must parse: {e}"))
}
const COMPOSE_ONE_DEMO: &str = "version: \"2.0.0\"\nprojects:\n - file: projects/demo.yaml\n";
#[test]
fn tree_manager_with_two_workers_nests_and_id_sorts() {
let project = "\
version: 2
project:
id: demo
name: Demo
cwd: ./workspace
managers:
boss: {}
workers:
zeta:
reports_to: boss
alpha:
reports_to: boss
";
let files = tree_files(COMPOSE_ONE_DEMO, &[("projects/demo.yaml", project)]);
let lines = plain_tree(&files);
assert_eq!(
lines.iter().filter(|l| *l == "You").count(),
1,
"one agent-bearing project → exactly one `You`; got: {lines:?}"
);
let boss = lines
.iter()
.find(|l| l.contains("boss"))
.expect("boss line present");
assert!(
boss.starts_with("└── boss"),
"manager sits at top level with a connector; got: {boss:?}"
);
let alpha_i = lines
.iter()
.position(|l| l.contains("alpha"))
.expect("alpha present");
let zeta_i = lines
.iter()
.position(|l| l.contains("zeta"))
.expect("zeta present");
assert!(
alpha_i < zeta_i,
"workers are id-sorted (alpha before zeta); got: {lines:?}"
);
assert!(
lines[alpha_i].starts_with(" "),
"worker is indented under its manager; got: {:?}",
lines[alpha_i]
);
assert!(
lines[alpha_i].contains("├── alpha"),
"first worker uses a tee connector; got: {:?}",
lines[alpha_i]
);
assert!(
lines[zeta_i].contains("└── zeta"),
"last worker uses an elbow connector; got: {:?}",
lines[zeta_i]
);
}
#[test]
fn tree_two_managers_split_workers_by_reports_to() {
let project = "\
version: 2
project:
id: demo
name: Demo
cwd: ./workspace
managers:
ay_mgr: {}
zee_mgr: {}
workers:
hand_a:
reports_to: ay_mgr
hand_z:
reports_to: zee_mgr
";
let files = tree_files(COMPOSE_ONE_DEMO, &[("projects/demo.yaml", project)]);
let lines = plain_tree(&files);
let pos = |needle: &str| {
lines
.iter()
.position(|l| l.contains(needle))
.unwrap_or_else(|| panic!("`{needle}` present; got: {lines:?}"))
};
assert!(
pos("ay_mgr") < pos("hand_a") && pos("hand_a") < pos("zee_mgr"),
"hand_a nests under ay_mgr, above zee_mgr; got: {lines:?}"
);
assert!(
pos("zee_mgr") < pos("hand_z"),
"hand_z nests under zee_mgr; got: {lines:?}"
);
assert!(
lines[pos("hand_a")].starts_with("│ └── hand_a"),
"hand_a is the only/last child of the non-last manager ay_mgr; got: {:?}",
lines[pos("hand_a")]
);
assert!(
lines[pos("hand_z")].starts_with(" └── hand_z"),
"hand_z is the last child of the last manager zee_mgr; got: {:?}",
lines[pos("hand_z")]
);
}
#[test]
fn tree_orphan_worker_hangs_at_top_level() {
let project = "\
version: 2
project:
id: demo
name: Demo
cwd: ./workspace
managers:
boss: {}
workers:
loose:
reports_to: nobody
";
let files = tree_files(COMPOSE_ONE_DEMO, &[("projects/demo.yaml", project)]);
let lines = plain_tree(&files);
let loose = lines
.iter()
.find(|l| l.contains("loose"))
.expect("loose present");
assert!(
loose.starts_with("├── loose") || loose.starts_with("└── loose"),
"orphan worker hangs at top level (flush-left connector); got: {loose:?}"
);
}
#[test]
fn tree_agentless_project_yields_empty() {
let project = "\
version: 2
project:
id: demo
name: Demo
cwd: ./workspace
managers: {}
workers: {}
";
let files = tree_files(COMPOSE_ONE_DEMO, &[("projects/demo.yaml", project)]);
assert!(
team_structure_lines(&files).is_empty(),
"agentless project must yield no preview lines"
);
}
#[test]
fn tree_unparseable_compose_and_project_degrade_to_empty() {
let garbage_compose = tree_files(
"this: is: not: valid: yaml: {[",
&[("projects/demo.yaml", "also not valid: {[")],
);
assert!(
team_structure_lines(&garbage_compose).is_empty(),
"unparseable compose + project degrade to empty, never panic"
);
let bad_project = tree_files(
COMPOSE_ONE_DEMO,
&[("projects/demo.yaml", "@@@ not yaml @@@")],
);
assert!(
team_structure_lines(&bad_project).is_empty(),
"valid compose + unparseable project degrades to empty"
);
}
#[test]
fn tree_falls_back_to_scanning_projects_without_compose() {
let project = "\
version: 2
project:
id: demo
name: Demo
cwd: ./workspace
managers:
boss: {}
workers: {}
";
let files: Vec<(String, &'static str)> = vec![("projects/demo.yaml".to_string(), project)];
let lines = plain_tree(&files);
assert_eq!(
lines.iter().filter(|l| *l == "You").count(),
1,
"fallback scan previews the team without a compose; got: {lines:?}"
);
assert!(
lines.iter().any(|l| l.contains("boss")),
"fallback preview includes the manager; got: {lines:?}"
);
}
#[test]
fn tree_uses_display_name_over_id() {
let project = "\
version: 2
project:
id: demo
name: Demo
cwd: ./workspace
managers:
boss:
display_name: The Captain
workers: {}
";
let files = tree_files(COMPOSE_ONE_DEMO, &[("projects/demo.yaml", project)]);
let lines = plain_tree(&files);
assert!(
lines.iter().any(|l| l.contains("The Captain")),
"tree line shows display_name; got: {lines:?}"
);
assert!(
!lines.iter().any(|l| l.contains("boss")),
"tree line should not fall back to the id when display_name is set; got: {lines:?}"
);
}
#[test]
fn tree_line_carries_agent_descriptor() {
let project = "\
version: 2
project:
id: demo
name: Demo
cwd: ./workspace
managers:
boss: {}
workers: {}
";
let files = tree_files(COMPOSE_ONE_DEMO, &[("projects/demo.yaml", project)]);
let lines = plain_tree(&files);
let boss = lines
.iter()
.find(|l| l.contains("boss"))
.expect("boss present");
assert!(
boss.contains("Claude Code") && boss.contains("×a"),
"manager row carries the runtime + counts descriptor; got: {boss:?}"
);
}
#[test]
fn tree_essentials_template_emits_single_root() {
let essentials = TEMPLATES
.iter()
.find(|t| t.key == "essentials")
.expect("essentials template");
let lines = plain_tree(&essentials.entries());
assert_eq!(
lines.iter().filter(|l| *l == "You").count(),
1,
"essentials previews exactly one team root; got: {lines:?}"
);
assert!(
lines.iter().any(|l| l.contains("ops")),
"essentials preview includes the ops agent; got: {lines:?}"
);
}
fn compose_with(project: team_core::compose::Project) -> team_core::compose::Compose {
let global = serde_yaml::from_str::<team_core::compose::Global>(
"version: \"2.0.0\"\nprojects: []\n",
)
.expect("minimal global parses");
team_core::compose::Compose {
root: std::path::PathBuf::from("."),
global,
projects: vec![project],
}
}
#[test]
fn lead_attach_prefers_the_manager_workers_report_to() {
let project = parse_project(
"\
version: 2
project:
id: demo
name: Demo
cwd: ./workspace
managers:
boss: {}
compass: {}
workers:
hand:
reports_to: boss
",
);
let compose = compose_with(project);
assert_eq!(
lead_attach_target(&compose).as_deref(),
Some("demo:boss"),
"the manager workers report to wins the attach pick"
);
}
#[test]
fn lead_attach_falls_back_to_first_manager_when_none_reported_to() {
let project = parse_project(
"\
version: 2
project:
id: demo
name: Demo
cwd: ./workspace
managers:
ay_mgr: {}
zee_mgr: {}
workers: {}
",
);
let compose = compose_with(project);
assert_eq!(
lead_attach_target(&compose).as_deref(),
Some("demo:ay_mgr"),
"no reports_to → first manager in agents() order"
);
}
#[test]
fn lead_attach_falls_back_to_any_agent_when_no_managers() {
let project = parse_project(
"\
version: 2
project:
id: demo
name: Demo
cwd: ./workspace
managers: {}
workers:
ay_worker: {}
zee_worker: {}
",
);
let compose = compose_with(project);
assert_eq!(
lead_attach_target(&compose).as_deref(),
Some("demo:ay_worker"),
"no managers → first agent overall"
);
}
#[test]
fn lead_attach_none_when_no_agents() {
let project = parse_project(
"\
version: 2
project:
id: demo
name: Demo
cwd: ./workspace
managers: {}
workers: {}
",
);
let compose = compose_with(project);
assert_eq!(
lead_attach_target(&compose),
None,
"no agents → None (caller omits attach/bot hints)"
);
}
#[test]
fn lead_attach_scopes_reports_to_by_project() {
let solo = parse_project(
"\
version: 2
project:
id: solo
name: Solo
cwd: ./workspace
managers:
boss: {}
workers: {}
",
);
let team = parse_project(
"\
version: 2
project:
id: team
name: Team
cwd: ./workspace
managers:
boss: {}
workers:
hand:
reports_to: boss
",
);
let global = serde_yaml::from_str::<team_core::compose::Global>(
"version: \"2.0.0\"\nprojects: []\n",
)
.expect("minimal global parses");
let compose = team_core::compose::Compose {
root: std::path::PathBuf::from("."),
global,
projects: vec![solo, team],
};
assert_eq!(
lead_attach_target(&compose).as_deref(),
Some("team:boss"),
"the reported-to manager in the worker's OWN project wins, not a same-named manager elsewhere"
);
}
#[test]
fn agent_descriptor_all_zero_counts() {
let project = parse_project(
"\
version: 2
project:
id: demo
name: Demo
cwd: ./workspace
managers:
boss: {}
workers: {}
",
);
let agent = &project.managers["boss"];
assert_eq!(agent_descriptor(agent), "Claude Code · 0×a 0×s 0×h 0×m");
}
#[test]
fn agent_descriptor_counts_and_model_segment() {
let project = parse_project(
"\
version: 2
project:
id: demo
name: Demo
cwd: ./workspace
managers:
boss:
model: claude-opus-4-8
subagents: [a.md, b.md, c.md]
skills: [skills/one]
workers: {}
",
);
let agent = &project.managers["boss"];
assert_eq!(
agent_descriptor(agent),
"Claude Code · Opus 4.8 · 3×a 1×s 0×h 0×m",
"descriptor = runtime · model · counts, joined by ` · `"
);
}
#[test]
fn agent_descriptor_omits_model_when_unset() {
let project = parse_project(
"\
version: 2
project:
id: demo
name: Demo
cwd: ./workspace
managers:
boss:
subagents: [a.md]
workers: {}
",
);
let agent = &project.managers["boss"];
let desc = agent_descriptor(agent);
assert_eq!(desc, "Claude Code · 1×a 0×s 0×h 0×m");
assert_eq!(
desc.matches(" · ").count(),
1,
"no model → no model segment; got: {desc:?}"
);
}
#[test]
fn runtime_label_known_and_unknown() {
assert_eq!(runtime_label("claude-code"), "Claude Code");
assert_eq!(runtime_label("codex"), "codex");
assert_eq!(runtime_label(""), "");
}
#[test]
fn model_label_all_arms_including_both_haiku_aliases() {
assert_eq!(model_label("claude-opus-4-8"), "Opus 4.8");
assert_eq!(model_label("claude-sonnet-4-6"), "Sonnet 4.6");
assert_eq!(model_label("claude-haiku-4-5"), "Haiku 4.5");
assert_eq!(
model_label("claude-haiku-4-5-20251001"),
"Haiku 4.5",
"the dated haiku alias maps to the same label"
);
assert_eq!(model_label("gpt-5"), "gpt-5");
}
#[test]
fn label_for_prefers_display_name_else_id() {
let project = parse_project(
"\
version: 2
project:
id: demo
name: Demo
cwd: ./workspace
managers:
named:
display_name: The Captain
bare: {}
workers: {}
",
);
assert_eq!(
label_for("named", &project.managers["named"]),
"The Captain",
"display_name wins when set"
);
assert_eq!(
label_for("bare", &project.managers["bare"]),
"bare",
"falls back to the id when display_name is absent"
);
}
fn render_tree(tree: &TreeNode) -> Vec<String> {
let mut out = Vec::new();
tree.render("", &mut |line| out.push(line));
out
}
#[test]
fn treenode_renders_dirs_files_and_connectors() {
let mut tree = TreeNode::default();
tree.insert(&["a", "b.txt"]);
tree.insert(&["a", "c.txt"]);
tree.insert(&["d.txt"]);
let lines = render_tree(&tree);
assert_eq!(
lines,
vec![
"├── a/".to_string(),
"│ ├── b.txt".to_string(),
"│ └── c.txt".to_string(),
"└── d.txt".to_string(),
],
"dir gets a trailing slash; leaves don't; connectors + continuation match"
);
}
#[test]
fn treenode_preserves_insertion_order_not_alpha() {
let mut tree = TreeNode::default();
tree.insert(&["z.txt"]);
tree.insert(&["a.txt"]);
let lines = render_tree(&tree);
assert_eq!(
lines,
vec!["├── z.txt".to_string(), "└── a.txt".to_string()],
"insertion order is preserved (z before a), not alpha-sorted"
);
}
#[test]
fn treenode_empty_renders_nothing() {
let tree = TreeNode::default();
assert!(
render_tree(&tree).is_empty(),
"an empty tree emits no lines"
);
}
#[test]
fn treenode_inserting_existing_path_does_not_duplicate() {
let mut tree = TreeNode::default();
tree.insert(&["a", "b.txt"]);
tree.insert(&["a", "c.txt"]);
assert_eq!(
tree.children.len(),
1,
"the shared `a/` dir is a single node, not duplicated"
);
}
}