Skip to main content

aether_cli/headless/
mod.rs

1pub mod error;
2pub mod run;
3
4use aether_core::agent_spec::AgentSpec;
5use aether_project::load_agent_catalog;
6use error::CliError;
7use std::io::{IsTerminal, Read as _, stdin};
8use std::path::PathBuf;
9use std::process::ExitCode;
10
11use crate::resolve::resolve_agent_spec;
12
13#[derive(Clone)]
14pub enum OutputFormat {
15    Text,
16    Pretty,
17    Json,
18}
19
20pub struct RunConfig {
21    pub prompt: String,
22    pub cwd: PathBuf,
23    pub mcp_config: Option<PathBuf>,
24    pub spec: AgentSpec,
25    pub system_prompt: Option<String>,
26    pub output: OutputFormat,
27    pub verbose: bool,
28}
29
30pub async fn run_headless(args: HeadlessArgs) -> Result<ExitCode, CliError> {
31    let prompt = resolve_prompt(&args)?;
32    let cwd = args.cwd.canonicalize().map_err(CliError::IoError)?;
33    let spec = resolve_spec(args.agent.as_deref(), args.model.as_deref(), &cwd)?;
34
35    let output = match args.output {
36        CliOutputFormat::Text => OutputFormat::Text,
37        CliOutputFormat::Pretty => OutputFormat::Pretty,
38        CliOutputFormat::Json => OutputFormat::Json,
39    };
40
41    let config = RunConfig {
42        prompt,
43        cwd,
44        mcp_config: args.mcp_config,
45        spec,
46        system_prompt: args.system_prompt,
47        output,
48        verbose: args.verbose,
49    };
50
51    run::run(config).await
52}
53
54#[derive(Clone, clap::ValueEnum)]
55pub enum CliOutputFormat {
56    Text,
57    Pretty,
58    Json,
59}
60
61#[derive(clap::Args)]
62pub struct HeadlessArgs {
63    /// Prompt to send (reads stdin if omitted and stdin is not a TTY)
64    pub prompt: Vec<String>,
65
66    /// Named agent from settings.json (defaults to first user-invocable agent)
67    #[arg(short = 'a', long = "agent")]
68    pub agent: Option<String>,
69
70    /// Model for ad-hoc runs (e.g. "anthropic:claude-sonnet-4-5"). Mutually exclusive with --agent.
71    #[arg(short, long)]
72    pub model: Option<String>,
73
74    /// Working directory
75    #[arg(short = 'C', long = "cwd", default_value = ".")]
76    pub cwd: PathBuf,
77
78    /// Path to mcp.json (auto-detected if omitted)
79    #[arg(long = "mcp-config")]
80    pub mcp_config: Option<PathBuf>,
81
82    /// Additional system prompt
83    #[arg(long = "system-prompt")]
84    pub system_prompt: Option<String>,
85
86    /// Output format
87    #[arg(long, default_value = "text")]
88    pub output: CliOutputFormat,
89
90    /// Verbose logging to stderr
91    #[arg(short, long)]
92    pub verbose: bool,
93}
94
95fn resolve_prompt(args: &HeadlessArgs) -> Result<String, CliError> {
96    match args.prompt.as_slice() {
97        args if !args.is_empty() => Ok(args.join(" ")),
98
99        _ if !stdin().is_terminal() => {
100            let mut buf = String::new();
101            stdin()
102                .read_to_string(&mut buf)
103                .map_err(CliError::IoError)?;
104
105            match buf.trim() {
106                "" => Err(CliError::NoPrompt),
107                s => Ok(s.to_string()),
108            }
109        }
110        _ => Err(CliError::NoPrompt),
111    }
112}
113
114fn resolve_spec(
115    agent: Option<&str>,
116    model: Option<&str>,
117    cwd: &std::path::Path,
118) -> Result<AgentSpec, CliError> {
119    if agent.is_some() && model.is_some() {
120        return Err(CliError::ConflictingArgs(
121            "Cannot specify both --agent and --model".to_string(),
122        ));
123    }
124
125    let catalog = load_agent_catalog(cwd).map_err(|e| CliError::AgentError(e.to_string()))?;
126
127    match model {
128        Some(m) => {
129            let parsed = m.parse().map_err(|e: String| CliError::ModelError(e))?;
130            Ok(catalog.resolve_default(&parsed, None, cwd))
131        }
132        None => resolve_agent_spec(&catalog, agent, cwd),
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    fn write_file(dir: &std::path::Path, path: &str, content: &str) {
141        let full = dir.join(path);
142        if let Some(parent) = full.parent() {
143            std::fs::create_dir_all(parent).unwrap();
144        }
145        std::fs::write(full, content).unwrap();
146    }
147
148    fn setup_dir_with_agents() -> tempfile::TempDir {
149        let dir = tempfile::tempdir().unwrap();
150        write_file(dir.path(), "PROMPT.md", "Be helpful");
151        write_file(
152            dir.path(),
153            ".aether/settings.json",
154            r#"{"agents": [
155                {"name": "alpha", "description": "Alpha agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["PROMPT.md"]},
156                {"name": "beta", "description": "Beta agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["PROMPT.md"]}
157            ]}"#,
158        );
159        dir
160    }
161
162    #[test]
163    fn resolve_spec_with_named_agent() {
164        let dir = setup_dir_with_agents();
165        let spec = resolve_spec(Some("beta"), None, dir.path()).unwrap();
166        assert_eq!(spec.name, "beta");
167    }
168
169    #[test]
170    fn resolve_spec_with_model_creates_default() {
171        let dir = setup_dir_with_agents();
172        let spec = resolve_spec(None, Some("anthropic:claude-sonnet-4-5"), dir.path()).unwrap();
173        assert_eq!(spec.name, "__default__");
174    }
175
176    #[test]
177    fn resolve_spec_defaults_to_first_user_invocable() {
178        let dir = setup_dir_with_agents();
179        let spec = resolve_spec(None, None, dir.path()).unwrap();
180        assert_eq!(spec.name, "alpha");
181    }
182
183    #[test]
184    fn resolve_spec_defaults_to_fallback_without_settings() {
185        let dir = tempfile::tempdir().unwrap();
186        let spec = resolve_spec(None, None, dir.path()).unwrap();
187        assert_eq!(spec.name, "__default__");
188    }
189
190    #[test]
191    fn resolve_spec_rejects_both_agent_and_model() {
192        let dir = setup_dir_with_agents();
193        let err = resolve_spec(
194            Some("alpha"),
195            Some("anthropic:claude-sonnet-4-5"),
196            dir.path(),
197        )
198        .unwrap_err();
199        assert!(
200            err.to_string().contains("Cannot specify both"),
201            "unexpected error: {err}"
202        );
203    }
204
205    #[test]
206    fn resolve_spec_rejects_invalid_model() {
207        let dir = tempfile::tempdir().unwrap();
208        let err = resolve_spec(None, Some("not-a-valid-model"), dir.path()).unwrap_err();
209        assert!(matches!(err, CliError::ModelError(_)));
210    }
211
212    #[test]
213    fn resolve_spec_rejects_unknown_agent() {
214        let dir = setup_dir_with_agents();
215        let err = resolve_spec(Some("nonexistent"), None, dir.path()).unwrap_err();
216        assert!(matches!(err, CliError::AgentError(_)));
217    }
218}