Skip to main content

aether_cli/acp/
mod.rs

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