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 pub prompt: Vec<String>,
111
112 #[arg(short = 'a', long = "agent")]
114 pub agent: Option<String>,
115
116 #[arg(short, long)]
118 pub model: Option<String>,
119
120 #[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 #[arg(long = "system-prompt")]
135 pub system_prompt: Option<String>,
136
137 #[arg(long, default_value = "text")]
139 pub output: OutputFormat,
140
141 #[arg(short, long)]
143 pub verbose: bool,
144
145 #[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}