Skip to main content

aether_cli/acp/
mod.rs

1pub(crate) mod agent;
2pub(crate) mod agent_key;
3pub(crate) mod agent_runtime;
4pub(crate) mod config_setting;
5pub(crate) mod error;
6pub(crate) mod fake_prompt_mcp;
7pub(crate) mod model_config;
8pub(crate) mod prompt_history_index;
9pub(crate) mod protocol;
10pub(crate) mod session_actor;
11pub(crate) mod session_config_state;
12pub(crate) mod session_factory;
13pub(crate) mod session_store;
14pub(crate) mod slash_commands;
15pub(crate) mod state;
16pub(crate) mod stdio;
17pub mod testing;
18
19pub use protocol::map_mcp_prompt_to_available_command;
20
21use crate::acp::agent::acp_agent_builder;
22use crate::acp::state::{AcpState, AcpStateConfig};
23use crate::acp::stdio::Stdio;
24use crate::provider_connection_args::ProviderConnectionArgs;
25use crate::settings_args::SettingsSourceArgs;
26use agent_client_protocol as acp;
27use llm::ReasoningEffort;
28use std::env::current_dir;
29use std::sync::Arc;
30use std::{fs::create_dir_all, path::PathBuf};
31use thiserror::Error;
32use tracing::info;
33use tracing_appender::rolling::daily;
34use tracing_subscriber::EnvFilter;
35
36use crate::credentials::build_oauth_credential_store;
37use aether_auth::OAuthError;
38use session_factory::InitialSessionSelection;
39use session_store::SessionStore;
40
41#[derive(clap::Args, Debug)]
42pub struct AcpArgs {
43    /// Path to log file directory (default: /tmp/aether-acp-logs)
44    #[clap(long, default_value = "/tmp/aether-acp-logs")]
45    pub log_dir: PathBuf,
46
47    /// Initial agent (mode) to select for new sessions. Mutually exclusive with `--model` and `--reasoning-effort`.
48    #[clap(long, conflicts_with_all = ["model", "reasoning_effort"])]
49    pub agent: Option<String>,
50
51    /// Initial model id (e.g. `anthropic:claude-sonnet-4-5`) for new sessions.
52    /// Mutually exclusive with `--agent`.
53    #[clap(long, conflicts_with = "agent")]
54    pub model: Option<String>,
55
56    /// Initial reasoning effort for an explicit model session. Requires `--model` and is mutually exclusive with `--agent`.
57    #[clap(long, value_name = "low|medium|high|xhigh", requires = "model", conflicts_with = "agent")]
58    pub reasoning_effort: Option<ReasoningEffort>,
59
60    #[command(flatten)]
61    pub provider_connection: ProviderConnectionArgs,
62
63    #[command(flatten)]
64    pub settings_source: SettingsSourceArgs,
65}
66
67/// Outcome of running the ACP server successfully.
68#[derive(Debug)]
69pub enum AcpRunOutcome {
70    /// The client disconnected cleanly (e.g. EOF on stdin).
71    CleanDisconnect,
72}
73
74/// Errors that terminate the ACP server run.
75#[derive(Debug, Error)]
76pub enum AcpRunError {
77    #[error("ACP protocol error: {0}")]
78    Protocol(#[from] acp::Error),
79
80    #[error("Failed to initialize OAuth credential store: {0}")]
81    CredentialStore(#[from] OAuthError),
82}
83
84pub async fn run_acp(args: AcpArgs) -> Result<AcpRunOutcome, AcpRunError> {
85    info!("Starting Aether ACP server");
86
87    setup_logging(&args);
88
89    let initial_selection = if let Some(agent) = args.agent.clone() {
90        InitialSessionSelection::agent(agent)
91    } else if let Some(model) = args.model.clone() {
92        InitialSessionSelection::model(model, args.reasoning_effort)
93    } else {
94        InitialSessionSelection::default()
95    };
96    let session_store =
97        SessionStore::new().map_or_else(|e| panic!("Failed to initialize session store: {e}"), Arc::new);
98    let cwd = current_dir().unwrap_or_else(|_| PathBuf::from("."));
99    let oauth_credential_store = build_oauth_credential_store(&args.settings_source, &cwd)?;
100    let provider_connections = args.provider_connection.into_overrides();
101    let state = Arc::new(AcpState::new(AcpStateConfig {
102        session_store,
103        oauth_credential_store,
104        initial_selection,
105        settings_source: args.settings_source,
106        provider_connections,
107    }));
108
109    let connect_result = acp_agent_builder(state.clone()).connect_to(Stdio::new()).await;
110    state.shutdown_all().await;
111
112    match connect_result {
113        Ok(()) => Ok(AcpRunOutcome::CleanDisconnect),
114        Err(err) => Err(AcpRunError::Protocol(err)),
115    }
116}
117
118fn setup_logging(args: &AcpArgs) {
119    create_dir_all(&args.log_dir).ok();
120    tracing_subscriber::fmt()
121        .with_writer(daily(&args.log_dir, "aether-acp.log"))
122        .with_ansi(false) // No ANSI colors in log files
123        .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn")))
124        .pretty()
125        .init();
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use clap::Parser;
132
133    #[derive(Debug, Parser)]
134    struct TestCli {
135        #[command(flatten)]
136        args: AcpArgs,
137    }
138
139    #[test]
140    fn agent_conflicts_with_model() {
141        let err = TestCli::try_parse_from(["test", "--agent", "planner", "--model", "anthropic:claude-sonnet-4-5"])
142            .expect_err("agent and model should conflict");
143        assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
144    }
145
146    #[test]
147    fn agent_conflicts_with_reasoning_effort() {
148        let err = TestCli::try_parse_from(["test", "--agent", "planner", "--reasoning-effort", "high"])
149            .expect_err("agent and reasoning effort should conflict");
150        assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
151    }
152
153    #[test]
154    fn reasoning_effort_requires_model() {
155        let err = TestCli::try_parse_from(["test", "--reasoning-effort", "high"])
156            .expect_err("reasoning effort should require model");
157        assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
158    }
159
160    #[test]
161    fn reasoning_effort_with_model_is_allowed() {
162        let cli =
163            TestCli::try_parse_from(["test", "--model", "anthropic:claude-sonnet-4-5", "--reasoning-effort", "high"])
164                .expect("reasoning effort can configure an explicit model session");
165        assert_eq!(cli.args.reasoning_effort, Some(ReasoningEffort::High));
166    }
167}