atman-cli 1.5.0

atman command-line interface — AI coding agent runtime with a Turing-complete .at flow DSL
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use atman_runtime::templates::{AGENT_AT, SYSTEM_MD};

pub struct InitReport {
    pub config_dir: PathBuf,
    pub written: Vec<PathBuf>,
    pub skipped: Vec<PathBuf>,
    pub managed: Vec<PathBuf>,
}

#[cfg(test)]
pub fn init_config_dir(config_dir: &Path) -> Result<InitReport> {
    init_config_dir_with_mode(config_dir, None)
}

pub fn init_config_dir_with_mode(
    config_dir: &Path,
    fs_access: Option<atman_runtime::fs_access::FsAccessMode>,
) -> Result<InitReport> {
    std::fs::create_dir_all(config_dir)
        .with_context(|| format!("mkdir {}", config_dir.display()))?;
    let commands_dir = config_dir.join("commands");
    std::fs::create_dir_all(&commands_dir)
        .with_context(|| format!("mkdir {}", commands_dir.display()))?;

    let config_toml_body: String = match fs_access {
        Some(mode) => CONFIG_TOML.replace(
            "# [fs_access]\n# mode = \"workspace-write\"",
            &format!("[fs_access]\nmode = \"{}\"", mode.as_str()),
        ),
        None => CONFIG_TOML.to_string(),
    };

    let config_path = config_dir.join("config.toml");
    let managed_path = commands_dir.join("agent.at");
    let optional_templates: [(PathBuf, String); 4] = [
        (config_path.clone(), config_toml_body),
        (config_dir.join("routes.at"), ROUTES_AT.into()),
        (
            config_dir.join("on_session_start.at"),
            ON_SESSION_START_AT.into(),
        ),
        (commands_dir.join("hello.at"), HELLO_AT.into()),
    ];

    let mut written = Vec::new();
    let mut skipped = Vec::new();
    let mut managed = Vec::new();

    std::fs::write(&managed_path, AGENT_AT)
        .with_context(|| format!("write {}", managed_path.display()))?;
    written.push(managed_path.clone());
    managed.push(managed_path);

    let prompts_dir = config_dir.join("prompts");
    std::fs::create_dir_all(&prompts_dir)
        .with_context(|| format!("mkdir {}", prompts_dir.display()))?;
    let system_md = prompts_dir.join("system.md");
    std::fs::write(&system_md, SYSTEM_MD)
        .with_context(|| format!("write {}", system_md.display()))?;
    written.push(system_md.clone());
    managed.push(system_md);

    for (path, body) in optional_templates {
        if path.exists() {
            skipped.push(path);
            continue;
        }
        std::fs::write(&path, body).with_context(|| format!("write {}", path.display()))?;
        written.push(path);
    }
    Ok(InitReport {
        config_dir: config_dir.to_path_buf(),
        written,
        skipped,
        managed,
    })
}

pub const CONFIG_TOML: &str = r#"# atman configuration
#
# Every section here is optional. atman ships with sensible defaults;
# uncomment a section to override.

# ── Model config + alias ──────────────────────────────────────────
# Flows reference models by alias (e.g. model: "smart") so you can
# switch providers without editing flows.
#
# Each [models.xxx] block can declare:
#   provider   = "anthropic" | "openai"   — which API protocol to use
#   api_key    = "sk-..."                  — key (or use env vars below)
#   base_url   = "https://..."             — override the default endpoint
#   max_tokens = 8192                      — response token cap
#   context_budget = 200000                — context window size
#   thinking   = true                      — enable extended thinking
#
# Provider env vars (alternative to api_key in config):
#   Anthropic / DeepSeek (anthropic-compat):  ANTHROPIC_API_KEY + ANTHROPIC_BASE_URL
#   OpenAI / OpenAI-compat:                   OPENAI_API_KEY + OPENAI_BASE_URL

# Note: model names containing "/" must be quoted: [models."provider/model-id"]

[models.claude-opus-4.7]
context_budget = 200000
thinking = true

[models.gpt-4o-mini]
context_budget = 128000

[alias.smart]
model = "claude-opus-4.7"

[alias.cheap]
model = "gpt-4o-mini"

[suggest]
# model = "cheap"

[registry]
# auto_snapshot = true

[interjection]
# classifier = "rule"

[sandbox]
# enabled = true
# strict = false
# allow_network = false

# Theme: auto (detect from terminal), dark, or light.
# [theme]
# mode = "auto"

# [fs_access]
# mode = "workspace-write"

[preview]
# base_url = "http://127.0.0.1:65097"
# timeout_ms = 3000
"#;

pub const ROUTES_AT: &str = r#"route "hi " { flow: hello }

default_route { flow: agent }
"#;

pub const ON_SESSION_START_AT: &str = r#"flow on_session_start() -> string {
    return "atman ready. `/hello` for a smoke test, plain text to chat."
}
"#;

