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