feat(bin): Add CLI binary, clap args, shell hook execution.

This commit is contained in:
2026-03-13 21:59:10 -04:00
parent 3f2e5c779b
commit 73de161a09
2 changed files with 586 additions and 0 deletions

278
crates/pikl/src/hook.rs Normal file
View File

@@ -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 { .. })));
}
}

308
crates/pikl/src/main.rs Normal file
View File

@@ -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<String>,
/// Shell command to run on cancel
#[arg(long)]
on_cancel: Option<String>,
/// Read action script from this file descriptor (enables headless mode)
#[arg(long, value_name = "FD")]
action_fd: Option<i32>,
/// Timeout in seconds for reading stdin (default: 30 with --action-fd, 0 otherwise)
#[arg(long, value_name = "SECONDS")]
stdin_timeout: Option<u64>,
}
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<Vec<ScriptAction>> = 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<Item>,
label_key: String,
script: Vec<ScriptAction>,
) -> Result<MenuResult, PiklError> {
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<Item>, label_key: String) -> Result<MenuResult, PiklError> {
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<MenuResult, PiklError>, 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<Vec<Item>, 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(())
}