teamctl 0.8.1

Declarative CLI for running persistent AI agent teams.
//! `teamctl init [name] [--template <name>] [--project <id>] [--force] [--yes]`
//!
//! Scaffold a `.team/` directory. With `name`, creates `<cwd>/<name>/.team/`
//! so a fresh `cd <name> && teamctl up` Just Works. Without `name`,
//! scaffolds `.team/` directly in cwd (the legacy in-place flow). Templates
//! are baked into the binary so `init` works offline and produces
//! consistent output. When run interactively (no `--yes`), the user picks a
//! template and confirms; `--yes` accepts the `solo` template silently.
//! `--force` overwrites an existing `.team/` at the target path.

use std::collections::BTreeMap;
use std::fs;
use std::io::{self, BufRead, Write};

use anyhow::{anyhow, bail, Context, Result};

#[derive(Clone, Copy)]
pub struct Template {
    pub key: &'static str,
    pub label: &'static str,
    pub blurb: &'static str,
    pub files: &'static [(&'static str, &'static str)],
}

pub const TEMPLATES: &[Template] = &[
    Template {
        key: "solo",
        label: "Solo team",
        blurb: "One manager + one dev, both on Claude Code. Smallest useful team.",
        files: &[
            (
                "team-compose.yaml",
                include_str!("../../assets/templates/solo/team-compose.yaml"),
            ),
            (
                "projects/main.yaml",
                include_str!("../../assets/templates/solo/projects/main.yaml"),
            ),
            (
                "roles/manager.md",
                include_str!("../../assets/templates/solo/roles/manager.md"),
            ),
            (
                "roles/dev.md",
                include_str!("../../assets/templates/solo/roles/dev.md"),
            ),
            (
                ".env.example",
                include_str!("../../assets/templates/solo/.env.example"),
            ),
            (
                ".gitignore",
                include_str!("../../assets/templates/_common/.gitignore"),
            ),
            (
                "README.md",
                include_str!("../../assets/templates/solo/README.md"),
            ),
        ],
    },
    Template {
        key: "blank",
        label: "Blank",
        blurb: "Empty compose tree. Wire it up yourself.",
        files: &[
            (
                "team-compose.yaml",
                include_str!("../../assets/templates/blank/team-compose.yaml"),
            ),
            (
                "projects/main.yaml",
                include_str!("../../assets/templates/blank/projects/main.yaml"),
            ),
            (
                ".env.example",
                include_str!("../../assets/templates/_common/.env.example"),
            ),
            (
                ".gitignore",
                include_str!("../../assets/templates/_common/.gitignore"),
            ),
        ],
    },
];

pub fn run(
    name: Option<String>,
    template: Option<String>,
    project_id: Option<String>,
    force: bool,
    yes: bool,
) -> Result<()> {
    let cwd = std::env::current_dir().context("get cwd")?;

    // Target layout:
    //   teamctl init my-team   →  <cwd>/my-team/.team/...   (`name` set)
    //   teamctl init           →  <cwd>/.team/...           (in-place)
    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 tpl = match template {
        Some(k) => TEMPLATES
            .iter()
            .find(|t| t.key == k)
            .ok_or_else(|| anyhow!("unknown template `{k}` — known: {}", template_keys()))?,
        None if yes => {
            // Default in non-interactive mode.
            TEMPLATES.iter().find(|t| t.key == "solo").unwrap()
        }
        None => choose_template_interactive()?,
    };

    // Project id derives from (in order): explicit --project flag,
    // positional `name`, parent-directory basename. The slugify pass
    // is the same for all three so a name like "My Team!" still
    // produces a usable id.
    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));

    if !yes {
        eprintln!();
        eprintln!("About to scaffold `.team/` at {}:", target.display());
        eprintln!("  template:    {} ({})", tpl.label, tpl.key);
        eprintln!("  project id:  {pid}");
        eprintln!("  files:");
        for (path, _) in tpl.files {
            eprintln!("    .team/{path}");
        }
        if !confirm("Proceed?")? {
            bail!("aborted");
        }
    }

    fs::create_dir_all(&target)?;
    for (relpath, contents) in tpl.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!();
    println!("Next:");
    if name.is_some() {
        let display = name.as_deref().unwrap_or(".");
        println!("  cd {display}");
    }
    println!("  cp .team/.env.example .team/.env   # edit secrets");
    println!("  teamctl validate                   # sanity-check");
    println!("  teamctl up                         # start the team");
    Ok(())
}

fn choose_template_interactive() -> Result<&'static Template> {
    eprintln!("Pick a template:");
    for (i, t) in TEMPLATES.iter().enumerate() {
        eprintln!("  {}) {:<14} — {}", i + 1, t.label, t.blurb);
    }
    eprint!("Choice [1]: ");
    io::stderr().flush().ok();
    let mut line = String::new();
    io::stdin().lock().read_line(&mut line)?;
    let trimmed = line.trim();
    let idx = if trimmed.is_empty() {
        0
    } else {
        trimmed
            .parse::<usize>()
            .map(|n| n.saturating_sub(1))
            .unwrap_or(0)
    };
    Ok(TEMPLATES.get(idx).unwrap_or(&TEMPLATES[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)?;
    let s = line.trim().to_lowercase();
    Ok(s.is_empty() || s == "y" || s == "yes")
}

fn template_keys() -> String {
    TEMPLATES
        .iter()
        .map(|t| t.key)
        .collect::<Vec<_>>()
        .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(" ")
}

#[allow(dead_code)]
pub fn template_list_for_cli() -> Vec<&'static str> {
    TEMPLATES.iter().map(|t| t.key).collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[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");
    }
}