1pub mod error;
2pub mod run;
3
4use aether_core::agent_spec::AgentSpec;
5use aether_project::load_agent_catalog;
6use error::CliError;
7use std::io::{IsTerminal, Read as _, stdin};
8use std::path::PathBuf;
9use std::process::ExitCode;
10
11use crate::resolve::resolve_agent_spec;
12
13#[derive(Clone)]
14pub enum OutputFormat {
15 Text,
16 Pretty,
17 Json,
18}
19
20pub struct RunConfig {
21 pub prompt: String,
22 pub cwd: PathBuf,
23 pub mcp_config: Option<PathBuf>,
24 pub spec: AgentSpec,
25 pub system_prompt: Option<String>,
26 pub output: OutputFormat,
27 pub verbose: bool,
28}
29
30pub async fn run_headless(args: HeadlessArgs) -> Result<ExitCode, CliError> {
31 let prompt = resolve_prompt(&args)?;
32 let cwd = args.cwd.canonicalize().map_err(CliError::IoError)?;
33 let spec = resolve_spec(args.agent.as_deref(), args.model.as_deref(), &cwd)?;
34
35 let output = match args.output {
36 CliOutputFormat::Text => OutputFormat::Text,
37 CliOutputFormat::Pretty => OutputFormat::Pretty,
38 CliOutputFormat::Json => OutputFormat::Json,
39 };
40
41 let config = RunConfig {
42 prompt,
43 cwd,
44 mcp_config: args.mcp_config,
45 spec,
46 system_prompt: args.system_prompt,
47 output,
48 verbose: args.verbose,
49 };
50
51 run::run(config).await
52}
53
54#[derive(Clone, clap::ValueEnum)]
55pub enum CliOutputFormat {
56 Text,
57 Pretty,
58 Json,
59}
60
61#[derive(clap::Args)]
62pub struct HeadlessArgs {
63 pub prompt: Vec<String>,
65
66 #[arg(short = 'a', long = "agent")]
68 pub agent: Option<String>,
69
70 #[arg(short, long)]
72 pub model: Option<String>,
73
74 #[arg(short = 'C', long = "cwd", default_value = ".")]
76 pub cwd: PathBuf,
77
78 #[arg(long = "mcp-config")]
80 pub mcp_config: Option<PathBuf>,
81
82 #[arg(long = "system-prompt")]
84 pub system_prompt: Option<String>,
85
86 #[arg(long, default_value = "text")]
88 pub output: CliOutputFormat,
89
90 #[arg(short, long)]
92 pub verbose: bool,
93}
94
95fn resolve_prompt(args: &HeadlessArgs) -> Result<String, CliError> {
96 match args.prompt.as_slice() {
97 args if !args.is_empty() => Ok(args.join(" ")),
98
99 _ if !stdin().is_terminal() => {
100 let mut buf = String::new();
101 stdin()
102 .read_to_string(&mut buf)
103 .map_err(CliError::IoError)?;
104
105 match buf.trim() {
106 "" => Err(CliError::NoPrompt),
107 s => Ok(s.to_string()),
108 }
109 }
110 _ => Err(CliError::NoPrompt),
111 }
112}
113
114fn resolve_spec(
115 agent: Option<&str>,
116 model: Option<&str>,
117 cwd: &std::path::Path,
118) -> Result<AgentSpec, CliError> {
119 if agent.is_some() && model.is_some() {
120 return Err(CliError::ConflictingArgs(
121 "Cannot specify both --agent and --model".to_string(),
122 ));
123 }
124
125 let catalog = load_agent_catalog(cwd).map_err(|e| CliError::AgentError(e.to_string()))?;
126
127 match model {
128 Some(m) => {
129 let parsed = m.parse().map_err(|e: String| CliError::ModelError(e))?;
130 Ok(catalog.resolve_default(&parsed, None, cwd))
131 }
132 None => resolve_agent_spec(&catalog, agent, cwd),
133 }
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139
140 fn write_file(dir: &std::path::Path, path: &str, content: &str) {
141 let full = dir.join(path);
142 if let Some(parent) = full.parent() {
143 std::fs::create_dir_all(parent).unwrap();
144 }
145 std::fs::write(full, content).unwrap();
146 }
147
148 fn setup_dir_with_agents() -> tempfile::TempDir {
149 let dir = tempfile::tempdir().unwrap();
150 write_file(dir.path(), "PROMPT.md", "Be helpful");
151 write_file(
152 dir.path(),
153 ".aether/settings.json",
154 r#"{"agents": [
155 {"name": "alpha", "description": "Alpha agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["PROMPT.md"]},
156 {"name": "beta", "description": "Beta agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["PROMPT.md"]}
157 ]}"#,
158 );
159 dir
160 }
161
162 #[test]
163 fn resolve_spec_with_named_agent() {
164 let dir = setup_dir_with_agents();
165 let spec = resolve_spec(Some("beta"), None, dir.path()).unwrap();
166 assert_eq!(spec.name, "beta");
167 }
168
169 #[test]
170 fn resolve_spec_with_model_creates_default() {
171 let dir = setup_dir_with_agents();
172 let spec = resolve_spec(None, Some("anthropic:claude-sonnet-4-5"), dir.path()).unwrap();
173 assert_eq!(spec.name, "__default__");
174 }
175
176 #[test]
177 fn resolve_spec_defaults_to_first_user_invocable() {
178 let dir = setup_dir_with_agents();
179 let spec = resolve_spec(None, None, dir.path()).unwrap();
180 assert_eq!(spec.name, "alpha");
181 }
182
183 #[test]
184 fn resolve_spec_defaults_to_fallback_without_settings() {
185 let dir = tempfile::tempdir().unwrap();
186 let spec = resolve_spec(None, None, dir.path()).unwrap();
187 assert_eq!(spec.name, "__default__");
188 }
189
190 #[test]
191 fn resolve_spec_rejects_both_agent_and_model() {
192 let dir = setup_dir_with_agents();
193 let err = resolve_spec(
194 Some("alpha"),
195 Some("anthropic:claude-sonnet-4-5"),
196 dir.path(),
197 )
198 .unwrap_err();
199 assert!(
200 err.to_string().contains("Cannot specify both"),
201 "unexpected error: {err}"
202 );
203 }
204
205 #[test]
206 fn resolve_spec_rejects_invalid_model() {
207 let dir = tempfile::tempdir().unwrap();
208 let err = resolve_spec(None, Some("not-a-valid-model"), dir.path()).unwrap_err();
209 assert!(matches!(err, CliError::ModelError(_)));
210 }
211
212 #[test]
213 fn resolve_spec_rejects_unknown_agent() {
214 let dir = setup_dir_with_agents();
215 let err = resolve_spec(Some("nonexistent"), None, dir.path()).unwrap_err();
216 assert!(matches!(err, CliError::AgentError(_)));
217 }
218}