Skip to main content

aether_cli/headless/
mod.rs

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