Skip to main content

routa_server/api/
acp_registry.rs

1//! ACP Registry API Routes
2//!
3//! GET  /api/acp/registry           - List all agents with status
4//! GET  /api/acp/registry?id=x      - Get specific agent details
5//! POST /api/acp/registry           - Force refresh registry cache
6//!
7//! POST   /api/acp/install          - Install an agent
8//! DELETE /api/acp/install          - Uninstall an agent
9
10use axum::{
11    extract::{Query, State},
12    routing::{get, post},
13    Json, Router,
14};
15use serde::{Deserialize, Serialize};
16
17use crate::acp::{get_presets, AcpPaths, DistributionType, RuntimeType, WarmupStatus};
18use crate::error::ServerError;
19use crate::shell_env;
20use crate::state::AppState;
21
22/// ACP Registry URL
23const ACP_REGISTRY_URL: &str =
24    "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json";
25
26pub fn router() -> Router<AppState> {
27    Router::new()
28        .route("/registry", get(get_registry).post(refresh_registry))
29        .route("/install", post(install_agent).delete(uninstall_agent))
30        .route("/runtime", get(get_runtime_status).post(ensure_runtime))
31        .route("/warmup", get(get_warmup_status).post(warmup_agent))
32}
33
34// ─── Types ─────────────────────────────────────────────────────────────────
35
36#[derive(Debug, Deserialize)]
37struct RegistryQuery {
38    id: Option<String>,
39    #[allow(dead_code)]
40    refresh: Option<bool>,
41}
42
43#[derive(Debug, Serialize, Deserialize)]
44pub struct RegistryAgent {
45    pub id: String,
46    pub name: String,
47    pub version: String,
48    pub description: String,
49    pub repository: Option<String>,
50    pub authors: Vec<String>,
51    pub license: String,
52    pub icon: Option<String>,
53    pub distribution: serde_json::Value,
54}
55
56#[derive(Debug, Serialize, Deserialize)]
57pub struct AcpRegistry {
58    pub version: String,
59    pub agents: Vec<RegistryAgent>,
60}
61
62#[derive(Debug, Serialize)]
63struct AgentWithStatus {
64    agent: RegistryAgent,
65    available: bool,
66    installed: bool,
67    uninstallable: bool,
68    #[serde(rename = "distributionTypes")]
69    distribution_types: Vec<String>,
70    source: &'static str,
71}
72
73#[derive(Debug, Serialize)]
74#[allow(dead_code)]
75struct RegistryResponse {
76    agents: Vec<AgentWithStatus>,
77    platform: Option<String>,
78    #[serde(rename = "runtimeAvailability")]
79    runtime_availability: RuntimeAvailability,
80}
81
82#[derive(Debug, Serialize)]
83#[allow(dead_code)]
84struct RuntimeAvailability {
85    npx: bool,
86    uvx: bool,
87}
88
89#[derive(Debug, Deserialize)]
90struct InstallRequest {
91    #[serde(rename = "agentId")]
92    agent_id: String,
93    #[serde(rename = "distributionType")]
94    distribution_type: Option<String>,
95}
96
97// ─── Handlers ──────────────────────────────────────────────────────────────
98
99/// GET /api/acp/registry - List all agents with installation status
100async fn get_registry(
101    State(state): State<AppState>,
102    Query(query): Query<RegistryQuery>,
103) -> Result<Json<serde_json::Value>, ServerError> {
104    // Load installation state from disk
105    let _ = state.acp_installation_state.load().await;
106
107    // Fetch registry from CDN
108    let registry = fetch_registry().await?;
109
110    // Check runtime availability
111    let npx_available = shell_env::which("npx").is_some();
112    let uvx_available = shell_env::which("uv").is_some();
113
114    // If specific agent requested
115    if let Some(agent_id) = query.id {
116        if let Some(agent) = registry.agents.into_iter().find(|a| a.id == agent_id) {
117            let status = get_agent_status(&state, &agent, npx_available, uvx_available).await;
118            return Ok(Json(serde_json::json!({
119                "agent": agent,
120                "available": status.available,
121                "installed": status.installed,
122                "uninstallable": status.uninstallable,
123                "platform": detect_platform(),
124                "distributionType": status.resolved_distribution_type,
125            })));
126        } else {
127            return Err(ServerError::NotFound(format!(
128                "Agent '{agent_id}' not found"
129            )));
130        }
131    }
132
133    // List all agents with status
134    let registry_ids: std::collections::HashSet<String> = registry
135        .agents
136        .iter()
137        .map(|agent| agent.id.clone())
138        .collect();
139    let mut agents_with_status = Vec::new();
140    for agent in registry.agents {
141        let dist_types = get_distribution_types(&agent.distribution);
142        let status = get_agent_status(&state, &agent, npx_available, uvx_available).await;
143        agents_with_status.push(AgentWithStatus {
144            agent,
145            available: status.available,
146            installed: status.installed,
147            uninstallable: status.uninstallable,
148            distribution_types: dist_types,
149            source: "registry",
150        });
151    }
152
153    for preset in get_presets() {
154        if preset.id == "claude" {
155            continue;
156        }
157        if registry_ids.contains(&preset.id) {
158            continue;
159        }
160
161        let resolved =
162            resolve_preset_command(&preset).and_then(|command| shell_env::which(&command));
163        agents_with_status.push(AgentWithStatus {
164            agent: RegistryAgent {
165                id: preset.id,
166                name: preset.name,
167                version: String::new(),
168                description: preset.description,
169                repository: None,
170                authors: vec![],
171                license: String::new(),
172                icon: None,
173                distribution: serde_json::json!({}),
174            },
175            available: resolved.is_some(),
176            installed: resolved.is_some(),
177            uninstallable: false,
178            distribution_types: vec![],
179            source: "builtin",
180        });
181    }
182
183    Ok(Json(serde_json::json!({
184        "agents": agents_with_status,
185        "platform": detect_platform(),
186        "runtimeAvailability": {
187            "npx": npx_available,
188            "uvx": uvx_available,
189        }
190    })))
191}
192
193/// POST /api/acp/install - Install an agent
194async fn install_agent(
195    State(state): State<AppState>,
196    Json(req): Json<InstallRequest>,
197) -> Result<Json<serde_json::Value>, ServerError> {
198    let registry = fetch_registry().await?;
199
200    let agent = registry
201        .agents
202        .into_iter()
203        .find(|a| a.id == req.agent_id)
204        .ok_or_else(|| {
205            ServerError::NotFound(format!("Agent '{}' not found in registry", req.agent_id))
206        })?;
207
208    let dist_types = get_distribution_types(&agent.distribution);
209    let npx_available = shell_env::which("npx").is_some();
210    let uvx_available = shell_env::which("uv").is_some();
211
212    // Determine distribution type to use
213    let dist_type = req.distribution_type.unwrap_or_else(|| {
214        if dist_types.contains(&"npx".to_string()) && npx_available {
215            "npx".to_string()
216        } else if dist_types.contains(&"uvx".to_string()) && uvx_available {
217            "uvx".to_string()
218        } else if dist_types.contains(&"binary".to_string()) {
219            "binary".to_string()
220        } else {
221            "npx".to_string()
222        }
223    });
224
225    tracing::info!(
226        "[ACP Install] Installing agent: {} via {}",
227        req.agent_id,
228        dist_type
229    );
230
231    let version = if agent.version.is_empty() {
232        "latest".to_string()
233    } else {
234        agent.version.clone()
235    };
236
237    match dist_type.as_str() {
238        "npx" => {
239            // Ensure we have a Node.js / npx runtime (managed download if system npx absent)
240            let npx_system = shell_env::which("npx").is_some();
241            if !npx_system {
242                tracing::info!("[ACP Install] npx not found on PATH — downloading managed Node.js");
243                state
244                    .acp_runtime_manager
245                    .ensure_runtime(&RuntimeType::Npx)
246                    .await
247                    .map_err(|e| {
248                        ServerError::Internal(format!("Failed to ensure npx runtime: {e}"))
249                    })?;
250            }
251
252            // For npx, mark installed (runs on demand via npx)
253            let package = agent
254                .distribution
255                .get("npx")
256                .and_then(|v| v.get("package"))
257                .and_then(|v| v.as_str())
258                .map(|s| s.to_string());
259
260            state
261                .acp_installation_state
262                .mark_installed(
263                    &req.agent_id,
264                    &version,
265                    DistributionType::Npx,
266                    None,
267                    package,
268                )
269                .await
270                .map_err(|e| ServerError::Internal(format!("Failed to save state: {e}")))?;
271
272            // Trigger background warmup to pre-cache the npm package
273            state
274                .acp_warmup_service
275                .warmup_in_background(&req.agent_id)
276                .await;
277
278            Ok(Json(serde_json::json!({
279                "success": true,
280                "agentId": req.agent_id,
281                "distributionType": dist_type,
282                "message": format!("Agent '{}' configured for npx (warmup started)", agent.name)
283            })))
284        }
285        "uvx" => {
286            // Ensure we have a uv / uvx runtime (managed download if system uv absent)
287            let uvx_system = shell_env::which("uv").is_some();
288            if !uvx_system {
289                tracing::info!("[ACP Install] uv not found on PATH — downloading managed uv");
290                state
291                    .acp_runtime_manager
292                    .ensure_runtime(&RuntimeType::Uvx)
293                    .await
294                    .map_err(|e| {
295                        ServerError::Internal(format!("Failed to ensure uvx runtime: {e}"))
296                    })?;
297            }
298
299            // For uvx, mark installed (runs on demand via uvx)
300            let package = agent
301                .distribution
302                .get("uvx")
303                .and_then(|v| v.get("package"))
304                .and_then(|v| v.as_str())
305                .map(|s| s.to_string());
306
307            state
308                .acp_installation_state
309                .mark_installed(
310                    &req.agent_id,
311                    &version,
312                    DistributionType::Uvx,
313                    None,
314                    package,
315                )
316                .await
317                .map_err(|e| ServerError::Internal(format!("Failed to save state: {e}")))?;
318
319            // Trigger background warmup to pre-cache the Python package
320            state
321                .acp_warmup_service
322                .warmup_in_background(&req.agent_id)
323                .await;
324
325            Ok(Json(serde_json::json!({
326                "success": true,
327                "agentId": req.agent_id,
328                "distributionType": dist_type,
329                "message": format!("Agent '{}' configured for uvx (warmup started)", agent.name)
330            })))
331        }
332        "binary" => {
333            // For binary, download and extract
334            let platform = AcpPaths::current_platform();
335            let binary_config = agent
336                .distribution
337                .get("binary")
338                .and_then(|v| v.get(&platform))
339                .ok_or_else(|| {
340                    ServerError::BadRequest(format!("No binary available for platform: {platform}"))
341                })?;
342
343            let binary_info: crate::acp::BinaryInfo = serde_json::from_value(binary_config.clone())
344                .map_err(|e| ServerError::Internal(format!("Failed to parse binary info: {e}")))?;
345
346            let exe_path = state
347                .acp_binary_manager
348                .install_binary(&req.agent_id, &version, &binary_info)
349                .await
350                .map_err(|e| ServerError::Internal(format!("Binary installation failed: {e}")))?;
351
352            let exe_path_str = exe_path.to_string_lossy().to_string();
353            state
354                .acp_installation_state
355                .mark_installed(
356                    &req.agent_id,
357                    &version,
358                    DistributionType::Binary,
359                    Some(exe_path_str.clone()),
360                    None,
361                )
362                .await
363                .map_err(|e| ServerError::Internal(format!("Failed to save state: {e}")))?;
364
365            Ok(Json(serde_json::json!({
366                "success": true,
367                "agentId": req.agent_id,
368                "distributionType": dist_type,
369                "installedPath": exe_path_str,
370                "message": format!("Agent '{}' binary installed successfully", agent.name)
371            })))
372        }
373        _ => Err(ServerError::BadRequest(format!(
374            "Unknown distribution type: {dist_type}"
375        ))),
376    }
377}
378
379/// DELETE /api/acp/install - Uninstall an agent
380async fn uninstall_agent(
381    State(state): State<AppState>,
382    Json(req): Json<InstallRequest>,
383) -> Result<Json<serde_json::Value>, ServerError> {
384    tracing::info!("[ACP Install] Uninstalling agent: {}", req.agent_id);
385
386    // Check if installed and get type
387    if let Some(info) = state
388        .acp_installation_state
389        .get_installed_info(&req.agent_id)
390        .await
391    {
392        if info.dist_type == DistributionType::Binary {
393            // Remove binary files
394            state
395                .acp_binary_manager
396                .uninstall(&req.agent_id)
397                .await
398                .map_err(|e| ServerError::Internal(format!("Failed to remove binary: {e}")))?;
399        }
400    }
401
402    // Remove from installation state
403    state
404        .acp_installation_state
405        .uninstall(&req.agent_id)
406        .await
407        .map_err(|e| ServerError::Internal(format!("Failed to update state: {e}")))?;
408
409    Ok(Json(serde_json::json!({
410        "success": true,
411        "agentId": req.agent_id,
412        "message": format!("Agent '{}' uninstalled", req.agent_id)
413    })))
414}
415
416// ─── Helper Functions ──────────────────────────────────────────────────────
417
418/// Fetch the ACP registry from CDN
419pub async fn fetch_registry() -> Result<AcpRegistry, ServerError> {
420    let response = reqwest::get(ACP_REGISTRY_URL)
421        .await
422        .map_err(|e| ServerError::Internal(format!("Failed to fetch registry: {e}")))?;
423
424    if !response.status().is_success() {
425        return Err(ServerError::Internal(format!(
426            "Registry fetch failed: {}",
427            response.status()
428        )));
429    }
430
431    let registry: AcpRegistry = response
432        .json()
433        .await
434        .map_err(|e| ServerError::Internal(format!("Failed to parse registry: {e}")))?;
435
436    Ok(registry)
437}
438
439/// Get distribution types from agent distribution config
440fn get_distribution_types(distribution: &serde_json::Value) -> Vec<String> {
441    let mut types = Vec::new();
442    if distribution.get("npx").is_some() {
443        types.push("npx".to_string());
444    }
445    if distribution.get("uvx").is_some() {
446        types.push("uvx".to_string());
447    }
448    if distribution.get("binary").is_some() {
449        types.push("binary".to_string());
450    }
451    types
452}
453
454#[derive(Debug)]
455struct RegistryAgentStatus {
456    available: bool,
457    installed: bool,
458    uninstallable: bool,
459    resolved_distribution_type: Option<&'static str>,
460}
461
462async fn get_agent_status(
463    state: &AppState,
464    agent: &RegistryAgent,
465    npx_available: bool,
466    uvx_available: bool,
467) -> RegistryAgentStatus {
468    let installed_info = state
469        .acp_installation_state
470        .get_installed_info(&agent.id)
471        .await;
472
473    if let Some(info) = installed_info {
474        if info.dist_type == DistributionType::Binary {
475            return RegistryAgentStatus {
476                available: true,
477                installed: true,
478                uninstallable: true,
479                resolved_distribution_type: Some("binary"),
480            };
481        }
482    }
483
484    let dist = &agent.distribution;
485    if dist.get("npx").is_some() && npx_available {
486        return RegistryAgentStatus {
487            available: true,
488            installed: false,
489            uninstallable: false,
490            resolved_distribution_type: Some("npx"),
491        };
492    }
493
494    if dist.get("uvx").is_some() && uvx_available {
495        return RegistryAgentStatus {
496            available: true,
497            installed: false,
498            uninstallable: false,
499            resolved_distribution_type: Some("uvx"),
500        };
501    }
502
503    RegistryAgentStatus {
504        available: false,
505        installed: false,
506        uninstallable: false,
507        resolved_distribution_type: None,
508    }
509}
510
511fn resolve_preset_command(preset: &crate::acp::AcpPreset) -> Option<String> {
512    if let Some(env_var) = &preset.env_bin_override {
513        if let Ok(value) = std::env::var(env_var) {
514            let trimmed = value.trim();
515            if !trimmed.is_empty() {
516                return Some(trimmed.to_string());
517            }
518        }
519    }
520
521    let trimmed = preset.command.trim();
522    if trimmed.is_empty() {
523        None
524    } else {
525        Some(trimmed.to_string())
526    }
527}
528
529/// Detect the current platform
530pub fn detect_platform() -> Option<String> {
531    let os = std::env::consts::OS;
532    let arch = std::env::consts::ARCH;
533
534    let platform = match (os, arch) {
535        ("macos", "aarch64") => "darwin-aarch64",
536        ("macos", "x86_64") => "darwin-x86_64",
537        ("linux", "aarch64") => "linux-aarch64",
538        ("linux", "x86_64") => "linux-x86_64",
539        ("windows", "aarch64") => "windows-aarch64",
540        ("windows", "x86_64") => "windows-x86_64",
541        _ => return None,
542    };
543
544    Some(platform.to_string())
545}
546
547/// POST /api/acp/registry - Force refresh registry cache
548async fn refresh_registry(
549    State(_state): State<AppState>,
550) -> Result<Json<serde_json::Value>, ServerError> {
551    let registry = fetch_registry().await?;
552    Ok(Json(serde_json::json!({
553        "success": true,
554        "version": registry.version,
555        "agentCount": registry.agents.len(),
556        "message": "Registry cache refreshed"
557    })))
558}
559
560// ─── Runtime handlers ──────────────────────────────────────────────────────
561
562#[derive(Debug, serde::Deserialize)]
563struct EnsureRuntimeRequest {
564    /// Which runtime to ensure: "node", "npx", "uv", or "uvx"
565    runtime: String,
566}
567
568/// GET /api/acp/runtime — Show current Node.js / uv runtime status.
569async fn get_runtime_status(
570    State(state): State<AppState>,
571) -> Result<Json<serde_json::Value>, ServerError> {
572    use crate::acp::runtime_manager::current_platform;
573
574    let rm = &state.acp_runtime_manager;
575    let platform = current_platform();
576
577    // Check version for each runtime too
578    let check_with_version = |rt: RuntimeType| async move {
579        let managed = rm.get_managed_runtime(&rt).await;
580        let system = rm.get_system_runtime(&rt);
581        let version = rm.get_version(&rt).await;
582        serde_json::json!({
583            "available": managed.is_some() || system.is_some(),
584            "managed": managed.as_ref().map(|i| i.path.to_string_lossy().to_string()),
585            "system":  system.as_ref().map(|i| i.path.to_string_lossy().to_string()),
586            "version": version,
587        })
588    };
589
590    let (node, npx, uv, uvx) = tokio::join!(
591        check_with_version(RuntimeType::Node),
592        check_with_version(RuntimeType::Npx),
593        check_with_version(RuntimeType::Uv),
594        check_with_version(RuntimeType::Uvx),
595    );
596
597    Ok(Json(serde_json::json!({
598        "platform": platform,
599        "runtimes": {
600            "node": node,
601            "npx":  npx,
602            "uv":   uv,
603            "uvx":  uvx,
604        }
605    })))
606}
607
608/// POST /api/acp/runtime — Ensure (and possibly download) a managed runtime.
609///
610/// Body: `{ "runtime": "node" | "npx" | "uv" | "uvx" }`
611async fn ensure_runtime(
612    State(state): State<AppState>,
613    Json(req): Json<EnsureRuntimeRequest>,
614) -> Result<Json<serde_json::Value>, ServerError> {
615    let rt = match req.runtime.as_str() {
616        "node" => RuntimeType::Node,
617        "npx" => RuntimeType::Npx,
618        "uv" => RuntimeType::Uv,
619        "uvx" => RuntimeType::Uvx,
620        other => {
621            return Err(ServerError::BadRequest(format!(
622                "Unknown runtime '{other}'. Use node, npx, uv, or uvx."
623            )));
624        }
625    };
626
627    tracing::info!("[ACP Runtime] Ensuring runtime: {:?}", rt);
628    let info = state
629        .acp_runtime_manager
630        .ensure_runtime(&rt)
631        .await
632        .map_err(|e| ServerError::Internal(format!("Failed to ensure runtime: {e}")))?;
633
634    // Get actual version string
635    let version = state.acp_runtime_manager.get_version(&rt).await;
636
637    Ok(Json(serde_json::json!({
638        "success": true,
639        "runtime": req.runtime,
640        "path": info.path.to_string_lossy(),
641        "version": version.or(info.version),
642        "managed": info.is_managed,
643    })))
644}
645
646// ─── Warmup handlers ───────────────────────────────────────────────────────
647
648#[derive(Debug, serde::Deserialize)]
649struct WarmupRequest {
650    #[serde(rename = "agentId")]
651    agent_id: String,
652    /// If true, wait for warmup to finish before returning
653    #[serde(default)]
654    sync: bool,
655}
656
657#[derive(Debug, serde::Deserialize)]
658struct WarmupQuery {
659    id: Option<String>,
660}
661
662/// GET /api/acp/warmup - Get warmup status
663async fn get_warmup_status(
664    State(state): State<AppState>,
665    Query(query): Query<WarmupQuery>,
666) -> Result<Json<serde_json::Value>, ServerError> {
667    if let Some(agent_id) = query.id {
668        let status = state.acp_warmup_service.get_status(&agent_id).await;
669        return Ok(Json(
670            serde_json::to_value(status).map_err(|e| ServerError::Internal(e.to_string()))?,
671        ));
672    }
673    let statuses: Vec<WarmupStatus> = state.acp_warmup_service.get_all_statuses().await;
674    Ok(Json(serde_json::json!({ "statuses": statuses })))
675}
676
677/// POST /api/acp/warmup - Start warmup for an agent
678async fn warmup_agent(
679    State(state): State<AppState>,
680    Json(req): Json<WarmupRequest>,
681) -> Result<Json<serde_json::Value>, ServerError> {
682    tracing::info!("[ACP Warmup] Warming up agent: {}", req.agent_id);
683
684    if req.sync {
685        let ok = state
686            .acp_warmup_service
687            .warmup(&req.agent_id)
688            .await
689            .unwrap_or(false);
690
691        let status = state.acp_warmup_service.get_status(&req.agent_id).await;
692        return Ok(Json(serde_json::json!({
693            "agentId": req.agent_id,
694            "success": ok,
695            "status": status,
696        })));
697    }
698
699    state
700        .acp_warmup_service
701        .warmup_in_background(&req.agent_id)
702        .await;
703
704    Ok(Json(serde_json::json!({
705        "agentId": req.agent_id,
706        "started": true,
707        "message": format!("Warmup started for agent '{}' in the background", req.agent_id),
708    })))
709}