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