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