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().read_to_string(&mut buf).map_err(CliError::IoError)?;
102
103 match buf.trim() {
104 "" => Err(CliError::NoPrompt),
105 s => Ok(s.to_string()),
106 }
107 }
108 _ => Err(CliError::NoPrompt),
109 }
110}
111
112fn resolve_spec(agent: Option<&str>, model: Option<&str>, cwd: &std::path::Path) -> Result<AgentSpec, CliError> {
113 if agent.is_some() && model.is_some() {
114 return Err(CliError::ConflictingArgs("Cannot specify both --agent and --model".to_string()));
115 }
116
117 let catalog = load_agent_catalog(cwd).map_err(|e| CliError::AgentError(e.to_string()))?;
118
119 match model {
120 Some(m) => {
121 let parsed = m.parse().map_err(|e: String| CliError::ModelError(e))?;
122 Ok(catalog.resolve_default(&parsed, None, cwd))
123 }
124 None => resolve_agent_spec(&catalog, agent, cwd),
125 }
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131
132 fn write_file(dir: &std::path::Path, path: &str, content: &str) {
133 let full = dir.join(path);
134 if let Some(parent) = full.parent() {
135 std::fs::create_dir_all(parent).unwrap();
136 }
137 std::fs::write(full, content).unwrap();
138 }
139
140 fn setup_dir_with_agents() -> tempfile::TempDir {
141 let dir = tempfile::tempdir().unwrap();
142 write_file(dir.path(), "PROMPT.md", "Be helpful");
143 write_file(
144 dir.path(),
145 ".aether/settings.json",
146 r#"{"agents": [
147 {"name": "alpha", "description": "Alpha agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["PROMPT.md"]},
148 {"name": "beta", "description": "Beta agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["PROMPT.md"]}
149 ]}"#,
150 );
151 dir
152 }
153
154 #[test]
155 fn resolve_spec_with_named_agent() {
156 let dir = setup_dir_with_agents();
157 let spec = resolve_spec(Some("beta"), None, dir.path()).unwrap();
158 assert_eq!(spec.name, "beta");
159 }
160
161 #[test]
162 fn resolve_spec_with_model_creates_default() {
163 let dir = setup_dir_with_agents();
164 let spec = resolve_spec(None, Some("anthropic:claude-sonnet-4-5"), dir.path()).unwrap();
165 assert_eq!(spec.name, "__default__");
166 }
167
168 #[test]
169 fn resolve_spec_defaults_to_first_user_invocable() {
170 let dir = setup_dir_with_agents();
171 let spec = resolve_spec(None, None, dir.path()).unwrap();
172 assert_eq!(spec.name, "alpha");
173 }
174
175 #[test]
176 fn resolve_spec_defaults_to_fallback_without_settings() {
177 let dir = tempfile::tempdir().unwrap();
178 let spec = resolve_spec(None, None, dir.path()).unwrap();
179 assert_eq!(spec.name, "__default__");
180 }
181
182 #[test]
183 fn resolve_spec_rejects_both_agent_and_model() {
184 let dir = setup_dir_with_agents();
185 let err = resolve_spec(Some("alpha"), Some("anthropic:claude-sonnet-4-5"), dir.path()).unwrap_err();
186 assert!(err.to_string().contains("Cannot specify both"), "unexpected error: {err}");
187 }
188
189 #[test]
190 fn resolve_spec_rejects_invalid_model() {
191 let dir = tempfile::tempdir().unwrap();
192 let err = resolve_spec(None, Some("not-a-valid-model"), dir.path()).unwrap_err();
193 assert!(matches!(err, CliError::ModelError(_)));
194 }
195
196 #[test]
197 fn resolve_spec_rejects_unknown_agent() {
198 let dir = setup_dir_with_agents();
199 let err = resolve_spec(Some("nonexistent"), None, dir.path()).unwrap_err();
200 assert!(matches!(err, CliError::AgentError(_)));
201 }
202}