pub const HELLO_AT: &str = r#"flow hello() -> string {
    return "hello from atman"
}
"#;

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

    #[test]
    fn fresh_init_writes_every_template() {
        let tmp = tempfile::tempdir().unwrap();
        let cfg = tmp.path().join("atman");
        let rep = init_config_dir(&cfg).unwrap();
        assert!(rep.skipped.is_empty());
        assert_eq!(rep.written.len(), 6, "written: {:?}", rep.written);
        assert_eq!(rep.managed.len(), 2);
        assert!(cfg.join("config.toml").exists());
        assert!(cfg.join("routes.at").exists());
        assert!(cfg.join("on_session_start.at").exists());
        assert!(cfg.join("commands/agent.at").exists());
        assert!(cfg.join("commands/hello.at").exists());
        assert!(cfg.join("prompts/system.md").exists());
    }

    #[test]
    fn init_with_explicit_mode_persists_uncommented_section() {
        use atman_runtime::fs_access::FsAccessMode;
        let tmp = tempfile::tempdir().unwrap();
        let cfg = tmp.path().join("atman");
        init_config_dir_with_mode(&cfg, Some(FsAccessMode::ReadOnly)).unwrap();
        let body = std::fs::read_to_string(cfg.join("config.toml")).unwrap();
        assert!(body.contains("[fs_access]"));
        assert!(body.contains("mode = \"read-only\""));
        assert!(
            !body.contains("# [fs_access]"),
            "explicit mode must uncomment the block"
        );
    }

    #[test]
    fn init_without_mode_leaves_section_commented() {
        let tmp = tempfile::tempdir().unwrap();
        let cfg = tmp.path().join("atman");
        init_config_dir(&cfg).unwrap();
        let body = std::fs::read_to_string(cfg.join("config.toml")).unwrap();
        assert!(body.contains("# [fs_access]"));
    }

    #[test]
    fn second_init_leaves_existing_files_alone() {
        let tmp = tempfile::tempdir().unwrap();
        let cfg = tmp.path().join("atman");
        init_config_dir(&cfg).unwrap();
        let touched = cfg.join("commands/hello.at");
        std::fs::write(&touched, "flow hello() { return \"CUSTOM\" }\n").unwrap();
        let agent = cfg.join("commands/agent.at");
        std::fs::write(&agent, "flow agent() { return \"BROKEN\" }\n").unwrap();

        let rep = init_config_dir(&cfg).unwrap();
        assert!(rep.skipped.iter().any(|p| p.ends_with("hello.at")));
        assert!(rep.managed.iter().any(|p| p.ends_with("agent.at")));
        let hello_body = std::fs::read_to_string(&touched).unwrap();
        assert!(
            hello_body.contains("CUSTOM"),
            "user edit preserved: {hello_body}"
        );
        let agent_body = std::fs::read_to_string(&agent).unwrap();
        assert!(agent_body.contains("flow agent(user_prompt: string) -> string"));
        assert!(!agent_body.contains("BROKEN"));
    }

    #[test]
    fn init_creates_commands_dir_even_if_config_dir_pre_exists() {
        let tmp = tempfile::tempdir().unwrap();
        let cfg = tmp.path().join("atman");
        std::fs::create_dir_all(&cfg).unwrap();
        std::fs::write(cfg.join("some-other.toml"), "unrelated").unwrap();
        let rep = init_config_dir(&cfg).unwrap();
        assert!(cfg.join("commands").is_dir());
        assert_eq!(rep.written.len(), 6);
    }

    #[test]
    fn agent_template_parses_as_valid_dsl() {
        let file = atman_dsl::parse::parse_file(AGENT_AT).expect("agent template must parse");
        let names: Vec<&str> = file.flows.iter().map(|f| f.name.name.as_str()).collect();
        assert_eq!(names, vec!["agent", "agent_loop"]);
    }

    #[test]
    fn agent_template_exposes_flow_named_agent_for_slash_resolver() {
        let file = atman_dsl::parse::parse_file(AGENT_AT).unwrap();
        let entry = file.flows.iter().find(|f| f.name.name == "agent");
        assert!(
            entry.is_some(),
            "commands/agent.at must contain a `flow agent(...)` so slash-command resolver can find it by name (regression: 2-flow file previously errored)"
        );
    }

    #[test]
    fn hello_template_parses_and_returns_hello() {
        let file = atman_dsl::parse::parse_file(HELLO_AT).expect("hello template must parse");
        assert_eq!(file.flows.len(), 1);
        assert_eq!(file.flows[0].name.name, "hello");
    }

    #[test]
    fn routes_template_parses() {
        let file = atman_dsl::parse::parse_file(ROUTES_AT).expect("routes template must parse");
        assert!(
            !file.routes.is_empty() || file.default_route.is_some(),
            "want at least one route or default_route"
        );
    }

    #[test]
    fn on_session_start_template_parses() {
        let file = atman_dsl::parse::parse_file(ON_SESSION_START_AT)
            .expect("on_session_start template must parse");
        assert_eq!(file.flows.len(), 1);
        assert_eq!(file.flows[0].name.name, "on_session_start");
    }
}