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