Skip to main content

aether_cli/headless/
mod.rs

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    /// Prompt to send (reads stdin if omitted and stdin is not a TTY)
112    pub prompt: Vec<String>,
113
114    /// Named agent from settings.json (defaults to first user-invocable agent)
115    #[arg(short = 'a', long = "agent")]
116    pub agent: Option<String>,
117
118    /// Model for ad-hoc runs (e.g. "anthropic:claude-sonnet-4-5"). Mutually exclusive with --agent.
119    #[arg(short, long)]
120    pub model: Option<String>,
121
122    /// Working directory
123    #[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    /// Additional system prompt
133    #[arg(long = "system-prompt")]
134    pub system_prompt: Option<String>,
135
136    /// Output format
137    #[arg(long, default_value = "text")]
138    pub output: CliOutputFormat,
139
140    /// Verbose diagnostic logging to stderr.
141    #[arg(short, long)]
142    pub verbose: bool,
143
144    /// Comma-separated list of events to emit (e.g. `tool_call,tool_result`).
145    /// Omit to emit everything. When set, `error` is only shown if explicitly listed.
146    #[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}