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::{AetherSettings, AgentCatalog};
6use error::CliError;
7use llm::ProviderConnectionOverrides;
8use std::io::{IsTerminal, Read as _, stdin};
9use std::path::PathBuf;
10use std::process::ExitCode;
11
12use crate::mcp_config_args::McpConfigArgs;
13use crate::provider_connection_args::ProviderConnectionArgs;
14use crate::resolve::resolve_agent_spec;
15use crate::settings_args::SettingsSourceArgs;
16
17#[derive(Clone)]
18pub enum OutputFormat {
19    Text,
20    Pretty,
21    Json,
22}
23
24#[derive(Clone, Copy, PartialEq, Eq, Debug, clap::ValueEnum)]
25#[clap(rename_all = "snake_case")]
26pub enum CliEventKind {
27    Text,
28    Thought,
29    ToolCall,
30    ToolResult,
31    ToolError,
32    Error,
33    Cancelled,
34    AutoContinue,
35    Retrying,
36    ModelSwitched,
37    ToolProgress,
38    ContextCompactionStarted,
39    ContextCompactionResult,
40    ContextUsage,
41    ContextCleared,
42}
43
44impl CliEventKind {
45    pub fn as_str(self) -> &'static str {
46        match self {
47            Self::Text => "text",
48            Self::Thought => "thought",
49            Self::ToolCall => "tool_call",
50            Self::ToolResult => "tool_result",
51            Self::ToolError => "tool_error",
52            Self::Error => "error",
53            Self::Cancelled => "cancelled",
54            Self::AutoContinue => "auto_continue",
55            Self::Retrying => "retrying",
56            Self::ModelSwitched => "model_switched",
57            Self::ToolProgress => "tool_progress",
58            Self::ContextCompactionStarted => "context_compaction_started",
59            Self::ContextCompactionResult => "context_compaction_result",
60            Self::ContextUsage => "context_usage",
61            Self::ContextCleared => "context_cleared",
62        }
63    }
64}
65
66pub struct RunConfig {
67    pub prompt: String,
68    pub cwd: PathBuf,
69    pub mcp_config_sources: Vec<aether_core::agent_spec::McpConfigSource>,
70    pub spec: AgentSpec,
71    pub system_prompt: Option<String>,
72    pub output: OutputFormat,
73    pub verbose: bool,
74    pub events: Vec<CliEventKind>,
75}
76
77pub async fn run_headless(args: HeadlessArgs) -> Result<ExitCode, CliError> {
78    let prompt = resolve_prompt(&args)?;
79    let cwd = args.cwd.canonicalize().map_err(CliError::IoError)?;
80    let provider_connections = args.provider_connection.clone().into_overrides();
81    let spec =
82        resolve_spec(args.agent.as_deref(), args.model.as_deref(), &cwd, &args.settings_source, provider_connections)?;
83
84    let output = match args.output {
85        CliOutputFormat::Text => OutputFormat::Text,
86        CliOutputFormat::Pretty => OutputFormat::Pretty,
87        CliOutputFormat::Json => OutputFormat::Json,
88    };
89
90    let mcp_config_sources = args.mcp_config.sources(&cwd);
91
92    let config = RunConfig {
93        prompt,
94        cwd,
95        mcp_config_sources,
96        spec,
97        system_prompt: args.system_prompt,
98        output,
99        verbose: args.verbose,
100        events: args.events,
101    };
102
103    run::run(config).await
104}
105
106#[derive(Clone, clap::ValueEnum)]
107pub enum CliOutputFormat {
108    Text,
109    Pretty,
110    Json,
111}
112
113#[derive(clap::Args)]
114pub struct HeadlessArgs {
115    /// Prompt to send (reads stdin if omitted and stdin is not a TTY)
116    pub prompt: Vec<String>,
117
118    /// Named agent from settings.json (defaults to first user-invocable agent)
119    #[arg(short = 'a', long = "agent")]
120    pub agent: Option<String>,
121
122    /// Model for ad-hoc runs (e.g. "anthropic:claude-sonnet-4-5"). Mutually exclusive with --agent.
123    #[arg(short, long)]
124    pub model: Option<String>,
125
126    /// Working directory
127    #[arg(short = 'C', long = "cwd", default_value = ".")]
128    pub cwd: PathBuf,
129
130    #[command(flatten)]
131    pub settings_source: SettingsSourceArgs,
132
133    #[command(flatten)]
134    pub provider_connection: ProviderConnectionArgs,
135
136    #[command(flatten)]
137    pub mcp_config: McpConfigArgs,
138
139    /// Additional system prompt
140    #[arg(long = "system-prompt")]
141    pub system_prompt: Option<String>,
142
143    /// Output format
144    #[arg(long, default_value = "text")]
145    pub output: CliOutputFormat,
146
147    /// Verbose diagnostic logging to stderr.
148    #[arg(short, long)]
149    pub verbose: bool,
150
151    /// Comma-separated list of events to emit (e.g. `tool_call,tool_result`).
152    /// Omit to emit everything. When set, `error` is only shown if explicitly listed.
153    #[arg(long = "events", value_enum, value_delimiter = ',')]
154    pub events: Vec<CliEventKind>,
155}
156
157fn resolve_prompt(args: &HeadlessArgs) -> Result<String, CliError> {
158    match args.prompt.as_slice() {
159        args if !args.is_empty() => Ok(args.join(" ")),
160
161        _ if !stdin().is_terminal() => {
162            let mut buf = String::new();
163            stdin().read_to_string(&mut buf).map_err(CliError::IoError)?;
164
165            match buf.trim() {
166                "" => Err(CliError::NoPrompt),
167                s => Ok(s.to_string()),
168            }
169        }
170        _ => Err(CliError::NoPrompt),
171    }
172}
173
174fn resolve_spec(
175    agent: Option<&str>,
176    model: Option<&str>,
177    cwd: &std::path::Path,
178    settings_source: &SettingsSourceArgs,
179    provider_connections: ProviderConnectionOverrides,
180) -> Result<AgentSpec, CliError> {
181    if agent.is_some() && model.is_some() {
182        return Err(CliError::ConflictingArgs("Cannot specify both --agent and --model".to_string()));
183    }
184
185    let config = if let Some(source) = settings_source.source(cwd) {
186        AetherSettings::load(cwd, [source])
187    } else {
188        AetherSettings::load_default(cwd)
189    }
190    .map_err(|e| CliError::AgentError(e.to_string()))?;
191    let catalog = if config.agents.is_empty() {
192        AgentCatalog::empty(cwd.to_path_buf())
193    } else {
194        AgentCatalog::from_settings(cwd, config).map_err(|e| CliError::AgentError(e.to_string()))?
195    };
196
197    let mut spec = match model {
198        Some(m) => {
199            let parsed = m.parse().map_err(CliError::ModelError)?;
200            AgentSpec::default_spec(&parsed, None, Vec::new())
201        }
202        None => resolve_agent_spec(&catalog, agent)?,
203    };
204    spec.provider_connections.merge(provider_connections);
205    Ok(spec)
206}
207
208#[cfg(test)]
209mod tests {
210    use std::fs::{create_dir_all, write};
211
212    use super::*;
213
214    #[test]
215    fn resolve_spec_with_named_agent() {
216        let dir = setup_dir_with_agents();
217        let spec = resolve_spec(
218            Some("beta"),
219            None,
220            dir.path(),
221            &project_settings_args(),
222            ProviderConnectionOverrides::default(),
223        )
224        .unwrap();
225        assert_eq!(spec.name, "beta");
226    }
227
228    #[test]
229    fn resolve_spec_with_model_creates_default() {
230        let dir = setup_dir_with_agents();
231        let spec = resolve_spec(
232            None,
233            Some("anthropic:claude-sonnet-4-5"),
234            dir.path(),
235            &project_settings_args(),
236            ProviderConnectionOverrides::default(),
237        )
238        .unwrap();
239        assert_eq!(spec.name, "__default__");
240    }
241
242    #[test]
243    fn resolve_spec_defaults_to_first_user_invocable() {
244        let dir = setup_dir_with_agents();
245        let spec =
246            resolve_spec(None, None, dir.path(), &project_settings_args(), ProviderConnectionOverrides::default())
247                .unwrap();
248        assert_eq!(spec.name, "alpha");
249    }
250
251    #[test]
252    fn resolve_spec_defaults_to_fallback_without_settings() {
253        let dir = tempfile::tempdir().unwrap();
254        let spec = resolve_spec(None, None, dir.path(), &empty_settings_args(), ProviderConnectionOverrides::default())
255            .unwrap();
256        assert_eq!(spec.name, "__default__");
257    }
258
259    #[test]
260    fn resolve_spec_rejects_both_agent_and_model() {
261        let dir = setup_dir_with_agents();
262        let err = resolve_spec(
263            Some("alpha"),
264            Some("anthropic:claude-sonnet-4-5"),
265            dir.path(),
266            &SettingsSourceArgs::default(),
267            ProviderConnectionOverrides::default(),
268        )
269        .unwrap_err();
270        assert!(err.to_string().contains("Cannot specify both"), "unexpected error: {err}");
271    }
272
273    #[test]
274    fn resolve_spec_rejects_invalid_model() {
275        let dir = tempfile::tempdir().unwrap();
276        let err = resolve_spec(
277            None,
278            Some("not-a-valid-model"),
279            dir.path(),
280            &empty_settings_args(),
281            ProviderConnectionOverrides::default(),
282        )
283        .unwrap_err();
284        assert!(matches!(err, CliError::ModelError(_)));
285    }
286
287    #[test]
288    fn resolve_spec_rejects_unknown_agent() {
289        let dir = setup_dir_with_agents();
290        let err = resolve_spec(
291            Some("nonexistent"),
292            None,
293            dir.path(),
294            &project_settings_args(),
295            ProviderConnectionOverrides::default(),
296        )
297        .unwrap_err();
298        assert!(matches!(err, CliError::AgentError(_)));
299    }
300
301    fn write_file(dir: &std::path::Path, path: &str, content: &str) {
302        let full = dir.join(path);
303        if let Some(parent) = full.parent() {
304            create_dir_all(parent).unwrap();
305        }
306        write(full, content).unwrap();
307    }
308
309    fn project_settings_args() -> SettingsSourceArgs {
310        SettingsSourceArgs { settings_json: None, settings_file: Some(PathBuf::from(".aether/settings.json")) }
311    }
312
313    fn empty_settings_args() -> SettingsSourceArgs {
314        SettingsSourceArgs { settings_json: Some(r#"{"agents":[]}"#.to_string()), settings_file: None }
315    }
316
317    fn setup_dir_with_agents() -> tempfile::TempDir {
318        let dir = tempfile::tempdir().unwrap();
319        write_file(dir.path(), "PROMPT.md", "Be helpful");
320        write_file(
321            dir.path(),
322            ".aether/settings.json",
323            r#"{"agents": [
324                {"name": "alpha", "description": "Alpha agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": [{"type":"file","path":"PROMPT.md"}]},
325                {"name": "beta", "description": "Beta agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": [{"type":"file","path":"PROMPT.md"}]}
326            ]}"#,
327        );
328        dir
329    }
330}