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 aether_telemetry::AgentTraceContext;
7use error::CliError;
8use llm::{ProviderConnectionOverride, ProviderConnectionOverrides};
9use mcp_utils::client::McpConfig;
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12use std::collections::BTreeMap;
13use std::io::{IsTerminal, Read as _, stdin};
14use std::path::PathBuf;
15use std::process::ExitCode;
16
17use crate::credentials::oauth_credential_store_from_config;
18use crate::mcp_config_args::McpConfigArgs;
19use crate::output::OutputFormat;
20use crate::provider_connection_args::ProviderConnectionArgs;
21use crate::resolve::{AgentSelectionError, InitialSessionSelection, resolve_agent_from_settings};
22use crate::settings_args::SettingsSourceArgs;
23use aether_auth::OAuthCredentialStorage;
24use std::sync::Arc;
25
26#[derive(Clone, Copy, PartialEq, Eq, Debug, clap::ValueEnum, Deserialize, Serialize, JsonSchema)]
27#[clap(rename_all = "snake_case")]
28#[serde(rename_all = "snake_case")]
29pub enum CliEventKind {
30    Text,
31    Thought,
32    ToolCall,
33    ToolResult,
34    ToolError,
35    AutoContinue,
36    ModelSwitched,
37    ToolProgress,
38    ContextCompactionStarted,
39    ContextCompactionEnded,
40    ContextCompactionResult,
41    ContextUsage,
42    SessionUsage,
43    ContextCleared,
44    TurnStarted,
45    TurnEnded,
46    LlmRetryScheduled,
47    LlmCallStarted,
48    LlmCallEnded,
49    ToolExecutionStarted,
50    ToolDefinitionsUpdated,
51}
52
53pub struct RunConfig {
54    pub prompt: String,
55    pub cwd: PathBuf,
56    pub mcp_config_sources: Vec<McpConfigSource>,
57    pub spec: AgentSpec,
58    pub agent_catalog: AgentCatalog,
59    pub system_prompt: Option<String>,
60    pub output: OutputFormat,
61    pub verbose: bool,
62    pub events: Vec<CliEventKind>,
63    pub oauth_credential_store: Arc<dyn OAuthCredentialStorage>,
64    pub telemetry: Option<TelemetrySettings>,
65    pub trace_context: Option<AgentTraceContext>,
66}
67
68#[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema)]
69#[serde(rename_all = "camelCase", deny_unknown_fields)]
70pub struct HeadlessOptions {
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub prompt: Option<String>,
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub cwd: Option<PathBuf>,
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub providers: Option<BTreeMap<String, ProviderConnectionOverride>>,
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub settings: Option<AetherSettings>,
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub settings_file: Option<PathBuf>,
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub agent: Option<String>,
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub model: Option<String>,
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub mcp_config: Option<McpConfig>,
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub system_prompt: Option<String>,
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub output: Option<OutputFormat>,
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub verbose: Option<bool>,
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub events: Option<Vec<CliEventKind>>,
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub trace_context: Option<AgentTraceContext>,
97}
98
99pub async fn run_headless(args: HeadlessArgs) -> Result<ExitCode, CliError> {
100    run::run(RunConfig::from_args(args)?).await
101}
102
103#[derive(clap::Args)]
104pub struct HeadlessArgs {
105    #[arg(long = "options-json", value_name = "JSON", hide = true)]
106    pub options_json: Option<String>,
107
108    /// Prompt to send (reads stdin if omitted and stdin is not a TTY)
109    pub prompt: Vec<String>,
110
111    /// Named agent from settings.json (defaults to first user-invocable agent)
112    #[arg(short = 'a', long = "agent")]
113    pub agent: Option<String>,
114
115    /// Model for ad-hoc runs (e.g. "anthropic:claude-sonnet-4-5"). Mutually exclusive with --agent.
116    #[arg(short, long)]
117    pub model: Option<String>,
118
119    /// Working directory
120    #[arg(short = 'C', long = "cwd", default_value = ".")]
121    pub cwd: PathBuf,
122
123    #[command(flatten)]
124    pub settings_source: SettingsSourceArgs,
125
126    #[command(flatten)]
127    pub provider_connection: ProviderConnectionArgs,
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: OutputFormat,
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,turn_ended`).
145    /// Omit to emit every output event. When set, turn outcomes are only shown if `turn_ended` is listed.
146    #[arg(long = "events", value_enum, value_delimiter = ',')]
147    pub events: Vec<CliEventKind>,
148}
149
150impl RunConfig {
151    fn from_args(args: HeadlessArgs) -> Result<Self, CliError> {
152        if let Some(json) = args.options_json {
153            return Self::from_options(serde_json::from_str(&json).map_err(CliError::InvalidOptionsJson)?);
154        }
155
156        let prompt = resolve_prompt(&args)?;
157        let cwd = args.cwd.canonicalize().map_err(CliError::IoError)?;
158        let settings = args.settings_source.load_settings(&cwd)?;
159        let provider_connections = args.provider_connection.clone().into_overrides();
160        let oauth_credential_store = oauth_credential_store_from_config(settings.credentials_store.clone())?;
161        let telemetry = settings.telemetry.clone();
162        let selection = match (args.agent, args.model) {
163            (Some(agent), None) => InitialSessionSelection::Agent(agent),
164            (None, Some(model)) => InitialSessionSelection::Model { model, reasoning_effort: None },
165            (None, None) => InitialSessionSelection::Default,
166            (Some(_), Some(_)) => {
167                return Err(CliError::ConflictingArgs("Cannot specify both --agent and --model".to_string()));
168            }
169        };
170        let resolved = resolve_agent_from_settings(&cwd, settings, provider_connections, &selection)
171            .map_err(map_selection_error)?;
172        let mcp_config_sources = args.mcp_config.sources(&cwd);
173
174        Ok(Self {
175            prompt,
176            cwd,
177            mcp_config_sources,
178            spec: resolved.spec,
179            agent_catalog: resolved.catalog,
180            system_prompt: args.system_prompt,
181            output: args.output,
182            verbose: args.verbose,
183            events: args.events,
184            oauth_credential_store,
185            telemetry,
186            trace_context: None,
187        })
188    }
189
190    fn from_options(options: HeadlessOptions) -> Result<Self, CliError> {
191        let prompt = options.prompt.ok_or(CliError::NoPrompt)?;
192        let cwd = options.cwd.unwrap_or_else(|| PathBuf::from(".")).canonicalize().map_err(CliError::IoError)?;
193        let settings_source = SettingsSourceArgs::from_json_options(options.settings, options.settings_file)?;
194        let settings = settings_source.load_settings(&cwd)?;
195        let provider_connections = ProviderConnectionOverrides::new(options.providers.unwrap_or_default());
196        let oauth_credential_store = oauth_credential_store_from_config(settings.credentials_store.clone())?;
197        let telemetry = settings.telemetry.clone();
198        let selection = match (options.agent, options.model) {
199            (Some(agent), None) => InitialSessionSelection::Agent(agent),
200            (None, Some(model)) => InitialSessionSelection::Model { model, reasoning_effort: None },
201            (None, None) => InitialSessionSelection::Default,
202            (Some(_), Some(_)) => {
203                return Err(CliError::ConflictingArgs("Cannot specify both --agent and --model".to_string()));
204            }
205        };
206        let resolved = resolve_agent_from_settings(&cwd, settings, provider_connections, &selection)
207            .map_err(map_selection_error)?;
208        let mcp_config_sources = options
209            .mcp_config
210            .map(|config| serde_json::to_string(&config).expect("mcp config serialize"))
211            .map(McpConfigSource::Json)
212            .into_iter()
213            .collect();
214
215        Ok(Self {
216            prompt,
217            cwd,
218            mcp_config_sources,
219            spec: resolved.spec,
220            agent_catalog: resolved.catalog,
221            system_prompt: options.system_prompt,
222            output: options.output.unwrap_or(OutputFormat::Text),
223            verbose: options.verbose.unwrap_or(false),
224            events: options.events.unwrap_or_default(),
225            oauth_credential_store,
226            telemetry,
227            trace_context: options.trace_context,
228        })
229    }
230}
231
232fn resolve_prompt(args: &HeadlessArgs) -> Result<String, CliError> {
233    match args.prompt.as_slice() {
234        args if !args.is_empty() => Ok(args.join(" ")),
235
236        _ if !stdin().is_terminal() => {
237            let mut buf = String::new();
238            stdin().read_to_string(&mut buf).map_err(CliError::IoError)?;
239
240            match buf.trim() {
241                "" => Err(CliError::NoPrompt),
242                s => Ok(s.to_string()),
243            }
244        }
245        _ => Err(CliError::NoPrompt),
246    }
247}
248
249fn map_selection_error(error: AgentSelectionError) -> CliError {
250    match error {
251        AgentSelectionError::Settings(error) => CliError::Settings(error),
252        AgentSelectionError::Agent(error) => CliError::AgentError(error.to_string()),
253        AgentSelectionError::Model(error) => CliError::ModelError(error),
254    }
255}