Add ACP agent discovery to coder-lite.

a2438e0b2fd4 · AtlantisPleb · · parent f94912a3a02f

Add ACP agent discovery to coder-lite.

- New `crates/coder-lite/src/acp.rs` reads the ACP registry
  (`~/work/projects/agentclientprotocol/repos/registry` or `ACP_REGISTRY`)
  and checks which known agents are installed on the current system.
- Detection checks the platform-specific `binary.cmd` in PATH, plus `npx` and
  `uvx` availability for npx/uvx-distributed agents.
- At TUI startup, `interactive.rs` pushes a `Notice` entry with
  "found ACP agents: <comma-separated ids>" before the main loop.
- Adds `serde`/`serde_json` workspace deps and a `#[tokio::test]` that
  verifies discovery completes without panic.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By
Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified Cargo.lock
  • modified crates/coder-lite/Cargo.toml
  • added crates/coder-lite/src/acp.rs
  • modified crates/coder-lite/src/interactive.rs
  • modified crates/coder-lite/src/lib.rs

Diff

5 files changed, +207 -0

Cargo.lock modified +2

@@ -393,6 +393,8 @@ dependencies = [

393 393
 "openresponses-rust",
394 394
 "ratatui",
395 395
 "ratatui-markdown",
396
 "serde",
397
 "serde_json",
396 398
 "tokio",
397 399
]
398 400
crates/coder-lite/Cargo.toml modified +2

@@ -15,4 +15,6 @@ crossterm = { version = "0.28", features = ["event-stream"] }

15 15
ratatui = { version = "0.29", default-features = false, features = ["crossterm"] }
16 16
openresponses-rust = "2026.7.26"
17 17
futures = "0.3"
18
serde = { workspace = true, features = ["derive"] }
19
serde_json = { workspace = true }
18 20
ratatui-markdown = { version = "0.3.6", default-features = false, features = ["markdown"] }
crates/coder-lite/src/acp.rs added +181

@@ -0,0 +1,181 @@

