feat(bin): Add CLI binary, clap args, shell hook execution.
This commit is contained in:
278
crates/pikl/src/hook.rs
Normal file
278
crates/pikl/src/hook.rs
Normal 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
308
crates/pikl/src/main.rs
Normal 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(())
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user