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