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
20#[derive(Clone, Copy, PartialEq, Eq, Debug, clap::ValueEnum)]
21#[clap(rename_all = "snake_case")]
22pub enum CliEventKind {
23    Text,
24    Thought,
25    ToolCall,
26    ToolResult,
27    ToolError,
28    Error,
29    Cancelled,
30    AutoContinue,
31    ModelSwitched,
32    ToolProgress,
33    ContextCompactionStarted,
34    ContextCompactionResult,
35    ContextUsage,
36    ContextCleared,
37}
38
39impl CliEventKind {
40    pub fn as_str(self) -> &'static str {
41        match self {
42            Self::Text => "text",
43            Self::Thought => "thought",
44            Self::ToolCall => "tool_call",
45            Self::ToolResult => "tool_result",
46            Self::ToolError => "tool_error",
47            Self::Error => "error",
48            Self::Cancelled => "cancelled",
49            Self::AutoContinue => "auto_continue",
50            Self::ModelSwitched => "model_switched",
51            Self::ToolProgress => "tool_progress",
52            Self::ContextCompactionStarted => "context_compaction_started",
53            Self::ContextCompactionResult => "context_compaction_result",
54            Self::ContextUsage => "context_usage",
55            Self::ContextCleared => "context_cleared",
56        }
57    }
58}
59
60pub struct RunConfig {
61    pub prompt: String,
62    pub cwd: PathBuf,
63    pub mcp_configs: Vec<PathBuf>,
64    pub spec: AgentSpec,
65    pub system_prompt: Option<String>,
66    pub output: OutputFormat,
67    pub verbose: bool,
68    pub events: Vec<CliEventKind>,
69}
70
71pub async fn run_headless(args: HeadlessArgs) -> Result<ExitCode, CliError> {
72    let prompt = resolve_prompt(&args)?;
73    let cwd = args.cwd.canonicalize().map_err(CliError::IoError)?;
74    let spec = resolve_spec(args.agent.as_deref(), args.model.as_deref(), &cwd)?;
75
76    let output = match args.output {
77        CliOutputFormat::Text => OutputFormat::Text,
78        CliOutputFormat::Pretty => OutputFormat::Pretty,
79        CliOutputFormat::Json => OutputFormat::Json,
80    };
81
82    let config = RunConfig {
83        prompt,
84        cwd,
85        mcp_configs: args.mcp_configs,
86        spec,
87        system_prompt: args.system_prompt,
88        output,
89        verbose: args.verbose,
90        events: args.events,
91    };
92
93    run::run(config).await
94}
95
96#[derive(Clone, clap::ValueEnum)]
97pub enum CliOutputFormat {
98    Text,
99    Pretty,
100    Json,
101}
102
103#[derive(clap::Args)]
104pub struct HeadlessArgs {
105    /// Prompt to send (reads stdin if omitted and stdin is not a TTY)
106    pub prompt: Vec<String>,
107
108    /// Named agent from settings.json (defaults to first user-invocable agent)
109    #[arg(short = 'a', long = "agent")]
110    pub agent: Option<String>,
111
112    /// Model for ad-hoc runs (e.g. "anthropic:claude-sonnet-4-5"). Mutually exclusive with --agent.
113    #[arg(short, long)]
114    pub model: Option<String>,
115
116    /// Working directory
117    #[arg(short = 'C', long = "cwd", default_value = ".")]
118    pub cwd: PathBuf,
119
120    /// Path(s) to mcp.json. Pass multiple times to layer configs (last wins on collisions).
121    /// If omitted, paths from settings.json `mcpServers` are used (or `cwd/mcp.json` is auto-detected).
122    #[arg(long = "mcp-config")]
123    pub mcp_configs: Vec<PathBuf>,
124
125    /// Additional system prompt
126    #[arg(long = "system-prompt")]
127    pub system_prompt: Option<String>,
128
129    /// Output format
130    #[arg(long, default_value = "text")]
131    pub output: CliOutputFormat,
132
133    /// Verbose diagnostic logging to stderr.
134    #[arg(short, long)]
135    pub verbose: bool,
136
137    /// Comma-separated list of events to emit (e.g. `tool_call,tool_result`).
138    /// Omit to emit everything. When set, `error` is only shown if explicitly listed.
139    #[arg(long = "events", value_enum, value_delimiter = ',')]
140    pub events: Vec<CliEventKind>,
141}
142
143fn resolve_prompt(args: &HeadlessArgs) -> Result<String, CliError> {
144    match args.prompt.as_slice() {
145        args if !args.is_empty() => Ok(args.join(" ")),
146
147        _ if !stdin().is_terminal() => {
148            let mut buf = String::new();
149            stdin().read_to_string(&mut buf).map_err(CliError::IoError)?;
150
151            match buf.trim() {
152                "" => Err(CliError::NoPrompt),
153                s => Ok(s.to_string()),
154            }
155        }
156        _ => Err(CliError::NoPrompt),
157    }
158}
159
160fn resolve_spec(agent: Option<&str>, model: Option<&str>, cwd: &std::path::Path) -> Result<AgentSpec, CliError> {
161    if agent.is_some() && model.is_some() {
162        return Err(CliError::ConflictingArgs("Cannot specify both --agent and --model".to_string()));
163    }
164
165    let catalog = load_agent_catalog(cwd).map_err(|e| CliError::AgentError(e.to_string()))?;
166
167    match model {
168        Some(m) => {
169            let parsed = m.parse().map_err(|e: String| CliError::ModelError(e))?;
170            Ok(catalog.resolve_default(&parsed, None, cwd))
171        }
172        None => resolve_agent_spec(&catalog, agent, cwd),
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    fn write_file(dir: &std::path::Path, path: &str, content: &str) {
181        let full = dir.join(path);
182        if let Some(parent) = full.parent() {
183            std::fs::create_dir_all(parent).unwrap();
184        }
185        std::fs::write(full, content).unwrap();
186    }
187
188    fn setup_dir_with_agents() -> tempfile::TempDir {
189        let dir = tempfile::tempdir().unwrap();
190        write_file(dir.path(), "PROMPT.md", "Be helpful");
191        write_file(
192            dir.path(),
193            ".aether/settings.json",
194            r#"{"agents": [
195                {"name": "alpha", "description": "Alpha agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["PROMPT.md"]},
196                {"name": "beta", "description": "Beta agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["PROMPT.md"]}
197            ]}"#,
198        );
199        dir
200    }
201
202    #[test]
203    fn resolve_spec_with_named_agent() {
204        let dir = setup_dir_with_agents();
205        let spec = resolve_spec(Some("beta"), None, dir.path()).unwrap();
206        assert_eq!(spec.name, "beta");
207    }
208
209    #[test]
210    fn resolve_spec_with_model_creates_default() {
211        let dir = setup_dir_with_agents();
212        let spec = resolve_spec(None, Some("anthropic:claude-sonnet-4-5"), dir.path()).unwrap();
213        assert_eq!(spec.name, "__default__");
214    }
215
216    #[test]
217    fn resolve_spec_defaults_to_first_user_invocable() {
218        let dir = setup_dir_with_agents();
219        let spec = resolve_spec(None, None, dir.path()).unwrap();
220        assert_eq!(spec.name, "alpha");
221    }
222
223    #[test]
224    fn resolve_spec_defaults_to_fallback_without_settings() {
225        let dir = tempfile::tempdir().unwrap();
226        let spec = resolve_spec(None, None, dir.path()).unwrap();
227        assert_eq!(spec.name, "__default__");
228    }
229
230    #[test]
231    fn resolve_spec_rejects_both_agent_and_model() {
232        let dir = setup_dir_with_agents();
233        let err = resolve_spec(Some("alpha"), Some("anthropic:claude-sonnet-4-5"), dir.path()).unwrap_err();
234        assert!(err.to_string().contains("Cannot specify both"), "unexpected error: {err}");
235    }
236
237    #[test]
238    fn resolve_spec_rejects_invalid_model() {
239        let dir = tempfile::tempdir().unwrap();
240        let err = resolve_spec(None, Some("not-a-valid-model"), dir.path()).unwrap_err();
241        assert!(matches!(err, CliError::ModelError(_)));
242    }
243
244    #[test]
245    fn resolve_spec_rejects_unknown_agent() {
246        let dir = setup_dir_with_agents();
247        let err = resolve_spec(Some("nonexistent"), None, dir.path()).unwrap_err();
248        assert!(matches!(err, CliError::AgentError(_)));
249    }
250}