1use std::collections::HashMap;
2
3use axum::{
4 Json, Router,
5 body::Body,
6 extract::{Path, Query, State},
7 http,
8 response::{Json as ResponseJson, Response},
9 routing::{get, put},
10};
11use forge_core_deployment::{Deployment, DeploymentError};
12use forge_core_executors::{
13 executors::{BaseAgentCapability, BaseCodingAgent, StandardCodingAgentExecutor},
14 mcp_config::{McpConfig, read_agent_config, write_agent_config},
15 profile::{ExecutorConfigs, ExecutorProfileId},
16};
17use forge_core_services::services::config::{Config, ConfigError, SoundFile, save_config_to_file};
18use forge_core_utils::{assets::config_path, response::ApiResponse};
19use serde::{Deserialize, Serialize};
20use serde_json::Value;
21use tokio::fs;
22use ts_rs_forge::TS;
23
24use crate::{DeploymentImpl, error::ApiError};
25
26pub fn router() -> Router<DeploymentImpl> {
27 Router::new()
28 .route("/info", get(get_user_system_info))
29 .route("/config", put(update_config))
30 .route("/sounds/{sound}", get(get_sound))
31 .route("/mcp-config", get(get_mcp_servers).post(update_mcp_servers))
32 .route("/profiles", get(get_profiles).put(update_profiles))
33}
34
35#[derive(Debug, Serialize, Deserialize, TS)]
36pub struct Environment {
37 pub os_type: String,
38 pub os_version: String,
39 pub os_architecture: String,
40 pub bitness: String,
41}
42
43impl Default for Environment {
44 fn default() -> Self {
45 Self::new()
46 }
47}
48
49impl Environment {
50 pub fn new() -> Self {
51 let info = os_info::get();
52 Environment {
53 os_type: info.os_type().to_string(),
54 os_version: info.version().to_string(),
55 os_architecture: info.architecture().unwrap_or("unknown").to_string(),
56 bitness: info.bitness().to_string(),
57 }
58 }
59}
60
61#[derive(Debug, Serialize, Deserialize, TS)]
62pub struct UserSystemInfo {
63 pub config: Config,
64 pub analytics_user_id: String,
65 #[serde(flatten)]
66 pub profiles: ExecutorConfigs,
67 pub environment: Environment,
68 pub capabilities: HashMap<String, Vec<BaseAgentCapability>>,
70}
71
72#[axum::debug_handler]
74async fn get_user_system_info(
75 State(deployment): State<DeploymentImpl>,
76) -> ResponseJson<ApiResponse<UserSystemInfo>> {
77 let config = deployment.config().read().await;
78
79 let user_system_info = UserSystemInfo {
80 config: config.clone(),
81 analytics_user_id: deployment.user_id().to_string(),
82 profiles: ExecutorConfigs::get_cached(),
83 environment: Environment::new(),
84 capabilities: {
85 let mut caps: HashMap<String, Vec<BaseAgentCapability>> = HashMap::new();
86 let profs = ExecutorConfigs::get_cached();
87 for key in profs.executors.keys() {
88 if let Some(agent) = profs.get_coding_agent(&ExecutorProfileId::new(*key)) {
89 caps.insert(key.to_string(), agent.capabilities());
90 }
91 }
92 caps
93 },
94 };
95
96 ResponseJson(ApiResponse::success(user_system_info))
97}
98
99async fn update_config(
100 State(deployment): State<DeploymentImpl>,
101 Json(new_config): Json<Config>,
102) -> ResponseJson<ApiResponse<Config>> {
103 let config_path = config_path();
104
105 if !forge_core_utils::git::is_valid_branch_prefix(&new_config.git_branch_prefix) {
107 return ResponseJson(ApiResponse::error(
108 "Invalid git branch prefix. Must be a valid git branch name component without slashes.",
109 ));
110 }
111
112 let old_config = deployment.config().read().await.clone();
114
115 match save_config_to_file(&new_config, &config_path).await {
116 Ok(_) => {
117 let mut config = deployment.config().write().await;
118 *config = new_config.clone();
119 drop(config);
120
121 handle_config_events(&deployment, &old_config, &new_config).await;
123
124 ResponseJson(ApiResponse::success(new_config))
125 }
126 Err(e) => ResponseJson(ApiResponse::error(&format!("Failed to save config: {}", e))),
127 }
128}
129
130async fn track_config_events(deployment: &DeploymentImpl, old: &Config, new: &Config) {
132 let events = [
133 (
134 !old.disclaimer_acknowledged && new.disclaimer_acknowledged,
135 "onboarding_disclaimer_accepted",
136 serde_json::json!({}),
137 ),
138 (
139 !old.onboarding_acknowledged && new.onboarding_acknowledged,
140 "onboarding_completed",
141 serde_json::json!({
142 "profile": new.executor_profile,
143 "editor": new.editor
144 }),
145 ),
146 (
147 !old.github_login_acknowledged && new.github_login_acknowledged,
148 "onboarding_github_login_completed",
149 serde_json::json!({
150 "username": new.github.username,
151 "email": new.github.primary_email,
152 "auth_method": if new.github.oauth_token.is_some() { "oauth" }
153 else if new.github.pat.is_some() { "pat" }
154 else { "none" },
155 "has_default_pr_base": new.github.default_pr_base.is_some(),
156 "skipped": new.github.username.is_none()
157 }),
158 ),
159 (
160 !old.telemetry_acknowledged && new.telemetry_acknowledged,
161 "onboarding_telemetry_choice",
162 serde_json::json!({}),
163 ),
164 (
165 !old.analytics_enabled.unwrap_or(false) && new.analytics_enabled.unwrap_or(false),
166 "analytics_session_start",
167 serde_json::json!({}),
168 ),
169 ];
170
171 for (should_track, event_name, properties) in events {
172 if should_track {
173 deployment
174 .track_if_analytics_allowed(event_name, properties)
175 .await;
176 }
177 }
178}
179
180async fn handle_config_events(deployment: &DeploymentImpl, old: &Config, new: &Config) {
181 track_config_events(deployment, old, new).await;
182
183 if !old.disclaimer_acknowledged && new.disclaimer_acknowledged {
184 let deployment_clone = deployment.clone();
186 tokio::spawn(async move {
187 deployment_clone.trigger_auto_project_setup().await;
188 });
189 }
190}
191
192async fn get_sound(Path(sound): Path<SoundFile>) -> Result<Response, ApiError> {
193 let sound = sound.serve().await.map_err(DeploymentError::Other)?;
194 let response = Response::builder()
195 .status(http::StatusCode::OK)
196 .header(
197 http::header::CONTENT_TYPE,
198 http::HeaderValue::from_static("audio/wav"),
199 )
200 .body(Body::from(sound.data.into_owned()))
201 .unwrap();
202 Ok(response)
203}
204
205#[derive(TS, Debug, Deserialize)]
206pub struct McpServerQuery {
207 executor: BaseCodingAgent,
208}
209
210#[derive(TS, Debug, Serialize, Deserialize)]
211pub struct GetMcpServerResponse {
212 mcp_config: McpConfig,
214 config_path: String,
215}
216
217#[derive(TS, Debug, Serialize, Deserialize)]
218pub struct UpdateMcpServersBody {
219 servers: HashMap<String, Value>,
220}
221
222async fn get_mcp_servers(
223 State(_deployment): State<DeploymentImpl>,
224 Query(query): Query<McpServerQuery>,
225) -> Result<ResponseJson<ApiResponse<GetMcpServerResponse>>, ApiError> {
226 let coding_agent = ExecutorConfigs::get_cached()
227 .get_coding_agent(&ExecutorProfileId::new(query.executor))
228 .ok_or(ConfigError::ValidationError(
229 "Executor not found".to_string(),
230 ))?;
231
232 if !coding_agent.supports_mcp() {
233 return Ok(ResponseJson(ApiResponse::error(
234 "MCP not supported by this executor",
235 )));
236 }
237
238 let config_path = match coding_agent.default_mcp_config_path() {
240 Some(path) => path,
241 None => {
242 return Ok(ResponseJson(ApiResponse::error(
243 "Could not determine config file path",
244 )));
245 }
246 };
247
248 let mut mcpc = coding_agent.get_mcp_config();
249 let raw_config = read_agent_config(&config_path, &mcpc).await?;
250 let servers = get_mcp_servers_from_config_path(&raw_config, &mcpc.servers_path);
251 mcpc.set_servers(servers);
252 Ok(ResponseJson(ApiResponse::success(GetMcpServerResponse {
253 mcp_config: mcpc,
254 config_path: config_path.to_string_lossy().to_string(),
255 })))
256}
257
258async fn update_mcp_servers(
259 State(_deployment): State<DeploymentImpl>,
260 Query(query): Query<McpServerQuery>,
261 Json(payload): Json<UpdateMcpServersBody>,
262) -> Result<ResponseJson<ApiResponse<String>>, ApiError> {
263 let profiles = ExecutorConfigs::get_cached();
264 let agent = profiles
265 .get_coding_agent(&ExecutorProfileId::new(query.executor))
266 .ok_or(ConfigError::ValidationError(
267 "Executor not found".to_string(),
268 ))?;
269
270 if !agent.supports_mcp() {
271 return Ok(ResponseJson(ApiResponse::error(
272 "This executor does not support MCP servers",
273 )));
274 }
275
276 let config_path = match agent.default_mcp_config_path() {
278 Some(path) => path.to_path_buf(),
279 None => {
280 return Ok(ResponseJson(ApiResponse::error(
281 "Could not determine config file path",
282 )));
283 }
284 };
285
286 let mcpc = agent.get_mcp_config();
287 match update_mcp_servers_in_config(&config_path, &mcpc, payload.servers).await {
288 Ok(message) => Ok(ResponseJson(ApiResponse::success(message))),
289 Err(e) => Ok(ResponseJson(ApiResponse::error(&format!(
290 "Failed to update MCP servers: {}",
291 e
292 )))),
293 }
294}
295
296async fn update_mcp_servers_in_config(
297 config_path: &std::path::Path,
298 mcpc: &McpConfig,
299 new_servers: HashMap<String, Value>,
300) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
301 if let Some(parent) = config_path.parent() {
303 fs::create_dir_all(parent).await?;
304 }
305 let mut config = read_agent_config(config_path, mcpc).await?;
307
308 let old_servers = get_mcp_servers_from_config_path(&config, &mcpc.servers_path).len();
310
311 set_mcp_servers_in_config_path(&mut config, &mcpc.servers_path, &new_servers)?;
313
314 write_agent_config(config_path, mcpc, &config).await?;
316
317 let new_count = new_servers.len();
318 let message = match (old_servers, new_count) {
319 (0, 0) => "No MCP servers configured".to_string(),
320 (0, n) => format!("Added {} MCP server(s)", n),
321 (old, new) if old == new => format!("Updated MCP server configuration ({} server(s))", new),
322 (old, new) => format!(
323 "Updated MCP server configuration (was {}, now {})",
324 old, new
325 ),
326 };
327
328 Ok(message)
329}
330
331fn get_mcp_servers_from_config_path(raw_config: &Value, path: &[String]) -> HashMap<String, Value> {
333 let mut current = raw_config;
334 for part in path {
335 current = match current.get(part) {
336 Some(val) => val,
337 None => return HashMap::new(),
338 };
339 }
340 match current.as_object() {
342 Some(servers) => servers
343 .iter()
344 .map(|(k, v)| (k.clone(), v.clone()))
345 .collect(),
346 None => HashMap::new(),
347 }
348}
349
350fn set_mcp_servers_in_config_path(
352 raw_config: &mut Value,
353 path: &[String],
354 servers: &HashMap<String, Value>,
355) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
356 if !raw_config.is_object() {
358 *raw_config = serde_json::json!({});
359 }
360
361 let mut current = raw_config;
362 for part in &path[..path.len() - 1] {
364 if current.get(part).is_none() {
365 current
366 .as_object_mut()
367 .unwrap()
368 .insert(part.to_string(), serde_json::json!({}));
369 }
370 current = current.get_mut(part).unwrap();
371 if !current.is_object() {
372 *current = serde_json::json!({});
373 }
374 }
375
376 let final_attr = path.last().unwrap();
378 current
379 .as_object_mut()
380 .unwrap()
381 .insert(final_attr.to_string(), serde_json::to_value(servers)?);
382
383 Ok(())
384}
385
386#[derive(Debug, Serialize, Deserialize)]
387pub struct ProfilesContent {
388 pub content: String,
389 pub path: String,
390}
391
392async fn get_profiles(
393 State(_deployment): State<DeploymentImpl>,
394) -> ResponseJson<ApiResponse<ProfilesContent>> {
395 let profiles_path = forge_core_utils::assets::profiles_path();
396
397 let profiles = ExecutorConfigs::get_cached();
399
400 let content = serde_json::to_string_pretty(&profiles).unwrap_or_else(|e| {
401 tracing::error!("Failed to serialize profiles to JSON: {}", e);
402 serde_json::to_string_pretty(&ExecutorConfigs::from_defaults())
403 .unwrap_or_else(|_| "{}".to_string())
404 });
405
406 ResponseJson(ApiResponse::success(ProfilesContent {
407 content,
408 path: profiles_path.display().to_string(),
409 }))
410}
411
412async fn update_profiles(
413 State(_deployment): State<DeploymentImpl>,
414 body: String,
415) -> ResponseJson<ApiResponse<String>> {
416 match serde_json::from_str::<ExecutorConfigs>(&body) {
418 Ok(executor_profiles) => {
419 match executor_profiles.update_cache() {
421 Ok(_) => {
422 tracing::info!("Executor profiles cache updated successfully");
423 ResponseJson(ApiResponse::success(
424 "Executor profiles updated successfully".to_string(),
425 ))
426 }
427 Err(e) => {
428 tracing::error!("Failed to save executor profiles: {}", e);
429 ResponseJson(ApiResponse::error(&format!(
430 "Failed to save executor profiles: {}",
431 e
432 )))
433 }
434 }
435 }
436 Err(e) => ResponseJson(ApiResponse::error(&format!(
437 "Invalid executor profiles format: {}",
438 e
439 ))),
440 }
441}