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() {
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}