Skip to main content

aether_cli/headless/
mod.rs

1pub mod error;
2pub mod run;
3
4use aether_core::agent_spec::{AgentSpec, McpConfigSource};
5use aether_project::AetherSettings;
6use error::CliError;
7use llm::{ProviderConnectionOverride, ProviderConnectionOverrides};
8use mcp_utils::client::McpConfig;
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11use std::collections::BTreeMap;
12use std::io::{IsTerminal, Read as _, stdin};
13use std::path::{Path, PathBuf};
14use std::process::ExitCode;
15
16use crate::credentials::build_oauth_credential_store;
17use crate::mcp_config_args::McpConfigArgs;
18use crate::output::OutputFormat;
19use crate::provider_connection_args::ProviderConnectionArgs;
20use crate::resolve::resolve_agent_spec;
21use crate::settings_args::SettingsSourceArgs;
22use aether_auth::OAuthCredentialStorage;
23use std::sync::Arc;
24
25#[derive(Clone, Copy, PartialEq, Eq, Debug, clap::ValueEnum, Deserialize, Serialize, JsonSchema)]
26#[clap(rename_all = "snake_case")]
27#[serde(rename_all = "snake_case")]
28pub enum CliEventKind {
29    Text,
30    Thought,
31    ToolCall,
32    ToolResult,
33    ToolError,
34    Error,
35    Cancelled,
36    AutoContinue,
37    Retrying,
38    ModelSwitched,
39    ToolProgress,
40    ContextCompactionStarted,
41    ContextCompactionResult,
42    ContextUsage,
43    ContextCleared,
44    Done,
45}
46
47pub struct RunConfig {
48    pub prompt: String,
49    pub cwd: PathBuf,
50    pub mcp_config_sources: Vec<McpConfigSource>,
51    pub spec: AgentSpec,
52    pub system_prompt: Option<String>,
53    pub output: OutputFormat,
54    pub verbose: bool,
55    pub events: Vec<CliEventKind>,
56    pub oauth_credential_store: Arc<dyn OAuthCredentialStorage>,
57}
58
59#[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema)]
60#[serde(rename_all = "camelCase", deny_unknown_fields)]
61pub struct HeadlessOptions {
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub prompt: Option<String>,
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub cwd: Option<PathBuf>,
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub providers: Option<BTreeMap<String, ProviderConnectionOverride>>,
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub settings: Option<AetherSettings>,
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub settings_file: Option<PathBuf>,
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub agent: Option<String>,
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub model: Option<String>,
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub mcp_config: Option<McpConfig>,
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub system_prompt: Option<String>,
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub output: Option<OutputFormat>,
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub verbose: Option<bool>,
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub events: Option<Vec<CliEventKind>>,
86}
87
88pub async fn run_headless(args: HeadlessArgs) -> Result<ExitCode, CliError> {
89    run::run(RunConfig::from_args(args)?).await
90}
91
92#[derive(clap::Args)]
93pub struct HeadlessArgs {
94    #[arg(long = "options-json", value_name = "JSON", hide = true)]
95    pub options_json: Option<String>,
96
97    /// Prompt to send (reads stdin if omitted and stdin is not a TTY)
98    pub prompt: Vec<String>,
99
100    /// Named agent from settings.json (defaults to first user-invocable agent)
101    #[arg(short = 'a', long = "agent")]
102    pub agent: Option<String>,
103
104    /// Model for ad-hoc runs (e.g. "anthropic:claude-sonnet-4-5"). Mutually exclusive with --agent.
105    #[arg(short, long)]
106    pub model: Option<String>,
107
108    /// Working directory
109    #[arg(short = 'C', long = "cwd", default_value = ".")]
110    pub cwd: PathBuf,
111
112    #[command(flatten)]
113    pub settings_source: SettingsSourceArgs,
114
115    #[command(flatten)]
116    pub provider_connection: ProviderConnectionArgs,
117
118    #[command(flatten)]
119    pub mcp_config: McpConfigArgs,
120
121    /// Additional system prompt
122    #[arg(long = "system-prompt")]
123    pub system_prompt: Option<String>,
124
125    /// Output format
126    #[arg(long, default_value = "text")]
127    pub output: OutputFormat,
128
129    /// Verbose diagnostic logging to stderr.
130    #[arg(short, long)]
131    pub verbose: bool,
132
133    /// Comma-separated list of events to emit (e.g. `tool_call,tool_result,done`).
134    /// Omit to emit every output event. When set, `error` is only shown if explicitly listed.
135    #[arg(long = "events", value_enum, value_delimiter = ',')]
136    pub events: Vec<CliEventKind>,
137}
138
139impl RunConfig {
140    fn from_args(args: HeadlessArgs) -> Result<Self, CliError> {
141        if let Some(json) = args.options_json {
142            return Self::from_options(serde_json::from_str(&json).map_err(CliError::InvalidOptionsJson)?);
143        }
144
145        let prompt = resolve_prompt(&args)?;
146        let cwd = args.cwd.canonicalize().map_err(CliError::IoError)?;
147        let provider_connections = args.provider_connection.clone().into_overrides();
148        let spec = resolve_spec(
149            args.agent.as_deref(),
150            args.model.as_deref(),
151            &cwd,
152            &args.settings_source,
153            provider_connections,
154        )?;
155        let mcp_config_sources = args.mcp_config.sources(&cwd);
156        let oauth_credential_store = build_oauth_credential_store(&args.settings_source, &cwd)?;
157
158        Ok(Self {
159            prompt,
160            cwd,
161            mcp_config_sources,
162            spec,
163            system_prompt: args.system_prompt,
164            output: args.output,
165            verbose: args.verbose,
166            events: args.events,
167            oauth_credential_store,
168        })
169    }
170
171    fn from_options(options: HeadlessOptions) -> Result<Self, CliError> {
172        let prompt = options.prompt.ok_or(CliError::NoPrompt)?;
173        let cwd = options.cwd.unwrap_or_else(|| PathBuf::from(".")).canonicalize().map_err(CliError::IoError)?;
174        let settings_source = SettingsSourceArgs::from_json_options(options.settings, options.settings_file)?;
175        let provider_connections = ProviderConnectionOverrides::new(options.providers.unwrap_or_default());
176        let spec = resolve_spec(
177            options.agent.as_deref(),
178            options.model.as_deref(),
179            &cwd,
180            &settings_source,
181            provider_connections,
182        )?;
183        let mcp_config_sources = options
184            .mcp_config
185            .map(|config| serde_json::to_string(&config).expect("mcp config serialize"))
186            .map(McpConfigSource::Json)
187            .into_iter()
188            .collect();
189        let oauth_credential_store = build_oauth_credential_store(&settings_source, &cwd)?;
190
191        Ok(Self {
192            prompt,
193            cwd,
194            mcp_config_sources,
195            spec,
196            system_prompt: options.system_prompt,
197            output: options.output.unwrap_or(OutputFormat::Text),
198            verbose: options.verbose.unwrap_or(false),
199            events: options.events.unwrap_or_default(),
200            oauth_credential_store,
201        })
202    }
203}
204
205fn resolve_prompt(args: &HeadlessArgs) -> Result<String, CliError> {
206    match args.prompt.as_slice() {
207        args if !args.is_empty() => Ok(args.join(" ")),
208
209        _ if !stdin().is_terminal() => {
210            let mut buf = String::new();
211            stdin().read_to_string(&mut buf).map_err(CliError::IoError)?;
212
213            match buf.trim() {
214                "" => Err(CliError::NoPrompt),
215                s => Ok(s.to_string()),
216            }
217        }
218        _ => Err(CliError::NoPrompt),
219    }
220}
221
222fn resolve_spec(
223    agent: Option<&str>,
224    model: Option<&str>,
225    cwd: &Path,
226    settings_source: &SettingsSourceArgs,
227    provider_connections: ProviderConnectionOverrides,
228) -> Result<AgentSpec, CliError> {
229    if agent.is_some() && model.is_some() {
230        return Err(CliError::ConflictingArgs("Cannot specify both --agent and --model".to_string()));
231    }
232
233    let catalog = settings_source.load_agent_catalog(cwd).map_err(|e| CliError::AgentError(e.to_string()))?;
234
235    let mut spec = match model {
236        Some(m) => {
237            let parsed = m.parse().map_err(CliError::ModelError)?;
238            AgentSpec::default_spec(&parsed, None, Vec::new())
239        }
240        None => resolve_agent_spec(&catalog, agent)?,
241    };
242    spec.provider_connections.merge(provider_connections);
243    Ok(spec)
244}
245
246#[cfg(test)]
247mod tests {
248    use std::fs::{create_dir_all, write};
249
250    use super::*;
251
252    #[test]
253    fn resolve_spec_with_named_agent() {
254        let dir = setup_dir_with_agents();
255        let spec = resolve_spec(
256            Some("beta"),
257            None,
258            dir.path(),
259            &project_settings_args(),
260            ProviderConnectionOverrides::default(),
261        )
262        .unwrap();
263        assert_eq!(spec.name, "beta");
264    }
265
266    #[test]
267    fn resolve_spec_with_model_creates_default() {
268        let dir = setup_dir_with_agents();
269        let spec = resolve_spec(
270            None,
271            Some("anthropic:claude-sonnet-4-5"),
272            dir.path(),
273            &project_settings_args(),
274            ProviderConnectionOverrides::default(),
275        )
276        .unwrap();
277        assert_eq!(spec.name, "__default__");
278    }
279
280    #[test]
281    fn resolve_spec_defaults_to_first_user_invocable() {
282        let dir = setup_dir_with_agents();
283        let spec =
284            resolve_spec(None, None, dir.path(), &project_settings_args(), ProviderConnectionOverrides::default())
285                .unwrap();
286        assert_eq!(spec.name, "alpha");
287    }
288
289    #[test]
290    fn resolve_spec_defaults_to_fallback_without_settings() {
291        let dir = tempfile::tempdir().unwrap();
292        let spec = resolve_spec(None, None, dir.path(), &empty_settings_args(), ProviderConnectionOverrides::default())
293            .unwrap();
294        assert_eq!(spec.name, "__default__");
295    }
296
297    #[test]
298    fn resolve_spec_rejects_both_agent_and_model() {
299        let dir = setup_dir_with_agents();
300        let err = resolve_spec(
301            Some("alpha"),
302            Some("anthropic:claude-sonnet-4-5"),
303            dir.path(),
304            &SettingsSourceArgs::default(),
305            ProviderConnectionOverrides::default(),
306        )
307        .unwrap_err();
308        assert!(err.to_string().contains("Cannot specify both"), "unexpected error: {err}");
309    }
310
311    #[test]
312    fn resolve_spec_rejects_invalid_model() {
313        let dir = tempfile::tempdir().unwrap();
314        let err = resolve_spec(
315            None,
316            Some("not-a-valid-model"),
317            dir.path(),
318            &empty_settings_args(),
319            ProviderConnectionOverrides::default(),
320        )
321        .unwrap_err();
322        assert!(matches!(err, CliError::ModelError(_)));
323    }
324
325    #[test]
326    fn resolve_spec_rejects_unknown_agent() {
327        let dir = setup_dir_with_agents();
328        let err = resolve_spec(
329            Some("nonexistent"),
330            None,
331            dir.path(),
332            &project_settings_args(),
333            ProviderConnectionOverrides::default(),
334        )
335        .unwrap_err();
336        assert!(matches!(err, CliError::AgentError(_)));
337    }
338
339    fn write_file(dir: &std::path::Path, path: &str, content: &str) {
340        let full = dir.join(path);
341        if let Some(parent) = full.parent() {
342            create_dir_all(parent).unwrap();
343        }
344        write(full, content).unwrap();
345    }
346
347    fn project_settings_args() -> SettingsSourceArgs {
348        SettingsSourceArgs { settings_json: None, settings_file: Some(PathBuf::from(".aether/settings.json")) }
349    }
350
351    fn empty_settings_args() -> SettingsSourceArgs {
352        SettingsSourceArgs { settings_json: Some(r#"{"agents":[]}"#.to_string()), settings_file: None }
353    }
354
355    fn setup_dir_with_agents() -> tempfile::TempDir {
356        let dir = tempfile::tempdir().unwrap();
357        write_file(dir.path(), "PROMPT.md", "Be helpful");
358        write_file(
359            dir.path(),
360            ".aether/settings.json",
361            r#"{"agents": [
362                {"name": "alpha", "description": "Alpha agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": [{"type":"file","path":"PROMPT.md"}]},
363                {"name": "beta", "description": "Beta agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": [{"type":"file","path":"PROMPT.md"}]}
364            ]}"#,
365        );
366        dir
367    }
368}