1
//! ACP agent discovery.
2
//!
3
//! Reads the ACP registry on disk and checks which known agents are available
4
//! on the current system. Used by coder-lite to announce discovered ACP agents
5
//! at startup.
6
7
use serde::Deserialize;
8
use std::collections::HashMap;
9
use std::path::{Path, PathBuf};
10
11
/// A discovered ACP agent.
12
#[derive(Debug, Clone)]
13
pub struct Agent {
14
    pub id: String,
15
    pub name: String,
16
}
17
18
/// Discover all ACP agents in the registry that are also available locally.
19
///
20
/// Looks for `ACP_REGISTRY` first, then falls back to the default checkout path
21
/// under the user's home directory.
22
pub async fn find_agents() -> Result<Vec<Agent>, Box<dyn std::error::Error>> {
23
    let registry_dir = std::env::var("ACP_REGISTRY")
24
        .ok()
25
        .map(PathBuf::from)
26
        .or_else(|| home_dir().map(|h| h.join("work/projects/agentclientprotocol/repos/registry")));
27
28
    let Some(dir) = registry_dir else {
29
        return Ok(Vec::new());
30
    };
31
    if !tokio::fs::metadata(&dir).await.map(|m| m.is_dir()).unwrap_or(false) {
32
        return Ok(Vec::new());
33
    }
34
35
    let mut entries = tokio::fs::read_dir(&dir).await?;
36
    let mut found = Vec::new();
37
38
    while let Some(entry) = entries.next_entry().await? {
39
        let path = entry.path();
40
        if !path.is_dir() {
41
            continue;
42
        }
43
44
        let agent_json = path.join("agent.json");
45
        let content = match tokio::fs::read_to_string(&agent_json).await {
46
            Ok(c) => c,
47
            Err(_) => continue,
48
        };
49
50
        let agent: RegistryAgent = match serde_json::from_str(&content) {
51
            Ok(a) => a,
52
            Err(_) => continue,
53
        };
54
55
        if is_available(&agent).await {
56
            found.push(Agent {
57
                id: agent.id,
58
                name: agent.name,
59
            });
60
        }
61
    }
62
63
    found.sort_by(|a, b| a.id.cmp(&b.id));
64
    Ok(found)
65
}
66
67
#[derive(Debug, Deserialize)]
68
struct RegistryAgent {
69
    id: String,
70
    name: String,
71
    #[allow(dead_code)]
72
    version: String,
73
    #[serde(default)]
74
    distribution: Option<Distribution>,
75
}
76
77
#[derive(Debug, Deserialize)]
78
struct Distribution {
79
    #[serde(default)]
80
    binary: Option<HashMap<String, BinaryTarget>>,
81
    #[serde(default)]
82
    npx: Option<NpxUvx>,
83
    #[serde(default)]
84
    uvx: Option<NpxUvx>,
85
}
86
87
#[derive(Debug, Deserialize)]
88
#[allow(dead_code)]
89
struct BinaryTarget {
90
    cmd: String,
91
    #[serde(default)]
92
    args: Option<Vec<String>>,
93
    #[serde(default)]
94
    sha256: Option<String>,
95
}
96
97
#[derive(Debug, Deserialize)]
98
#[allow(dead_code)]
99
struct NpxUvx {
100
    package: String,
101
    #[serde(default)]
102
    args: Option<Vec<String>>,
103
}
104
105
async fn is_available(agent: &RegistryAgent) -> bool {
106
    let Some(dist) = &agent.distribution else {
107
        return false;
108
    };
109
110
    if let Some(binary) = &dist.binary {
111
        let platform = current_platform();
112
        if let Some(target) = binary.get(&platform) {
113
            let name = Path::new(&target.cmd)
114
                .file_name()
115
                .and_then(|s| s.to_str())
116
                .unwrap_or(&target.cmd);
117
            if has_command(name).await {
118
                return true;
119
            }
120
        }
121
    }
122
123
    if dist.npx.is_some() && has_command("npx").await {
124
        return true;
125
    }
126
127
    if dist.uvx.is_some() && has_command("uvx").await {
128
        return true;
129
    }
130
131
    false
132
}
133
134
fn current_platform() -> String {
135
    use std::env::consts::{ARCH, OS};
136
    let os = match OS {
137
        "macos" => "darwin",
138
        "windows" => "windows",
139
        _ => "linux",
140
    };
141
    format!("{}-{}", os, ARCH)
142
}
143
144
async fn has_command(name: &str) -> bool {
145
    if cfg!(target_os = "windows") {
146
        tokio::process::Command::new("where")
147
            .arg(name)
148
            .output()
149
            .await
150
            .map(|o| o.status.success())
151
            .unwrap_or(false)
152
    } else {
153
        tokio::process::Command::new("which")
154
            .arg(name)
155
            .output()
156
            .await
157
            .map(|o| o.status.success())
158
            .unwrap_or(false)
159
    }
160
}
161
162
fn home_dir() -> Option<PathBuf> {
163
    std::env::var_os("HOME")
164
        .or_else(|| std::env::var_os("USERPROFILE"))
165
        .map(PathBuf::from)
166
}
167
168
#[cfg(test)]
169
mod tests {
170
    use super::*;
171
172
    #[tokio::test]
173
    async fn find_agents_does_not_panic() {
174
        let agents = find_agents().await.unwrap();
175
        // We cannot assert exact IDs because PATH and installed agents vary by host.
176
        // The call must simply return Ok and not panic on missing registry or which.
177
        for agent in &agents {
178
            assert!(!agent.id.is_empty());
179
        }
180
    }
181
}
crates/coder-lite/src/interactive.rs modified +21

@@ -4,6 +4,7 @@

4 4
//! grok-pager: destructure `crossterm::event::KeyEvent` by `code` and
5 5
//! `modifiers` so control chords do not fall through to plain character input.
6 6
7
use crate::acp;
7 8
use crate::runtime::{CoderRuntimeSession, Control};
8 9
use crate::tui::{CoderUi, Entry, Role};
9 10
use std::sync::mpsc;

@@ -43,6 +44,26 @@ pub async fn run_tui() -> Result<(), Box<dyn std::error::Error>> {

43 44
    let (tx, rx) = mpsc::channel::<Control>();
44 45
    let mut ui = CoderUi::new();
45 46
47
    match acp::find_agents().await {
48
        Ok(agents) => {
49
            let list = agents
50
                .iter()
51
                .map(|a| a.id.as_str())
52
                .collect::<Vec<_>>()
53
                .join(", ");
54
            ui.entries.push(Entry {
55
                role: Role::Notice,
56
                text: format!("found ACP agents: {}", list),
57
            });
58
        }
59
        Err(_) => {
60
            ui.entries.push(Entry {
61
                role: Role::Notice,
62
                text: "found ACP agents: none".to_string(),
63
            });
64
        }
65
    }
66
46 67
    loop {
47 68
        while let Ok(control) = rx.try_recv() {
48 69
            match control {
crates/coder-lite/src/lib.rs modified +1

@@ -1,5 +1,6 @@

1 1
//! coder-lite: a minimal ratatui TUI boot crate
2 2
3
pub mod acp;
3 4
pub mod interactive;
4 5
pub mod runtime;
5 6
pub mod tui;

This page updates live while a promote is in flight · changelog