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() {
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 super::*;
201
202 fn write_file(dir: &std::path::Path, path: &str, content: &str) {
203 let full = dir.join(path);
204 if let Some(parent) = full.parent() {
205 std::fs::create_dir_all(parent).unwrap();
206 }
207 std::fs::write(full, content).unwrap();
208 }
209
210 fn setup_dir_with_agents() -> tempfile::TempDir {
211 let dir = tempfile::tempdir().unwrap();
212 write_file(dir.path(), "PROMPT.md", "Be helpful");
213 write_file(
214 dir.path(),
215 ".aether/settings.json",
216 r#"{"agents": [
217 {"name": "alpha", "description": "Alpha agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": [{"type":"file","path":"PROMPT.md"}]},
218 {"name": "beta", "description": "Beta agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": [{"type":"file","path":"PROMPT.md"}]}
219 ]}"#,
220 );
221 dir
222 }
223
224 #[test]
225 fn resolve_spec_with_named_agent() {
226 let dir = setup_dir_with_agents();
227 let spec = resolve_spec(Some("beta"), None, dir.path(), &SettingsSourceArgs::default()).unwrap();
228 assert_eq!(spec.name, "beta");
229 }
230
231 #[test]
232 fn resolve_spec_with_model_creates_default() {
233 let dir = setup_dir_with_agents();
234 let spec = resolve_spec(None, Some("anthropic:claude-sonnet-4-5"), dir.path(), &SettingsSourceArgs::default())
235 .unwrap();
236 assert_eq!(spec.name, "__default__");
237 }
238
239 #[test]
240 fn resolve_spec_defaults_to_first_user_invocable() {
241 let dir = setup_dir_with_agents();
242 let spec = resolve_spec(None, None, dir.path(), &SettingsSourceArgs::default()).unwrap();
243 assert_eq!(spec.name, "alpha");
244 }
245
246 #[test]
247 fn resolve_spec_defaults_to_fallback_without_settings() {
248 let dir = tempfile::tempdir().unwrap();
249 let spec = resolve_spec(None, None, dir.path(), &SettingsSourceArgs::default()).unwrap();
250 assert_eq!(spec.name, "__default__");
251 }
252
253 #[test]
254 fn resolve_spec_rejects_both_agent_and_model() {
255 let dir = setup_dir_with_agents();
256 let err = resolve_spec(
257 Some("alpha"),
258 Some("anthropic:claude-sonnet-4-5"),
259 dir.path(),
260 &SettingsSourceArgs::default(),
261 )
262 .unwrap_err();
263 assert!(err.to_string().contains("Cannot specify both"), "unexpected error: {err}");
264 }
265
266 #[test]
267 fn resolve_spec_rejects_invalid_model() {
268 let dir = tempfile::tempdir().unwrap();
269 let err =
270 resolve_spec(None, Some("not-a-valid-model"), dir.path(), &SettingsSourceArgs::default()).unwrap_err();
271 assert!(matches!(err, CliError::ModelError(_)));
272 }
273
274 #[test]
275 fn resolve_spec_rejects_unknown_agent() {
276 let dir = setup_dir_with_agents();
277 let err = resolve_spec(Some("nonexistent"), None, dir.path(), &SettingsSourceArgs::default()).unwrap_err();
278 assert!(matches!(err, CliError::AgentError(_)));
279 }
280}