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