Skip to main content

aether_cli/headless/
mod.rs

1pub mod error;
2pub mod run;
3
4use aether_core::agent_spec::{AgentSpec, McpConfigSource};
5use aether_project::AetherSettings;
6use error::CliError;
7use llm::{ProviderConnectionOverride, ProviderConnectionOverrides};
8use mcp_utils::client::McpConfig;
9use std::collections::BTreeMap;
10use std::io::{IsTerminal, Read as _, stdin};
11use std::path::{Path, PathBuf};
12use std::process::ExitCode;
13
14use crate::credentials::build_oauth_credential_store;
15use crate::mcp_config_args::McpConfigArgs;
16use crate::output::OutputFormat;
17use crate::provider_connection_args::ProviderConnectionArgs;
18use crate::resolve::resolve_agent_spec;
19use crate::settings_args::SettingsSourceArgs;
20use aether_auth::OAuthCredentialStorage;
21use std::sync::Arc;
22
23#[derive(Clone, Copy, PartialEq, Eq, Debug, clap::ValueEnum, serde::Deserialize, schemars::JsonSchema)]
24#[clap(rename_all = "snake_case")]
25#[serde(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    Done,
43}
44
45pub struct RunConfig {
46    pub prompt: String,
47    pub cwd: PathBuf,
48    pub mcp_config_sources: Vec<McpConfigSource>,
49    pub spec: AgentSpec,
50    pub system_prompt: Option<String>,
51    pub output: OutputFormat,
52    pub verbose: bool,
53    pub events: Vec<CliEventKind>,
54    pub oauth_credential_store: Arc<dyn OAuthCredentialStorage>,
55}
56
57#[derive(Clone, Debug, Default, serde::Deserialize, schemars::JsonSchema)]
58#[serde(rename_all = "camelCase", deny_unknown_fields)]
59pub struct HeadlessOptions {
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub prompt: Option<String>,
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub cwd: Option<PathBuf>,
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub providers: Option<BTreeMap<String, ProviderConnectionOverride>>,
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub settings: Option<AetherSettings>,
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub settings_file: Option<PathBuf>,
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub agent: Option<String>,
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub model: Option<String>,
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub mcp_config: Option<McpConfig>,
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub system_prompt: Option<String>,
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub output: Option<OutputFormat>,
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub verbose: Option<bool>,
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub events: Option<Vec<CliEventKind>>,
84}
85
86pub async fn run_headless(args: HeadlessArgs) -> Result<ExitCode, CliError> {
87    run::run(RunConfig::from_args(args)?).await
88}
89
90#[derive(clap::Args)]
91pub struct HeadlessArgs {
92    #[arg(long = "options-json", value_name = "JSON")]
93    pub options_json: Option<String>,
94
95    /// Prompt to send (reads stdin if omitted and stdin is not a TTY)
96    pub prompt: Vec<String>,
97
98    /// Named agent from settings.json (defaults to first user-invocable agent)
99    #[arg(short = 'a', long = "agent")]
100    pub agent: Option<String>,
101
102    /// Model for ad-hoc runs (e.g. "anthropic:claude-sonnet-4-5"). Mutually exclusive with --agent.
103    #[arg(short, long)]
104    pub model: Option<String>,
105
106    /// Working directory
107    #[arg(short = 'C', long = "cwd", default_value = ".")]
108    pub cwd: PathBuf,
109
110    #[command(flatten)]
111    pub settings_source: SettingsSourceArgs,
112
113    #[command(flatten)]
114    pub provider_connection: ProviderConnectionArgs,
115
116    #[command(flatten)]
117    pub mcp_config: McpConfigArgs,
118
119    /// Additional system prompt
120    #[arg(long = "system-prompt")]
121    pub system_prompt: Option<String>,
122
123    /// Output format
124    #[arg(long, default_value = "text")]
125    pub output: OutputFormat,
126
127    /// Verbose diagnostic logging to stderr.
128    #[arg(short, long)]
129    pub verbose: bool,
130
131    /// Comma-separated list of events to emit (e.g. `tool_call,tool_result,done`).
132    /// Omit to emit every output event. When set, `error` is only shown if explicitly listed.
133    #[arg(long = "events", value_enum, value_delimiter = ',')]
134    pub events: Vec<CliEventKind>,
135}
136
137impl RunConfig {
138    fn from_args(args: HeadlessArgs) -> Result<Self, CliError> {
139        if let Some(json) = args.options_json {
140            return Self::from_options(serde_json::from_str(&json).map_err(CliError::InvalidOptionsJson)?);
141        }
142
143        let prompt = resolve_prompt(&args)?;
144        let cwd = args.cwd.canonicalize().map_err(CliError::IoError)?;
145        let provider_connections = args.provider_connection.clone().into_overrides();
146        let spec = resolve_spec(
147            args.agent.as_deref(),
148            args.model.as_deref(),
149            &cwd,
150            &args.settings_source,
151            provider_connections,
152        )?;
153        let mcp_config_sources = args.mcp_config.sources(&cwd);
154        let oauth_credential_store = build_oauth_credential_store(&args.settings_source, &cwd)?;
155
156        Ok(Self {
157            prompt,
158            cwd,
159            mcp_config_sources,
160            spec,
161            system_prompt: args.system_prompt,
162            output: args.output,
163            verbose: args.verbose,
164            events: args.events,
165            oauth_credential_store,
166        })
167    }
168
169    fn from_options(options: HeadlessOptions) -> Result<Self, CliError> {
170        let prompt = options.prompt.ok_or(CliError::NoPrompt)?;
171        let cwd = options.cwd.unwrap_or_else(|| PathBuf::from(".")).canonicalize().map_err(CliError::IoError)?;
172        let settings_source = SettingsSourceArgs::from_json_options(options.settings, options.settings_file)?;
173        let provider_connections = ProviderConnectionOverrides::new(options.providers.unwrap_or_default());
174        let spec = resolve_spec(
175            options.agent.as_deref(),
176            options.model.as_deref(),
177            &cwd,
178            &settings_source,
179            provider_connections,
180        )?;
181        let mcp_config_sources = options
182            .mcp_config
183            .map(|config| serde_json::to_string(&config).expect("mcp config serialize"))
184            .map(McpConfigSource::Json)
185            .into_iter()
186            .collect();
187        let oauth_credential_store = build_oauth_credential_store(&settings_source, &cwd)?;
188
189        Ok(Self {
190            prompt,
191            cwd,
192            mcp_config_sources,
193            spec,
194            system_prompt: options.system_prompt,
195            output: options.output.unwrap_or(OutputFormat::Text),
196            verbose: options.verbose.unwrap_or(false),
197            events: options.events.unwrap_or_default(),
198            oauth_credential_store,
199        })
200    }
201}
202
203fn resolve_prompt(args: &HeadlessArgs) -> Result<String, CliError> {
204    match args.prompt.as_slice() {
205        args if !args.is_empty() => Ok(args.join(" ")),
206
207        _ if !stdin().is_terminal() => {
208            let mut buf = String::new();
209            stdin().read_to_string(&mut buf).map_err(CliError::IoError)?;
210
211            match buf.trim() {
212                "" => Err(CliError::NoPrompt),
213                s => Ok(s.to_string()),
214            }
215        }
216        _ => Err(CliError::NoPrompt),
217    }
218}
219
220fn resolve_spec(
221    agent: Option<&str>,
222    model: Option<&str>,
223    cwd: &Path,
224    settings_source: &SettingsSourceArgs,
225    provider_connections: ProviderConnectionOverrides,
226) -> Result<AgentSpec, CliError> {
227    if agent.is_some() && model.is_some() {
228        return Err(CliError::ConflictingArgs("Cannot specify both --agent and --model".to_string()));
229    }
230
231    let catalog = settings_source.load_agent_catalog(cwd).map_err(|e| CliError::AgentError(e.to_string()))?;
232
233    let mut spec = match model {
234        Some(m) => {
235            let parsed = m.parse().map_err(CliError::ModelError)?;
236            AgentSpec::default_spec(&parsed, None, Vec::new())
237        }
238        None => resolve_agent_spec(&catalog, agent)?,
239    };
240    spec.provider_connections.merge(provider_connections);
241    Ok(spec)
242}
243
244#[cfg(test)]
245mod tests {
246    use std::fs::{create_dir_all, write};
247
248    use super::*;
249
250    #[test]
251    fn resolve_spec_with_named_agent() {
252        let dir = setup_dir_with_agents();
253        let spec = resolve_spec(
254            Some("beta"),
255            None,
256            dir.path(),
257            &project_settings_args(),
258            ProviderConnectionOverrides::default(),
259        )
260        .unwrap();
261        assert_eq!(spec.name, "beta");
262    }
263
264    #[test]
265    fn resolve_spec_with_model_creates_default() {
266        let dir = setup_dir_with_agents();
267        let spec = resolve_spec(
268            None,
269            Some("anthropic:claude-sonnet-4-5"),
270            dir.path(),
271            &project_settings_args(),
272            ProviderConnectionOverrides::default(),
273        )
274        .unwrap();
275        assert_eq!(spec.name, "__default__");
276    }
277
278    #[test]
279    fn resolve_spec_defaults_to_first_user_invocable() {
280        let dir = setup_dir_with_agents();
281        let spec =
282            resolve_spec(None, None, dir.path(), &project_settings_args(), ProviderConnectionOverrides::default())
283                .unwrap();
284        assert_eq!(spec.name, "alpha");
285    }
286
287    #[test]
288    fn resolve_spec_defaults_to_fallback_without_settings() {
289        let dir = tempfile::tempdir().unwrap();
290        let spec = resolve_spec(None, None, dir.path(), &empty_settings_args(), ProviderConnectionOverrides::default())
291            .unwrap();
292        assert_eq!(spec.name, "__default__");
293    }
294
295    #[test]
296    fn resolve_spec_rejects_both_agent_and_model() {
297        let dir = setup_dir_with_agents();
298        let err = resolve_spec(
299            Some("alpha"),
300            Some("anthropic:claude-sonnet-4-5"),
301            dir.path(),
302            &SettingsSourceArgs::default(),
303            ProviderConnectionOverrides::default(),
304        )
305        .unwrap_err();
306        assert!(err.to_string().contains("Cannot specify both"), "unexpected error: {err}");
307    }
308
309    #[test]
310    fn resolve_spec_rejects_invalid_model() {
311        let dir = tempfile::tempdir().unwrap();
312        let err = resolve_spec(
313            None,
314            Some("not-a-valid-model"),
315            dir.path(),
316            &empty_settings_args(),
317            ProviderConnectionOverrides::default(),
318        )
319        .unwrap_err();
320        assert!(matches!(err, CliError::ModelError(_)));
321    }
322
323    #[test]
324    fn resolve_spec_rejects_unknown_agent() {
325        let dir = setup_dir_with_agents();
326        let err = resolve_spec(
327            Some("nonexistent"),
328            None,
329            dir.path(),
330            &project_settings_args(),
331            ProviderConnectionOverrides::default(),
332        )
333        .unwrap_err();
334        assert!(matches!(err, CliError::AgentError(_)));
335    }
336
337    fn write_file(dir: &std::path::Path, path: &str, content: &str) {
338        let full = dir.join(path);
339        if let Some(parent) = full.parent() {
340            create_dir_all(parent).unwrap();
341        }
342        write(full, content).unwrap();
343    }
344
345    fn project_settings_args() -> SettingsSourceArgs {
346        SettingsSourceArgs { settings_json: None, settings_file: Some(PathBuf::from(".aether/settings.json")) }
347    }
348
349    fn empty_settings_args() -> SettingsSourceArgs {
350        SettingsSourceArgs { settings_json: Some(r#"{"agents":[]}"#.to_string()), settings_file: None }
351    }
352
353    fn setup_dir_with_agents() -> tempfile::TempDir {
354        let dir = tempfile::tempdir().unwrap();
355        write_file(dir.path(), "PROMPT.md", "Be helpful");
356        write_file(
357            dir.path(),
358            ".aether/settings.json",
359            r#"{"agents": [
360                {"name": "alpha", "description": "Alpha agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": [{"type":"file","path":"PROMPT.md"}]},
361                {"name": "beta", "description": "Beta agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": [{"type":"file","path":"PROMPT.md"}]}
362            ]}"#,
363        );
364        dir
365    }
366}