diff --git a/crates/rowbit-cli/src/demo.rs b/crates/rowbit-cli/src/demo.rs index dc7bcdf..ce1cb48 100644 --- a/crates/rowbit-cli/src/demo.rs +++ b/crates/rowbit-cli/src/demo.rs @@ -62,6 +62,9 @@ pub struct DemoArgs { /// Don't connect an agent client #[arg(long)] no_connect: bool, + /// Don't open the review page in the browser + #[arg(long)] + no_open: bool, } #[derive(clap::Subcommand)] @@ -273,7 +276,8 @@ pub async fn run(a: DemoArgs) -> anyhow::Result<()> { println!(); say("Rowbit is guarding the demo shop database."); say(&format!( - "Review page: {address} password: {password} name: anything" + "Review page: {} password: {password} name: anything", + crate::term::link(&address) )); println!(); match (&connected, &open) { @@ -347,6 +351,14 @@ pub async fn run(a: DemoArgs) -> anyhow::Result<()> { println!(); std::io::stdout().flush()?; + let open = !a.no_open + && std::io::stdin().is_terminal() + && std::io::stdout().is_terminal() + && crate::term::can_open_browser(); + if open { + say("Opening the review page in your browser (--no-open skips this)."); + println!(); + } std::env::set_current_dir(&dir)?; crate::serve( None, @@ -355,6 +367,7 @@ pub async fn run(a: DemoArgs) -> anyhow::Result<()> { config: dir.join("rowbit-server.yml"), }, &setup, + open, ) .await } diff --git a/crates/rowbit-cli/src/main.rs b/crates/rowbit-cli/src/main.rs index 4978764..51d45af 100644 --- a/crates/rowbit-cli/src/main.rs +++ b/crates/rowbit-cli/src/main.rs @@ -4,6 +4,7 @@ mod grants; mod place; mod review; mod setup; +mod term; use std::path::PathBuf; @@ -349,6 +350,7 @@ async fn serve( set_password: bool, server: &ServerArgs, setup: &Setup, + open_browser: bool, ) -> anyhow::Result<()> { let config = server.load()?; let listen = config.listen(listen); @@ -399,14 +401,24 @@ async fn serve( mcp: config.mcp_http.then_some(database), }; let address = address.trim_end_matches('/'); - eprintln!("rowbit: review page on {address}/"); + eprintln!( + "rowbit: review page on {}", + term::link_err(&format!("{address}/")) + ); if config.mcp_http { eprintln!("rowbit: agents over MCP at {address}/mcp, each with its own token"); } for n in &config.notify { eprintln!("rowbit: held changes are announced to {}", n.name); } - rowbit_web::serve(engine, web, listen).await?; + let page = format!("{address}/"); + rowbit_web::serve_then(engine, web, listen, move |_| { + // Bound: whatever answers is this server, not another left running. + if open_browser { + term::open_browser(&page); + } + }) + .await?; Ok(()) } @@ -788,7 +800,7 @@ async fn main() -> anyhow::Result<()> { set_password, server, setup, - } => serve(listen, set_password, &server, &setup).await?, + } => serve(listen, set_password, &server, &setup, false).await?, Command::Agents { command, policy, diff --git a/crates/rowbit-cli/src/setup.rs b/crates/rowbit-cli/src/setup.rs index 4939806..042118e 100644 --- a/crates/rowbit-cli/src/setup.rs +++ b/crates/rowbit-cli/src/setup.rs @@ -12,6 +12,7 @@ use rowbit_mysql::grants::{self, Account, Grant, Object}; use crate::connect::{self, Client, Env, Scope}; use crate::place; +use crate::term; /// What Rowbit's own user needs on each database, as in docs/privileges.md. const NEEDED: [&str; 6] = [ @@ -313,10 +314,12 @@ pub async fn run(a: SetupArgs) -> anyhow::Result<()> { } match &password { Some(p) => say(&format!( - "Review page: {address} password: {p} (shown this once)" + "Review page: {} password: {p} (shown this once)", + term::link(&address) )), None => say(&format!( - "Review page: {address} (password as set before)" + "Review page: {} (password as set before)", + term::link(&address) )), } for (client, scope) in &connected { @@ -343,8 +346,9 @@ pub async fn run(a: SetupArgs) -> anyhow::Result<()> { } say("Writes wait for you on the review page until you add rules to rowbit.yml."); say(&format!( - "Agents can read every table {} can. Limit what each one sees with scopes: https://ermag.github.io/rowbit/docs/spec/policy.html#scopes", - found.account + "Agents can read every table {} can. Limit what each one sees with scopes: {}", + found.account, + term::link("https://ermag.github.io/rowbit/docs/spec/policy.html#scopes") )); let dir_flag = if dir != place::home()?.join("default") { format!(" --dir {}", quoted(&dir)) diff --git a/crates/rowbit-cli/src/term.rs b/crates/rowbit-cli/src/term.rs new file mode 100644 index 0000000..981fa41 --- /dev/null +++ b/crates/rowbit-cli/src/term.rs @@ -0,0 +1,146 @@ +//! Links people can click in a terminal. Plain URLs depend on the +//! terminal spotting them, and some only open them on ⌘-click or not at +//! all; OSC 8 marks the text as a link outright. See "Links in the +//! terminal" in docs/spec/setup.md. + +use std::io::IsTerminal; + +/// `url` as a link where the terminal is known to show OSC 8 links, and +/// as it is otherwise (a pipe, a log, an unknown terminal). +pub fn link(url: &str) -> String { + link_if(url, std::io::stdout().is_terminal()) +} + +/// [`link`], for standard error. +pub fn link_err(url: &str) -> String { + link_if(url, std::io::stderr().is_terminal()) +} + +fn link_if(url: &str, terminal: bool) -> String { + if terminal && supports(|k| std::env::var(k).ok()) { + osc8(url, url) + } else { + url.to_owned() + } +} + +fn osc8(url: &str, text: &str) -> String { + // Control characters would end the sequence early. + let clean = |s: &str| s.chars().filter(|c| !c.is_control()).collect::(); + let (url, text) = (clean(url), clean(text)); + format!("\x1b]8;;{url}\x1b\\{text}\x1b]8;;\x1b\\") +} + +/// Whether the environment names a terminal that shows OSC 8 links. +/// `ROWBIT_HYPERLINKS=0` or `1` decides; otherwise a terminal on the list, +/// and never inside tmux or screen, which drop the sequence unless set up +/// to pass it on. +fn supports(var: impl Fn(&str) -> Option) -> bool { + match var("ROWBIT_HYPERLINKS").as_deref() { + Some("0") => return false, + Some("1") => return true, + _ => {} + } + if var("TMUX").is_some() || var("STY").is_some() { + return false; + } + if var("TERM").as_deref() == Some("dumb") { + return false; + } + let program = var("TERM_PROGRAM").unwrap_or_default(); + matches!( + program.as_str(), + "ghostty" | "iTerm.app" | "WezTerm" | "vscode" | "Hyper" | "rio" | "WarpTerminal" + ) || var("TERM").is_some_and(|t| t == "xterm-kitty" || t == "xterm-ghostty") + || var("KITTY_WINDOW_ID").is_some() + || var("WT_SESSION").is_some() + || var("VTE_VERSION") + .and_then(|v| v.parse::().ok()) + .is_some_and(|v| v >= 5000) +} + +/// Opens `url` in the default browser. Returns whether that started. +pub fn open_browser(url: &str) -> bool { + let opener = if cfg!(target_os = "macos") { + "open" + } else { + "xdg-open" + }; + std::process::Command::new(opener) + .arg(url) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .is_ok() +} + +/// Whether a browser can be opened here: not over SSH, and on Linux only +/// with a display. +pub fn can_open_browser() -> bool { + can_open(|k| std::env::var_os(k).is_some(), cfg!(target_os = "macos")) +} + +fn can_open(set: impl Fn(&str) -> bool, macos: bool) -> bool { + if set("SSH_CONNECTION") || set("SSH_TTY") { + return false; + } + macos || set("DISPLAY") || set("WAYLAND_DISPLAY") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn env<'a>(vars: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option + 'a { + move |k| { + vars.iter() + .find(|(n, _)| *n == k) + .map(|(_, v)| (*v).to_owned()) + } + } + + #[test] + fn known_terminals_get_links() { + assert!(supports(env(&[("TERM_PROGRAM", "ghostty")]))); + assert!(supports(env(&[("TERM_PROGRAM", "iTerm.app")]))); + assert!(supports(env(&[("TERM", "xterm-kitty")]))); + assert!(supports(env(&[("VTE_VERSION", "7600")]))); + assert!(!supports(env(&[("TERM_PROGRAM", "Apple_Terminal")]))); + assert!(!supports(env(&[]))); + assert!(!supports(env(&[ + ("TERM_PROGRAM", "ghostty"), + ("TMUX", "/tmp/x") + ]))); + assert!(!supports(env(&[ + ("TERM_PROGRAM", "ghostty"), + ("ROWBIT_HYPERLINKS", "0") + ]))); + assert!(supports(env(&[("ROWBIT_HYPERLINKS", "1")]))); + } + + #[test] + fn browsers_open_only_where_someone_can_see_them() { + let set = |vars: &'static [&'static str]| move |k: &str| vars.contains(&k); + assert!(can_open(set(&[]), true)); + assert!(!can_open(set(&["SSH_CONNECTION"]), true)); + assert!(!can_open(set(&[]), false)); + assert!(can_open(set(&["WAYLAND_DISPLAY"]), false)); + } + + #[test] + fn control_characters_stay_out_of_links() { + assert_eq!( + osc8("http://x/\x1b]8;;evil\x07", "a\x1bb"), + "\x1b]8;;http://x/]8;;evil\x1b\\ab\x1b]8;;\x1b\\" + ); + } + + #[test] + fn a_link_shows_its_address() { + assert_eq!( + osc8("http://127.0.0.1:8080/", "http://127.0.0.1:8080/"), + "\x1b]8;;http://127.0.0.1:8080/\x1b\\http://127.0.0.1:8080/\x1b]8;;\x1b\\" + ); + } +} diff --git a/crates/rowbit-mcp/src/lib.rs b/crates/rowbit-mcp/src/lib.rs index 6d71542..09ec37b 100644 --- a/crates/rowbit-mcp/src/lib.rs +++ b/crates/rowbit-mcp/src/lib.rs @@ -29,7 +29,9 @@ send one INSERT, UPDATE or DELETE (or a schema change) to `propose_write`. \ Rowbit previews it without running it and checks it against the team's \ rules: it may be applied at once, held for a person, or rejected with a \ reason. You cannot approve changes; a person does. When a change is held, \ -give the person its `review_url`, say what it changes, and stop: don't \ +give the person its `review_url` as a link whose text is the URL \ +itself, as in , so they can click it and \ +see where it goes; say what it changes, and stop: don't \ retry the write in another form or split it to get under a rule. Follow \ it with `change_status`."; diff --git a/crates/rowbit-web/src/lib.rs b/crates/rowbit-web/src/lib.rs index b33e18d..e3d8ee5 100644 --- a/crates/rowbit-web/src/lib.rs +++ b/crates/rowbit-web/src/lib.rs @@ -147,13 +147,25 @@ pub fn router(engine: Arc, web: Web) -> Router { /// Serves the page on `addr` until the process stops. `web.hosts` gains /// the address it listens on. -pub async fn serve(engine: Arc, mut web: Web, addr: SocketAddr) -> std::io::Result<()> { +pub async fn serve(engine: Arc, web: Web, addr: SocketAddr) -> std::io::Result<()> { + serve_then(engine, web, addr, |_| {}).await +} + +/// [`serve`], calling `listening` once the address is bound, before the +/// first request: for opening a browser on this server and no other. +pub async fn serve_then( + engine: Arc, + mut web: Web, + addr: SocketAddr, + listening: impl FnOnce(SocketAddr), +) -> std::io::Result<()> { let listener = tokio::net::TcpListener::bind(addr).await?; let local = listener.local_addr()?; web.hosts.push(local.to_string()); if local.ip().is_loopback() { web.hosts.push(format!("localhost:{}", local.port())); } + listening(local); axum::serve(listener, router(engine, web)).await } diff --git a/docs/spec/setup.md b/docs/spec/setup.md index 4e38102..bb290c2 100644 --- a/docs/spec/setup.md +++ b/docs/spec/setup.md @@ -188,7 +188,13 @@ The home page and the README also offer install links for VS Code (`https://insi ## Review links -So that a held write ends with the person knowing where to look, `propose_write` and `change_status` return `review_url` for a held change: `/changes/`, or `http:///changes/` without a `public_url`, the same link notifications use. `rowbit serve` records that address in the state file when it starts, so `rowbit mcp` over stdio, which shares the file, links to the same place; before `rowbit serve` has ever run, the link uses `http://127.0.0.1:8080`. The server's instructions tell the agent to give the person that link and not to retry the write in another form. The link reveals nothing: the page needs sign-in, and the agent could reach its address already. Opened signed out, it goes through sign-in (carrying only the change's number) and then to that change. See [Agent tools](change-lifecycle.md#agent-tools-mcp). +So that a held write ends with the person knowing where to look, `propose_write` and `change_status` return `review_url` for a held change: `/changes/`, or `http:///changes/` without a `public_url`, the same link notifications use. `rowbit serve` records that address in the state file when it starts, so `rowbit mcp` over stdio, which shares the file, links to the same place; before `rowbit serve` has ever run, the link uses `http://127.0.0.1:8080`. The server's instructions tell the agent to give the person that link, as a link whose text is the URL itself (``), so clients that render Markdown make it clickable while the person still sees where it goes (a link labelled "Review change 4" could hide a look-alike page asking for the review password), and not to retry the write in another form. The link reveals nothing: the page needs sign-in, and the agent could reach its address already. Opened signed out, it goes through sign-in (carrying only the change's number) and then to that change. See [Agent tools](change-lifecycle.md#agent-tools-mcp). + +## Links in the terminal + +A plain URL in a terminal is only clickable if the terminal spots it, and some open it only on ⌘-click or Ctrl-click. So where Rowbit prints an address for a person (the review page in `setup`, `demo` and `serve`, the docs link in `setup`), it marks it as a link with OSC 8, in terminals known to show those: Ghostty, iTerm2, WezTerm, kitty, VS Code's terminal, Windows Terminal, Hyper, Rio, Warp and VTE-based ones (GNOME Terminal and others). It prints the address as it is when the output isn't a terminal, in Terminal.app, which doesn't support them, and inside tmux or screen, which drop them unless set up to pass them on. `ROWBIT_HYPERLINKS=1` or `0` overrides the choice. The link's text is the address itself, so copying still works. + +`rowbit demo` also opens the review page in the browser, when run in a terminal: once its own server has bound the address, so never another server left on that port. It doesn't over SSH or, on Linux, without a display; `--no-open` skips it. Link targets have control characters removed, so a `public_url` can't end the sequence early. ## The skill diff --git a/plugins/rowbit/skills/rowbit/SKILL.md b/plugins/rowbit/skills/rowbit/SKILL.md index 00c2e0f..f153de8 100644 --- a/plugins/rowbit/skills/rowbit/SKILL.md +++ b/plugins/rowbit/skills/rowbit/SKILL.md @@ -31,7 +31,7 @@ Send one `INSERT`, `UPDATE` or `DELETE` (or one schema change) to `propose_write The result has a `state`: - `applied`: done. Tell the person what changed (table, row count). -- `held`: waiting for a person. **Give them the `review_url` from the result**, say in a sentence what the write changes and why, and stop. Don't wait in a loop; they may take a while. If they ask, check it later with `change_status`. +- `held`: waiting for a person. **Give them the `review_url` from the result as a link that shows the URL itself** (``, never a link with other text), so they can click it and see where it goes, say in a sentence what the write changes and why, and stop. Don't wait in a loop; they may take a while. If they ask, check it later with `change_status`. - `rejected`: refused by a rule or by a person, with a `reason`. Tell the person the reason. Don't try again in another form. - `failed`: Rowbit couldn't preview or apply it; the reason says why. Fix a genuine mistake (a typo, a wrong column) once; otherwise report it. - `approved` means a person approved it and Rowbit is applying it; `undone` means a person took an applied change back.