Skip to main content

escouade_acp/
lib.rs

1#![doc = include_str!("../README.md")]
2//!
3//! ## Internals
4//!
5//! The ACP client of Escouade (Agent Client Protocol v1: JSON-RPC 2.0, one message a line on the agent's input and
6//! output), drawn from the one Cursor CLI was driven with (`engine/cursor/`, `agent/cursor.rs` of the app), with what is
7//! Cursor's own (`cursor/*`) kept apart in `cursor` (docs/PLUGINS.md §1.1, PLG-6).
8//!
9//! It holds no process: `Agent` is a state machine that reads what the agent wrote (`Agent::frames`) and answers the
10//! normalized items of the conversation (`escouade_plugin_protocol::engine::Item`) and the lines to write back to it
11//! (`Item::Write`, or the `writes` of an answer), as the contract of the harnesses in process wants it (INT-9). The plugin
12//! `dev.escouade.acp` (`escouade-plugins acp`) runs one for each agent of a harness a manifest describes
13//! (`contributes.harnesses[].acp`).
14//!
15//! The helpers at the top are those of the ACP harnesses, and of Antigravity CLI, whose client puts its stream in the
16//! form of this one (`plugins/antigravity`, INT-10.8).
17
18pub mod agent;
19pub mod cursor;
20pub mod gemini;
21pub mod git;
22pub mod limits;
23pub mod paths;
24pub mod questions;
25mod texts;
26pub mod tools;
27pub mod vibe;
28
29pub use agent::Agent;
30pub use tools::{call_id, content_text, diff_of, line_hunks, tool_of};
31
32use serde_json::{json, Value};
33
34/// The version of ACP Escouade speaks.
35pub const PROTOCOL: u64 = 1;
36
37/// The name of Escouade's MCP server, as its agents know it.
38pub const SERVER: &str = "escouade";
39
40/// What `session/new` and `session/load` are given of MCP servers: Escouade's (its address and token; the token in a
41/// header, on the process's input, never its command line), or none. An entry of the user's of that name is replaced
42/// by it (tried with Cursor CLI).
43pub fn mcp_servers(escouade: Option<(&str, &str)>) -> Value {
44    match escouade {
45        Some((url, token)) => json!([{
46            "type": "http", "name": SERVER, "url": url,
47            "headers": [{ "name": "Authorization", "value": format!("Bearer {token}") }],
48        }]),
49        None => json!([]),
50    }
51}
52
53/// The instructions added to an agent (a ticket's protocol), as they go before its first message: ACP has no system
54/// message, and `session/load` keeps them (tried with Cursor CLI).
55pub fn preamble(instructions: &str) -> String {
56    format!("<instructions>\n{}\n</instructions>", instructions.trim())
57}
58
59/// What was said in the agent's own interface (« Vue Cursor », the core's relay), as it goes before its next message.
60pub fn relayed(relay: &str) -> String {
61    format!("<relais_escouade>\n{}\n</relais_escouade>", relay.trim())
62}
63
64/// The option of a permission (`session/request_permission`) of `kind` (`allow_once`, `allow_always`, `reject_once`),
65/// by the id the agent gave it.
66pub fn option_of(params: &Value, kind: &str) -> String {
67    params["options"]
68        .as_array()
69        .into_iter()
70        .flatten()
71        .find(|o| o["kind"] == kind)
72        .and_then(|o| o["optionId"].as_str())
73        .map(str::to_string)
74        .unwrap_or_else(|| kind.replace('_', "-"))
75}
76
77/// Whether `text` (a path, a command) names `path` or a path under it, whatever its separators and, under Windows, its
78/// case. A guard: a longer name that starts alike counts too.
79pub fn names(text: &str, path: &str) -> bool {
80    let norm = |s: &str| {
81        let s = s.replace('\\', "/");
82        let s = s.trim_end_matches('/').to_string();
83        if cfg!(windows) {
84            s.to_lowercase()
85        } else {
86            s
87        }
88    };
89    let p = norm(path);
90    !p.is_empty() && norm(text).contains(&p)
91}
92
93/// « Projet privé » (ESC-140): whether the permission an agent asks (`params` of `session/request_permission`) names a
94/// path of `fence` (as its program names them): its command, its title, the files it writes or reads. A command can
95/// reach a path it does not name: a guard, not a barrier; what holds is the files read and written through the client
96/// (`Agent`, `fs/*`).
97pub fn fenced(params: &Value, fence: &[String]) -> bool {
98    if fence.is_empty() {
99        return false;
100    }
101    let tc = &params["toolCall"];
102    let mut texts: Vec<&str> = tc["title"].as_str().into_iter().collect();
103    for v in tc["rawInput"]
104        .as_object()
105        .into_iter()
106        .flatten()
107        .map(|(_, v)| v)
108    {
109        texts.extend(v.as_str());
110    }
111    for k in ["content", "locations"] {
112        texts.extend(
113            tc[k]
114                .as_array()
115                .into_iter()
116                .flatten()
117                .filter_map(|c| c["path"].as_str()),
118        );
119    }
120    texts.iter().any(|t| fence.iter().any(|p| names(t, p)))
121}
122
123/// Whether `path` is in the folder `dir` (or is it), whatever its slashes and case.
124pub fn inside(path: &str, dir: &str) -> bool {
125    let norm = |p: &str| p.replace('\\', "/").trim_end_matches('/').to_lowercase();
126    let (p, d) = (norm(path), norm(dir));
127    !d.is_empty() && (p == d || p.starts_with(&format!("{d}/")))
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[test]
135    fn the_token_goes_in_a_header_never_elsewhere() {
136        let s = mcp_servers(Some(("http://127.0.0.1:1/mcp", "s3cr3t")));
137        assert_eq!(s[0]["url"], "http://127.0.0.1:1/mcp");
138        assert_eq!(s[0]["headers"][0]["value"], "Bearer s3cr3t");
139        assert_eq!(mcp_servers(None), json!([]));
140    }
141
142    #[test]
143    fn a_permission_naming_a_fenced_path_is_fenced() {
144        let p = json!({ "toolCall": { "title": "cat C:\\Prive\\a.txt", "locations": [] } });
145        assert!(
146            fenced(&p, &["c:/prive".into()]) == cfg!(windows) || fenced(&p, &["C:\\Prive".into()])
147        );
148        assert!(!fenced(&p, &[]));
149        let q = json!({ "toolCall": { "locations": [{ "path": "/srv/secret/x" }] } });
150        assert!(fenced(&q, &["/srv/secret".into()]));
151        assert!(!fenced(&q, &["/srv/other".into()]));
152    }
153
154    #[test]
155    fn an_option_is_found_by_its_kind_or_named_after_it() {
156        let p = json!({ "options": [{ "kind": "allow_once", "optionId": "yes" }] });
157        assert_eq!(option_of(&p, "allow_once"), "yes");
158        assert_eq!(option_of(&p, "reject_once"), "reject-once");
159    }
160}