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::path::PathBuf;
14use std::process::ExitCode;
15
16use crate::credentials::oauth_credential_store_from_config;
17use crate::mcp_config_args::McpConfigArgs;
18use crate::output::OutputFormat;
19use crate::prompt::prompt_or_stdin;
20use crate::provider_connection_args::ProviderConnectionArgs;
21use crate::resolve::{AgentSelectionError, InitialSessionSelection, resolve_agent_from_settings};
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    SessionUsage,
43    ContextCleared,
44    TurnStarted,
45    TurnEnded,
46    LlmRetryScheduled,
47    LlmCallStarted,
48    LlmCallEnded,
49    ToolExecutionStarted,
50    ToolDefinitionsUpdated,
51}
52
53pub struct RunConfig {
54    pub prompt: String,
55    pub cwd: PathBuf,
56    pub mcp_config_sources: Vec<McpConfigSource>,
57    pub spec: AgentSpec,
58    pub agent_catalog: AgentCatalog,
59    pub system_prompt: Option<String>,
60    pub output: OutputFormat,
61    pub verbose: bool,
62    pub events: Vec<CliEventKind>,
63    pub oauth_credential_store: Arc<dyn OAuthCredentialStorage>,
64    pub telemetry: Option<TelemetrySettings>,
65    pub trace_context: Option<AgentTraceContext>,
66}
67
68#[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema)]
69#[serde(rename_all = "camelCase", deny_unknown_fields)]
70#[schemars(rename = "AetherHeadlessCliOptions")]
71pub struct HeadlessOptions {
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub prompt: Option<String>,
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub cwd: Option<PathBuf>,
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub providers: Option<BTreeMap<String, ProviderConnectionOverride>>,
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub settings: Option<AetherSettings>,
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub settings_file: Option<PathBuf>,
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub agent: Option<String>,
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub model: Option<String>,
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub mcp_config: Option<McpConfig>,
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub system_prompt: Option<String>,
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub output: Option<OutputFormat>,
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub verbose: Option<bool>,
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub events: Option<Vec<CliEventKind>>,
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub trace_context: Option<AgentTraceContext>,
98}
99
100pub async fn run_headless(args: HeadlessArgs) -> Result<ExitCode, CliError> {
101    run::run(RunConfig::from_args(args)?).await
102}
103
104#[derive(clap::Args)]
105pub struct HeadlessArgs {
106    #[arg(long = "options-json", value_name = "JSON", hide = true)]
107    pub options_json: Option<String>,
108
109    /// Prompt to send (reads stdin if omitted and stdin is not a TTY)
110    pub prompt: Vec<String>,
111
112    /// Named agent from settings.json (defaults to first user-invocable agent)
113    #[arg(short = 'a', long = "agent")]
114    pub agent: Option<String>,
115
116    /// Model for ad-hoc runs (e.g. "anthropic:claude-sonnet-4-5"). Mutually exclusive with --agent.
117    #[arg(short, long)]
118    pub model: Option<String>,
119
120    /// Working directory
121    #[arg(short = 'C', long = "cwd", default_value = ".")]
122    pub cwd: PathBuf,
123
124    #[command(flatten)]
125    pub settings_source: SettingsSourceArgs,
126
127    #[command(flatten)]
128    pub provider_connection: ProviderConnectionArgs,
129
130    #[command(flatten)]
131    pub mcp_config: McpConfigArgs,
132
133    /// Additional system prompt
134    #[arg(long = "system-prompt")]
135    pub system_prompt: Option<String>,
136
137    /// Output format
138    #[arg(long, default_value = "text")]
139    pub output: OutputFormat,
140
141    /// Verbose diagnostic logging to stderr.
142    #[arg(short, long)]
143    pub verbose: bool,
144
145    /// Comma-separated list of events to emit (e.g. `tool_call,tool_result,turn_ended`).
146    /// Omit to emit every output event. When set, turn outcomes are only shown if `turn_ended` is listed.
147    #[arg(long = "events", value_enum, value_delimiter = ',')]
148    pub events: Vec<CliEventKind>,
149}
150
151impl RunConfig {
152    fn from_args(args: HeadlessArgs) -> Result<Self, CliError> {
153        if let Some(json) = args.options_json {
154            return Self::from_options(serde_json::from_str(&json).map_err(CliError::InvalidOptionsJson)?);
155        }
156
157        let prompt = resolve_prompt(&args)?;
158        let cwd = args.cwd.canonicalize().map_err(CliError::IoError)?;
159        let settings = args.settings_source.load_settings(&cwd)?;
160        let provider_connections = args.provider_connection.clone().into_overrides();
161        let oauth_credential_store = oauth_credential_store_from_config(settings.credentials_store.clone())?;
162        let telemetry = settings.telemetry.clone();
163        let selection = match (args.agent, args.model) {
164            (Some(agent), None) => InitialSessionSelection::Agent(agent),
165            (None, Some(model)) => InitialSessionSelection::Model { model, reasoning_effort: None },
166            (None, None) => InitialSessionSelection::Default,
167            (Some(_), Some(_)) => {
168                return Err(CliError::ConflictingArgs("Cannot specify both --agent and --model".to_string()));
169            }
170        };
171        let resolved = resolve_agent_from_settings(&cwd, settings, provider_connections, &selection)
172            .map_err(map_selection_error)?;
173        let mcp_config_sources = args.mcp_config.sources(&cwd);
174
175        Ok(Self {
176            prompt,
177            cwd,
178            mcp_config_sources,
179            spec: resolved.spec,
180            agent_catalog: resolved.catalog,
181            system_prompt: args.system_prompt,
182            output: args.output,
183            verbose: args.verbose,
184            events: args.events,
185            oauth_credential_store,
186            telemetry,
187            trace_context: None,
188        })
189    }
190
191    fn from_options(options: HeadlessOptions) -> Result<Self, CliError> {
192        let prompt = options.prompt.ok_or(CliError::NoPrompt)?;
193        let cwd = options.cwd.unwrap_or_else(|| PathBuf::from(".")).canonicalize().map_err(CliError::IoError)?;
194        let settings_source = SettingsSourceArgs::from_json_options(options.settings, options.settings_file)?;
195        let settings = settings_source.load_settings(&cwd)?;
196        let provider_connections = ProviderConnectionOverrides::new(options.providers.unwrap_or_default());
197        let oauth_credential_store = oauth_credential_store_from_config(settings.credentials_store.clone())?;
198        let telemetry = settings.telemetry.clone();
199        let selection = match (options.agent, options.model) {
200            (Some(agent), None) => InitialSessionSelection::Agent(agent),
201            (None, Some(model)) => InitialSessionSelection::Model { model, reasoning_effort: None },
202            (None, None) => InitialSessionSelection::Default,
203            (Some(_), Some(_)) => {
204                return Err(CliError::ConflictingArgs("Cannot specify both --agent and --model".to_string()));
205            }
206        };
207        let resolved = resolve_agent_from_settings(&cwd, settings, provider_connections, &selection)
208            .map_err(map_selection_error)?;
209        let mcp_config_sources = options
210            .mcp_config
211            .map(|config| serde_json::to_string(&config).expect("mcp config serialize"))
212            .map(McpConfigSource::Json)
213            .into_iter()
214            .collect();
215
216        Ok(Self {
217            prompt,
218            cwd,
219            mcp_config_sources,
220            spec: resolved.spec,
221            agent_catalog: resolved.catalog,
222            system_prompt: options.system_prompt,
223            output: options.output.unwrap_or(OutputFormat::Text),
224            verbose: options.verbose.unwrap_or(false),
225            events: options.events.unwrap_or_default(),
226            oauth_credential_store,
227            telemetry,
228            trace_context: options.trace_context,
229        })
230    }
231}
232
233fn resolve_prompt(args: &HeadlessArgs) -> Result<String, CliError> {
234    let explicit = (!args.prompt.is_empty()).then(|| args.prompt.join(" "));
235    prompt_or_stdin(explicit).map_err(CliError::IoError)?.ok_or(CliError::NoPrompt)
236}
237
238fn map_selection_error(error: AgentSelectionError) -> CliError {
239    match error {
240        AgentSelectionError::Settings(error) => CliError::Settings(error),
241        AgentSelectionError::Agent(error) => CliError::AgentError(error.to_string()),
242        AgentSelectionError::Model(error) => CliError::ModelError(error),
243    }
244}