From 73de161a09df3231f0691545474cb2cbae56c9c0 Mon Sep 17 00:00:00 2001 From: "J. Champagne" Date: Fri, 13 Mar 2026 21:59:10 -0400 Subject: [PATCH] feat(bin): Add CLI binary, clap args, shell hook execution. --- crates/pikl/src/hook.rs | 278 ++++++++++++++++++++++++++++++++++++ crates/pikl/src/main.rs | 308 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 586 insertions(+) create mode 100644 crates/pikl/src/hook.rs create mode 100644 crates/pikl/src/main.rs diff --git a/crates/pikl/src/hook.rs b/crates/pikl/src/hook.rs new file mode 100644 index 0000000..5292466 --- /dev/null +++ b/crates/pikl/src/hook.rs @@ -0,0 +1,278 @@ +//! Shell hook execution. Hooks are shell commands that fire +//! on menu events (selection, cancellation). The selected +//! item's JSON is piped to the hook's stdin. +//! +//! Hook stdout is redirected to stderr so it doesn't end up +//! mixed into pikl's structured output on stdout. + +use serde_json::Value; +use tokio::io::AsyncWriteExt; +use tokio::process::Command; + +use pikl_core::error::PiklError; + +/// Duplicate stderr as a [`Stdio`] handle for use as a +/// child process's stdout. Keeps hook output on stderr +/// so stdout stays clean for pikl's JSON output. +fn stderr_as_stdio() -> std::process::Stdio { + #[cfg(unix)] + { + use std::os::unix::io::FromRawFd; + let fd = unsafe { libc::dup(libc::STDERR_FILENO) }; + if fd >= 0 { + return unsafe { std::process::Stdio::from(std::fs::File::from_raw_fd(fd)) }; + } + } + std::process::Stdio::inherit() +} + +/// Run a shell hook, piping the value as JSON to stdin. +/// Hook stdout goes to stderr (see module docs). Returns +/// an error if the command exits non-zero. +pub async fn run_hook(command: &str, value: &Value) -> Result<(), PiklError> { + run_hook_with_stdout(command, value, stderr_as_stdio()).await +} + +/// Serialize a value as JSON and write it to a child process's +/// stdin. Takes ownership of stdin and drops it after writing +/// so the child sees EOF. +async fn write_json_stdin( + child: &mut tokio::process::Child, + value: &Value, +) -> Result<(), PiklError> { + if let Some(mut stdin) = child.stdin.take() { + let json = serde_json::to_string(value)?; + let _ = stdin.write_all(json.as_bytes()).await; + drop(stdin); + } + Ok(()) +} + +/// Run a shell hook with a custom stdout handle. Used by +/// [`run_hook`] to redirect hook output to stderr. +async fn run_hook_with_stdout( + command: &str, + value: &Value, + stdout: std::process::Stdio, +) -> Result<(), PiklError> { + let mut child = Command::new("sh") + .arg("-c") + .arg(command) + .stdin(std::process::Stdio::piped()) + .stdout(stdout) + .stderr(std::process::Stdio::inherit()) + .spawn()?; + + write_json_stdin(&mut child, value).await?; + + let status = child.wait().await?; + if !status.success() { + return Err(PiklError::HookFailed { + command: command.to_string(), + status, + }); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[tokio::test] + async fn successful_hook() { + let value = json!("test"); + let result = run_hook("true", &value).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn failed_hook() { + let value = json!("test"); + let result = run_hook("false", &value).await; + assert!(result.is_err()); + } + + // -- Hook stdin verification -- + + /// Helper: run `cat` with piped stdout so we can capture what it echoes back + /// from the value JSON written to stdin. + async fn capture_hook_stdin(value: &Value) -> String { + let child = Command::new("sh") + .arg("-c") + .arg("cat") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::inherit()) + .spawn(); + let Ok(mut child) = child else { + return String::new(); + }; + + let _ = write_json_stdin(&mut child, value).await; + + let output = child + .wait_with_output() + .await + .unwrap_or_else(|_| std::process::Output { + status: std::process::ExitStatus::default(), + stdout: Vec::new(), + stderr: Vec::new(), + }); + String::from_utf8(output.stdout).unwrap_or_default() + } + + #[tokio::test] + async fn write_json_stdin_sends_correct_data() { + let value = json!({"key": "value"}); + let mut child = Command::new("cat") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .spawn() + .unwrap(); + write_json_stdin(&mut child, &value).await.unwrap(); + let output = child.wait_with_output().await.unwrap(); + let got = String::from_utf8(output.stdout).unwrap(); + let parsed: Value = serde_json::from_str(&got).unwrap(); + assert_eq!(parsed["key"], "value"); + } + + #[tokio::test] + async fn hook_receives_plain_text_json() { + let value = json!("hello"); + let got = capture_hook_stdin(&value).await; + assert_eq!(got, r#""hello""#); + } + + #[tokio::test] + async fn hook_receives_object_json() { + let value = json!({"label": "foo", "value": 42}); + let got = capture_hook_stdin(&value).await; + let parsed: Value = serde_json::from_str(&got).unwrap_or_default(); + assert_eq!(parsed["label"], "foo"); + assert_eq!(parsed["value"], 42); + } + + #[tokio::test] + async fn hook_receives_special_chars() { + let value = json!("he said \"hi\"\nand left"); + let got = capture_hook_stdin(&value).await; + let parsed: Value = serde_json::from_str(&got).unwrap_or_default(); + assert_eq!( + parsed.as_str().unwrap_or_default(), + "he said \"hi\"\nand left" + ); + } + + // -- Hook stdout-to-stderr redirection -- + + #[tokio::test] + async fn hook_stdout_not_on_piped_stdout() { + // With piped stdout, `echo hello` output is capturable: + let value = json!("test"); + let child = Command::new("sh") + .arg("-c") + .arg("echo hello") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::inherit()) + .spawn(); + assert!(child.is_ok(), "should be able to spawn echo"); + if let Ok(mut child) = child { + if let Some(mut stdin) = child.stdin.take() { + let json = serde_json::to_string(&value).unwrap_or_default(); + let _ = stdin.write_all(json.as_bytes()).await; + drop(stdin); + } + let output = child + .wait_with_output() + .await + .unwrap_or_else(|_| std::process::Output { + status: std::process::ExitStatus::default(), + stdout: Vec::new(), + stderr: Vec::new(), + }); + let piped_out = String::from_utf8(output.stdout).unwrap_or_default(); + assert_eq!(piped_out.trim(), "hello"); + } + + // With stderr_as_stdio(), hook stdout is redirected away from stdout. + // Verify the hook still succeeds (output goes to stderr instead). + let result = run_hook("echo hello", &value).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn stderr_as_stdio_returns_valid_fd() { + // Verify stderr_as_stdio() produces a usable Stdio. + // A child process using it should spawn and exit cleanly. + let child = Command::new("sh") + .arg("-c") + .arg("echo ok >&1") + .stdin(std::process::Stdio::null()) + .stdout(stderr_as_stdio()) + .stderr(std::process::Stdio::inherit()) + .spawn(); + assert!(child.is_ok()); + let output = child + .unwrap_or_else(|_| unreachable!()) + .wait_with_output() + .await; + assert!(output.is_ok()); + assert!( + output + .unwrap_or_else(|_| std::process::Output { + status: std::process::ExitStatus::default(), + stdout: Vec::new(), + stderr: Vec::new(), + }) + .status + .success() + ); + } + + // -- Hook error propagation -- + + #[tokio::test] + async fn hook_nonzero_exit() { + let value = json!("test"); + let result = run_hook("exit 42", &value).await; + assert!( + matches!(&result, Err(PiklError::HookFailed { command, .. }) if command == "exit 42") + ); + if let Err(PiklError::HookFailed { status, .. }) = &result { + assert_eq!(status.code(), Some(42)); + } + } + + #[tokio::test] + async fn hook_missing_command() { + let value = json!("test"); + let result = run_hook("/nonexistent_binary_that_does_not_exist_12345", &value).await; + // sh -c will fail with 127 (command not found) + assert!(result.is_err()); + } + + #[tokio::test] + async fn hook_empty_command() { + let value = json!("test"); + // Empty string passed to sh -c is a no-op, exits 0 + let result = run_hook("", &value).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn hook_with_stdout_uses_custom_stdio() { + let value = json!("custom"); + let result = run_hook_with_stdout("echo ok", &value, std::process::Stdio::piped()).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn hook_with_stdout_propagates_failure() { + let value = json!("test"); + let result = run_hook_with_stdout("exit 1", &value, std::process::Stdio::piped()).await; + assert!(matches!(result, Err(PiklError::HookFailed { .. }))); + } +} diff --git a/crates/pikl/src/main.rs b/crates/pikl/src/main.rs new file mode 100644 index 0000000..6322835 --- /dev/null +++ b/crates/pikl/src/main.rs @@ -0,0 +1,308 @@ +mod hook; + +use std::io::{BufReader, IsTerminal, Write}; +use std::time::Duration; + +use clap::Parser; + +use pikl_core::error::PiklError; +use pikl_core::event::{Action, MenuResult}; +use pikl_core::input::read_items_sync; +use pikl_core::item::Item; +use pikl_core::json_menu::JsonMenu; +use pikl_core::menu::MenuRunner; +use pikl_core::script::action_fd::{self, ScriptAction, ShowAction}; + +#[derive(Parser)] +#[command( + name = "pikl", + about = "Keyboard-driven streaming menu. Pipe stuff in, pick stuff out." +)] +struct Cli { + /// JSON key to use as the display label for object items + #[arg(long, default_value = "label")] + label_key: String, + + /// Shell command to run on selection (item JSON piped to stdin) + #[arg(long)] + on_select: Option, + + /// Shell command to run on cancel + #[arg(long)] + on_cancel: Option, + + /// Read action script from this file descriptor (enables headless mode) + #[arg(long, value_name = "FD")] + action_fd: Option, + + /// Timeout in seconds for reading stdin (default: 30 with --action-fd, 0 otherwise) + #[arg(long, value_name = "SECONDS")] + stdin_timeout: Option, +} + +fn main() { + let cli = Cli::parse(); + + // Install a panic hook that restores the terminal so a crash + // doesn't leave the user staring at a broken shell. + let default_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + pikl_tui::restore_terminal(); + default_hook(info); + })); + + // STEP 1: If action-fd, load + validate script FIRST (fail fast on bad scripts) + let script: Option> = match cli.action_fd { + Some(fd) => { + #[cfg(unix)] + { + use std::os::unix::io::FromRawFd; + let file = unsafe { std::fs::File::from_raw_fd(fd) }; + match action_fd::load_script(BufReader::new(file)) { + Ok(s) => Some(s), + Err(e) => { + let _ = writeln!(std::io::stderr().lock(), "pikl: {e}"); + std::process::exit(2); + } + } + } + #[cfg(not(unix))] + { + let _ = writeln!( + std::io::stderr().lock(), + "pikl: --action-fd is only supported on unix" + ); + std::process::exit(2); + } + } + None => None, + }; + + // STEP 2: Read stdin items (terminal check only when NOT in headless mode) + if script.is_none() && std::io::stdin().is_terminal() { + let _ = writeln!( + std::io::stderr().lock(), + "pikl: no input. pipe something in (e.g. ls | pikl)" + ); + std::process::exit(2); + } + + let timeout = cli + .stdin_timeout + .unwrap_or(if script.is_some() { 30 } else { 0 }); + + let items = match read_stdin_with_timeout(timeout, &cli.label_key) { + Ok(items) => items, + Err(e) => { + let _ = writeln!(std::io::stderr().lock(), "pikl: {e}"); + std::process::exit(2); + } + }; + + if items.is_empty() { + let _ = writeln!( + std::io::stderr().lock(), + "pikl: empty input. nothing to pick from" + ); + std::process::exit(2); + } + + // STEP 3: Build menu, start runtime + let rt = tokio::runtime::Runtime::new().unwrap_or_else(|e| { + let _ = writeln!( + std::io::stderr().lock(), + "pikl: failed to start runtime: {e}" + ); + std::process::exit(2); + }); + + // Reopen stdin from /dev/tty before entering async context. + // Both headless (show-ui branch) and interactive paths need this, + // so do it once here. Headless-only (no show-ui) doesn't need + // terminal input, but reopening is harmless. + if script.is_none() + && let Err(e) = reopen_stdin_from_tty() + { + let _ = writeln!(std::io::stderr().lock(), "pikl: {e}"); + std::process::exit(2); + } + + // STEP 4: Branch on headless vs interactive + let label_key = cli.label_key.clone(); + let result = if let Some(script) = script { + rt.block_on(run_headless(items, label_key, script)) + } else { + rt.block_on(run_interactive(items, label_key)) + }; + + // STEP 5: Handle result + handle_result(result, &cli, &rt); +} + +/// Run in headless mode: replay a script, optionally hand +/// off to a TUI if the script ends with show-ui/show-tui/show-gui. +async fn run_headless( + items: Vec, + label_key: String, + script: Vec, +) -> Result { + let (menu, action_tx) = MenuRunner::new(JsonMenu::new(items, label_key)); + let event_rx = menu.subscribe(); + + // Default headless viewport + let _ = action_tx.send(Action::Resize { height: 50 }).await; + + let menu_handle = tokio::spawn(menu.run()); + + // Run script. May return a Show* action. + let show = action_fd::run_script(script, &action_tx).await?; + + if let Some(show_action) = show { + // Hand off to interactive frontend + reopen_stdin_from_tty()?; + + match show_action { + ShowAction::Ui | ShowAction::Tui | ShowAction::Gui => { + // GUI doesn't exist yet. All show-* variants launch TUI for now. + let tui_handle = tokio::spawn(pikl_tui::run(action_tx, event_rx)); + let result = menu_handle + .await + .map_err(|e| PiklError::Io(std::io::Error::other(e.to_string())))??; + let _ = tui_handle.await; + Ok(result) + } + } + } else { + // No show-ui. Drop sender, let menu finish. + drop(action_tx); + menu_handle + .await + .map_err(|e| PiklError::Io(std::io::Error::other(e.to_string())))? + } +} + +/// Run in interactive mode: start the TUI and let the user +/// pick from the menu. +async fn run_interactive(items: Vec, label_key: String) -> Result { + let (menu, action_tx) = MenuRunner::new(JsonMenu::new(items, label_key)); + let event_rx = menu.subscribe(); + + // Handle SIGINT/SIGTERM: restore terminal and exit cleanly. + let signal_tx = action_tx.clone(); + tokio::spawn(async move { + if let Ok(()) = tokio::signal::ctrl_c().await { + pikl_tui::restore_terminal(); + // Send cancel so the menu loop exits cleanly. + let _ = signal_tx.send(Action::Cancel).await; + } + }); + + let tui_handle = tokio::spawn(async move { pikl_tui::run(action_tx, event_rx).await }); + + let result = menu.run().await; + + let _ = tui_handle.await; + result +} + +/// Serialize a value as JSON and write it to the given writer. +fn write_selected_json( + writer: &mut impl Write, + value: &serde_json::Value, +) -> Result<(), std::io::Error> { + let json = serde_json::to_string(value).unwrap_or_default(); + writeln!(writer, "{json}") +} + +/// Run a hook command if present. On failure, print the error +/// to stderr and exit. +fn run_result_hook( + rt: &tokio::runtime::Runtime, + hook_name: &str, + command: Option<&str>, + value: &serde_json::Value, +) { + if let Some(cmd) = command { + if let Err(e) = rt.block_on(hook::run_hook(cmd, value)) { + let _ = writeln!(std::io::stderr().lock(), "pikl: {hook_name} hook: {e}"); + std::process::exit(2); + } + } +} + +/// Process the menu result: print selected item JSON to +/// stdout, run hooks, or exit with the appropriate code. +fn handle_result(result: Result, cli: &Cli, rt: &tokio::runtime::Runtime) { + match result { + Ok(MenuResult::Selected(value)) => { + run_result_hook(rt, "on-select", cli.on_select.as_deref(), &value); + let _ = write_selected_json(&mut std::io::stdout().lock(), &value); + } + Ok(MenuResult::Cancelled) => { + let empty = serde_json::Value::String(String::new()); + run_result_hook(rt, "on-cancel", cli.on_cancel.as_deref(), &empty); + std::process::exit(1); + } + Err(e) => { + let _ = writeln!(std::io::stderr().lock(), "pikl: {e}"); + std::process::exit(2); + } + } +} + +/// Read items from stdin. If `timeout_secs` is non-zero, +/// spawn a thread and bail if it doesn't finish in time. +/// A timeout of 0 means no timeout (blocking read). +// TODO: The interactive path blocks on all of stdin before showing +// the menu. Switch to streaming items via Action::AddItems so the +// menu renders immediately and populates as lines arrive. +fn read_stdin_with_timeout(timeout_secs: u64, label_key: &str) -> Result, PiklError> { + if timeout_secs == 0 { + return read_items_sync(std::io::stdin().lock(), label_key); + } + + let label_key = label_key.to_string(); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(read_items_sync(std::io::stdin().lock(), &label_key)); + }); + + match rx.recv_timeout(Duration::from_secs(timeout_secs)) { + Ok(result) => result, + Err(_) => { + let _ = writeln!( + std::io::stderr().lock(), + "pikl: timed out reading stdin ({timeout_secs}s)" + ); + let _ = writeln!( + std::io::stderr().lock(), + " = help: use --stdin-timeout to increase or set to 0 to disable" + ); + std::process::exit(2); + } + } +} + +/// Redirect stdin to `/dev/tty` so the TUI can read keyboard +/// input after stdin was consumed for piped items. Flushes +/// stale input so crossterm starts clean. +fn reopen_stdin_from_tty() -> Result<(), PiklError> { + #[cfg(unix)] + { + use std::os::unix::io::AsRawFd; + let tty = std::fs::File::open("/dev/tty")?; + // SAFETY: dup2 is a standard POSIX call. We're + // redirecting stdin to the controlling tty so the + // TUI can read keyboard input after stdin was + // consumed for piped items. + let r = unsafe { libc::dup2(tty.as_raw_fd(), libc::STDIN_FILENO) }; + if r < 0 { + return Err(PiklError::Io(std::io::Error::last_os_error())); + } + // SAFETY: tcflush is a standard POSIX call. Flush + // stale input that arrived between dup2 and raw + // mode so crossterm starts clean. + unsafe { libc::tcflush(libc::STDIN_FILENO, libc::TCIFLUSH) }; + } + Ok(()) +}