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::io;
30use std::sync::Arc;
31use std::{fs::create_dir_all, path::PathBuf};
32use thiserror::Error;
33use tracing::info;
34use tracing_appender::rolling::daily;
35use tracing_subscriber::EnvFilter;
36
37use crate::credentials::build_oauth_credential_store;
38use crate::workspace::WorkspaceManager;
39use aether_auth::OAuthError;
40use session_factory::InitialSessionSelection;
41use session_store::SessionStore;
42
43#[derive(clap::Args, Debug)]
44pub struct AcpArgs {
45 #[clap(long, default_value = "/tmp/aether-acp-logs")]
47 pub log_dir: PathBuf,
48
49 #[clap(long, conflicts_with_all = ["model", "reasoning_effort"])]
51 pub agent: Option<String>,
52
53 #[clap(long, conflicts_with = "agent")]
56 pub model: Option<String>,
57
58 #[clap(long, value_name = "low|medium|high|xhigh", requires = "model", conflicts_with = "agent")]
60 pub reasoning_effort: Option<ReasoningEffort>,
61
62 #[command(flatten)]
63 pub provider_connection: ProviderConnectionArgs,
64
65 #[command(flatten)]
66 pub settings_source: SettingsSourceArgs,
67}
68
69#[derive(Debug)]
71pub enum AcpRunOutcome {
72 CleanDisconnect,
74}
75
76#[derive(Debug, Error)]
78pub enum AcpRunError {
79 #[error("ACP protocol error: {0}")]
80 Protocol(#[from] acp::Error),
81
82 #[error("Failed to initialize OAuth credential store: {0}")]
83 CredentialStore(#[from] OAuthError),
84
85 #[error("Failed to initialize session store: {0}")]
86 SessionStore(#[source] io::Error),
87
88 #[error("Failed to initialize workspace manager: {0}")]
89 WorkspaceManager(#[source] io::Error),
90}
91
92pub async fn run_acp(args: AcpArgs) -> Result<AcpRunOutcome, AcpRunError> {
93 info!("Starting Aether ACP server");
94
95 setup_logging(&args);
96
97 let initial_selection = if let Some(agent) = args.agent.clone() {
98 InitialSessionSelection::agent(agent)
99 } else if let Some(model) = args.model.clone() {
100 InitialSessionSelection::model(model, args.reasoning_effort)
101 } else {
102 InitialSessionSelection::default()
103 };
104 let session_store = Arc::new(SessionStore::new().map_err(AcpRunError::SessionStore)?);
105 let workspace_manager = Arc::new(WorkspaceManager::new().map_err(AcpRunError::WorkspaceManager)?);
106 let cwd = current_dir().unwrap_or_else(|_| PathBuf::from("."));
107 let oauth_credential_store = build_oauth_credential_store(&args.settings_source, &cwd)?;
108 let provider_connections = args.provider_connection.into_overrides();
109 let state = Arc::new(AcpState::new(AcpStateConfig {
110 session_store,
111 workspace_manager,
112 oauth_credential_store,
113 initial_selection,
114 settings_source: args.settings_source,
115 provider_connections,
116 }));
117
118 let connect_result = acp_agent_builder(state.clone()).connect_to(Stdio::new()).await;
119 state.shutdown_all().await;
120
121 match connect_result {
122 Ok(()) => Ok(AcpRunOutcome::CleanDisconnect),
123 Err(err) => Err(AcpRunError::Protocol(err)),
124 }
125}
126
127fn setup_logging(args: &AcpArgs) {
128 create_dir_all(&args.log_dir).ok();
129 tracing_subscriber::fmt()
130 .with_writer(daily(&args.log_dir, "aether-acp.log"))
131 .with_ansi(false) .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn")))
133 .pretty()
134 .init();
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140 use clap::Parser;
141
142 #[derive(Debug, Parser)]
143 struct TestCli {
144 #[command(flatten)]
145 args: AcpArgs,
146 }
147
148 #[test]
149 fn agent_conflicts_with_model() {
150 let err = TestCli::try_parse_from(["test", "--agent", "planner", "--model", "anthropic:claude-sonnet-4-5"])
151 .expect_err("agent and model should conflict");
152 assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
153 }
154
155 #[test]
156 fn agent_conflicts_with_reasoning_effort() {
157 let err = TestCli::try_parse_from(["test", "--agent", "planner", "--reasoning-effort", "high"])
158 .expect_err("agent and reasoning effort should conflict");
159 assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
160 }
161
162 #[test]
163 fn reasoning_effort_requires_model() {
164 let err = TestCli::try_parse_from(["test", "--reasoning-effort", "high"])
165 .expect_err("reasoning effort should require model");
166 assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
167 }
168
169 #[test]
170 fn reasoning_effort_with_model_is_allowed() {
171 let cli =
172 TestCli::try_parse_from(["test", "--model", "anthropic:claude-sonnet-4-5", "--reasoning-effort", "high"])
173 .expect("reasoning effort can configure an explicit model session");
174 assert_eq!(cli.args.reasoning_effort, Some(ReasoningEffort::High));
175 }
176}