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