OXYGENHUB

Coverage / src/cli/auto.rs 36.7%

47 of 128 lines run, 11 of 60 functions · run #34 on main · run missed

1
//! `spectra mcp --auto` (spec §15.1): one MCP entry for every folder. The
2
//! shim finds the agent for the folder its client started in, from the
3
//! agent homes under `$SPECTRA_AGENTS` (else `~/.spectra-agents`), each of
4
//! which `spectra agent add` labels with an `agent.toml` naming the
5
//! agent, its kind, its project and the project's folder. Each folder
6
//! still signs as its own agent. A folder with no agent gets a quiet
7
//! server that says how to add one, instead of a failure.
8
9
use anyhow::{Context, Result};
10
use serde_json::{json, Value as Json};
11
use spectra_core::keys::KeyPair;
12
use spectra_mcp::client::Client;
13
use std::path::{Path, PathBuf};
14
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
15
16
/// What `agent add` writes into an agent's home.
17
#[derive(Clone, Debug)]
18
pub struct Label {
19
    pub name: String,
20
    pub kind: String,
21
    pub folder: Option<PathBuf>,
22
    pub home: PathBuf,
23
}
24
250×
pub fn agents_dir() -> PathBuf {
260×
    if let Some(d) = std::env::var_os("SPECTRA_AGENTS").filter(|d| !d.is_empty()) {
270×
        return PathBuf::from(d);
280×
    }
290×
    std::env::var_os("HOME").map(PathBuf::from).unwrap_or_default().join(".spectra-agents")
300×
}
31
3229×
pub fn write_label(home: &Path, name: &str, kind: &str, project: &str, folder: Option<&str>) -> Result<()> {
3329×
    let mut t = toml::Table::new();
3429×
    t.insert("name".into(), name.into());
3529×
    t.insert("kind".into(), kind.into());
3629×
    t.insert("project".into(), project.into());
3729×
    if let Some(f) = folder.filter(|f| !f.is_empty()) {
3824×
        t.insert("folder".into(), f.into());
3924×
    }
4029×
    let text = format!("# Written by `spectra agent add`: lets `spectra mcp --auto` find this agent.\n{t}");
4129×
    spectra_core::durable::write_atomic(&home.join("agent.toml"), text.as_bytes())?;
4229×
    Ok(())
4329×
}
44
450×
fn read_label(home: &Path) -> Option<Label> {
460×
    let t: toml::Table = std::fs::read_to_string(home.join("agent.toml")).ok()?.parse().ok()?;
470×
    let s = |k: &str| t.get(k).and_then(|v| v.as_str()).map(String::from);
480×
    s("project")?;
490×
    Some(Label { name: s("name")?, kind: s("kind")?, folder: s("folder").map(PathBuf::from), home: home.to_path_buf() })
500×
}
51
52
/// Label an agent home made before labels existed, by asking Spectra (as
53
/// that agent) which project it works on and where the project lives.
540×
async fn learn_label(endpoint: &spectra_mcp::client::Endpoint, home: &Path) -> Option<Label> {
550×
    let key = KeyPair::load_home(home).ok()?;
560×
    let c = Client::at(endpoint.clone(), Some(key.clone()));
570×
    let agents = c.send("GET", "/api/v1/agents", &[], vec![]).await.ok()?.json().ok()?;
580×
    let me = agents.as_array()?.iter().find(|a| a["key"] == key.key.to_hex())?.clone();
590×
    let projects = c.send("GET", "/api/v1/projects", &[], vec![]).await.ok()?.json().ok()?;
600×
    let project = me["project"].as_str()?.to_string();
610×
    let folder = projects.as_array()?.iter().find(|p| p["name"] == project.as_str()).and_then(|p| p["path"].as_str().map(String::from));
620×
    let (name, kind) = (me["name"].as_str()?.to_string(), me["kind"].as_str()?.to_string());
630×
    let _ = write_label(home, &name, &kind, &project, folder.as_deref());
640×
    Some(Label { name, kind, folder: folder.map(PathBuf::from), home: home.to_path_buf() })
650×
}
66
67
/// Every labelled agent home, labelling older ones on the way.
680×
pub async fn labels(endpoint: Option<&spectra_mcp::client::Endpoint>) -> Vec<Label> {
690×
    let mut out = Vec::new();
700×
    let Ok(dirs) = std::fs::read_dir(agents_dir()) else { return out };
710×
    for d in dirs.filter_map(|e| e.ok()).map(|e| e.path()).filter(|p| p.join("keys").is_dir()) {
720×
        match read_label(&d) {
730×
            Some(l) => out.push(l),
74
            None => {
750×
                if let Some(ep) = endpoint {
760×
                    if let Some(l) = learn_label(ep, &d).await {
770×
                        out.push(l);
780×
                    }
790×
                }
80
            }
81
        }
82
    }
830×
    out
840×
}
85
86
/// The agent of `kind` for `cwd`: the one whose project folder holds it,
87
/// the deepest folder first, then by name.
885×
pub fn choose<'a>(labels: &'a [Label], cwd: &Path, kind: &str) -> Option<&'a Label> {
895×
    let cwd = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
