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::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::{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 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 agent_catalog: AgentCatalog,
58 pub system_prompt: Option<String>,
59 pub output: OutputFormat,
60 pub verbose: bool,
61 pub events: Vec<CliEventKind>,
62 pub oauth_credential_store: Arc<dyn OAuthCredentialStorage>,
63 pub telemetry: Option<TelemetrySettings>,
64 pub trace_context: Option<AgentTraceContext>,
65}
66
67#[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema)]
68#[serde(rename_all = "camelCase", deny_unknown_fields)]
69pub struct HeadlessOptions {
70 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub prompt: Option<String>,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub cwd: Option<PathBuf>,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub providers: Option<BTreeMap<String, ProviderConnectionOverride>>,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub settings: Option<AetherSettings>,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub settings_file: Option<PathBuf>,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub agent: Option<String>,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub model: Option<String>,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub mcp_config: Option<McpConfig>,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub system_prompt: Option<String>,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub output: Option<OutputFormat>,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub verbose: Option<bool>,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub events: Option<Vec<CliEventKind>>,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub trace_context: Option<AgentTraceContext>,
96}
97
98pub async fn run_headless(args: HeadlessArgs) -> Result<ExitCode, CliError> {
99 run::run(RunConfig::from_args(args)?).await
100}
101
102#[derive(clap::Args)]
103pub struct HeadlessArgs {
104 #[arg(long = "options-json", value_name = "JSON", hide = true)]
105 pub options_json: Option<String>,
106
107 pub prompt: Vec<String>,
109
110 #[arg(short = 'a', long = "agent")]
112 pub agent: Option<String>,
113
114 #[arg(short, long)]
116 pub model: Option<String>,
117
118 #[arg(short = 'C', long = "cwd", default_value = ".")]
120 pub cwd: PathBuf,
121
122 #[command(flatten)]
123 pub settings_source: SettingsSourceArgs,
124
125 #[command(flatten)]
126 pub provider_connection: ProviderConnectionArgs,
127
128 #[command(flatten)]
129 pub mcp_config: McpConfigArgs,
130
131 #[arg(long = "system-prompt")]
133 pub system_prompt: Option<String>,
134
135 #[arg(long, default_value = "text")]
137 pub output: OutputFormat,
138
139 #[arg(short, long)]
141 pub verbose: bool,
142
143 #[arg(long = "events", value_enum, value_delimiter = ',')]
146 pub events: Vec<CliEventKind>,
147}
148
149impl RunConfig {
150 fn from_args(args: HeadlessArgs) -> Result<Self, CliError> {
151 if let Some(json) = args.options_json {
152 return Self::from_options(serde_json::from_str(&json).map_err(CliError::InvalidOptionsJson)?);
153 }
154
155 let prompt = resolve_prompt(&args)?;
156 let cwd = args.cwd.canonicalize().map_err(CliError::IoError)?;
157 let settings = args.settings_source.load_settings(&cwd)?;
158 let provider_connections = args.provider_connection.clone().into_overrides();
159 let oauth_credential_store = oauth_credential_store_from_config(settings.credentials_store.clone())?;
160 let telemetry = settings.telemetry.clone();
161 let selection = match (args.agent, args.model) {
162 (Some(agent), None) => InitialSessionSelection::Agent(agent),
163 (None, Some(model)) => InitialSessionSelection::Model { model, reasoning_effort: None },
164 (None, None) => InitialSessionSelection::Default,
165 (Some(_), Some(_)) => {
166 return Err(CliError::ConflictingArgs("Cannot specify both --agent and --model".to_string()));
167 }
168 };
169 let resolved = resolve_agent_from_settings(&cwd, settings, provider_connections, &selection)
170 .map_err(map_selection_error)?;
171 let mcp_config_sources = args.mcp_config.sources(&cwd);
172
173 Ok(Self {
174 prompt,
175 cwd,
176 mcp_config_sources,
177 spec: resolved.spec,
178 agent_catalog: resolved.catalog,
179 system_prompt: args.system_prompt,
180 output: args.output,
181 verbose: args.verbose,
182 events: args.events,
183 oauth_credential_store,
184 telemetry,
185 trace_context: None,
186 })
187 }
188
189 fn from_options(options: HeadlessOptions) -> Result<Self, CliError> {
190 let prompt = options.prompt.ok_or(CliError::NoPrompt)?;
191 let cwd = options.cwd.unwrap_or_else(|| PathBuf::from(".")).canonicalize().map_err(CliError::IoError)?;
192 let settings_source = SettingsSourceArgs::from_json_options(options.settings, options.settings_file)?;
193 let settings = settings_source.load_settings(&cwd)?;
194 let provider_connections = ProviderConnectionOverrides::new(options.providers.unwrap_or_default());
195 let oauth_credential_store = oauth_credential_store_from_config(settings.credentials_store.clone())?;
196 let telemetry = settings.telemetry.clone();
197 let selection = match (options.agent, options.model) {
198 (Some(agent), None) => InitialSessionSelection::Agent(agent),
199 (None, Some(model)) => InitialSessionSelection::Model { model, reasoning_effort: None },
200 (None, None) => InitialSessionSelection::Default,
201 (Some(_), Some(_)) => {
202 return Err(CliError::ConflictingArgs("Cannot specify both --agent and --model".to_string()));
203 }
204 };
205 let resolved = resolve_agent_from_settings(&cwd, settings, provider_connections, &selection)
206 .map_err(map_selection_error)?;
207 let mcp_config_sources = options
208 .mcp_config
209 .map(|config| serde_json::to_string(&config).expect("mcp config serialize"))
210 .map(McpConfigSource::Json)
211 .into_iter()
212 .collect();
213
214 Ok(Self {
215 prompt,
216 cwd,
217 mcp_config_sources,
218 spec: resolved.spec,
219 agent_catalog: resolved.catalog,
220 system_prompt: options.system_prompt,
221 output: options.output.unwrap_or(OutputFormat::Text),
222 verbose: options.verbose.unwrap_or(false),
223 events: options.events.unwrap_or_default(),
224 oauth_credential_store,
225 telemetry,
226 trace_context: options.trace_context,
227 })
228 }
229}
230
231fn resolve_prompt(args: &HeadlessArgs) -> Result<String, CliError> {
232 match args.prompt.as_slice() {
233 args if !args.is_empty() => Ok(args.join(" ")),
234
235 _ if !stdin().is_terminal() => {
236 let mut buf = String::new();
237 stdin().read_to_string(&mut buf).map_err(CliError::IoError)?;
238
239 match buf.trim() {
240 "" => Err(CliError::NoPrompt),
241 s => Ok(s.to_string()),
242 }
243 }
244 _ => Err(CliError::NoPrompt),
245 }
246}
247
248fn map_selection_error(error: AgentSelectionError) -> CliError {
249 match error {
250 AgentSelectionError::Settings(error) => CliError::Settings(error),
251 AgentSelectionError::Agent(error) => CliError::AgentError(error.to_string()),
252 AgentSelectionError::Model(error) => CliError::ModelError(error),
253 }
254}