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 pub prompt: Vec<String>,
108
109 #[arg(short = 'a', long = "agent")]
111 pub agent: Option<String>,
112
113 #[arg(short, long)]
115 pub model: Option<String>,
116
117 #[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 #[arg(long = "system-prompt")]
132 pub system_prompt: Option<String>,
133
134 #[arg(long, default_value = "text")]
136 pub output: OutputFormat,
137
138 #[arg(short, long)]
140 pub verbose: bool,
141
142 #[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}