use std::io::{self, Read, Write};
use std::os::fd::AsRawFd;
use std::process::Command;
use std::time::Instant;
use bird::{
init, parse_query, CompactOptions, Config, EventFilters, InvocationBatch, InvocationRecord,
Query, SessionRecord, StorageMode, Store, BIRD_INVOCATION_UUID_VAR, BIRD_PARENT_CLIENT_VAR,
};
use pty_process::blocking::{Command as PtyCommand, open as pty_open};
fn session_id() -> String {
let ppid = std::os::unix::process::parent_id();
format!("shell-{}", ppid)
}
fn invoker_name() -> String {
std::env::var("SHELL")
.map(|s| {
std::path::Path::new(&s)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or(s)
})
.unwrap_or_else(|_| "unknown".to_string())
}
fn invoker_pid() -> u32 {
std::os::unix::process::parent_id()
}
const NOSAVE_OSC: &[u8] = b"\x1b]shq;nosave\x07";
const NOSAVE_OSC_ST: &[u8] = b"\x1b]shq;nosave\x1b\\";
fn contains_nosave_marker(data: &[u8]) -> bool {
data.windows(NOSAVE_OSC.len()).any(|w| w == NOSAVE_OSC)
|| data.windows(NOSAVE_OSC_ST.len()).any(|w| w == NOSAVE_OSC_ST)
}
pub fn run(shell_cmd: Option<&str>, cmd_args: &[String], tag: Option<&str>, extract_override: Option<bool>, format_override: Option<&str>, auto_compact: bool) -> bird::Result<()> {
let (cmd_str, shell, args): (String, String, Vec<String>) = match shell_cmd {
Some(cmd) => {
let shell = std::env::var("SHELL").unwrap_or_else(|_| "sh".to_string());
(cmd.to_string(), shell, vec!["-c".to_string(), cmd.to_string()])
}
None => {
if cmd_args.is_empty() {
return Err(bird::Error::Config(
"No command specified. Use -c \"cmd\" or provide command args".to_string(),
));
}
if cmd_args.len() == 1 && cmd_args[0].contains(' ') {
let shell = std::env::var("SHELL").unwrap_or_else(|_| "sh".to_string());
(cmd_args[0].clone(), shell, vec!["-c".to_string(), cmd_args[0].clone()])
} else {
(cmd_args.join(" "), cmd_args[0].clone(), cmd_args[1..].to_vec())
}
}
};
let config = Config::load()?;
let store = Store::open(config.clone())?;
let cwd = std::env::current_dir()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| ".".to_string());
let invocation_id = uuid::Uuid::now_v7();
let (mut pty, pts) = pty_open().map_err(|e| bird::Error::Io(io::Error::other(e)))?;
if let Ok(size) = terminal_size() {
let _ = pty.resize(pty_process::Size::new(size.0, size.1));
}
let stdin_is_tty = unsafe { libc::isatty(libc::STDIN_FILENO) == 1 };
if !stdin_is_tty {
disable_pty_echo(pty.as_raw_fd());
}
let cmd = PtyCommand::new(&shell)
.args(&args)
.env(BIRD_INVOCATION_UUID_VAR, invocation_id.to_string())
.env(BIRD_PARENT_CLIENT_VAR, "shq");
let start = Instant::now();
let mut child = cmd.spawn(pts)
.map_err(|e| bird::Error::Io(io::Error::other(e)))?;
let orig_termios = if stdin_is_tty {
set_raw_mode(libc::STDIN_FILENO)
} else {
None
};
let pty_write_fd = pty.as_raw_fd();
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
let running = Arc::new(AtomicBool::new(true));
let running_clone = running.clone();
let stdin_handle = std::thread::spawn(move || {
let mut buf = [0u8; 4096];
let stdin_fd = libc::STDIN_FILENO;
set_nonblocking(stdin_fd, true);
while running_clone.load(Ordering::Relaxed) {
let n = unsafe {
libc::read(stdin_fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len())
};
if n > 0 {
let _ = unsafe {
libc::write(pty_write_fd, buf.as_ptr() as *const libc::c_void, n as usize)
};
} else if n == 0 {
let ctrl_d = [4u8]; let _ = unsafe {
libc::write(pty_write_fd, ctrl_d.as_ptr() as *const libc::c_void, 1)
};
break;
} else {
std::thread::sleep(std::time::Duration::from_millis(10));
}
}
});
let mut output_buffer = Vec::new();
let mut buf = [0u8; 4096];
set_nonblocking(pty.as_raw_fd(), true);
loop {
match child.try_wait() {
Ok(Some(_status)) => {
set_nonblocking(pty.as_raw_fd(), false);
while let Ok(n) = pty.read(&mut buf) {
if n == 0 { break; }
output_buffer.extend_from_slice(&buf[..n]);
let _ = io::stdout().write_all(&buf[..n]);
let _ = io::stdout().flush();
}
break;
}
Ok(None) => {
match pty.read(&mut buf) {
Ok(0) => {
break;
}
Ok(n) => {
output_buffer.extend_from_slice(&buf[..n]);
let _ = io::stdout().write_all(&buf[..n]);
let _ = io::stdout().flush();
}
Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
std::thread::sleep(std::time::Duration::from_millis(10));
}
Err(_) => {
break;
}
}
}
Err(_) => break,
}
}
running.store(false, Ordering::Relaxed);
let _ = stdin_handle.join();
if let Some(termios) = orig_termios {
restore_termios(libc::STDIN_FILENO, &termios);
}
let status = child.wait().map_err(|e| bird::Error::Io(io::Error::other(e)))?;
let duration_ms = start.elapsed().as_millis() as i64;
let exit_code = status.code().unwrap_or(-1);
if contains_nosave_marker(&output_buffer) {
if !status.success() {
std::process::exit(exit_code);
}
return Ok(());
}
let sid = session_id();
let session = SessionRecord::new(
&sid,
&config.client_id,
invoker_name(),
invoker_pid(),
"shell",
);
let mut record = InvocationRecord::with_id(
invocation_id,
&sid,
&cmd_str,
&cwd,
exit_code,
&config.client_id,
)
.with_duration(duration_ms);
if let Some(t) = tag {
record = record.with_tag(t);
}
let inv_id = record.id;
let mut batch = InvocationBatch::new(record).with_session(session);
if !output_buffer.is_empty() {
batch = batch.with_output("combined", output_buffer);
}
store.write_batch(&batch)?;
let should_extract = extract_override.unwrap_or(config.auto_extract);
if should_extract {
let count = store.extract_events(&inv_id.to_string(), format_override)?;
if count > 0 {
eprintln!("shq: extracted {} events", count);
}
}
if auto_compact {
let session_id = sid.clone();
let _ = Command::new(std::env::current_exe().unwrap_or_else(|_| "shq".into()))
.args(["compact", "-s", &session_id, "--today", "-q"])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
}
if !status.success() {
std::process::exit(exit_code);
}
Ok(())
}
fn terminal_size() -> io::Result<(u16, u16)> {
use std::mem::MaybeUninit;
let mut size = MaybeUninit::<libc::winsize>::uninit();
let ret = unsafe { libc::ioctl(libc::STDOUT_FILENO, libc::TIOCGWINSZ, size.as_mut_ptr()) };
if ret == 0 {
let size = unsafe { size.assume_init() };
Ok((size.ws_row, size.ws_col))
} else {
Err(io::Error::last_os_error())
}
}
fn set_nonblocking(fd: i32, nonblocking: bool) {
unsafe {
let flags = libc::fcntl(fd, libc::F_GETFL);
if nonblocking {
libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK);
} else {
libc::fcntl(fd, libc::F_SETFL, flags & !libc::O_NONBLOCK);
}
}
}
fn set_raw_mode(fd: i32) -> Option<libc::termios> {
unsafe {
let mut orig: libc::termios = std::mem::zeroed();
if libc::tcgetattr(fd, &mut orig) != 0 {
return None;
}
let mut raw = orig;
raw.c_lflag &= !(libc::ICANON | libc::ECHO | libc::ISIG | libc::IEXTEN);
raw.c_iflag &= !(libc::IXON | libc::ICRNL | libc::BRKINT | libc::INPCK | libc::ISTRIP);
raw.c_oflag &= !libc::OPOST;
raw.c_cflag |= libc::CS8;
raw.c_cc[libc::VMIN] = 0;
raw.c_cc[libc::VTIME] = 0;
if libc::tcsetattr(fd, libc::TCSAFLUSH, &raw) != 0 {
return None;
}
Some(orig)
}
}
fn restore_termios(fd: i32, termios: &libc::termios) {
unsafe {
libc::tcsetattr(fd, libc::TCSAFLUSH, termios);
}
}
fn disable_pty_echo(fd: i32) {
unsafe {
let mut termios: libc::termios = std::mem::zeroed();
if libc::tcgetattr(fd, &mut termios) == 0 {
termios.c_lflag &= !libc::ECHO;
libc::tcsetattr(fd, libc::TCSANOW, &termios);
}
}
}
#[allow(clippy::too_many_arguments)]
pub fn save(
file: Option<&str>,
command: &str,
exit_code: i32,
duration_ms: Option<i64>,
stream: &str,
stdout_file: Option<&str>,
stderr_file: Option<&str>,
explicit_session_id: Option<&str>,
explicit_invoker_pid: Option<u32>,
explicit_invoker: Option<&str>,
explicit_invoker_type: &str,
extract: bool,
compact: bool,
tag: Option<&str>,
quiet: bool,
) -> bird::Result<()> {
use std::process::Command;
let (stdout_content, stderr_content, single_content) = if stdout_file.is_some() || stderr_file.is_some() {
let stdout = stdout_file.map(std::fs::read).transpose()?;
let stderr = stderr_file.map(std::fs::read).transpose()?;
(stdout, stderr, None)
} else {
let content = match file {
Some(path) => std::fs::read(path)?,
None => {
let mut buf = Vec::new();
io::stdin().read_to_end(&mut buf)?;
buf
}
};
(None, None, Some(content))
};
let has_nosave = stdout_content.as_ref().is_some_and(|c| contains_nosave_marker(c))
|| stderr_content.as_ref().is_some_and(|c| contains_nosave_marker(c))
|| single_content.as_ref().is_some_and(|c| contains_nosave_marker(c));
if has_nosave {
return Ok(());
}
let config = Config::load()?;
let store = Store::open(config.clone())?;
let cwd = std::env::current_dir()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| ".".to_string());
let sid = explicit_session_id
.map(|s| s.to_string())
.unwrap_or_else(session_id);
let inv_pid = explicit_invoker_pid.unwrap_or_else(invoker_pid);
let inv_name = explicit_invoker
.map(|s| s.to_string())
.unwrap_or_else(invoker_name);
let session = SessionRecord::new(
&sid,
&config.client_id,
&inv_name,
inv_pid,
explicit_invoker_type,
);
let mut inv_record = InvocationRecord::new(
&sid,
command,
&cwd,
exit_code,
&config.client_id,
);
if let Some(ms) = duration_ms {
inv_record = inv_record.with_duration(ms);
}
if let Some(t) = tag {
inv_record = inv_record.with_tag(t);
}
let inv_id = inv_record.id;
let mut batch = InvocationBatch::new(inv_record).with_session(session);
if let Some(content) = stdout_content {
batch = batch.with_output("stdout", content);
}
if let Some(content) = stderr_content {
batch = batch.with_output("stderr", content);
}
if let Some(content) = single_content {
batch = batch.with_output(stream, content);
}
store.write_batch(&batch)?;
let should_extract = extract || config.auto_extract;
if should_extract {
let count = store.extract_events(&inv_id.to_string(), None)?;
if !quiet && count > 0 {
eprintln!("shq: extracted {} events", count);
}
}
if compact {
let session_id = sid.clone();
let _ = Command::new(std::env::current_exe().unwrap_or_else(|_| "shq".into()))
.args(["compact", "-s", &session_id, "--today", "-q"])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
}
Ok(())
}
#[derive(Default)]
pub struct OutputOptions {
pub pager: bool,
pub strip_ansi: bool,
pub head: Option<usize>,
pub tail: Option<usize>,
}
pub fn output(query_str: &str, stream_filter: Option<&str>, opts: &OutputOptions) -> bird::Result<()> {
use std::io::Write;
use std::process::{Command, Stdio};
let config = Config::load()?;
let store = Store::open(config)?;
let query = parse_query(query_str);
let (db_filter, combine_to_stdout) = match stream_filter {
Some("O") | Some("o") => (Some("stdout"), false),
Some("E") | Some("e") => (Some("stderr"), false),
Some("A") | Some("a") | Some("all") => (None, true), Some(s) => (Some(s), false),
None => (None, false), };
let invocation_id = if let Some(id) = try_find_by_id(&store, query_str)? {
id
} else {
match resolve_query_to_invocation(&store, &query) {
Ok(id) => id,
Err(bird::Error::NotFound(_)) => {
eprintln!("No matching invocation found");
return Ok(());
}
Err(e) => return Err(e),
}
};
let outputs = store.get_outputs(&invocation_id, db_filter)?;
if outputs.is_empty() {
eprintln!("No output found for invocation {}", invocation_id);
return Ok(());
}
let mut stdout_content = Vec::new();
let mut stderr_content = Vec::new();
for output_info in &outputs {
match store.read_output_content(output_info) {
Ok(content) => {
if output_info.stream == "stderr" {
stderr_content.extend_from_slice(&content);
} else {
stdout_content.extend_from_slice(&content);
}
}
Err(e) => {
eprintln!("Failed to read output for stream '{}': {}", output_info.stream, e);
}
}
}
let process_content = |content: Vec<u8>| -> String {
let content = if opts.strip_ansi {
strip_ansi_escapes(&content)
} else {
content
};
let content_str = String::from_utf8_lossy(&content);
if opts.head.is_some() || opts.tail.is_some() {
let lines: Vec<&str> = content_str.lines().collect();
let selected: Vec<&str> = if let Some(n) = opts.head {
lines.into_iter().take(n).collect()
} else if let Some(n) = opts.tail {
let skip = lines.len().saturating_sub(n);
lines.into_iter().skip(skip).collect()
} else {
lines
};
selected.join("\n") + if content_str.ends_with('\n') { "\n" } else { "" }
} else {
content_str.into_owned()
}
};
if opts.pager {
let mut all_content = stdout_content;
all_content.extend_from_slice(&stderr_content);
let final_content = process_content(all_content);
let pager_cmd = std::env::var("PAGER").unwrap_or_else(|_| "less -R".to_string());
let parts: Vec<&str> = pager_cmd.split_whitespace().collect();
if let Some((cmd, args)) = parts.split_first() {
let mut child = Command::new(cmd)
.args(args)
.stdin(Stdio::piped())
.spawn()
.map_err(bird::Error::Io)?;
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(final_content.as_bytes());
}
let _ = child.wait();
}
} else if combine_to_stdout {
let mut all_content = stdout_content;
all_content.extend_from_slice(&stderr_content);
let final_content = process_content(all_content);
io::stdout().write_all(final_content.as_bytes())?;
} else {
if !stdout_content.is_empty() {
let content = process_content(stdout_content);
io::stdout().write_all(content.as_bytes())?;
}
if !stderr_content.is_empty() {
let content = process_content(stderr_content);
io::stderr().write_all(content.as_bytes())?;
}
}
Ok(())
}
fn strip_ansi_escapes(input: &[u8]) -> Vec<u8> {
let mut output = Vec::with_capacity(input.len());
let mut i = 0;
while i < input.len() {
if input[i] == 0x1b && i + 1 < input.len() && input[i + 1] == b'[' {
i += 2;
while i < input.len() {
let c = input[i];
i += 1;
if (0x40..=0x7e).contains(&c) {
break; }
}
} else if input[i] == 0x1b && i + 1 < input.len() && input[i + 1] == b']' {
i += 2;
while i < input.len() {
if input[i] == 0x07 {
i += 1;
break;
} else if input[i] == 0x1b && i + 1 < input.len() && input[i + 1] == b'\\' {
i += 2;
break;
}
i += 1;
}
} else {
output.push(input[i]);
i += 1;
}
}
output
}
pub fn init(mode: &str, force: bool) -> bird::Result<()> {
let storage_mode: StorageMode = mode.parse()?;
let mut config = Config::default_location()?;
if init::is_initialized(&config) {
if force {
let db_dir = config.bird_root.join("db");
if db_dir.exists() {
std::fs::remove_dir_all(&db_dir)?;
println!("Removed existing database at {}", db_dir.display());
}
} else {
println!("BIRD already initialized at {}", config.bird_root.display());
println!("Use --force to re-initialize (this will delete all data)");
return Ok(());
}
}
config.storage_mode = storage_mode;
init::initialize(&config)?;
println!("BIRD initialized at {}", config.bird_root.display());
println!("Client ID: {}", config.client_id);
println!("Storage mode: {}", config.storage_mode);
Ok(())
}
pub fn update_extensions(dry_run: bool) -> bird::Result<()> {
let config = Config::load()?;
let store = Store::open(config)?;
let extensions = [
("scalarfs", "data: URL support for inline blobs"),
("duck_hunt", "log/output parsing for event extraction"),
];
if dry_run {
println!("Would update the following extensions:");
for (name, desc) in &extensions {
println!(" {} - {}", name, desc);
}
return Ok(());
}
println!("Updating DuckDB extensions...\n");
for (name, desc) in &extensions {
print!(" {} ({})... ", name, desc);
match store.query(&format!("FORCE INSTALL {} FROM community", name)) {
Ok(_) => {
match store.query(&format!("LOAD {}", name)) {
Ok(_) => println!("updated"),
Err(e) => println!("installed but failed to load: {}", e),
}
}
Err(e) => println!("failed: {}", e),
}
}
println!("\nExtensions updated. New features available:");
println!(" - duck_hunt: compression support (.gz/.zst), duck_hunt_detect_format(),");
println!(" duck_hunt_diagnose_read(), severity_threshold parameter");
Ok(())
}
pub fn invocations(query_str: &str, format: &str, limit: Option<usize>) -> bird::Result<()> {
let config = Config::load()?;
let store = Store::open(config)?;
let mut query = parse_query(query_str);
if let Some(n) = limit {
query.range = Some(bird::RangeSelector { start: n, end: None });
}
let invocations = store.query_invocations(&query)?;
if invocations.is_empty() {
println!("No invocations recorded yet.");
return Ok(());
}
let inv_ids: Vec<&str> = invocations.iter().map(|i| i.id.as_str()).collect();
let output_info = get_output_info_batch(&store, &inv_ids)?;
match format {
"json" => {
println!("[");
for (i, inv) in invocations.iter().enumerate() {
let comma = if i < invocations.len() - 1 { "," } else { "" };
let out_state = output_info.get(inv.id.as_str()).copied().unwrap_or_default();
println!(
r#" {{"id": "{}", "timestamp": "{}", "cmd": "{}", "exit_code": {}, "duration_ms": {}, "has_stdout": {}, "has_stderr": {}, "has_combined": {}}}{}"#,
inv.id,
inv.timestamp,
inv.cmd.replace('\\', "\\\\").replace('"', "\\\""),
inv.exit_code,
inv.duration_ms.unwrap_or(0),
out_state.has_stdout,
out_state.has_stderr,
out_state.has_combined,
comma
);
}
println!("]");
}
"table" => {
println!("{:<20} {:<6} {:<10} {:<4} COMMAND", "TIMESTAMP", "EXIT", "DURATION", "OUT");
println!("{}", "-".repeat(80));
for inv in invocations {
let duration = inv
.duration_ms
.map(|d| format!("{}ms", d))
.unwrap_or_else(|| "-".to_string());
let timestamp = if inv.timestamp.len() > 19 {
&inv.timestamp[11..19]
} else {
&inv.timestamp
};
let out_state = output_info.get(inv.id.as_str()).copied().unwrap_or_default();
let out_indicator = out_state.glyph();
let cmd_display = if inv.cmd.len() > 50 {
format!("{}...", &inv.cmd[..47])
} else {
inv.cmd.clone()
};
println!(
"{:<20} {:<6} {:<10} {:<4} {}",
timestamp, inv.exit_code, duration, out_indicator, cmd_display
);
}
}
"commands" => {
for inv in invocations {
println!("{}", inv.cmd);
}
}
_ => {
for inv in invocations {
let (status_glyph, color_code) = if inv.exit_code == 0 {
("✓", "\x1b[32m") } else {
("✗", "\x1b[31m") };
let reset = "\x1b[0m";
let dim = "\x1b[2m";
let id_len = inv.id.len();
let short_id = if id_len >= 8 {
&inv.id[id_len - 8..]
} else {
&inv.id
};
let out_state = output_info.get(inv.id.as_str()).copied().unwrap_or_default();
let out_glyph = out_state.glyph();
let max_cmd_len = 65;
let cmd_display = if inv.cmd.len() > max_cmd_len {
format!("{}…", &inv.cmd[..max_cmd_len - 1])
} else {
inv.cmd.clone()
};
println!(
"{}{}{} {}{}{} {} {}",
color_code, status_glyph, reset,
dim, short_id, reset,
out_glyph,
cmd_display
);
}
}
}
Ok(())
}
#[derive(Debug, Clone, Copy, Default)]
struct OutputState {
has_stdout: bool,
has_stderr: bool,
has_combined: bool,
has_empty: bool, }
impl OutputState {
fn glyph(&self) -> &'static str {
if self.has_combined {
"◉" } else if self.has_stdout && self.has_stderr {
"●" } else if self.has_stdout {
"◐" } else if self.has_stderr {
"◑" } else if self.has_empty {
"○" } else {
"·" }
}
}
fn get_output_info_batch(store: &Store, inv_ids: &[&str]) -> bird::Result<std::collections::HashMap<String, OutputState>> {
use std::collections::HashMap;
if inv_ids.is_empty() {
return Ok(HashMap::new());
}
let ids_sql = inv_ids.iter().map(|id| format!("'{}'", id)).collect::<Vec<_>>().join(", ");
let sql = format!(
"SELECT invocation_id, stream, byte_length FROM outputs WHERE invocation_id IN ({})",
ids_sql
);
let result = store.query(&sql)?;
let mut info: HashMap<String, OutputState> = HashMap::new();
for row in &result.rows {
if row.len() >= 3 {
let inv_id = row[0].clone();
let stream = row[1].clone();
let byte_length: i64 = row[2].parse().unwrap_or(0);
let entry = info.entry(inv_id).or_default();
if byte_length > 0 {
match stream.as_str() {
"stdout" => entry.has_stdout = true,
"stderr" => entry.has_stderr = true,
"combined" => entry.has_combined = true,
_ => {}
}
} else {
entry.has_empty = true;
}
}
}
Ok(info)
}
pub fn quick_help() -> bird::Result<()> {
print!("{}", QUICK_HELP);
Ok(())
}
pub fn sql(query: &str) -> bird::Result<()> {
let config = Config::load()?;
let store = Store::open(config)?;
let result = store.query(query)?;
if result.rows.is_empty() {
println!("No results.");
return Ok(());
}
let mut widths: Vec<usize> = result.columns.iter().map(|c| c.len()).collect();
for row in &result.rows {
for (i, val) in row.iter().enumerate() {
widths[i] = widths[i].max(val.len().min(50));
}
}
for (i, col) in result.columns.iter().enumerate() {
print!("{:width$} ", col, width = widths[i]);
}
println!();
for width in &widths {
print!("{} ", "-".repeat(*width));
}
println!();
for row in &result.rows {
for (i, val) in row.iter().enumerate() {
let display = if val.len() > 50 {
format!("{}...", &val[..47])
} else {
val.clone()
};
print!("{:width$} ", display, width = widths[i]);
}
println!();
}
println!("\n({} rows)", result.rows.len());
Ok(())
}
#[derive(serde::Serialize)]
pub struct BirdStats {
pub root: String,
pub client_id: String,
pub storage_mode: String,
pub current_session: CurrentSession,
pub invocations: InvocationStats,
pub sessions: SessionStats,
pub events: EventStats,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub remotes: Vec<RemoteInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
pub schemas: Option<SchemaStats>,
}
#[derive(serde::Serialize)]
pub struct RemoteInfo {
pub name: String,
pub remote_type: String,
pub uri: String,
pub auto_attach: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub invocations: Option<i64>,
}
#[derive(serde::Serialize)]
pub struct SchemaStats {
pub local: SchemaCounts,
pub caches: SchemaCounts,
pub remotes: SchemaCounts,
pub main: SchemaCounts,
pub unified: SchemaCounts,
}
#[derive(serde::Serialize)]
pub struct SchemaCounts {
pub invocations: i64,
pub sessions: i64,
pub outputs: i64,
pub events: i64,
}
#[derive(serde::Serialize)]
pub struct InvocationStats {
pub total: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub last: Option<LastInvocation>,
}
#[derive(serde::Serialize)]
pub struct LastInvocation {
pub id: String,
pub cmd: String,
pub exit_code: i32,
pub timestamp: String,
}
#[derive(serde::Serialize)]
pub struct SessionStats {
pub total: i64,
}
#[derive(serde::Serialize)]
pub struct CurrentSession {
pub hostname: String,
pub username: String,
pub shell: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
}
#[derive(serde::Serialize)]
pub struct EventStats {
pub total: i64,
pub errors: i64,
pub warnings: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_error: Option<LastError>,
}
#[derive(serde::Serialize)]
pub struct LastError {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub file: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub line: Option<i32>,
}
pub fn stats(format: &str, details: bool, field: Option<&str>) -> bird::Result<()> {
let config = Config::load()?;
let store = Store::open(config.clone())?;
let conn = store.connection()?;
let (username, hostname) = config.client_id.split_once('@')
.map(|(u, h)| (u.to_string(), h.to_string()))
.unwrap_or_else(|| (config.client_id.clone(), "unknown".to_string()));
let shell = std::env::var("SHELL").unwrap_or_else(|_| "unknown".to_string());
let session_id = std::env::var("__shq_session_id").ok();
let inv_count: i64 = conn
.query_row("SELECT COUNT(*) FROM main.invocations", [], |r| r.get(0))
.unwrap_or(0);
let session_count: i64 = conn
.query_row("SELECT COUNT(*) FROM main.sessions", [], |r| r.get(0))
.unwrap_or(0);
let last_inv: Option<bird::InvocationSummary> = conn
.query_row(
"SELECT id, cmd, exit_code, timestamp FROM main.invocations ORDER BY timestamp DESC LIMIT 1",
[],
|row| {
Ok(bird::InvocationSummary {
id: row.get::<_, String>(0)?,
cmd: row.get::<_, String>(1)?,
exit_code: row.get::<_, i32>(2)?,
timestamp: row.get::<_, String>(3)?,
duration_ms: None,
})
},
)
.ok();
let event_count: i64 = conn
.query_row("SELECT COUNT(*) FROM main.events", [], |r| r.get(0))
.unwrap_or(0);
let error_count: i64 = conn
.query_row("SELECT COUNT(*) FROM main.events WHERE severity = 'error'", [], |r| r.get(0))
.unwrap_or(0);
let warning_count: i64 = conn
.query_row("SELECT COUNT(*) FROM main.events WHERE severity = 'warning'", [], |r| r.get(0))
.unwrap_or(0);
let last_error: Option<LastError> = None;
let remotes: Vec<RemoteInfo> = config
.remotes
.iter()
.map(|r| {
let inv_count = conn
.query_row(
&format!("SELECT COUNT(*) FROM {}.invocations", r.quoted_schema_name()),
[],
|row| row.get::<_, i64>(0),
)
.ok();
RemoteInfo {
name: r.name.clone(),
remote_type: format!("{:?}", r.remote_type).to_lowercase(),
uri: r.uri.clone(),
auto_attach: r.auto_attach,
invocations: inv_count,
}
})
.collect();
let schemas = if !config.remotes.is_empty() {
let get_schema_counts = |schema: &str| -> SchemaCounts {
SchemaCounts {
invocations: conn
.query_row(&format!("SELECT COUNT(*) FROM {}.invocations", schema), [], |r| r.get(0))
.unwrap_or(0),
sessions: conn
.query_row(&format!("SELECT COUNT(*) FROM {}.sessions", schema), [], |r| r.get(0))
.unwrap_or(0),
outputs: conn
.query_row(&format!("SELECT COUNT(*) FROM {}.outputs", schema), [], |r| r.get(0))
.unwrap_or(0),
events: conn
.query_row(&format!("SELECT COUNT(*) FROM {}.events", schema), [], |r| r.get(0))
.unwrap_or(0),
}
};
let get_macro_counts = |prefix: &str| -> SchemaCounts {
SchemaCounts {
invocations: conn
.query_row(&format!("SELECT COUNT(*) FROM {}_invocations()", prefix), [], |r| r.get(0))
.unwrap_or(0),
sessions: conn
.query_row(&format!("SELECT COUNT(*) FROM {}_sessions()", prefix), [], |r| r.get(0))
.unwrap_or(0),
outputs: conn
.query_row(&format!("SELECT COUNT(*) FROM {}_outputs()", prefix), [], |r| r.get(0))
.unwrap_or(0),
events: conn
.query_row(&format!("SELECT COUNT(*) FROM {}_events()", prefix), [], |r| r.get(0))
.unwrap_or(0),
}
};
Some(SchemaStats {
local: get_schema_counts("local"),
caches: get_schema_counts("caches"),
remotes: get_macro_counts("remotes"), main: get_schema_counts("main"),
unified: get_schema_counts("unified"),
})
} else {
None
};
let stats = BirdStats {
root: config.bird_root.display().to_string(),
client_id: config.client_id.clone(),
storage_mode: config.storage_mode.to_string(),
current_session: CurrentSession {
hostname,
username,
shell,
session_id,
},
invocations: InvocationStats {
total: inv_count,
last: last_inv.map(|inv| LastInvocation {
id: inv.id.clone(),
cmd: inv.cmd.clone(),
exit_code: inv.exit_code,
timestamp: inv.timestamp.clone(),
}),
},
sessions: SessionStats {
total: session_count,
},
events: EventStats {
total: event_count,
errors: error_count,
warnings: warning_count,
last_error,
},
remotes,
schemas,
};
if let Some(field_name) = field {
let value = match field_name {
"root" => stats.root.clone(),
"client_id" => stats.client_id.clone(),
"storage_mode" => stats.storage_mode.clone(),
"hostname" => stats.current_session.hostname.clone(),
"username" => stats.current_session.username.clone(),
"shell" => stats.current_session.shell.clone(),
"session_id" => stats.current_session.session_id.clone().unwrap_or_default(),
"invocations" | "invocations.total" => stats.invocations.total.to_string(),
"sessions" | "sessions.total" => stats.sessions.total.to_string(),
"events" | "events.total" => stats.events.total.to_string(),
"errors" | "events.errors" => stats.events.errors.to_string(),
"warnings" | "events.warnings" => stats.events.warnings.to_string(),
_ => {
eprintln!("Unknown field: {}", field_name);
eprintln!("Available fields: root, client_id, storage_mode, hostname, username, shell, session_id, invocations, sessions, events, errors, warnings");
return Ok(());
}
};
println!("{}", value);
return Ok(());
}
match format {
"json" => {
println!("{}", serde_json::to_string_pretty(&stats).unwrap());
}
_ => {
println!("Root: {}", stats.root);
println!("Client ID: {}", stats.client_id);
println!("Storage mode: {}", stats.storage_mode);
if details {
println!("Hostname: {}", stats.current_session.hostname);
println!("Username: {}", stats.current_session.username);
println!("Shell: {}", stats.current_session.shell);
if let Some(ref sid) = stats.current_session.session_id {
println!("Session ID: {}", sid);
}
}
println!();
println!("Total invocations: {}", stats.invocations.total);
println!("Total sessions: {}", stats.sessions.total);
if let Some(ref inv) = stats.invocations.last {
println!("Last command: {} (exit {})", inv.cmd, inv.exit_code);
}
println!();
println!("Total events: {}", stats.events.total);
println!(" Errors: {}", stats.events.errors);
println!(" Warnings: {}", stats.events.warnings);
if let Some(ref err) = stats.events.last_error {
let location = match (&err.file, err.line) {
(Some(f), Some(l)) => format!(" at {}:{}", f, l),
(Some(f), None) => format!(" in {}", f),
_ => String::new(),
};
let msg = err.message.as_deref().unwrap_or("-");
println!(" Last error: {}{}", truncate_string(msg, 40), location);
}
if !stats.remotes.is_empty() {
println!();
println!("Remotes:");
for r in &stats.remotes {
let inv_str = r.invocations.map(|n| format!(" ({} invocations)", n)).unwrap_or_default();
let attach = if r.auto_attach { "" } else { " [manual]" };
println!(" {} [{}]: {}{}{}", r.name, r.remote_type, r.uri, inv_str, attach);
}
}
if let Some(ref s) = stats.schemas {
println!();
println!("Schema Summary:");
println!(" {:12} {:>10} {:>10} {:>10} {:>10}", "SCHEMA", "INVOCS", "SESSIONS", "OUTPUTS", "EVENTS");
println!(" {:12} {:>10} {:>10} {:>10} {:>10}", "local", s.local.invocations, s.local.sessions, s.local.outputs, s.local.events);
println!(" {:12} {:>10} {:>10} {:>10} {:>10}", "caches", s.caches.invocations, s.caches.sessions, s.caches.outputs, s.caches.events);
println!(" {:12} {:>10} {:>10} {:>10} {:>10}", "remotes", s.remotes.invocations, s.remotes.sessions, s.remotes.outputs, s.remotes.events);
println!(" {:12} {:>10} {:>10} {:>10} {:>10}", "main", s.main.invocations, s.main.sessions, s.main.outputs, s.main.events);
println!(" {:12} {:>10} {:>10} {:>10} {:>10}", "unified", s.unified.invocations, s.unified.sessions, s.unified.outputs, s.unified.events);
}
}
}
Ok(())
}
pub fn archive(days: u32, dry_run: bool, extract_first: bool) -> bird::Result<()> {
let config = Config::load()?;
let store = Store::open(config)?;
if dry_run {
println!("Dry run - no changes will be made\n");
}
if extract_first && !dry_run {
println!("Extracting events from invocations to be archived...");
let cutoff_date = chrono::Utc::now().date_naive() - chrono::Duration::days(days as i64);
let invocations = store.invocations_without_events(Some(cutoff_date), None)?;
if !invocations.is_empty() {
let mut total_events = 0;
for inv in &invocations {
let count = store.extract_events(&inv.id, None)?;
total_events += count;
}
println!(
" Extracted {} events from {} invocations",
total_events,
invocations.len()
);
}
}
let stats = store.archive_old_data(days, dry_run)?;
if stats.partitions_archived > 0 {
println!(
"Archived {} partitions ({} files, {})",
stats.partitions_archived,
stats.files_moved,
format_bytes(stats.bytes_moved)
);
} else {
println!("Nothing to archive.");
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub fn compact(
file_threshold: usize,
recompact_threshold: usize,
consolidate: bool,
extract_first: bool,
session: Option<&str>,
today_only: bool,
quiet: bool,
recent_only: bool,
archive_only: bool,
dry_run: bool,
) -> bird::Result<()> {
let config = Config::load()?;
let store = Store::open(config)?;
if dry_run && !quiet {
println!("Dry run - no changes will be made\n");
}
if extract_first && !dry_run {
if !quiet {
println!("Extracting events from invocations before compacting...");
}
let invocations = store.invocations_without_events(None, None)?;
let mut extracted = 0;
for inv in &invocations {
let count = store.extract_events(&inv.id, None)?;
extracted += count;
}
if !quiet && extracted > 0 {
println!(" Extracted {} events from {} invocations\n", extracted, invocations.len());
}
}
let opts = CompactOptions {
file_threshold,
recompact_threshold,
consolidate,
dry_run,
session_filter: session.map(|s| s.to_string()),
};
if let Some(session_id) = session {
let stats = if today_only {
store.compact_session_today(session_id, file_threshold, dry_run)?
} else {
store.compact_for_session_with_opts(session_id, &opts)?
};
if stats.sessions_compacted > 0 {
let action = if consolidate { "Consolidated" } else { "Compacted" };
println!("{} session '{}':", action, session_id);
println!(" {} files -> {} files", stats.files_before, stats.files_after);
println!(
" {} -> {} ({})",
format_bytes(stats.bytes_before),
format_bytes(stats.bytes_after),
format_reduction(stats.bytes_before, stats.bytes_after)
);
} else if !quiet {
println!("Nothing to compact for session '{}'.", session_id);
}
return Ok(());
}
let mut total_stats = bird::CompactStats::default();
if !archive_only {
let stats = store.compact_recent_with_opts(&opts)?;
total_stats.add(&stats);
}
if !recent_only {
let stats = store.compact_archive_with_opts(&opts)?;
total_stats.add(&stats);
}
if total_stats.sessions_compacted > 0 {
let action = if consolidate { "Consolidated" } else { "Compacted" };
println!(
"{} {} sessions across {} partitions",
action, total_stats.sessions_compacted, total_stats.partitions_compacted
);
println!(
" {} files -> {} files",
total_stats.files_before, total_stats.files_after
);
println!(
" {} -> {} ({})",
format_bytes(total_stats.bytes_before),
format_bytes(total_stats.bytes_after),
format_reduction(total_stats.bytes_before, total_stats.bytes_after)
);
} else if !quiet {
println!("Nothing to compact.");
}
Ok(())
}
fn format_bytes(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
if bytes >= GB {
format!("{:.1} GB", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.1} MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.1} KB", bytes as f64 / KB as f64)
} else {
format!("{} bytes", bytes)
}
}
fn format_reduction(before: u64, after: u64) -> String {
if before == 0 {
return "0%".to_string();
}
if after >= before {
let increase = ((after - before) as f64 / before as f64) * 100.0;
format!("+{:.1}%", increase)
} else {
let reduction = ((before - after) as f64 / before as f64) * 100.0;
format!("-{:.1}%", reduction)
}
}
pub fn hook_ignore_patterns() -> bird::Result<()> {
let config = Config::load()?;
let patterns = config.hooks.ignore_patterns.join(":");
println!("{}", patterns);
Ok(())
}
pub fn hook_init(shell: Option<&str>, inactive: bool, prompt_indicator: bool, quiet: bool) -> bird::Result<()> {
use crate::hooks::{self, Shell, Mode};
let shell_str = shell
.map(|s| s.to_string())
.or_else(|| std::env::var("SHELL").ok())
.unwrap_or_default();
let shell_type = if shell_str.contains("zsh") {
Shell::Zsh
} else if shell_str.contains("bash") {
Shell::Bash
} else {
eprintln!("Unknown shell type. Use --shell zsh or --shell bash");
std::process::exit(1);
};
let mode = if inactive { Mode::Inactive } else { Mode::Active };
if quiet {
println!("__shq_quiet=1");
}
print!("{}", hooks::generate(shell_type, mode, prompt_indicator));
Ok(())
}
#[derive(Clone, Copy, Debug)]
pub enum LimitOrder {
Any, First, Last, }
#[allow(clippy::too_many_arguments)]
pub fn events(
query_str: &str,
severity: Option<&str>,
count_only: bool,
limit: usize,
order: LimitOrder,
reparse: bool,
extract: bool,
format: Option<&str>,
) -> bird::Result<()> {
let config = Config::load()?;
let store = Store::open(config)?;
let query = parse_query(query_str);
if reparse {
let invocations = store.query_invocations(&query)?;
let mut total_events = 0;
for inv in &invocations {
store.delete_events_for_invocation(&inv.id)?;
let count = store.extract_events(&inv.id, format)?;
total_events += count;
}
println!(
"Re-extracted {} events from {} invocations",
total_events,
invocations.len()
);
return Ok(());
}
let invocations = store.query_invocations(&query)?;
if invocations.is_empty() {
println!("No invocations found.");
return Ok(());
}
if extract {
for inv in &invocations {
let existing = store.event_count(&EventFilters {
invocation_id: Some(inv.id.clone()),
..Default::default()
})?;
if existing == 0 {
let _ = store.extract_events(&inv.id, format);
}
}
}
let inv_ids: Vec<String> = invocations.iter().map(|inv| inv.id.clone()).collect();
let filters = EventFilters {
severity: severity.map(|s| s.to_string()),
invocation_ids: Some(inv_ids),
limit: Some(limit),
..Default::default()
};
let _ = order;
if count_only {
let count = store.event_count(&filters)?;
println!("{}", count);
return Ok(());
}
let events = store.query_events(&filters)?;
if events.is_empty() {
println!("No events found.");
return Ok(());
}
println!(
"{:<8} {:<40} {:<30} MESSAGE",
"SEVERITY", "FILE:LINE", "CODE"
);
println!("{}", "-".repeat(100));
for event in &events {
let sev = event.severity.as_deref().unwrap_or("-");
let location = match (&event.ref_file, event.ref_line) {
(Some(f), Some(l)) => format!("{}:{}", truncate_path(f, 35), l),
(Some(f), None) => truncate_path(f, 40).to_string(),
_ => "-".to_string(),
};
let code = event
.error_code
.as_deref()
.or(event.test_name.as_deref())
.unwrap_or("-");
let message = event
.message
.as_deref()
.map(|m| truncate_string(m, 50))
.unwrap_or_else(|| "-".to_string());
let severity_display = match sev {
"error" => format!("\x1b[31m{:<8}\x1b[0m", sev),
"warning" => format!("\x1b[33m{:<8}\x1b[0m", sev),
_ => format!("{:<8}", sev),
};
println!(
"{} {:<40} {:<30} {}",
severity_display, location, code, message
);
}
println!("\n({} events)", events.len());
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub fn extract_events(
selector: &str,
format: Option<&str>,
quiet: bool,
force: bool,
all: bool,
since: Option<&str>,
limit: Option<usize>,
dry_run: bool,
) -> bird::Result<()> {
let config = Config::load()?;
let store = Store::open(config)?;
if all {
return extract_events_backfill(&store, format, quiet, since, limit, dry_run);
}
let invocation_id = resolve_invocation_id(&store, selector)?;
let existing_count = store.event_count(&EventFilters {
invocation_id: Some(invocation_id.clone()),
..Default::default()
})?;
if existing_count > 0 && !force {
if !quiet {
println!(
"Events already exist for invocation {} ({} events). Use --force to re-extract.",
invocation_id, existing_count
);
}
return Ok(());
}
if force && existing_count > 0 {
store.delete_events_for_invocation(&invocation_id)?;
}
let count = store.extract_events(&invocation_id, format)?;
if !quiet {
if count > 0 {
println!("Extracted {} events from invocation {}", count, invocation_id);
} else {
println!("No events found in invocation {}", invocation_id);
}
}
Ok(())
}
fn extract_events_backfill(
store: &Store,
format: Option<&str>,
quiet: bool,
since: Option<&str>,
limit: Option<usize>,
dry_run: bool,
) -> bird::Result<()> {
use chrono::NaiveDate;
let since_date = if let Some(date_str) = since {
Some(
NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
.map_err(|e| bird::Error::Config(format!("Invalid date '{}': {}", date_str, e)))?,
)
} else {
None
};
let invocations = store.invocations_without_events(since_date, limit)?;
if invocations.is_empty() {
if !quiet {
println!("No invocations found without events.");
}
return Ok(());
}
if dry_run {
println!("Would extract events from {} invocations:", invocations.len());
for inv in &invocations {
let cmd_preview: String = inv.cmd.chars().take(60).collect();
let suffix = if inv.cmd.len() > 60 { "..." } else { "" };
println!(" {} {}{}", &inv.id[..8], cmd_preview, suffix);
}
return Ok(());
}
let mut total_events = 0;
let mut processed = 0;
for inv in &invocations {
let count = store.extract_events(&inv.id, format)?;
total_events += count;
processed += 1;
if !quiet && count > 0 {
println!(" {} events from: {}", count, truncate_cmd(&inv.cmd, 50));
}
}
if !quiet {
println!(
"Extracted {} events from {} invocations.",
total_events, processed
);
}
Ok(())
}
fn truncate_cmd(cmd: &str, max_len: usize) -> String {
if cmd.len() <= max_len {
cmd.to_string()
} else {
format!("{}...", &cmd[..max_len])
}
}
fn resolve_invocation_id(store: &Store, selector: &str) -> bird::Result<String> {
if let Some(stripped) = selector.strip_prefix('~') {
if let Ok(n) = stripped.parse::<usize>() {
if n > 0 {
let invocations = store.recent_invocations(n)?;
if let Some(inv) = invocations.last() {
return Ok(inv.id.clone());
} else {
return Err(bird::Error::NotFound(format!(
"No invocation found at offset ~{}",
n
)));
}
}
}
}
if let Ok(offset) = selector.parse::<i64>() {
if offset < 0 {
let n = (-offset) as usize;
let invocations = store.recent_invocations(n)?;
if let Some(inv) = invocations.last() {
return Ok(inv.id.clone());
} else {
return Err(bird::Error::NotFound(format!(
"No invocation found at offset {}",
offset
)));
}
}
}
if let Some(id) = try_find_by_id(store, selector)? {
return Ok(id);
}
Ok(selector.to_string())
}
fn truncate_path(path: &str, max_len: usize) -> &str {
if path.len() <= max_len {
return path;
}
if let Some(pos) = path.rfind('/') {
let filename = &path[pos + 1..];
if filename.len() < max_len {
return &path[path.len() - max_len..];
}
}
&path[path.len() - max_len..]
}
fn truncate_string(s: &str, max_len: usize) -> String {
if s.len() <= max_len {
s.to_string()
} else {
format!("{}...", &s[..max_len - 3])
}
}
fn looks_like_hex_id(s: &str) -> bool {
s.len() >= 4 && s.chars().all(|c| c.is_ascii_hexdigit() || c == '-')
}
fn try_find_by_id(store: &Store, query_str: &str) -> bird::Result<Option<String>> {
let trimmed = query_str.trim();
if let Some(tag) = trimmed.strip_prefix(':') {
if let Some(id) = store.find_by_tag(tag)? {
return Ok(Some(id));
}
return Err(bird::Error::NotFound(format!("Tag '{}' not found", tag)));
}
if !looks_like_hex_id(trimmed) {
return Ok(None);
}
let result = store.query(&format!(
"SELECT id::VARCHAR FROM invocations WHERE id::VARCHAR = '{}' LIMIT 1",
trimmed
))?;
if !result.rows.is_empty() {
return Ok(Some(result.rows[0][0].clone()));
}
let result = store.query(&format!(
"SELECT id::VARCHAR FROM invocations WHERE suffix(id::VARCHAR, '{}') ORDER BY timestamp DESC LIMIT 1",
trimmed
))?;
if !result.rows.is_empty() {
return Ok(Some(result.rows[0][0].clone()));
}
Ok(None)
}
fn resolve_query_to_invocation(store: &Store, query: &Query) -> bird::Result<String> {
let invocations = store.query_invocations_with_limit(query, 1)?;
let n = query.range.map(|r| r.start).unwrap_or(1);
let idx = n.min(invocations.len()).saturating_sub(1);
if let Some(inv) = invocations.get(idx) {
Ok(inv.id.clone())
} else {
Err(bird::Error::NotFound("No matching invocation found".to_string()))
}
}
pub fn info(query_str: &str, format: &str, field: Option<&str>) -> bird::Result<()> {
let config = Config::load()?;
let store = Store::open(config)?;
let invocation_id = if let Some(id) = try_find_by_id(&store, query_str)? {
id
} else {
let query = parse_query(query_str);
resolve_query_to_invocation(&store, &query)?
};
let result = store.query(&format!(
"SELECT id, cmd, cwd, exit_code, timestamp, duration_ms, session_id, tag
FROM invocations
WHERE id = '{}'",
invocation_id
))?;
if result.rows.is_empty() {
return Err(bird::Error::NotFound(format!("Invocation {} not found", invocation_id)));
}
let row = &result.rows[0];
let id = &row[0];
let cmd = &row[1];
let cwd = &row[2];
let exit_code = &row[3];
let timestamp = &row[4];
let duration_ms = &row[5];
let session_id = &row[6];
let tag = &row[7];
let outputs = store.get_outputs(&invocation_id, None)?;
let stdout_size: i64 = outputs.iter().filter(|o| o.stream == "stdout").map(|o| o.byte_length).sum();
let stderr_size: i64 = outputs.iter().filter(|o| o.stream == "stderr").map(|o| o.byte_length).sum();
let event_count = store.event_count(&EventFilters {
invocation_id: Some(invocation_id.clone()),
..Default::default()
})?;
if let Some(f) = field {
let value = match f.to_lowercase().as_str() {
"id" => id.to_string(),
"cmd" | "command" => cmd.to_string(),
"cwd" | "dir" | "working_dir" => cwd.to_string(),
"exit" | "exit_code" => exit_code.to_string(),
"timestamp" | "time" => timestamp.to_string(),
"duration" | "duration_ms" => duration_ms.to_string(),
"session" | "session_id" => session_id.to_string(),
"tag" => tag.to_string(),
"stdout" | "stdout_bytes" => stdout_size.to_string(),
"stderr" | "stderr_bytes" => stderr_size.to_string(),
"events" | "event_count" => event_count.to_string(),
_ => return Err(bird::Error::Config(format!("Unknown field: {}", f))),
};
println!("{}", value);
return Ok(());
}
match format {
"json" => {
println!(r#"{{"#);
println!(r#" "id": "{}","#, id);
println!(r#" "timestamp": "{}","#, timestamp);
println!(r#" "cmd": "{}","#, cmd.replace('\\', "\\\\").replace('"', "\\\""));
println!(r#" "cwd": "{}","#, cwd.replace('\\', "\\\\").replace('"', "\\\""));
println!(r#" "exit_code": {},"#, exit_code);
println!(r#" "duration_ms": {},"#, duration_ms);
println!(r#" "session_id": "{}","#, session_id);
if tag != "NULL" && !tag.is_empty() {
println!(r#" "tag": "{}","#, tag);
}
println!(r#" "stdout_bytes": {},"#, stdout_size);
println!(r#" "stderr_bytes": {},"#, stderr_size);
println!(r#" "event_count": {}"#, event_count);
println!(r#"}}"#);
}
_ => {
println!("ID: {}", id);
println!("Timestamp: {}", timestamp);
println!("Command: {}", cmd);
println!("Working Dir: {}", cwd);
println!("Exit Code: {}", exit_code);
println!("Duration: {}ms", duration_ms);
println!("Session: {}", session_id);
if tag != "NULL" && !tag.is_empty() {
println!("Tag: {}", tag);
}
println!("Stdout: {} bytes", stdout_size);
println!("Stderr: {} bytes", stderr_size);
println!("Events: {}", event_count);
}
}
Ok(())
}
pub fn rerun(query_str: &str, dry_run: bool, no_capture: bool) -> bird::Result<()> {
use std::io::Write;
let config = Config::load()?;
let store = Store::open(config)?;
let invocation_id = if let Some(id) = try_find_by_id(&store, query_str)? {
id
} else {
let query = parse_query(query_str);
resolve_query_to_invocation(&store, &query)?
};
let result = store.query(&format!(
"SELECT cmd, cwd FROM invocations WHERE id = '{}'",
invocation_id
))?;
if result.rows.is_empty() {
return Err(bird::Error::NotFound(format!("Invocation {} not found", invocation_id)));
}
let cmd = &result.rows[0][0];
let cwd = &result.rows[0][1];
if dry_run {
println!("Would run: {}", cmd);
println!("In directory: {}", cwd);
return Ok(());
}
eprintln!("\x1b[2m$ {}\x1b[0m", cmd);
if no_capture {
let shell = std::env::var("SHELL").unwrap_or_else(|_| "sh".to_string());
let status = Command::new(&shell)
.arg("-c")
.arg(cmd)
.current_dir(cwd)
.status()?;
if !status.success() {
std::process::exit(status.code().unwrap_or(1));
}
} else {
let start = std::time::Instant::now();
let shell = std::env::var("SHELL").unwrap_or_else(|_| "sh".to_string());
let output = Command::new(&shell)
.arg("-c")
.arg(cmd)
.current_dir(cwd)
.output()?;
let duration_ms = start.elapsed().as_millis() as i64;
if !output.stdout.is_empty() {
io::stdout().write_all(&output.stdout)?;
}
if !output.stderr.is_empty() {
io::stderr().write_all(&output.stderr)?;
}
let exit_code = output.status.code().unwrap_or(-1);
let config = Config::load()?;
let store = Store::open(config.clone())?;
let sid = session_id();
let session = SessionRecord::new(
&sid,
&config.client_id,
invoker_name(),
invoker_pid(),
"shell",
);
let record = InvocationRecord::new(
&sid,
cmd,
cwd,
exit_code,
&config.client_id,
)
.with_duration(duration_ms);
let mut batch = InvocationBatch::new(record).with_session(session);
if !output.stdout.is_empty() {
batch = batch.with_output("stdout", output.stdout.clone());
}
if !output.stderr.is_empty() {
batch = batch.with_output("stderr", output.stderr.clone());
}
store.write_batch(&batch)?;
if !output.status.success() {
std::process::exit(exit_code);
}
}
Ok(())
}
const QUICK_HELP: &str = r#"
SHQ QUICK REFERENCE
===================
COMMANDS EXAMPLES
────────────────────────────────────────────────────────────────────────────────
output (o, show) Show captured output shq o ~1 shq o %/make/~1
invocations (i) List command history shq i ~20 shq i %exit<>0~10
events (e) Show parsed events shq e ~10 shq e -s error ~5
info (I) Invocation details shq I ~1 shq I %/test/~1
rerun (R, !!) Re-run a command shq R ~1 shq R %/make/~1
run (r) Run and capture shq r cargo test shq r -c "make all"
sql (q) Execute SQL query shq q "SELECT * FROM invocations LIMIT 5"
QUERY SYNTAX: [source][path][filters][range]
────────────────────────────────────────────────────────────────────────────────
RANGE 1 or ~1 Last command
5 or ~5 Last 5 commands
~10:5 Commands 10 to 5 ago
SOURCE Format: host:type:client:session:
shell: Shell commands on this host
shell:bash: Bash shells only
shell:zsh: Zsh shells only
myhost:shell:: All shells on myhost
*:*:*:*: Everything everywhere (all hosts, all types)
*:shell:*:*: All shell commands on all hosts
PATH . Current directory
~/Projects/ Home-relative
/tmp/ Absolute path
FILTERS %failed Non-zero exit code (alias for %exit<>0)
%success Successful commands (alias for %exit=0)
%ok Same as %success
%exit<>0 Non-zero exit code
%exit=0 Successful commands only
%duration>5000 Took > 5 seconds
%cmd~=test Command matches regex
%cwd~=/src/ Working dir matches
CMD REGEX %/make/ Commands containing "make"
%/^cargo/ Commands starting with "cargo"
%/test$/ Commands ending with "test"
OPERATORS = equals <> not equals ~= regex match
> greater < less >= gte <= lte
EXAMPLES
────────────────────────────────────────────────────────────────────────────────
shq o Show output of last command (default: 1)
shq o 1 Same as above
shq o -E 1 Show only stderr of last command
shq o %/make/~1 Output of last make command
shq o %exit<>0~1 Output of last failed command
shq i Last 20 commands (default)
shq i 50 Last 50 commands
shq i %failed~20 Last 20 failed commands
shq i %failed All failed commands (up to default limit)
shq i %duration>10000~10 Last 10 commands that took >10s
shq i %/cargo/~10 Last 10 cargo commands
shq e Events from last 10 commands (default)
shq e 5 Events from last 5 commands
shq e -s error 10 Only errors from last 10 commands
shq e %/cargo build/~1 Events from last cargo build
shq R Re-run last command
shq R 3 Re-run 3rd-last command
shq R %/make test/~1 Re-run last "make test"
shq R -n %/deploy/~1 Dry-run: show what would run
shq I Details about last command
shq I -f json 1 Details as JSON
.~5 Last 5 commands in current directory
~/Projects/foo/~10 Last 10 in ~/Projects/foo/
shell:%exit<>0~5 Last 5 failed shell commands
"#;
pub fn format_hints_list(show_builtin: bool, show_user: bool, filter: Option<&str>) -> bird::Result<()> {
let config = Config::load()?;
let store = Store::open(config)?;
let hints = store.load_format_hints()?;
let matches_filter = |pattern: &str, format: &str| -> bool {
match filter {
None => true,
Some(f) => {
let f_lower = f.to_lowercase();
pattern.to_lowercase().contains(&f_lower) || format.to_lowercase().contains(&f_lower)
}
}
};
if show_user {
let user_hints: Vec<_> = hints.hints()
.iter()
.filter(|h| matches_filter(&h.pattern, &h.format))
.collect();
if user_hints.is_empty() {
if filter.is_some() {
println!("No user-defined format hints matching filter.");
} else {
println!("No user-defined format hints.");
}
} else {
println!("User-defined format hints:");
println!("{:<6} {:<30} FORMAT", "PRI", "PATTERN");
println!("{}", "-".repeat(60));
for hint in user_hints {
println!("{:<6} {:<30} {}", hint.priority, hint.pattern, hint.format);
}
}
println!();
}
if show_builtin {
match store.list_builtin_formats() {
Ok(formats) => {
let filtered: Vec<_> = formats.iter()
.filter(|f| matches_filter(&f.pattern, &f.format))
.collect();
if filtered.is_empty() {
if filter.is_some() {
println!("No built-in formats matching filter.");
} else {
println!("No built-in formats available.");
}
} else {
println!("Available formats (from duck_hunt):");
println!("{:<6} {:<20} DESCRIPTION", "PRI", "FORMAT");
println!("{}", "-".repeat(70));
for fmt in filtered {
let desc = if fmt.pattern.len() > 45 {
format!("{}...", &fmt.pattern[..42])
} else {
fmt.pattern.clone()
};
println!("{:<6} {:<20} {}", fmt.priority, fmt.format, desc);
}
}
}
Err(e) => {
eprintln!("Warning: Could not list built-in formats: {}", e);
}
}
}
Ok(())
}
pub fn format_hints_add(pattern: &str, format: &str, priority: Option<i32>) -> bird::Result<()> {
let config = Config::load()?;
let store = Store::open(config)?;
let mut hints = store.load_format_hints()?;
let priority = priority.unwrap_or(bird::format_hints::DEFAULT_PRIORITY);
let hint = bird::FormatHint::with_priority(pattern, format, priority);
if hints.get(pattern).is_some() {
println!("Updating existing pattern: {}", pattern);
}
hints.add(hint);
store.save_format_hints(&hints)?;
println!("Added: {} -> {} (priority {})", pattern, format, priority);
Ok(())
}
pub fn format_hints_remove(pattern: &str) -> bird::Result<()> {
let config = Config::load()?;
let store = Store::open(config)?;
let mut hints = store.load_format_hints()?;
if hints.remove(pattern) {
store.save_format_hints(&hints)?;
println!("Removed: {}", pattern);
} else {
println!("Pattern not found: {}", pattern);
}
Ok(())
}
pub fn format_hints_check(cmd: &str) -> bird::Result<()> {
use bird::FormatSource;
let config = Config::load()?;
let store = Store::open(config)?;
let result = store.check_format(cmd)?;
println!("Command: {}", cmd);
println!("Format: {}", result.format);
match result.source {
FormatSource::UserDefined { pattern, priority } => {
println!("Source: user-defined (pattern: {}, priority: {})", pattern, priority);
}
FormatSource::Builtin { pattern, priority } => {
println!("Source: built-in (pattern: {}, priority: {})", pattern, priority);
}
FormatSource::Default => {
println!("Source: default (no pattern matched)");
}
}
Ok(())
}
pub fn format_hints_set_default(format: &str) -> bird::Result<()> {
let config = Config::load()?;
let store = Store::open(config)?;
let mut hints = store.load_format_hints()?;
hints.set_default_format(format);
store.save_format_hints(&hints)?;
println!("Default format set to: {}", format);
Ok(())
}
pub fn remote_add(
name: &str,
remote_type: &str,
uri: &str,
read_only: bool,
credential_provider: Option<&str>,
auto_attach: bool,
) -> bird::Result<()> {
use bird::{RemoteConfig, RemoteMode, RemoteType};
use std::str::FromStr;
let mut config = Config::load()?;
let rtype = RemoteType::from_str(remote_type)?;
let mut remote = RemoteConfig::new(name, rtype, uri);
if read_only {
remote.mode = RemoteMode::ReadOnly;
}
if let Some(provider) = credential_provider {
remote.credential_provider = Some(provider.to_string());
}
remote.auto_attach = auto_attach;
let updating = config.get_remote(name).is_some();
config.add_remote(remote);
config.save()?;
if updating {
println!("Updated remote: {}", name);
} else {
println!("Added remote: {}", name);
}
println!(" Type: {}", remote_type);
println!(" URI: {}", uri);
println!(" Mode: {}", if read_only { "read-only" } else { "read-write" });
if let Some(provider) = credential_provider {
println!(" Credentials: {}", provider);
}
println!(" Auto-attach: {}", auto_attach);
Ok(())
}
pub fn remote_list() -> bird::Result<()> {
let config = Config::load()?;
if config.remotes.is_empty() {
println!("No remotes configured.");
println!();
println!("Add a remote with:");
println!(" shq remote add <name> --type s3 --uri s3://bucket/path/bird.duckdb");
return Ok(());
}
println!("{:<12} {:<12} {:<10} {:<8} URI", "NAME", "TYPE", "MODE", "ATTACH");
println!("{}", "-".repeat(70));
for remote in &config.remotes {
println!(
"{:<12} {:<12} {:<10} {:<8} {}",
remote.name,
remote.remote_type,
remote.mode,
if remote.auto_attach { "auto" } else { "manual" },
remote.uri
);
}
Ok(())
}
pub fn remote_remove(name: &str) -> bird::Result<()> {
let mut config = Config::load()?;
if config.remove_remote(name) {
config.save()?;
println!("Removed remote: {}", name);
} else {
println!("Remote not found: {}", name);
}
Ok(())
}
pub fn remote_test(name: Option<&str>) -> bird::Result<()> {
let config = Config::load()?;
let store = Store::open(config.clone())?;
let remotes_to_test: Vec<_> = if let Some(n) = name {
match config.get_remote(n) {
Some(r) => vec![r],
None => {
println!("Remote not found: {}", n);
return Ok(());
}
}
} else {
config.remotes.iter().collect()
};
if remotes_to_test.is_empty() {
println!("No remotes configured.");
return Ok(());
}
for remote in remotes_to_test {
print!("Testing {}... ", remote.name);
match store.test_remote(remote) {
Ok(()) => println!("OK"),
Err(e) => println!("FAILED: {}", e),
}
}
Ok(())
}
pub fn remote_attach(name: &str) -> bird::Result<()> {
let config = Config::load()?;
match config.get_remote(name) {
Some(remote) => {
println!("To attach this remote in SQL:");
println!();
if remote.credential_provider.is_some() {
println!("LOAD httpfs;");
println!(
"CREATE SECRET IF NOT EXISTS \"bird_{}\" (TYPE s3, PROVIDER credential_chain);",
remote.name
);
}
println!("{};", remote.attach_sql());
println!();
println!("Then query with: SELECT * FROM {}.invocations LIMIT 10;", remote.quoted_schema_name());
}
None => {
println!("Remote not found: {}", name);
}
}
Ok(())
}
pub fn remote_status() -> bird::Result<()> {
use bird::PushOptions;
let config = Config::load()?;
let store = Store::open(config.clone())?;
println!("Sync Configuration:");
println!(" Default remote: {}", config.sync.default_remote.as_deref().unwrap_or("(none)"));
println!(" Push on compact: {}", config.sync.push_on_compact);
println!(" Push on archive: {}", config.sync.push_on_archive);
println!(" Sync invocations: {}", config.sync.sync_invocations);
println!(" Sync outputs: {}", config.sync.sync_outputs);
println!(" Sync events: {}", config.sync.sync_events);
println!(" Sync blobs: {}", config.sync.sync_blobs);
if config.sync.sync_blobs {
println!(" Blob min size: {} bytes", config.sync.blob_sync_min_bytes);
}
println!();
println!("Blob Roots (search order):");
for (i, root) in config.blob_roots().iter().enumerate() {
println!(" {}. {}", i + 1, root);
}
println!();
if config.remotes.is_empty() {
println!("No remotes configured.");
} else {
println!("Configured Remotes:");
for remote in &config.remotes {
println!(" {} ({}, {})", remote.name, remote.remote_type, remote.mode);
let opts = PushOptions {
since: None,
dry_run: true,
sync_blobs: true,
};
match store.push(remote, opts) {
Ok(stats) => {
let total = stats.sessions + stats.invocations + stats.outputs + stats.events;
if total > 0 || stats.blobs.count > 0 {
println!(" Pending push: {}", stats);
} else {
println!(" Pending push: (up to date)");
}
}
Err(e) => {
println!(" Status: error - {}", e);
}
}
}
}
Ok(())
}
pub fn push(remote: Option<&str>, since: Option<&str>, dry_run: bool, sync_blobs: bool) -> bird::Result<()> {
use bird::{parse_since, PushOptions};
let config = Config::load()?;
let store = Store::open(config.clone())?;
let remote_name = remote
.map(String::from)
.or_else(|| config.sync.default_remote.clone())
.ok_or_else(|| bird::Error::Config(
"No remote specified and no default remote configured. Use --remote <name> or set sync.default_remote in config.".to_string()
))?;
let remote_config = config.get_remote(&remote_name)
.ok_or_else(|| bird::Error::Config(format!("Remote '{}' not found", remote_name)))?;
let since_date = since.map(parse_since).transpose()?;
let opts = PushOptions {
since: since_date,
dry_run,
sync_blobs,
};
let stats = store.push(remote_config, opts)?;
if dry_run {
println!("Would push to '{}': {}", remote_name, stats);
} else {
println!("Pushed to '{}': {}", remote_name, stats);
}
Ok(())
}
pub fn pull(remote: Option<&str>, client: Option<&str>, since: Option<&str>, sync_blobs: bool) -> bird::Result<()> {
use bird::{parse_since, PullOptions};
let config = Config::load()?;
let store = Store::open(config.clone())?;
let remote_name = remote
.map(String::from)
.or_else(|| config.sync.default_remote.clone())
.ok_or_else(|| bird::Error::Config(
"No remote specified and no default remote configured. Use --remote <name> or set sync.default_remote in config.".to_string()
))?;
let remote_config = config.get_remote(&remote_name)
.ok_or_else(|| bird::Error::Config(format!("Remote '{}' not found", remote_name)))?;
let since_date = since.map(parse_since).transpose()?;
let opts = PullOptions {
since: since_date,
client_id: client.map(String::from),
sync_blobs,
};
let stats = store.pull(remote_config, opts)?;
println!("Pulled from '{}': {}", remote_name, stats);
Ok(())
}