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