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