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