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