Skip to main content

ljos_cli/
tools.rs

1//! The tools a runner lives in: terminal multiplexers and agent runtimes. A
2//! tool opens a pane the person can watch and hands the runner a line, and
3//! its `alive` verb says whether the pane is still there.
4//!
5//! `[[tool]]` tables in harnesses.toml declare them beside the runners.
6//! herdr and tmux ship as shapes ([`SHIPPED_TOOLS`]); a table of the same
7//! name replaces a shipped one. Every verb is an argv with
8//! `{placeholders}`. A tool the seat has never heard of is one table. A
9//! tool that refuses is named with its own words and the next one is
10//! tried, so a shape gone stale against its tool shows in what `hand`
11//! returns.
12
13use std::collections::BTreeMap;
14
15use anyhow::{bail, Context, Result};
16
17/// One tool, as harnesses.toml declares it.
18#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
19pub struct Tool {
20    pub name: String,
21    /// Exits 0 when the tool can open a pane on this machine now.
22    #[serde(default)]
23    pub detect: Vec<String>,
24    /// Ways to open the persona's pane, tried in order until one exits 0.
25    /// The pane runs `{script}`, which changes to `{home}` first. With
26    /// `run` set it starts as a shell, and `run` types the script into it.
27    #[serde(default)]
28    pub open: Vec<Vec<String>>,
29    /// Where `open`'s JSON output names the new pane, as a JSON pointer.
30    #[serde(default)]
31    pub pane_pointer: Option<String>,
32    /// Types the pane script into the pane `open` made, for a tool whose
33    /// panes start as shells.
34    #[serde(default)]
35    pub run: Vec<String>,
36    /// Starts the pane script again in a pane whose runner exited.
37    #[serde(default)]
38    pub respawn: Vec<String>,
39    /// Hands the runner one line, through the tool's agent interface where
40    /// it has one and as typed keys where it does not; each argv runs in
41    /// order.
42    #[serde(default)]
43    pub prompt: Vec<Vec<String>>,
44    /// Types the line raw when `prompt` is refused or absent, as for a
45    /// runner the tool does not recognise as an agent.
46    #[serde(default)]
47    pub type_line: Vec<Vec<String>>,
48    /// Exits 0 while the pane is there.
49    #[serde(default)]
50    pub alive: Vec<String>,
51    /// Exits 0 once a fresh runner takes input; retried until `ready_s`.
52    #[serde(default)]
53    pub ready: Vec<String>,
54    /// Seconds to wait for `ready`, or to sleep when there is none.
55    #[serde(default)]
56    pub ready_s: Option<u64>,
57}
58
59/// The shapes the seat ships, in the order they are tried: herdr's agent
60/// API when its server answers, else tmux.
61pub 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/// The shipped shapes, parsed.
102#[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/// The tools in the order they are tried: the file's own, then each
110/// shipped shape the file did not replace.
111#[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/// What fills a verb's placeholders.
123#[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/// A word quoted for `sh`.
135#[must_use]
136pub fn sh_quote(word: &str) -> String {
137    format!("'{}'", word.replace('\'', "'\\''"))
138}
139
140/// `argv` with every `{key}` replaced from `vars`. A placeholder with no
141/// value is left as written, so a typo reaches the tool and its refusal
142/// names it.
143#[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/// What a tool printed when it ran a verb.
157#[derive(Debug, Clone)]
158pub struct Ran {
159    pub stdout: String,
160}
161
162/// Run one filled argv. A refusal carries the tool's first stderr line, or
163/// its first stdout line when stderr is empty, and the argv's verb words.
164///
165/// # Errors
166///
167/// The verb is empty, the program will not start, or it exited non-zero.
168pub 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/// Whether a verb that answers by its exit status says yes. An empty verb
194/// says no.
195#[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/// The pane `open` named, read through `pointer` from its JSON output.
210#[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/// The tools that answer here, in the order they would be tried. A named
221/// tool (`LJOS_PANE_TOOL`) narrows the list to that one.
222#[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
231/// Open a pane with `tool`: the first `open` that exits 0, then `run` when
232/// the pane starts as a shell. Returns the pane the tool named, or the
233/// empty string for a tool whose verbs name the pane themselves.
234///
235/// # Errors
236///
237/// There is no `open` verb, every `open` refused, the pane pointer found no
238/// pane, or `run` refused.
239pub 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
271/// Hand the runner one line: `prompt`, and `type_line` when `prompt` is
272/// refused or absent.
273///
274/// # Errors
275///
276/// `prompt` refused and there is no `type_line`, `type_line` refused, or
277/// the tool has neither.
278pub 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
299/// Wait until a fresh runner takes input: `ready` retried each second until
300/// `ready_s` runs out, or a plain sleep of `ready_s` for a tool with no
301/// `ready`.
302pub 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/// The doctor's row on panes: which tool would open a persona's pane, and
319/// which others answer.
320#[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}