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