905×
    labels
915×
        .iter()
9215×
        .filter(|l| l.kind == kind)
939×
        .filter_map(|l| {
949×
            let f = l.folder.as_ref()?;
959×
            let f = f.canonicalize().unwrap_or_else(|_| f.clone());
969×
            cwd.starts_with(&f).then(|| (f.components().count(), l))
979×
        })
985×
        .max_by(|(a, x), (b, y)| a.cmp(b).then_with(|| y.name.cmp(&x.name)))
995×
        .map(|(_, l)| l)
1005×
}
101
102
/// The quiet server for a folder with no agent: it answers the handshake,
103
/// lists no tools, and its instructions say how to join.
1040×
pub async fn unconnected(cwd: &Path, kind: &str) -> Result<i32> {
1050×
    let why = format!(
106
        "This folder ({}) has no Spectra agent of kind {kind}, so Spectra's tools are not available here. \
107
         The owner can add one with the Spectra VS Code extension (Spectra: Add Agent) or with \
108
         `spectra agent add NAME --kind {kind} --project PROJECT --home ~/.spectra-agents/NAME`, \
109
         after `spectra project add {}`; then start a new session.",
1100×
        cwd.display(),
1110×
        cwd.display()
112
    );
1130×
    let mut out = tokio::io::stdout();
1140×
    let mut lines = BufReader::new(tokio::io::stdin()).lines();
1150×
    while let Some(line) = lines.next_line().await? {
1160×
        let Ok(req) = serde_json::from_str::<Json>(&line) else { continue };
1170×
        let Some(id) = req.get("id").cloned() else { continue };
1180×
        let result = match req.get("method").and_then(Json::as_str).unwrap_or("") {
1190×
            "initialize" => {
1200×
                let v = req.pointer("/params/protocolVersion").and_then(Json::as_str).unwrap_or(super::mcp::STDIO_VERSIONS[0]);
1210×
                let v = if super::mcp::STDIO_VERSIONS.contains(&v) { v } else { super::mcp::STDIO_VERSIONS[0] };
1220×
                json!({ "protocolVersion": v, "capabilities": { "tools": {} }, "serverInfo": { "name": "spectra", "version": env!("CARGO_PKG_VERSION") }, "instructions": why })
123
            }
1240×
            "tools/list" => json!({ "tools": [] }),
1250×
            "ping" => json!({}),
126
            _ => {
1270×
                let e = json!({ "jsonrpc": "2.0", "id": id, "error": { "code": -32601, "message": why } });
1280×
                out.write_all(format!("{e}\n").as_bytes()).await?;
1290×
                out.flush().await?;
1300×
                continue;
131
            }
132
        };
1330×
        out.write_all(format!("{}\n", json!({ "jsonrpc": "2.0", "id": id, "result": result })).as_bytes()).await?;
1340×
        out.flush().await?;
135
    }
1360×
    Ok(0)
1370×
}
138
139
/// Resolve the agent for this session's folder, or None.
1400×
pub async fn resolve(ctx: &super::Ctx, kind: &str) -> Result<Option<Label>> {
1410×
    let cwd = std::env::current_dir().context("this session's folder")?;
1420×
    let endpoint = ctx.endpoint().ok();
1430×
    let all = labels(endpoint.as_ref()).await;
1440×
    Ok(choose(&all, &cwd, kind).cloned())
1450×
}
146
147
#[cfg(test)]
148
mod tests {
149
    use super::*;
150
1513×
    fn l(name: &str, kind: &str, folder: &str) -> Label {
1523×
        Label { name: name.into(), kind: kind.into(), folder: Some(PathBuf::from(folder)), home: PathBuf::new() }
1533×
    }
154
155
    #[test]
1561×
    fn the_deepest_folder_of_the_right_kind_wins() {
1571×
        let all = [
1581×
            l("claude-dev", "claude-code", "/dev"),
1591×
            l("claude-book", "claude-code", "/dev/book"),
1601×
            l("kimi-book", "kimi-code", "/dev/book"),
1611×
        ];
1621×
        assert_eq!(choose(&all, Path::new("/dev/book/ch3"), "claude-code").unwrap().name, "claude-book");
1631×
        assert_eq!(choose(&all, Path::new("/dev/other"), "claude-code").unwrap().name, "claude-dev");
1641×
        assert_eq!(choose(&all, Path::new("/dev/book"), "kimi-code").unwrap().name, "kimi-book");
1651×
        assert!(choose(&all, Path::new("/elsewhere"), "claude-code").is_none());
1661×
        assert!(choose(&all, Path::new("/devx"), "claude-code").is_none(), "a prefix of the name is not inside");
1671×
    }
168
}