1#![doc = include_str!("../README.md")]
2pub 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
34pub const PROTOCOL: u64 = 1;
36
37pub const SERVER: &str = "escouade";
39
40pub 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
53pub fn preamble(instructions: &str) -> String {
56 format!("<instructions>\n{}\n</instructions>", instructions.trim())
57}
58
59pub fn relayed(relay: &str) -> String {
61 format!("<relais_escouade>\n{}\n</relais_escouade>", relay.trim())
62}
63
64pub 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
77pub 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
93pub fn fenced(params: &Value, fence: &[String]) -> bool {
98 if fence.is_empty() {
99 return false;
100 }
101 let tc = ¶ms["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
123pub 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}