1use std::collections::BTreeMap;
14
15use anyhow::{bail, Context, Result};
16
17#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
19pub struct Tool {
20 pub name: String,
21 #[serde(default)]
23 pub detect: Vec<String>,
24 #[serde(default)]
28 pub open: Vec<Vec<String>>,
29 #[serde(default)]
31 pub pane_pointer: Option<String>,
32 #[serde(default)]
35 pub run: Vec<String>,
36 #[serde(default)]
38 pub respawn: Vec<String>,
39 #[serde(default)]
43 pub prompt: Vec<Vec<String>>,
44 #[serde(default)]
47 pub type_line: Vec<Vec<String>>,
48 #[serde(default)]
50 pub alive: Vec<String>,
51 #[serde(default)]
53 pub ready: Vec<String>,
54 #[serde(default)]
56 pub ready_s: Option<u64>,
57}
58
59pub const SHIPPED_TOOLS: &str = r#"
62[[tool]]
63name = "herdr"
64# `herdr status server` exits 0 even with no server running;
65# `workspace list` needs the socket, so it fails without one.
66detect = ["herdr", "workspace", "list"]
67# One workspace a persona, opened in its home as its seat; the root pane is
68# a shell, and the pane script is typed into it.
69open = [["herdr", "workspace", "create", "--cwd", "{home}", "--env", "LJOS_SEAT={name}", "--label", "{label}", "--no-focus"]]
70pane_pointer = "/result/root_pane/pane_id"
71run = ["herdr", "pane", "run", "{pane}", "sh {script_q}"]
72respawn = ["herdr", "pane", "run", "{pane}", "sh {script_q}"]
73prompt = [["herdr", "agent", "prompt", "{pane}", "{line}"]]
74type_line = [["herdr", "pane", "send-text", "{pane}", "{line}"], ["herdr", "pane", "send-keys", "{pane}", "enter"]]
75alive = ["herdr", "pane", "get", "{pane}"]
76# `herdr agent wait` answers agent_not_found until herdr recognises the
77# runner; a runner it never recognises gets the line typed raw once
78# `ready_s` runs out.
79ready = ["herdr", "agent", "wait", "{pane}", "--until", "idle", "--until", "done", "--timeout", "5000"]
80ready_s = 20
81
82[[tool]]
83name = "tmux"
84detect = ["tmux", "-V"]
85open = [
86 ["tmux", "new-window", "-d", "-t", "{session}", "-n", "{name}", "sh", "{script}"],
87 ["tmux", "new-session", "-d", "-s", "{session}", "-n", "{name}", "sh", "{script}"],
88]
89respawn = ["tmux", "respawn-window", "-k", "-t", "{session}:{name}", "sh", "{script}"]
90prompt = [["tmux", "send-keys", "-t", "{session}:{name}", "-l", "{line}"], ["tmux", "send-keys", "-t", "{session}:{name}", "Enter"]]
91alive = ["tmux", "list-panes", "-t", "{session}:{name}"]
92ready_s = 8
93"#;
94
95#[derive(serde::Deserialize)]
96struct ToolFile {
97 #[serde(default)]
98 tool: Vec<Tool>,
99}
100
101#[must_use]
103pub fn shipped() -> Vec<Tool> {
104 toml::from_str::<ToolFile>(SHIPPED_TOOLS)
105 .map(|f| f.tool)
106 .unwrap_or_default()
107}
108
109#[must_use]
112pub fn ordered(declared: &[Tool]) -> Vec<Tool> {
113 let mut out: Vec<Tool> = declared.to_vec();
114 for t in shipped() {
115 if !out.iter().any(|d| d.name == t.name) {
116 out.push(t);
117 }
118 }
119 out
120}
121
122#[derive(Debug, Clone, Default)]
124pub struct Vars(pub BTreeMap<&'static str, String>);
125
126impl Vars {
127 #[must_use]
128 pub fn with(mut self, key: &'static str, value: impl Into<String>) -> Self {
129 self.0.insert(key, value.into());
130 self
131 }
132}
133
134#[must_use]
136pub fn sh_quote(word: &str) -> String {
137 format!("'{}'", word.replace('\'', "'\\''"))
138}
139
140#[must_use]
144pub fn fill(argv: &[String], vars: &Vars) -> Vec<String> {
145 argv.iter()
146 .map(|word| {
147 let mut w = word.clone();
148 for (k, v) in &vars.0 {
149 w = w.replace(&format!("{{{k}}}"), v);
150 }
151 w
152 })
153 .collect()
154}
155
156#[derive(Debug, Clone)]
158pub struct Ran {
159 pub stdout: String,
160}
161
162pub fn run(tool: &str, argv: &[String]) -> Result<Ran> {
169 let Some((bin, rest)) = argv.split_first() else {
170 bail!("{tool}: an empty verb");
171 };
172 let out = std::process::Command::new(bin)
173 .args(rest)
174 .stdin(std::process::Stdio::null())
175 .output()
176 .with_context(|| format!("{tool}: {bin} would not start"))?;
177 let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
178 if !out.status.success() {
179 let stderr = String::from_utf8_lossy(&out.stderr);
180 let said = stderr
181 .lines()
182 .chain(stdout.lines())
183 .map(str::trim)
184 .find(|l| !l.is_empty())
185 .unwrap_or("no output")
186 .to_string();
187 let verb: Vec<&str> = argv.iter().take(3).map(String::as_str).collect();
188 bail!("{tool}: `{}` exited {}: {said}", verb.join(" "), out.status);
189 }
190 Ok(Ran { stdout })
191}
192
193#[must_use]
196pub fn answers(argv: &[String]) -> bool {
197 let Some((bin, rest)) = argv.split_first() else {
198 return false;
199 };
200 std::process::Command::new(bin)
201 .args(rest)
202 .stdin(std::process::Stdio::null())
203 .stdout(std::process::Stdio::null())
204 .stderr(std::process::Stdio::null())
205 .status()
206 .is_ok_and(|s| s.success())
207}
208
209#[must_use]
211pub fn pane_from(stdout: &str, pointer: &str) -> Option<String> {
212 let v: serde_json::Value = serde_json::from_str(stdout.trim()).ok()?;
213 match v.pointer(pointer)? {
214 serde_json::Value::String(s) if !s.is_empty() => Some(s.clone()),
215 serde_json::Value::Number(n) => Some(n.to_string()),
216 _ => None,
217 }
218}
219
220#[must_use]
223pub fn available(declared: &[Tool], only: Option<&str>) -> Vec<Tool> {
224 ordered(declared)
225 .into_iter()
226 .filter(|t| only.is_none_or(|n| t.name == n))
227 .filter(|t| answers(&t.detect))
228 .collect()
229}
230
231pub fn open(tool: &Tool, vars: &Vars) -> Result<String> {
240 let mut refusals = Vec::new();
241 for argv in &tool.open {
242 match run(&tool.name, &fill(argv, vars)) {
243 Ok(ran) => {
244 let pane = tool
245 .pane_pointer
246 .as_deref()
247 .and_then(|p| pane_from(&ran.stdout, p))
248 .unwrap_or_default();
249 if tool.pane_pointer.is_some() && pane.is_empty() {
250 bail!(
251 "{}: open printed no pane at {}",
252 tool.name,
253 tool.pane_pointer.as_deref().unwrap_or("")
254 );
255 }
256 if !tool.run.is_empty() {
257 let vars = vars.clone().with("pane", pane.clone());
258 run(&tool.name, &fill(&tool.run, &vars))?;
259 }
260 return Ok(pane);
261 }
262 Err(e) => refusals.push(format!("{e:#}")),
263 }
264 }
265 if refusals.is_empty() {
266 bail!("{}: no open verb", tool.name);
267 }
268 bail!("{}", refusals.join("; "))
269}
270
271pub fn send(tool: &Tool, vars: &Vars) -> Result<()> {
279 let steps = |list: &[Vec<String>]| -> Result<()> {
280 for argv in list {
281 run(&tool.name, &fill(argv, vars))?;
282 }
283 Ok(())
284 };
285 match steps(&tool.prompt) {
286 Ok(()) if !tool.prompt.is_empty() => Ok(()),
287 first => {
288 if tool.type_line.is_empty() {
289 return first.and_then(|()| bail!("{}: no prompt verb", tool.name));
290 }
291 steps(&tool.type_line).map_err(|e| match first {
292 Err(f) => anyhow::anyhow!("{f:#}; then {e:#}"),
293 Ok(()) => e,
294 })
295 }
296 }
297}
298
299pub fn wait_ready(tool: &Tool, vars: &Vars) {
303 let budget = std::time::Duration::from_secs(tool.ready_s.unwrap_or(0));
304 if tool.ready.is_empty() {
305 std::thread::sleep(budget);
306 return;
307 }
308 let argv = fill(&tool.ready, vars);
309 let start = std::time::Instant::now();
310 while !answers(&argv) {
311 if start.elapsed() >= budget {
312 return;
313 }
314 std::thread::sleep(std::time::Duration::from_secs(1));
315 }
316}
317
318#[must_use]
321pub fn doctor_state(declared: &[Tool]) -> (bool, String) {
322 let here = available(declared, std::env::var("LJOS_PANE_TOOL").ok().as_deref());
323 match here.split_first() {
324 Some((first, [])) => (true, format!("{} opens persona panes", first.name)),
325 Some((first, rest)) => (
326 true,
327 format!(
328 "{} opens persona panes; {} also here",
329 first.name,
330 rest.iter().map(|t| t.name.as_str()).collect::<Vec<_>>().join(", ")
331 ),
332 ),
333 None => (
334 false,
335 format!(
336 "none of {} answers; a persona session needs one, or a [[tool]] table in harnesses.toml",
337 ordered(declared)
338 .iter()
339 .map(|t| t.name.as_str())
340 .collect::<Vec<_>>()
341 .join(", ")
342 ),
343 ),
344 }
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350
351 #[test]
352 fn the_shipped_shapes_parse_herdr_first() {
353 let tools = shipped();
354 let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
355 assert_eq!(names, ["herdr", "tmux"]);
356 let herdr = &tools[0];
357 assert_eq!(
358 herdr.pane_pointer.as_deref(),
359 Some("/result/root_pane/pane_id")
360 );
361 assert!(herdr.open[0].contains(&"--no-focus".to_string()));
362 assert!(
363 herdr.prompt[0].starts_with(&["herdr".into(), "agent".into(), "prompt".into()]),
364 "herdr's agent API, not the send verb it dropped"
365 );
366 assert!(herdr.ready.contains(&"--until".to_string()));
367 }
368
369 #[test]
370 fn a_declared_tool_replaces_the_shipped_one_of_its_name() {
371 let mine = Tool {
372 name: "tmux".into(),
373 detect: vec!["true".into()],
374 ..Tool::default()
375 };
376 let zellij = Tool {
377 name: "zellij".into(),
378 ..Tool::default()
379 };
380 let tools = ordered(&[zellij, mine.clone()]);
381 let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
382 assert_eq!(names, ["zellij", "tmux", "herdr"]);
383 assert_eq!(tools[1], mine);
384 }
385
386 #[test]
387 fn placeholders_fill_and_an_unknown_one_reaches_the_tool() {
388 let vars = Vars::default()
389 .with("home", "/s/personas/reviewer")
390 .with("name", "reviewer")
391 .with("script_q", sh_quote("/s/it's/pane.sh"));
392 let got = fill(
393 &[
394 "--cwd".into(),
395 "{home}".into(),
396 "LJOS_SEAT={name}".into(),
397 "sh {script_q}".into(),
398 "{nope}".into(),
399 ],
400 &vars,
401 );
402 assert_eq!(
403 got,
404 [
405 "--cwd",
406 "/s/personas/reviewer",
407 "LJOS_SEAT=reviewer",
408 "sh '/s/it'\\''s/pane.sh'",
409 "{nope}"
410 ]
411 );
412 }
413
414 #[test]
415 fn the_pane_is_read_off_the_json_open_printed() {
416 let out = r#"{"id":"cli:workspace:create","result":{"root_pane":{"pane_id":"w1:p2"}}}"#;
417 assert_eq!(
418 pane_from(out, "/result/root_pane/pane_id").as_deref(),
419 Some("w1:p2")
420 );
421 assert_eq!(pane_from("not json", "/result/root_pane/pane_id"), None);
422 assert_eq!(pane_from(out, "/result/tab/tab_id"), None);
423 }
424
425 #[test]
426 fn a_refusal_says_what_the_tool_said() {
427 let err = run(
428 "fake",
429 &[
430 "sh".into(),
431 "-c".into(),
432 "echo 'unknown option: --no-focus' >&2; exit 2".into(),
433 ],
434 )
435 .unwrap_err();
436 let said = format!("{err:#}");
437 assert!(said.contains("unknown option: --no-focus"), "{said}");
438 assert!(said.starts_with("fake: `sh -c"), "{said}");
439 }
440
441 #[test]
442 fn a_refused_prompt_falls_to_typing_the_line() {
443 let dir = tempfile::tempdir().unwrap();
444 let got = dir.path().join("typed");
445 let tool = Tool {
446 name: "fake".into(),
447 prompt: vec![vec!["false".into()]],
448 type_line: vec![vec![
449 "sh".into(),
450 "-c".into(),
451 format!("printf '%s' \"$0\" > {}", got.display()),
452 "{line}".into(),
453 ]],
454 ..Tool::default()
455 };
456 send(&tool, &Vars::default().with("line", "read the inbox")).unwrap();
457 assert_eq!(std::fs::read_to_string(&got).unwrap(), "read the inbox");
458 let refused = Tool {
459 name: "fake".into(),
460 prompt: vec![vec!["false".into()]],
461 ..Tool::default()
462 };
463 assert!(send(&refused, &Vars::default()).is_err());
464 }
465
466 #[test]
467 fn the_first_open_that_takes_wins_and_the_rest_are_reasons() {
468 let tool = Tool {
469 name: "fake".into(),
470 open: vec![
471 vec![
472 "sh".into(),
473 "-c".into(),
474 "echo 'no session' >&2; exit 1".into(),
475 ],
476 vec!["sh".into(), "-c".into(), "echo '{\"pane\":\"p7\"}'".into()],
477 ],
478 pane_pointer: Some("/pane".into()),
479 ..Tool::default()
480 };
481 assert_eq!(open(&tool, &Vars::default()).unwrap(), "p7");
482 let none = Tool {
483 name: "fake".into(),
484 open: vec![vec![
485 "sh".into(),
486 "-c".into(),
487 "echo 'refused' >&2; exit 3".into(),
488 ]],
489 ..Tool::default()
490 };
491 let err = format!("{:#}", open(&none, &Vars::default()).unwrap_err());
492 assert!(err.contains("refused"), "{err}");
493 }
494}