diff --git a/crates/bashkit/src/builtins/man.rs b/crates/bashkit/src/builtins/man.rs new file mode 100644 index 000000000..5462bd7bc --- /dev/null +++ b/crates/bashkit/src/builtins/man.rs @@ -0,0 +1,175 @@ +//! man builtin - show a command's usage as a manual page. +//! +//! Decisions: +//! - bashkit ships no man pages. `man CMD` shows the command's own `--help` +//! text, which every bashkit program builtin provides, so the page always +//! matches what the command accepts. +//! - `--help` is only run for registered program builtins. Shell builtins +//! (`cd`, `exit`, `read`...) use `help CMD` instead: running `exit --help` +//! would exit the shell, and functions or aliases never get `--help`. +//! - Inside a terminal session the page opens in `less`; everywhere else it +//! prints, like `man CMD | cat`. + +use async_trait::async_trait; + +use super::{Builtin, Context, ExecutionPlan, PlanDriver, PlanStep, SubCommand}; +use crate::error::Result; +use crate::interpreter::ExecResult; + +/// man builtin. +pub struct Man; + +const USAGE: &str = "Usage: man [SECTION] COMMAND\nShow COMMAND's usage as a manual page.\n\nInside a bashkit::terminal::Terminal session the page opens in less.\n\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n"; + +/// Exit status of real man when no page exists. +const NO_ENTRY: i32 = 16; + +#[async_trait] +impl Builtin for Man { + async fn execute(&self, ctx: Context<'_>) -> Result { + if let Some(r) = super::check_help_version(ctx.args, USAGE, Some("man (bashkit) 0.1")) { + return Ok(r); + } + // Reached only when there is no plan: nothing to look up. + Ok(ExecResult::err( + "What manual page do you want?\nFor example, try 'man man'.\n", + 1, + )) + } + + async fn execution_plan(&self, ctx: &Context<'_>) -> Result> { + if ctx.args.iter().any(|a| a == "--help" || a == "--version") { + return Ok(None); + } + // `man 1 ls`: skip a numeric section. + let Some(name) = ctx + .args + .iter() + .find(|a| !a.starts_with('-') && !a.chars().all(|c| c.is_ascii_digit())) + else { + return Ok(None); + }; + let program = !super::BASH_BUILTIN_NAMES.contains(&name.as_str()) + && ctx.shell.as_ref().is_some_and(|s| s.has_builtin(name)); + #[cfg(feature = "terminal")] + let tty = super::pager::terminal(ctx)?; + Ok(Some(ExecutionPlan::Driver(Box::new(ManRun { + name: name.clone(), + step: if program { Step::Help } else { Step::ShellHelp }, + #[cfg(feature = "terminal")] + tty, + })))) + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Step { + /// Next: run `NAME --help`. + Help, + /// Next: run `help NAME`. + ShellHelp, + Done, +} + +struct ManRun { + name: String, + step: Step, + #[cfg(feature = "terminal")] + tty: Option, +} + +impl ManRun { + fn run(&mut self, name: &str, args: Vec, next: Step) -> PlanStep { + self.step = next; + PlanStep::Run { + command: SubCommand { + name: name.to_string(), + args, + stdin: None, + assignments: Vec::new(), + }, + cwd: None, + } + } + + async fn show(&self, text: &str) -> Result { + let page = format!("{}(1)\n\n{}", self.name.to_uppercase(), text); + #[cfg(feature = "terminal")] + if let Some(tty) = &self.tty { + let title = format!("Manual page {}(1)", self.name); + let result = + super::pager::page_text(tty, &page, title, super::pager::Pager::Less, false) + .await?; + return Ok(PlanStep::Done(result)); + } + Ok(PlanStep::Done(ExecResult::ok(page))) + } +} + +/// Usable help text from a sub-command result, if any. +fn help_text(result: &ExecResult) -> Option { + if result.exit_code != 0 { + return None; + } + let text = result.stdout.text_lossy().into_owned(); + (!text.trim().is_empty()).then_some(text) +} + +#[async_trait] +impl PlanDriver for ManRun { + async fn next(&mut self, last: Option) -> Result { + if let Some(text) = last.as_ref().and_then(help_text) { + return self.show(&text).await; + } + let name = self.name.clone(); + Ok(match self.step { + Step::Help => self.run(&name, vec!["--help".into()], Step::ShellHelp), + Step::ShellHelp => self.run("help", vec![name], Step::Done), + Step::Done => PlanStep::Done(ExecResult::err( + format!("No manual entry for {}\n", self.name), + NO_ENTRY, + )), + }) + } +} + +#[cfg(test)] +mod tests { + use crate::Bash; + + async fn run(script: &str) -> crate::ExecResult { + Bash::new().exec(script).await.unwrap() + } + + #[tokio::test] + async fn shows_program_help_as_a_page() { + let r = run("man grep").await; + assert_eq!(r.exit_code, 0); + assert!( + r.stdout.starts_with("GREP(1)\n\nUsage: grep"), + "{}", + r.stdout + ); + let r = run("man 1 ls").await; + assert!(r.stdout.starts_with("LS(1)\n"), "{}", r.stdout); + } + + #[tokio::test] + async fn shell_builtins_use_help_not_dash_dash_help() { + let r = run("man exit; echo still-here").await; + assert!(r.stdout.contains("still-here"), "{}", r.stdout); + assert!(r.stdout.starts_with("EXIT(1)\n"), "{}", r.stdout); + } + + #[tokio::test] + async fn missing_page_and_usage_errors() { + let r = run("man no-such-cmd").await; + assert_eq!(r.exit_code, 16); + assert_eq!(r.stderr, "No manual entry for no-such-cmd\n"); + let r = run("f() { echo ran; }; man f").await; + assert!(!r.stdout.contains("ran"), "{}", r.stdout); + let r = run("man").await; + assert_eq!(r.exit_code, 1); + assert!(run("man --help").await.stdout.starts_with("Usage: man")); + } +} diff --git a/crates/bashkit/src/builtins/mod.rs b/crates/bashkit/src/builtins/mod.rs index c8ee7ed70..521a31c80 100644 --- a/crates/bashkit/src/builtins/mod.rs +++ b/crates/bashkit/src/builtins/mod.rs @@ -80,6 +80,7 @@ mod jq; mod json; mod log; mod ls; +mod man; mod mapfile; mod mkfifo; mod navigation; @@ -207,6 +208,7 @@ pub use json::Json; pub use log::Log; pub use ls::{Ls, Rmdir}; pub(crate) use ls::{fnmatch, glob_match}; +pub use man::Man; pub use mapfile::Mapfile; pub use mkfifo::Mkfifo; pub use navigation::{Cd, Pwd}; diff --git a/crates/bashkit/src/builtins/pager.rs b/crates/bashkit/src/builtins/pager.rs index fd6efd0fb..1cca7420c 100644 --- a/crates/bashkit/src/builtins/pager.rs +++ b/crates/bashkit/src/builtins/pager.rs @@ -102,8 +102,18 @@ pub(crate) async fn page(ctx: &Context<'_>, tty: &Tty, kind: Pager) -> Result Result { + let mut view = View::new(text, title, tty.size().cols as usize); let page_rows = (tty.size().rows as usize).saturating_sub(1).max(1); if quit_if_one_screen && view.rows.len() <= page_rows { for row in &view.rows { diff --git a/crates/bashkit/src/builtins/pipeline.rs b/crates/bashkit/src/builtins/pipeline.rs index c17a911db..a01c03d64 100644 --- a/crates/bashkit/src/builtins/pipeline.rs +++ b/crates/bashkit/src/builtins/pipeline.rs @@ -347,80 +347,226 @@ impl Builtin for Tee { /// The watch builtin - execute a program periodically. /// -/// Usage: watch [-n SECONDS] COMMAND +/// Usage: watch [-n SECONDS] [-t] [-g] [-x] COMMAND /// /// Options: /// -n SECONDS Specify update interval (default: 2) +/// -t No title line +/// -g Exit when the output changes (`--chgexit`) +/// -x Run COMMAND's words directly instead of `bash -c` /// -/// Note: In Bashkit's virtual environment, watch runs the command once -/// and returns, since continuous execution isn't supported. +/// Inside a terminal session, watch reruns the command every interval on the +/// alternate screen until Ctrl-C (or a change with `-g`); each run and the +/// interval count against the execution timeout like `sleep`. Elsewhere it +/// prints a one-line notice, since there is no screen to refresh. pub struct Watch; +const WATCH_USAGE: &str = "Usage: watch [OPTION]... COMMAND\nExecute a program periodically, showing output.\n\n -n SECONDS\tupdate interval (default: 2)\n -t\t\tno title line\n -g\t\texit when the output changes\n -x\t\tpass COMMAND's words to exec instead of bash -c\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n"; + +struct WatchArgs { + interval: f64, + title: bool, + chgexit: bool, + exec: bool, + command: Vec, +} + +fn parse_watch(args: &[String]) -> std::result::Result { + let mut parsed = WatchArgs { + interval: 2.0, + title: true, + chgexit: false, + exec: false, + command: Vec::new(), + }; + let mut i = 0; + while i < args.len() { + let arg = &args[i]; + if arg == "-n" || arg == "--interval" { + i += 1; + let Some(value) = args.get(i) else { + return Err("watch: option requires an argument -- 'n'\n".to_string()); + }; + match value.parse::() { + // THREAT[TM-DOS-119]: a floor keeps `-n 0` from spinning. + Ok(n) if n > 0.0 && n.is_finite() => parsed.interval = n.max(0.1), + _ => { + return Err(format!("watch: invalid interval '{value}'\n")); + } + } + } else if arg == "-t" || arg == "--no-title" { + parsed.title = false; + } else if arg == "-g" || arg == "--chgexit" { + parsed.chgexit = true; + } else if arg == "-x" || arg == "--exec" { + parsed.exec = true; + } else if arg.starts_with('-') && arg != "-" { + // Skip other options for compatibility + } else { + parsed.command = args[i..].to_vec(); + return Ok(parsed); + } + i += 1; + } + Err("watch: no command specified\n".to_string()) +} + #[async_trait] impl Builtin for Watch { async fn execute(&self, ctx: Context<'_>) -> Result { - if let Some(r) = super::check_help_version( - ctx.args, - "Usage: watch [OPTION]... COMMAND\nExecute a program periodically, showing output.\n\n -n SECONDS\tupdate interval (default: 2)\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n", - Some("watch (bashkit) 0.1"), - ) { + if let Some(r) = + super::check_help_version(ctx.args, WATCH_USAGE, Some("watch (bashkit) 0.1")) + { return Ok(r); } - let mut _interval: f64 = 2.0; - let mut command_start: Option = None; - - let mut i = 0; - while i < ctx.args.len() { - let arg = &ctx.args[i]; - if arg == "-n" { - i += 1; - if i >= ctx.args.len() { - return Ok(ExecResult::err( - "watch: option requires an argument -- 'n'\n".to_string(), - 1, - )); - } - match ctx.args[i].parse::() { - Ok(n) if n > 0.0 => _interval = n, - _ => { - return Ok(ExecResult::err( - format!("watch: invalid interval '{}'\n", ctx.args[i]), - 1, - )); - } - } - } else if arg.starts_with('-') && arg != "-" { - // Skip other options for compatibility - } else { - command_start = Some(i); - break; - } - i += 1; - } - - let start = match command_start { - Some(s) => s, - None => { - return Ok(ExecResult::err( - "watch: no command specified\n".to_string(), - 1, - )); - } + let parsed = match parse_watch(ctx.args) { + Ok(p) => p, + Err(msg) => return Ok(ExecResult::err(msg, 1)), }; - - let command: Vec<_> = ctx.args[start..].iter().collect(); let output = format!( - "Every {:.1}s: {}\n\n(watch: continuous execution not supported in virtual mode)\n", - _interval, - command - .iter() - .map(|s| s.as_str()) - .collect::>() - .join(" ") + "Every {:.1}s: {}\n\n(watch: continuous execution needs a terminal session)\n", + parsed.interval, + parsed.command.join(" ") ); Ok(ExecResult::ok(output)) } + + #[cfg(feature = "terminal")] + async fn execution_plan(&self, ctx: &Context<'_>) -> Result> { + if ctx.args.iter().any(|a| a == "--help" || a == "--version") { + return Ok(None); + } + let Some(tty) = super::pager::terminal(ctx)? else { + return Ok(None); + }; + let Ok(parsed) = parse_watch(ctx.args) else { + return Ok(None); + }; + Ok(Some(ExecutionPlan::Driver(Box::new(WatchRun { + tty, + args: parsed, + first: None, + on_screen: false, + })))) + } +} + +/// Reruns the command on the alternate screen. Dropped on Ctrl-C (the +/// interpreter future is dropped), which restores the primary screen. +#[cfg(feature = "terminal")] +struct WatchRun { + tty: crate::terminal::Tty, + args: WatchArgs, + /// Output of the first run, for `-g`. + first: Option, + on_screen: bool, +} + +#[cfg(feature = "terminal")] +impl WatchRun { + fn command(&self) -> super::PlanStep { + let (name, args) = if self.args.exec { + ( + self.args.command[0].clone(), + self.args.command[1..].to_vec(), + ) + } else { + ( + "bash".to_string(), + vec!["-c".to_string(), self.args.command.join(" ")], + ) + }; + super::PlanStep::Run { + command: SubCommand { + name, + args, + stdin: None, + assignments: Vec::new(), + }, + cwd: None, + } + } + + fn render(&self, output: &str) { + let size = self.tty.size(); + let cols = usize::from(size.cols); + let mut rows = usize::from(size.rows); + let mut screen = String::from("\x1b[H\x1b[2J"); + if self.args.title { + let title = format!( + "Every {:.1}s: {}", + self.args.interval, + self.args.command.join(" ") + ); + screen.extend(title.chars().take(cols)); + screen.push_str("\r\n\r\n"); + rows = rows.saturating_sub(2); + } + let lines: Vec = output + .lines() + .take(rows) + .map(|l| caret_notation(l).chars().take(cols).collect()) + .collect(); + screen.push_str(&lines.join("\r\n")); + self.tty.write(screen.as_bytes()); + } +} + +/// Control characters as `^X` (tabs kept, `\r` dropped), so command output +/// cannot move the cursor or inject escape sequences into the host terminal. +#[cfg(feature = "terminal")] +fn caret_notation(line: &str) -> String { + let mut out = String::with_capacity(line.len()); + for c in line.chars() { + match c { + '\r' => {} + '\t' => out.push(' '), + c if (c as u32) < 0x20 || c == '\x7f' => { + out.push('^'); + out.push(((c as u8) ^ 0x40) as char); + } + c => out.push(c), + } + } + out +} + +#[cfg(feature = "terminal")] +impl Drop for WatchRun { + fn drop(&mut self) { + if self.on_screen { + self.tty.write(b"\x1b[?1049l"); + } + } +} + +#[cfg(feature = "terminal")] +#[async_trait] +impl super::PlanDriver for WatchRun { + async fn next(&mut self, last: Option) -> Result { + let Some(result) = last else { + self.tty.write(b"\x1b[?1049h"); + self.on_screen = true; + return Ok(self.command()); + }; + let mut output = result.stdout.text_lossy().into_owned(); + output.push_str(&result.stderr.text_lossy()); + self.render(&output); + if self.args.chgexit { + match &self.first { + None => self.first = Some(output), + Some(first) if *first != output => { + self.tty.write(b"\x1b[?1049l"); + self.on_screen = false; + return Ok(super::PlanStep::Done(ExecResult::ok(""))); + } + Some(_) => {} + } + } + crate::time_compat::sleep(std::time::Duration::from_secs_f64(self.args.interval)).await; + Ok(self.command()) + } } #[cfg(test)] mod tests { diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 7eb0051c8..d79116513 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -636,7 +636,7 @@ fn unavailable_command_hint(name: &str) -> Option<&'static str> { "vi" | "vim" | "nano" | "emacs" => { Some("Interactive editors are not available. Use echo/printf/cat to write files.") } - "man" | "info" => Some("Manual pages are not available in the sandbox."), + "info" => Some("Info pages are not available; try `man CMD` or `CMD --help`."), _ => None, } } @@ -1529,6 +1529,7 @@ impl Interpreter { "shuf" => Shuf, // File inspection "less" => Less, + "man" => Man, "file" => File, "stat" => Stat, // Binary / encoding diff --git a/crates/bashkit/src/terminal/mod.rs b/crates/bashkit/src/terminal/mod.rs index e0b00cd8f..46d88c215 100644 --- a/crates/bashkit/src/terminal/mod.rs +++ b/crates/bashkit/src/terminal/mod.rs @@ -1106,6 +1106,75 @@ mod tests { ); } + #[tokio::test] + async fn watch_refreshes_until_ctrl_c() { + let mut term = Terminal::new(Bash::builder()); + term.send("printf '0\\033[2J\\n' > /tmp/n; watch -n 0.1 'cat /tmp/n'\r"); + let _ = tokio::time::timeout(std::time::Duration::from_millis(150), term.run_until_idle()) + .await; + assert!(term.is_alternate_screen()); + let text = term.screen_text(); + // Escape sequences in the output show as text, not terminal control. + assert!( + text.starts_with("Every 0.1s: cat /tmp/n\n\n0^[[2J"), + "{text}" + ); + // Change the file behind watch's back via the VFS; next tick shows it. + term.fs() + .write_file("/tmp/n".as_ref(), b"7\n") + .await + .unwrap(); + let _ = tokio::time::timeout(std::time::Duration::from_millis(300), term.run_until_idle()) + .await; + assert!( + term.screen_text().ends_with("\n7"), + "{}", + term.screen_text() + ); + assert_eq!(run(&mut term, "\x03").await, TerminalStatus::Idle); + assert!(!term.is_alternate_screen()); + assert!( + term.screen_text().ends_with("^C\n$"), + "{}", + term.screen_text() + ); + } + + #[tokio::test] + async fn watch_chgexit_returns_on_change() { + let mut term = Terminal::new(Bash::builder()); + term.send("echo a > /tmp/g; watch -g -n 0.1 cat /tmp/g; echo changed\r"); + let _ = tokio::time::timeout(std::time::Duration::from_millis(150), term.run_until_idle()) + .await; + term.fs() + .write_file("/tmp/g".as_ref(), b"b\n") + .await + .unwrap(); + let status = + tokio::time::timeout(std::time::Duration::from_secs(5), term.run_until_idle()).await; + assert_eq!(status, Ok(TerminalStatus::Idle)); + assert!(!term.is_alternate_screen()); + assert!( + term.screen_text().ends_with("changed\n$"), + "{}", + term.screen_text() + ); + } + + #[tokio::test] + async fn man_opens_in_less() { + let mut term = Terminal::new(Bash::builder()); + run(&mut term, "man grep\r").await; + assert!(term.is_alternate_screen()); + assert!( + term.screen_text().starts_with("GREP(1)"), + "{}", + term.screen_text() + ); + run(&mut term, "q").await; + assert!(!term.is_alternate_screen()); + } + #[tokio::test] async fn prompt_and_command_output_render_on_screen() { let mut term = Terminal::new(Bash::builder()); diff --git a/docs/terminal.md b/docs/terminal.md index ff7d95c05..0ff255151 100644 --- a/docs/terminal.md +++ b/docs/terminal.md @@ -296,6 +296,14 @@ a pipe (`git log | less`, `seq 1 1000 | more`). for the next page, Enter for the next line, `q` to stop. Input that fits on one screen is printed directly. +`man CMD` shows a command's `--help` text as a manual page, in `less` inside a +session (plain output elsewhere). Shell builtins such as `cd` use `help CMD`. + +`watch [-n SECS] [-t] [-g] [-x] COMMAND` reruns the command on the alternate +screen until Ctrl-C, or until its output changes with `-g`. Each run and each +interval count against the execution timeout, like `sleep`; outside a session +it prints a notice instead of looping. + Control characters in content show in caret notation (`^[`), so a file cannot send escape sequences to your terminal. diff --git a/knowledge/integrations/in-process-terminal.md b/knowledge/integrations/in-process-terminal.md index 71f008657..16e82b2c4 100644 --- a/knowledge/integrations/in-process-terminal.md +++ b/knowledge/integrations/in-process-terminal.md @@ -200,6 +200,26 @@ short input directly. Key decoding and the raw/alternate-screen guard are shared with `vi` in `terminal/keys.rs`. Pagers cannot tell whether stdout is redirected (L-TERM-004). +## man / watch + +`man` (`builtins/man.rs`, always registered) is a `PlanDriver`: it runs +`CMD --help` for registered program builtins, `help CMD` for bash builtins +(`exit --help` would exit the shell; functions never run), and shows the +first non-empty successful text, in `less` via `pager::page_text` when a +`Tty` is present. No page: `No manual entry for CMD`, status 16. Decision: +no man page corpus; `--help` is the one source of truth per command. + +`watch` (`builtins/pipeline.rs`) stays a one-shot notice outside a terminal. +With a `Tty` it returns a `PlanDriver` that enters the alternate screen +(cooked mode, so Ctrl-C still interrupts), runs `bash -c CMD` (or argv with +`-x`), redraws title + output clipped to the screen with control chars in +caret notation, sleeps the interval (floor 0.1 s) and repeats. Ctrl-C drops +the driver, whose `Drop` leaves the alternate screen. `-g` returns once the +output differs from the first run, which gives agents a "wait until changed" +primitive. Runs and sleeps count against the execution timeout (TM-DOS-120 +only exempts input waits). `top` is not provided: there is no process table +worth showing (background jobs run synchronously). + ## Tests - Unit: `terminal::tests` (line discipline, prompts, resize, exit, timeout diff --git a/knowledge/status/builtins.json b/knowledge/status/builtins.json index d3e0514ae..d5fb88139 100644 --- a/knowledge/status/builtins.json +++ b/knowledge/status/builtins.json @@ -373,6 +373,10 @@ "feature": null, "name": "ls" }, + { + "feature": null, + "name": "man" + }, { "feature": null, "name": "mapfile" @@ -786,5 +790,5 @@ "name": "zip" } ], - "count": 196 + "count": 197 }