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