diff options
Diffstat (limited to 'crates/client/src/command')
24 files changed, 2686 insertions, 0 deletions
diff --git a/crates/client/src/command/CONTRIBUTORS b/crates/client/src/command/CONTRIBUTORS new file mode 120000 index 00000000..1ca4115a --- /dev/null +++ b/crates/client/src/command/CONTRIBUTORS @@ -0,0 +1 @@ +../../../../CONTRIBUTORS
\ No newline at end of file diff --git a/crates/client/src/command/client.rs b/crates/client/src/command/client.rs new file mode 100644 index 00000000..0ecb4573 --- /dev/null +++ b/crates/client/src/command/client.rs @@ -0,0 +1,121 @@ +use clap::Subcommand; +use eyre::{Result, WrapErr}; + +use tracing_subscriber::filter::EnvFilter; + +use crate::atuin_client::settings::Settings; + +mod config; +mod daemon; +mod default_config; +mod history; +mod info; +mod stats; +// mod store; +mod sync; +mod wrapped; + +#[derive(Subcommand, Debug)] +#[command(infer_subcommands = true)] +pub(crate) enum Cmd { + /// Manipulate shell history + #[command(subcommand)] + History(history::Cmd), + + /// Interact with the daemon + #[command(subcommand)] + Daemon(daemon::Cmd), + + #[command(subcommand)] + /// Request a sync or view sync status + Sync(sync::Cmd), + + /// Information about dotfiles locations and ENV vars + #[command()] + Info, + + /// Calculate statistics for your history + Stats(stats::Cmd), + + #[command()] + /// Display a recap of your last year's history + Wrapped { year: Option<i32> }, + + /// Print the default atuin configuration (config.toml) + #[command()] + DefaultConfig, + + #[command(subcommand)] + /// Manage your configuration + Config(config::Cmd), +} + +impl Cmd { + pub(crate) fn run(self) -> Result<()> { + let mut runtime = tokio::runtime::Builder::new_current_thread(); + + let runtime = runtime.enable_all().build().unwrap(); + + let res = { + let settings = Settings::new().wrap_err("could not load client settings")?; + + runtime.block_on(self.run_inner(settings)) + }; + + runtime.shutdown_timeout(std::time::Duration::from_millis(50)); + + res + } + + async fn run_inner(self, settings: Settings) -> Result<()> { + // ATUIN_LOG env var overrides config file level settings + let env_log_set = std::env::var("ATUIN_LOG").is_ok(); + + // Base filter from env var (or empty if not set) + let base_filter = + EnvFilter::from_env("ATUIN_LOG").add_directive("sqlx_sqlite::regexp=off".parse()?); + + if env_log_set + && let Err(e) = tracing_subscriber::fmt() + .with_file(true) + .with_line_number(true) + .with_level(true) + .without_time() + .with_env_filter(base_filter) + .try_init() + { + eprintln!("failed to initialize logging: {e}"); + } + + tracing::trace!(command = ?self, "client command"); + + // Skip initializing any databases for history + // This is a pretty hot path, as it runs before and after every single command the user + // runs + match self { + Self::History(history) => return history.run(&settings).await, + Self::Config(config) => return config.run(&settings).await, + _ => {} + } + + match self { + Self::Daemon(cmd) => cmd.run(&settings).await, + Self::Sync(sync) => sync.run(settings).await, + + Self::Stats(stats) => stats.run(&settings).await, + Self::Wrapped { year } => wrapped::run(year, &settings).await, + + // Self::Store(store) => store.run(&settings, &db, sqlite_store).await, + Self::Info => info::run(&settings).await, + + Self::DefaultConfig => { + default_config::run(); + Ok(()) + } + + Self::History(_) | Self::Config(_) => { + unreachable!() + } + } + } +} diff --git a/crates/client/src/command/client/config.rs b/crates/client/src/command/client/config.rs new file mode 100644 index 00000000..73d1c35e --- /dev/null +++ b/crates/client/src/command/client/config.rs @@ -0,0 +1,352 @@ +use crate::atuin_client::settings::Settings; +use clap::{Args, Subcommand, ValueEnum}; +use eyre::Result; +use toml_edit::{Document, DocumentMut, Item, Table, TableLike, Value}; + +#[derive(Subcommand, Debug)] +#[command(infer_subcommands = true)] +pub(crate) enum Cmd { + /// Get a configuration value from your config.toml file + /// or after defaults and overrides are applied + #[command()] + Get(GetCmd), + + /// Set a configuration value in your config.toml file + #[command()] + Set(SetCmd), + + /// Print all configuration values from your config.toml file + /// in TOML format + /// + /// If a key is provided, only print the value of that key and all its children + #[command()] + Print(PrintCmd), +} + +impl Cmd { + pub(crate) async fn run(self, settings: &Settings) -> Result<()> { + match self { + Self::Get(get) => get.run(settings).await, + Self::Set(set) => set.run(settings).await, + Self::Print(print) => print.run(settings).await, + } + } +} + +/// Get a configuration value from your config.toml file, +/// or optionally the effective value after defaults and overrides are applied. +#[derive(Args, Debug)] +pub(crate) struct GetCmd { + /// The configuration key to get + pub(crate) key: String, + + /// Print the value after defaults and overrides are applied + #[arg(long, short)] + pub(crate) resolved: bool, + + /// Print both the config file value and the resolved value + #[arg(long, short)] + pub(crate) verbose: bool, +} + +impl GetCmd { + pub(crate) async fn run(&self, _settings: &Settings) -> Result<()> { + let key = self.key.trim(); + if key.is_empty() || key.contains(char::is_whitespace) { + eyre::bail!("Config key must be non-empty and must not contain whitespace"); + } + + if self.verbose { + println!("Config file:"); + self.print_current_value(key, " ").await?; + println!("\nResolved:"); + Self::print_effective_value(key, " "); + return Ok(()); + } + + if self.resolved { + Self::print_effective_value(key, ""); + } else { + self.print_current_value(key, "").await?; + } + + Ok(()) + } + + async fn print_current_value(&self, key: &str, prefix: &str) -> Result<()> { + let config_file = Settings::get_config_path()?; + let config_str = tokio::fs::read_to_string(&config_file).await?; + let doc = config_str.parse::<Document<_>>()?; + + let current = get_deep_key(&doc, key); + + match current { + Some(item) if item.is_table() || item.is_inline_table() => { + let table = item + .as_table_like() + .expect("is_table()/is_inline_table() but no table"); + println!("{prefix}[{key}]"); + dump_table(table, prefix, &mut vec![key.to_string()])?; + } + Some(item) => { + let val = item.to_string(); + let val = val.trim().trim_matches('"'); + println!("{prefix}{val}"); + } + None => { + println!("{prefix}(not set in config file)"); + } + } + + Ok(()) + } + + fn print_effective_value(key: &str, prefix: &str) { + match Settings::get_config_value(key) { + Ok(value) => { + for line in value.lines() { + println!("{prefix}{line}"); + } + } + Err(_) => { + println!("{prefix}(unknown key)"); + } + } + } +} + +#[derive(Args, Debug)] +pub(crate) struct SetCmd { + /// The configuration key to set + pub(crate) key: String, + + /// The value to set + pub(crate) value: String, + + /// Store value as an explicit type + #[arg(long = "type", short, value_enum, default_value_t = ValueType::Auto, value_name = "TYPE")] + pub(crate) the_type: ValueType, +} + +#[derive(ValueEnum, Debug, Clone, PartialEq, Eq)] +pub(crate) enum ValueType { + /// Automatically determine the type of the value + Auto, + /// Store value as a string + String, + /// Store value as a boolean + Boolean, + /// Store value as an integer + Integer, + /// Store the value as a float + Float, +} + +impl SetCmd { + pub(crate) async fn run(self, _settings: &Settings) -> Result<()> { + let key = self.key.trim(); + if key.is_empty() || key.contains(char::is_whitespace) { + eyre::bail!("Config key must be non-empty and must not contain whitespace"); + } + + let config_file = Settings::get_config_path()?; + let config_str = tokio::fs::read_to_string(&config_file).await?; + let mut doc: DocumentMut = config_str.parse()?; + + // When using auto type detection, try to match the existing value's type + // so we don't accidentally change e.g. "300" (string) to 300 (integer) + let existing_type = detect_existing_type(&doc, key); + let value = self.parse_value(existing_type.as_ref())?; + set_deep_key(&mut doc, key, value)?; + + tokio::fs::write(&config_file, doc.to_string()).await?; + + Ok(()) + } + + fn parse_value(&self, existing_type: Option<&ValueType>) -> Result<Value> { + let raw = &self.value; + + // Explicit --type takes priority, then existing value type, then auto-detect + let effective_type = if self.the_type != ValueType::Auto { + &self.the_type + } else if let Some(existing) = existing_type { + existing + } else { + &ValueType::Auto + }; + + match effective_type { + ValueType::String => Ok(Value::from(raw.as_str())), + ValueType::Boolean => { + let b: bool = raw + .parse() + .map_err(|_| eyre::eyre!("invalid boolean value: {raw}"))?; + Ok(Value::from(b)) + } + ValueType::Integer => { + let i: i64 = raw + .parse() + .map_err(|_| eyre::eyre!("invalid integer value: {raw}"))?; + Ok(Value::from(i)) + } + ValueType::Float => { + let f: f64 = raw + .parse() + .map_err(|_| eyre::eyre!("invalid float value: {raw}"))?; + Ok(Value::from(f)) + } + ValueType::Auto => { + if raw == "true" || raw == "false" { + return Ok(Value::from(raw == "true")); + } + if let Ok(i) = raw.parse::<i64>() { + return Ok(Value::from(i)); + } + if let Ok(f) = raw.parse::<f64>() { + return Ok(Value::from(f)); + } + Ok(Value::from(raw.as_str())) + } + } + } +} + +#[derive(Args, Debug)] +pub(crate) struct PrintCmd { + /// Print the value of a specific key and all its children + pub(crate) key: Option<String>, +} + +impl PrintCmd { + pub(crate) async fn run(&self, _settings: &Settings) -> Result<()> { + let config_file = Settings::get_config_path()?; + let config_str = tokio::fs::read_to_string(&config_file).await?; + let doc = config_str.parse::<Document<_>>()?; + + if let Some(key) = &self.key { + let current = get_deep_key(&doc, key); + + if let Some(current) = current { + if current.is_table() || current.is_inline_table() { + println!("[{key}]"); + dump_table( + current + .as_table_like() + .expect("is_table()/is_inline_table() but no table"), + "", + &mut vec![key.clone()], + )?; + } else { + println!("{}", current.to_string().trim().trim_matches('"')); + } + } else { + println!("key not found"); + } + } else { + dump_table(doc.as_table(), "", &mut Vec::new())?; + } + + Ok(()) + } +} + +fn dump_table(table: &dyn TableLike, prefix: &str, stack: &mut Vec<String>) -> Result<()> { + for (key, value) in table.iter() { + if value.is_table() || value.is_inline_table() { + stack.push(key.to_string()); + + let table = value + .as_table_like() + .expect("is_table()/is_inline_table() but no table"); + + println!("\n{}[{}]", prefix, stack.join(".")); + + dump_table(table, prefix, stack)?; + + stack.pop(); + } else { + println!("{prefix}{key} = {value}"); + } + } + + Ok(()) +} + +fn get_deep_key<'doc>(doc: &'doc Document<String>, key: &str) -> Option<&'doc Item> { + let parts = key.split('.'); + let mut current: Option<&Item> = Some(doc.as_item()); + + for part in parts { + current = current + .and_then(|item| item.as_table_like()) + .and_then(|table| table.get(part)); + } + + current +} + +/// Detect the TOML type of an existing key in the document, so `set` with auto +/// type detection preserves the original type rather than guessing from the value string. +fn detect_existing_type(doc: &DocumentMut, key: &str) -> Option<ValueType> { + let parts: Vec<&str> = key.split('.').collect(); + let mut current: &dyn TableLike = doc.as_table(); + + for &part in &parts[..parts.len().saturating_sub(1)] { + current = current.get(part)?.as_table_like()?; + } + + let last = parts.last()?; + let v = current.get(last)?.as_value()?; + + if v.is_str() { + Some(ValueType::String) + } else if v.is_bool() { + Some(ValueType::Boolean) + } else if v.is_integer() { + Some(ValueType::Integer) + } else if v.is_float() { + Some(ValueType::Float) + } else { + None + } +} + +fn set_deep_key(doc: &mut DocumentMut, key: &str, value: Value) -> Result<()> { + let parts: Vec<&str> = key.split('.').collect(); + + if parts.is_empty() { + eyre::bail!("empty config key"); + } + + let mut current: &mut dyn TableLike = doc.as_table_mut(); + + // Navigate/create intermediate tables + for &part in &parts[..parts.len() - 1] { + if !current.contains_key(part) { + current.insert(part, Item::Table(Table::new())); + } + current = current + .get_mut(part) + .expect("just inserted or already exists") + .as_table_like_mut() + .ok_or_else(|| eyre::eyre!("'{}' exists but is not a table", part))?; + } + + let last = *parts.last().unwrap(); + + // Don't silently overwrite a table with a scalar value + if let Some(existing) = current.get(last) + && (existing.is_table() || existing.is_inline_table()) + { + eyre::bail!( + "'{}' is a table; use a dotted key like '{}.key' to set a value within it", + key, + key + ); + } + + current.insert(last, Item::Value(value)); + + Ok(()) +} diff --git a/crates/client/src/command/client/daemon.rs b/crates/client/src/command/client/daemon.rs new file mode 100644 index 00000000..ccefc14f --- /dev/null +++ b/crates/client/src/command/client/daemon.rs @@ -0,0 +1,46 @@ +use clap::Subcommand; +use eyre::{Result, bail}; + +use turtle_api::client::{Probe, probe}; + +use crate::atuin_client::settings::Settings; + +#[derive(Subcommand, Debug)] +#[command(infer_subcommands = true)] +pub(crate) enum Cmd { + /// Show the daemon's current status + Status, +} + +impl Cmd { + pub(crate) async fn run(self, settings: &Settings) -> Result<()> { + match self { + Self::Status => status_cmd(settings).await, + } + } +} + +async fn status_cmd(settings: &Settings) -> Result<()> { + match probe(settings.daemon.socket_path.clone()).await { + Probe::Ready(mut client) => { + let status = client.status().await?; + println!("Daemon running"); + println!(" PID: {}", status.pid); + println!(" Version: {}", status.version); + println!(" Protocol: {}", status.protocol); + println!(" Healthy: {}", status.healthy); + println!(" Socket: {}", settings.daemon.socket_path); + } + Probe::NeedsRestart(reason) => { + println!("Daemon running (needs restart)"); + println!(" Reason: {reason}"); + bail!("Daemon connection failed") + } + Probe::Unreachable(_) => { + println!("Daemon is not running"); + bail!("Daemon connection failed") + } + } + + Ok(()) +} diff --git a/crates/client/src/command/client/default_config.rs b/crates/client/src/command/client/default_config.rs new file mode 100644 index 00000000..4b03c909 --- /dev/null +++ b/crates/client/src/command/client/default_config.rs @@ -0,0 +1,4 @@ +pub(crate) fn run() { + // TODO(@bpeetz): Re-add the default settings option back (Settings::example_config()) <2026-06-11> + println!("TODO"); +} diff --git a/crates/client/src/command/client/history/end.rs b/crates/client/src/command/client/history/end.rs new file mode 100644 index 00000000..290f7697 --- /dev/null +++ b/crates/client/src/command/client/history/end.rs @@ -0,0 +1,44 @@ +use std::time::Duration; + +use crate::atuin_client::settings::Settings; + +use eyre::{Result, eyre}; +use turtle_api::client::{HistoryClient, daemon_matches_expected, daemon_mismatch_message}; + +pub(super) async fn handle( + settings: &Settings, + id: &str, + exit: i64, + duration: Option<Duration>, +) -> Result<()> { + end_history( + settings, + id.to_string(), + duration.unwrap_or(Duration::ZERO), + exit, + ) + .await?; + + Ok(()) +} + +async fn end_history(settings: &Settings, id: String, duration: Duration, exit: i64) -> Result<()> { + let response = HistoryClient::new(settings.daemon.socket_path.clone()) + .await? + .end_history(id.clone(), duration, exit) + .await; + + match response { + Ok(resp) => { + if daemon_matches_expected(resp.protocol) { + return Ok(()); + } + + Err(eyre!( + "{}. Restart the daemon manually", + daemon_mismatch_message(resp.protocol) + )) + } + Err(err) => Err(err), + } +} diff --git a/crates/client/src/command/client/history/list.rs b/crates/client/src/command/client/history/list.rs new file mode 100644 index 00000000..6fd28660 --- /dev/null +++ b/crates/client/src/command/client/history/list.rs @@ -0,0 +1,283 @@ +use std::{ + fmt::{self, Display}, + io::{self, IsTerminal, Write}, + time::Duration, +}; + +use crate::{ + atuin_client::settings::{Settings, Timezone}, + command::client::history::format_duration_into, +}; + +use eyre::Result; +use runtime_format::{FormatKey, FormatKeyError, ParseSegment, ParsedFmt}; +use time::{OffsetDateTime, macros::format_description}; +use turtle_api::{client::HistoryClient, history::History}; + +#[derive(Clone, Copy, Debug)] +pub(super) enum ListMode { + Human, + CmdOnly, + Regular, +} + +impl ListMode { + pub(super) const fn from_flags(human: bool, cmd_only: bool) -> Self { + if human { + Self::Human + } else if cmd_only { + Self::CmdOnly + } else { + Self::Regular + } + } +} + +/// Type wrapper around `History` with formatting settings. +#[derive(Clone, Copy, Debug)] +struct FmtHistory<'a> { + history: &'a History, + cmd_format: CmdFormat, + tz: &'a Timezone, +} + +#[derive(Clone, Copy, Debug)] +enum CmdFormat { + Literal, + Escaped, +} +impl CmdFormat { + fn for_output<O: IsTerminal>(out: &O) -> Self { + if out.is_terminal() { + Self::Escaped + } else { + Self::Literal + } + } +} + +static TIME_FMT: &[time::format_description::FormatItem<'static>] = + format_description!("[year]-[month]-[day] [hour repr:24]:[minute]:[second]"); + +/// defines how to format the history +impl FormatKey for FmtHistory<'_> { + fn fmt(&self, key: &str, f: &mut fmt::Formatter<'_>) -> Result<(), FormatKeyError> { + match key { + "command" => match self.cmd_format { + CmdFormat::Literal | CmdFormat::Escaped => f.write_str(self.history.command.trim()), + }?, + "directory" => f.write_str(self.history.cwd.trim())?, + "exit" => f.write_str(&self.history.exit.to_string())?, + "duration" => { + let dur = self.history.duration; + format_duration_into(dur, f)?; + } + "time" => { + self.history + .timestamp + .to_offset(self.tz.0) + .format(TIME_FMT) + .map_err(|_| fmt::Error)? + .fmt(f)?; + } + "relativetime" => { + let since = OffsetDateTime::now_utc() - self.history.timestamp; + let d = Duration::try_from(since).unwrap_or_default(); + format_duration_into(d, f)?; + } + "host" => f.write_str( + self.history + .hostname + .split_once(':') + .map_or(&self.history.hostname, |(host, _)| host), + )?, + "author" => f.write_str(&self.history.author)?, + "intent" => f.write_str(self.history.intent.as_deref().unwrap_or_default())?, + "user" => f.write_str( + self.history + .hostname + .split_once(':') + .map_or("", |(_, user)| user), + )?, + "session" => f.write_str(&self.history.session)?, + "uuid" => f.write_str(&self.history.id.to_string())?, + _ => return Err(FormatKeyError::UnknownKey), + } + Ok(()) + } +} + +fn parse_fmt(format: &str) -> ParsedFmt<'_> { + match ParsedFmt::new(format) { + Ok(fmt) => fmt, + Err(err) => { + eprintln!("ERROR: History formatting failed with the following error: {err}"); + + if format.contains('"') && (format.contains(":{") || format.contains(",{")) { + eprintln!("It looks like you're trying to create JSON output."); + eprintln!("For JSON, you need to escape literal braces by doubling them:"); + eprintln!("Example: '{{\"command\":\"{{command}}\",\"time\":\"{{time}}\"}}'"); + } else { + eprintln!( + "If your formatting string contains literal curly braces, you need to escape them by doubling:" + ); + eprintln!("Use {{{{ for literal {{ and }}}} for literal }}"); + } + std::process::exit(1) + } + } +} + +fn print_list( + h: &[History], + list_mode: ListMode, + format: Option<&str>, + print0: bool, + reverse: bool, + tz: Timezone, +) { + let w = io::stdout(); + let mut w = w.lock(); + + let fmt_str = match list_mode { + ListMode::Human => format + .unwrap_or("{time} · {duration}\t{command}") + .replace("\\t", "\t"), + ListMode::Regular => format + .unwrap_or("{time}\t{command}\t{duration}") + .replace("\\t", "\t"), + // not used + ListMode::CmdOnly => String::new(), + }; + + let parsed_fmt = match list_mode { + ListMode::Human | ListMode::Regular => parse_fmt(&fmt_str), + ListMode::CmdOnly => std::iter::once(ParseSegment::Key("command")).collect(), + }; + + #[expect(trivial_casts, reason = "It's more explicit with one")] + let iterator = if reverse { + Box::new(h.iter().rev()) as Box<dyn Iterator<Item = &History>> + } else { + Box::new(h.iter()) as Box<dyn Iterator<Item = &History>> + }; + + let entry_terminator = if print0 { "\0" } else { "\n" }; + let flush_each_line = print0; + + for history in iterator { + let fh = FmtHistory { + history, + cmd_format: CmdFormat::for_output(&w), + tz: &tz, + }; + let args = parsed_fmt.with_args(&fh); + + // Check for formatting errors before attempting to write + if let Err(err) = args.status() { + eprintln!("ERROR: history output failed with: {err}"); + std::process::exit(1); + } + + let write_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + write!(w, "{args}{entry_terminator}") + })); + + match write_result { + Ok(Ok(())) => { + // Write succeeded + } + Ok(Err(err)) => { + if err.kind() != io::ErrorKind::BrokenPipe { + eprintln!("ERROR: Failed to write history output: {err}"); + std::process::exit(1); + } + } + Err(_) => { + eprintln!("ERROR: Format string caused a formatting error."); + eprintln!( + "This may be due to an unsupported format string containing special characters." + ); + eprintln!( + "Please check your format string syntax and ensure literal braces are properly escaped." + ); + std::process::exit(1); + } + } + if flush_each_line { + check_for_write_errors(w.flush()); + } + } + + if !flush_each_line { + check_for_write_errors(w.flush()); + } +} +fn check_for_write_errors(write: Result<(), io::Error>) { + if let Err(err) = write { + // Ignore broken pipe (issue #626) + if err.kind() != io::ErrorKind::BrokenPipe { + eprintln!("ERROR: History output failed with the following error: {err}"); + std::process::exit(1); + } + } +} + +pub(super) async fn handle( + settings: &Settings, + mode: ListMode, + format: Option<String>, + print0: bool, + reverse: bool, + tz: Timezone, +) -> Result<()> { + let mut client = HistoryClient::new(settings.daemon.socket_path.clone()).await?; + let history = client.history(None).await?; + + print_list( + &history, + mode, + match format { + None => Some("{time}\t{command}\t{duration}"), + _ => format.as_deref(), + }, + print0, + reverse, + tz, + ); + + Ok(()) +} + +// pub(super) async fn handle(settings: &Settings) -> Result<()> { +// const CSI: &str = "\x1b["; +// const CSE: &str = "m"; +// fn col(v: impl Display, num: u32) -> String { +// format!("{CSI}{num}{CSE}{v}{CSI}0{CSE}") +// } +// +// struct F(Duration); +// impl Display for F { +// fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { +// format_duration_into(self.0, f) +// } +// } +// +// let mut client = HistoryClient::new(settings.daemon.socket_path.clone()).await?; +// +// let hists = client.history(None).await?; +// +// for hist in hists { +// println!( +// "{}@{}: {} at {} for {} [{}]", +// hist.author, +// hist.hostname, +// col(hist.command, 36), +// col(hist.cwd, 32), +// F(hist.duration), +// col(hist.exit, 31) +// ); +// } +// +// Ok(()) +// } diff --git a/crates/client/src/command/client/history/mod.rs b/crates/client/src/command/client/history/mod.rs new file mode 100644 index 00000000..55647e15 --- /dev/null +++ b/crates/client/src/command/client/history/mod.rs @@ -0,0 +1,261 @@ +use std::{ + fmt::{self}, + ops::ControlFlow, + time::Duration, +}; + +use clap::Subcommand; +use eyre::Result; +use time::macros::format_description; +use turtle_api::history::History; + +use crate::{ + atuin_client::settings::{Settings, Timezone}, + command::client::history::list::ListMode, +}; + +mod end; +mod list; +mod start; +mod tail; + +#[derive(Subcommand, Debug)] +#[command(infer_subcommands = true)] +pub(crate) enum Cmd { + /// Begins a new command in the history + Start { + /// Collects the command from the `ATUIN_COMMAND_LINE` environment variable, + /// which does not need escaping and is more compatible between OS and shells + #[arg(long = "command-from-env", hide = true)] + cmd_env: bool, + + /// Author of this command, eg `ellie`, `claude`, or `copilot` + #[arg(long)] + author: Option<String>, + + /// Optional intent/rationale for running this command + #[arg(long)] + intent: Option<String>, + + command: Vec<String>, + }, + + /// Finishes a new command in the history (adds time, exit code) + End { + id: String, + + #[arg(long, short)] + exit: i64, + + /// The duration this command ran, specified as nano seconds. + #[arg(long, short)] + duration: Option<u64>, + }, + + /// Stream history events from the daemon as they are received + Tail, + + /// List all items in history + List { + #[arg(long, short)] + cwd: bool, + + #[arg(long, short)] + session: bool, + + #[arg(long)] + human: bool, + + /// Show only the text of the command + #[arg(long)] + cmd_only: bool, + + /// Terminate the output with a null, for better multiline support + #[arg(long)] + print0: bool, + + #[arg(long, short, default_value = "true")] + // accept no value + #[arg(num_args(0..=1), default_missing_value("true"))] + // accept a value + #[arg(action = clap::ArgAction::Set)] + reverse: bool, + + /// Display the command time in another timezone other than the configured default. + /// + /// This option takes one of the following kinds of values: + /// + /// - the special value "local" (or "l") which refers to the system time zone + /// - an offset from UTC (e.g. "+9", "-2:30") + #[arg(long, visible_alias = "tz", verbatim_doc_comment)] + timezone: Option<Timezone>, + + /// Available variables: {command}, {directory}, {duration}, {user}, {host}, {author}, {intent}, {exit}, {time}, {session}, and {uuid} + /// + /// Example: --format "{time} - [{duration}] - {directory}$\t{command}" + #[arg(long, short)] + format: Option<String>, + }, +} + +impl Cmd { + pub(crate) async fn run(self, settings: &Settings) -> Result<()> { + match self { + Self::Start { + cmd_env, + author, + intent, + command, + } => { + let command = if cmd_env { + std::env::var("ATUIN_COMMAND_LINE").unwrap_or_default() + } else { + command.join(" ") + }; + + if let Some(id) = + start::handle(settings, &command, author.as_deref(), intent.as_deref()).await? + { + println!("{id}"); + } + + Ok(()) + } + Self::End { id, exit, duration } => { + end::handle(settings, &id, exit, duration.map(Duration::from_nanos)).await + } + Self::Tail => tail::handle(settings).await, + Self::List { + cwd: _, + session: _, + human, + cmd_only, + print0, + reverse, + timezone, + format, + } => { + let mode = ListMode::from_flags(human, cmd_only); + let tz = timezone.unwrap_or(settings.timezone); + list::handle(settings, mode, format, print0, reverse, tz).await + } + } + } +} + +fn format_duration_into(dur: Duration, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fn item(unit: &'static str, value: u64) -> ControlFlow<(&'static str, u64)> { + if value > 0 { + ControlFlow::Break((unit, value)) + } else { + ControlFlow::Continue(()) + } + } + + // impl taken and modified from + // https://github.com/tailhook/humantime/blob/master/src/duration.rs#L295-L331 + // Copyright (c) 2016 The humantime Developers + fn fmt(f: Duration) -> ControlFlow<(&'static str, u64), ()> { + let secs = f.as_secs(); + let nanos = f.subsec_nanos(); + + let years = secs / 31_557_600; // 365.25d + let year_days = secs % 31_557_600; + let months = year_days / 2_630_016; // 30.44d + let month_days = year_days % 2_630_016; + let days = month_days / 86400; + let day_secs = month_days % 86400; + let hours = day_secs / 3600; + let minutes = day_secs % 3600 / 60; + let seconds = day_secs % 60; + + let millis = nanos / 1_000_000; + let micros = nanos / 1_000; + + // a difference from our impl than the original is that + // we only care about the most-significant segment of the duration. + // If the item call returns `Break`, then the `?` will early-return. + // This allows for a very consise impl + item("y", years)?; + item("mo", months)?; + item("d", days)?; + item("h", hours)?; + item("m", minutes)?; + item("s", seconds)?; + item("ms", u64::from(millis))?; + item("us", u64::from(micros))?; + item("ns", u64::from(nanos))?; + ControlFlow::Continue(()) + } + + match fmt(dur) { + ControlFlow::Break((unit, value)) => write!(f, "{value}{unit}"), + ControlFlow::Continue(()) => write!(f, "0s"), + } +} + +static TIME_FMT: &[time::format_description::FormatItem<'static>] = + format_description!("[year]-[month]-[day] [hour repr:24]:[minute]:[second]"); + +fn apply_start_metadata(history: &mut History, author: Option<&str>, intent: Option<&str>) { + if let Some(author) = author.map(str::trim).filter(|author| !author.is_empty()) { + author.clone_into(&mut history.author); + } + + if let Some(intent) = intent.map(str::trim).filter(|intent| !intent.is_empty()) { + history.intent = Some(intent.to_owned()); + } else if intent.is_some() { + history.intent = None; + } +} + +fn normalize_command_for_storage<'a>(command: &'a str, settings: &Settings) -> &'a str { + if !settings.strip_trailing_whitespace { + return command; + } + + let trimmed = command.trim_end_matches([' ', '\t']); + if trimmed.len() == command.len() { + return command; + } + + let trailing_backslashes = trimmed + .as_bytes() + .iter() + .rev() + .take_while(|&&byte| byte == b'\\') + .count(); + + if trailing_backslashes % 2 == 1 { + command + } else { + trimmed + } +} + +#[cfg(test)] +mod tests { + use super::{Settings, normalize_command_for_storage}; + + #[test] + fn normalize_command_strips_trailing_spaces_and_tabs() { + let settings = Settings::new().unwrap(); + + assert!(settings.strip_trailing_whitespace); + assert_eq!(normalize_command_for_storage("ls \t", &settings), "ls"); + } + + #[test] + fn normalize_command_preserves_escaped_trailing_space() { + let settings = Settings::new().unwrap(); + + assert_eq!( + normalize_command_for_storage("printf foo\\ ", &settings), + "printf foo\\ " + ); + assert_eq!( + normalize_command_for_storage("printf foo\\\\ ", &settings), + "printf foo\\\\" + ); + } +} diff --git a/crates/client/src/command/client/history/start.rs b/crates/client/src/command/client/history/start.rs new file mode 100644 index 00000000..c462755e --- /dev/null +++ b/crates/client/src/command/client/history/start.rs @@ -0,0 +1,80 @@ +use crate::{ + atuin_client::settings::Settings, + command::{ + client::history::{apply_start_metadata, normalize_command_for_storage}, + current_session, + }, +}; + +use eyre::{Result, eyre}; +use time::OffsetDateTime; +use tracing::debug; +use turtle_api::{ + client::{HistoryClient, daemon_matches_expected, daemon_mismatch_message}, + history::{History, SettingsFilter}, +}; +use turtle_common::utils::{self, get_hostname, get_username}; + +pub(super) async fn handle( + settings: &Settings, + command: &str, + author: Option<&str>, + intent: Option<&str>, +) -> Result<Option<String>> { + // It's better for atuin to silently fail here and attempt to + // store whatever is ran, than to throw an error to the terminal + let cwd = utils::get_current_dir(); + let command = normalize_command_for_storage(command, settings); + + let mut h: History = History::daemon() + .timestamp(OffsetDateTime::now_utc()) + .command(command) + .cwd(cwd) + .session(current_session()?) + .hostname(get_hostname()) + .author(get_username()) + .build() + .into(); + apply_start_metadata(&mut h, author, intent); + + if !h.should_save(SettingsFilter { + history: &settings.history_filter, + cwd: &settings.cwd_filter, + secrets: settings.secrets_filter, + }) { + return Ok(None); + } + + // Attempt to start history via daemon, but silently ignore errors + // to avoid breaking the shell when the daemon is unavailable or disk is full + let resp = match start_history(settings, h.clone()).await { + Ok(id) => id, + Err(e) => { + debug!("failed to start history via daemon: {e}"); + h.id.to_string() + } + }; + + Ok(Some(resp)) +} + +async fn start_history(settings: &Settings, history: History) -> Result<String> { + let response = HistoryClient::new(settings.daemon.socket_path.clone()) + .await? + .start_history(history.clone()) + .await; + + match response { + Ok(resp) => { + if daemon_matches_expected(resp.protocol) { + return Ok(resp.id); + } + + Err(eyre!( + "{}. Restart the daemon manually", + daemon_mismatch_message(resp.protocol) + )) + } + Err(err) => Err(err), + } +} diff --git a/crates/client/src/command/client/history/tail.rs b/crates/client/src/command/client/history/tail.rs new file mode 100644 index 00000000..2cad5dd6 --- /dev/null +++ b/crates/client/src/command/client/history/tail.rs @@ -0,0 +1,322 @@ +use crate::{ + atuin_client::settings::{Settings, Timezone}, + command::client::history::{TIME_FMT, format_duration_into}, +}; + +use colored::Colorize; +use eyre::{Context, Result, bail}; +use serde::Serialize; +use time::OffsetDateTime; +use turtle_api::{ + client::{ + HistoryClient, HistoryEventKind, Probe, TailHistoryReply, history_entry_to_history, probe, + }, + history::History, +}; +use turtle_common::utils::Escapable; + +use std::{ + fmt::{self, Display}, + io::{self, IsTerminal, Write}, + time::Duration, +}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum TailKind { + Started, + Ended, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct TailEvent { + kind: TailKind, + history: History, +} + +#[derive(Serialize)] +struct TailJsonEvent<'a> { + event: &'static str, + history: TailJsonHistory<'a>, +} + +#[derive(Serialize)] +struct TailJsonHistory<'a> { + id: &'a str, + timestamp: String, + timestamp_unix_ns: u64, + command: &'a str, + cwd: &'a str, + session: &'a str, + hostname: &'a str, + host: &'a str, + user: &'a str, + author: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + intent: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + exit: Option<i64>, + #[serde(skip_serializing_if = "Option::is_none")] + duration: Option<Duration>, + #[serde(skip_serializing_if = "Option::is_none")] + success: Option<bool>, + #[serde(skip_serializing_if = "Option::is_none")] + finished_at: Option<String>, +} + +impl TailEvent { + fn from_proto(reply: TailHistoryReply) -> Result<Self> { + let history = reply + .history + .ok_or_else(|| eyre::eyre!("daemon sent a history tail event without history"))?; + let kind = match HistoryEventKind::try_from(reply.kind) + .unwrap_or(HistoryEventKind::Unspecified) + { + HistoryEventKind::Started => TailKind::Started, + HistoryEventKind::Ended => TailKind::Ended, + HistoryEventKind::Unspecified => bail!("daemon sent an unspecified history tail event"), + }; + + Ok(Self { + kind, + history: history_entry_to_history(history), + }) + } + + fn render(&self, tty: bool, tz: Timezone) -> Result<String> { + if tty { + Ok(self.render_pretty(tz)) + } else { + let mut json = self.render_json(tz)?; + json.push('\n'); + Ok(json) + } + } + + fn render_json(&self, tz: Timezone) -> Result<String> { + let payload = TailJsonEvent { + event: self.kind.as_str(), + history: TailJsonHistory { + id: &self.history.id.to_string(), + timestamp: format_history_time(self.history.timestamp, tz)?, + timestamp_unix_ns: u64::try_from(self.history.timestamp.unix_timestamp_nanos()) + .context("history timestamp predates unix epoch")?, + command: &self.history.command, + cwd: &self.history.cwd, + session: &self.history.session, + hostname: &self.history.hostname, + host: self.host(), + user: self.user(), + author: &self.history.author, + intent: self.history.intent.as_deref(), + exit: self.exit_value(), + duration: self.duration_value(), + success: self.success_value(), + finished_at: self + .finished_at() + .map(|time| format_history_time(time, tz)) + .transpose()?, + }, + }; + + Ok(serde_json::to_string(&payload)?) + } + + fn render_pretty(&self, tz: Timezone) -> String { + let mut out = String::new(); + let border = match self.kind { + TailKind::Started => "-".repeat(72).bright_blue().to_string(), + TailKind::Ended if self.history.exit == 0 => "-".repeat(72).bright_green().to_string(), + TailKind::Ended => "-".repeat(72).bright_red().to_string(), + }; + + out.push_str(&border); + out.push('\n'); + + let command = self.history.command.trim(); + let escaped_command = command.escape_control(); + let mut command_lines = escaped_command.lines(); + let header = format!( + "{} {}", + self.kind.badge(self.history.exit), + command_lines.next().unwrap_or_default().bold() + ); + out.push_str(&header); + out.push('\n'); + + for line in command_lines { + out.push_str(" "); + out.push_str(line); + out.push('\n'); + } + + push_pretty_field( + &mut out, + "start", + &format_history_time(self.history.timestamp, tz) + .unwrap_or_else(|_| "invalid".to_owned()), + ); + push_pretty_field(&mut out, "history", &self.history.id.to_string()); + push_pretty_field(&mut out, "session", &self.history.session); + push_pretty_field(&mut out, "exit", &self.exit_display()); + push_pretty_field(&mut out, "duration", &self.duration_display()); + + out.push('\n'); + + push_pretty_field(&mut out, "cwd", &self.history.cwd); + push_pretty_field(&mut out, "hostname", &self.history.hostname); + push_pretty_field(&mut out, "host", self.host()); + push_pretty_field(&mut out, "user", self.user()); + push_pretty_field(&mut out, "author", &self.history.author); + + if let Some(intent) = self.history.intent.as_deref() { + push_pretty_field(&mut out, "intent", intent); + } + + if let Some(finished) = self.finished_at() { + let finished = + format_history_time(finished, tz).unwrap_or_else(|_| "invalid".to_owned()); + push_pretty_field(&mut out, "finished", &finished); + } + + out.push_str(&border); + out.push_str("\n\n"); + out + } + + fn host(&self) -> &str { + self.history + .hostname + .split_once(':') + .map_or(self.history.hostname.as_str(), |(host, _)| host) + } + + fn user(&self) -> &str { + self.history + .hostname + .split_once(':') + .map_or("", |(_, user)| user) + } + + fn exit_value(&self) -> Option<i64> { + matches!(self.kind, TailKind::Ended).then_some(self.history.exit) + } + + fn duration_value(&self) -> Option<Duration> { + matches!(self.kind, TailKind::Ended).then_some(self.history.duration) + } + + fn success_value(&self) -> Option<bool> { + matches!(self.kind, TailKind::Ended).then_some(self.history.exit == 0) + } + + fn finished_at(&self) -> Option<OffsetDateTime> { + self.duration_value() + .filter(|duration| *duration >= Duration::ZERO) + .map(|d| { + time::Duration::nanoseconds_i128( + i128::try_from(d.as_nanos()).expect("to be small enough"), + ) + }) + .and_then(|duration| self.history.timestamp.checked_add(duration)) + } + + fn exit_display(&self) -> String { + match self.exit_value() { + Some(0) => "0 (success)".bright_green().to_string(), + Some(code) => format!("{code} (failure)").bright_red().to_string(), + None => "pending".bright_yellow().to_string(), + } + } + + fn duration_display(&self) -> String { + match self.duration_value() { + Some(duration) if duration >= Duration::ZERO => format_duration_ns(duration), + Some(_) => "unknown".bright_yellow().to_string(), + None => "running".bright_yellow().to_string(), + } + } +} + +impl TailKind { + const fn as_str(self) -> &'static str { + match self { + Self::Started => "started", + Self::Ended => "ended", + } + } + + fn badge(self, exit: i64) -> colored::ColoredString { + match self { + Self::Started => "STARTED".bold().bright_blue(), + Self::Ended if exit == 0 => "ENDED".bold().bright_green(), + Self::Ended => "ENDED".bold().bright_red(), + } + } +} + +fn push_pretty_field(out: &mut String, label: &str, value: &str) { + out.push_str(" "); + let label = format!("{label}:"); + out.push_str(&label.bright_cyan().bold().to_string()); + if label.len() < 10 { + out.push_str(&" ".repeat(10 - label.len())); + } + + let mut lines = value.lines(); + if let Some(first) = lines.next() { + out.push_str(first); + } + out.push('\n'); + + for line in lines { + out.push_str(" "); + out.push_str(line); + out.push('\n'); + } +} + +fn format_duration_ns(duration: Duration) -> String { + struct F(Duration); + impl Display for F { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + format_duration_into(self.0, f) + } + } + + F(duration).to_string() +} + +fn format_history_time(timestamp: OffsetDateTime, tz: Timezone) -> Result<String> { + Ok(timestamp.to_offset(tz.0).format(TIME_FMT)?) +} + +async fn tail_client(settings: &Settings) -> Result<HistoryClient> { + match probe(settings.daemon.socket_path.clone()).await { + Probe::Ready(_) => HistoryClient::new(settings.daemon.socket_path.clone()).await, + Probe::NeedsRestart(reason) => { + bail!("{reason}. Restart the daemon manually"); + } + Probe::Unreachable(err) => Err(err), + } +} + +pub(super) async fn handle(settings: &Settings) -> Result<()> { + let tty = io::stdout().is_terminal(); + let mut client = tail_client(settings).await?; + let mut stream = client.tail_history().await?; + let stdout = io::stdout(); + + while let Some(reply) = stream.message().await? { + let event = TailEvent::from_proto(reply)?; + let rendered = event.render(tty, settings.timezone)?; + let mut out = stdout.lock(); + + match out.write_all(rendered.as_bytes()) { + Ok(()) => out.flush()?, + Err(err) if err.kind() == io::ErrorKind::BrokenPipe => break, + Err(err) => return Err(err.into()), + } + } + + Ok(()) +} diff --git a/crates/client/src/command/client/info.rs b/crates/client/src/command/client/info.rs new file mode 100644 index 00000000..1af8ee39 --- /dev/null +++ b/crates/client/src/command/client/info.rs @@ -0,0 +1,46 @@ +use crate::atuin_client::settings::Settings;
+use crate::{SHA, VERSION};
+
+use eyre::Result;
+use turtle_api::client::ControlClient;
+
+pub(crate) async fn run(settings: &Settings) -> Result<()> {
+ let config = turtle_common::utils::config_dir();
+
+ let mut client = ControlClient::new(settings.daemon.socket_path.clone()).await?;
+ let paths = client.paths().await?;
+
+ let mut config_file = config.clone();
+ config_file.push("config.toml");
+ let mut sever_config = config;
+ sever_config.push("server.toml");
+
+ let config_paths = format!(
+ "\
+ Config files:
+ client config: {:?}
+ server config: {:?}
+ deamon config: {:?}
+ deamon db path: {:?}
+ deamon socket path: {:?}\
+ ",
+ config_file.to_string_lossy(),
+ sever_config.to_string_lossy(),
+ paths.config,
+ paths.db,
+ paths.socket,
+ );
+
+ let env_vars = format!(
+ "Env Vars:\nATUIN_CONFIG_DIR = {:?}",
+ std::env::var("ATUIN_CONFIG_DIR").unwrap_or_else(|_| "None".into())
+ );
+
+ let general_info = format!("Version info:\nversion: {VERSION}\ncommit: {SHA}");
+
+ let print_out = format!("{config_paths}\n\n{env_vars}\n\n{general_info}");
+
+ println!("{print_out}");
+
+ Ok(())
+}
diff --git a/crates/client/src/command/client/stats.rs b/crates/client/src/command/client/stats.rs new file mode 100644 index 00000000..9f74ecc3 --- /dev/null +++ b/crates/client/src/command/client/stats.rs @@ -0,0 +1,85 @@ +use clap::Parser; +use eyre::Result; +use interim::{Dialect, parse_date_string}; +use time::{Duration, OffsetDateTime, Time}; +use turtle_api::client::{HistoryClient, Range}; + +use crate::atuin_client::settings::Settings; + +use crate::atuin_history::stats::{compute, pretty_print}; + +fn parse_ngram_size(s: &str) -> Result<usize, String> { + let value = s + .parse::<usize>() + .map_err(|_| format!("'{s}' is not a valid window size"))?; + + if value == 0 { + return Err("ngram window size must be at least 1".to_string()); + } + + Ok(value) +} + +#[derive(Parser, Debug)] +#[command(infer_subcommands = true)] +pub(crate) struct Cmd { + /// Compute statistics for the specified period, leave blank for statistics since the beginning. See [this](https://docs.atuin.sh/reference/stats/) for more details. + period: Vec<String>, + + /// How many top commands to list + #[arg(long, short, default_value = "10")] + count: usize, + + /// The number of consecutive commands to consider + #[arg(long, short, default_value = "1", value_parser = parse_ngram_size)] + ngram_size: usize, +} + +impl Cmd { + pub(crate) async fn run(&self, settings: &Settings) -> Result<()> { + let mut client = HistoryClient::new(settings.daemon.socket_path.clone()).await?; + + let words = if self.period.is_empty() { + String::from("all") + } else { + self.period.join(" ") + }; + + let now = OffsetDateTime::now_utc().to_offset(settings.timezone.0); + let last_night = now.replace_time(Time::MIDNIGHT); + + let range = if words.as_str() == "all" { + None + } else if words.trim() == "today" { + let start = last_night; + let end = start + Duration::days(1); + Some(Range { start, end }) + } else if words.trim() == "month" { + let end = last_night; + let start = end - Duration::days(31); + Some(Range { start, end }) + } else if words.trim() == "week" { + let end = last_night; + let start = end - Duration::days(7); + Some(Range { start, end }) + } else if words.trim() == "year" { + let end = last_night; + let start = end - Duration::days(365); + Some(Range { start, end }) + } else { + let start = parse_date_string(&words, now, Dialect::Uk)?; + let end = start + Duration::days(1); + Some(Range { start, end }) + }; + + let history = client.history(range).await?; + + let stats = compute(settings, &history, self.count, self.ngram_size); + + if let Some(stats) = stats { + pretty_print(stats, self.ngram_size); + } + + Ok(()) + } +} diff --git a/crates/client/src/command/client/store/mod.rs b/crates/client/src/command/client/store/mod.rs new file mode 100644 index 00000000..bc57488d --- /dev/null +++ b/crates/client/src/command/client/store/mod.rs @@ -0,0 +1,108 @@ +use clap::Subcommand; +use eyre::Result; + +use crate::atuin_client::{ + database::ClientSqlite, record::sqlite_store::SqliteStore, settings::Settings, +}; +use itertools::Itertools; +use time::{OffsetDateTime, UtcOffset}; + +mod pull; +mod purge; +mod push; +mod rebuild; +mod rekey; +mod verify; + +#[derive(Subcommand, Debug)] +#[command(infer_subcommands = true)] +pub(crate) enum Cmd { + /// Print the current status of the record store + Status, + + /// Rebuild a store (eg atuin store rebuild history) + Rebuild(rebuild::Rebuild), + + /// Re-encrypt the store with a new key (potential for data loss!) + Rekey(rekey::Rekey), + + /// Delete all records in the store that cannot be decrypted with the current key + Purge(purge::Purge), + + /// Verify that all records in the store can be decrypted with the current key + Verify(verify::Verify), + + /// Push all records to the remote sync server (one way sync) + Push(push::Push), + + /// Pull records from the remote sync server (one way sync) + Pull(pull::Pull), +} + +impl Cmd { + pub(crate) async fn run( + &self, + settings: &Settings, + database: &ClientSqlite, + store: SqliteStore, + ) -> Result<()> { + match self { + Self::Status => self.status(store).await, + Self::Rebuild(rebuild) => rebuild.run(settings, store, database).await, + Self::Rekey(rekey) => rekey.run(settings, store).await, + Self::Verify(verify) => verify.run(settings, store).await, + Self::Purge(purge) => purge.run(settings, store).await, + Self::Push(push) => push.run(settings, store).await, + Self::Pull(pull) => pull.run(settings, store, database).await, + } + } + + pub(crate) async fn status(&self, store: SqliteStore) -> Result<()> { + let host_id = Settings::host_id().await?; + let offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC); + + let status = store.status().await?; + + // TODO: should probs build some data structure and then pretty-print it or smth + for (host, st) in status.hosts.iter().sorted_by_key(|(h, _)| *h) { + let host_string = if host == &host_id { + format!("host: {} <- CURRENT HOST", host.0.as_hyphenated()) + } else { + format!("host: {}", host.0.as_hyphenated()) + }; + + println!("{host_string}"); + + for (tag, idx) in st.iter().sorted_by_key(|(tag, _)| *tag) { + println!("\tstore: {tag}"); + + let first = store.first(*host, tag).await?; + let last = store.last(*host, tag).await?; + + println!("\t\tidx: {idx}"); + + if let Some(first) = first { + println!("\t\tfirst: {}", first.id.0.as_hyphenated()); + + let time = + OffsetDateTime::from_unix_timestamp_nanos(i128::from(first.timestamp))? + .to_offset(offset); + println!("\t\t\tcreated: {time}"); + } + + if let Some(last) = last { + println!("\t\tlast: {}", last.id.0.as_hyphenated()); + + let time = + OffsetDateTime::from_unix_timestamp_nanos(i128::from(last.timestamp))? + .to_offset(offset); + println!("\t\t\tcreated: {time}"); + } + } + + println!(); + } + + Ok(()) + } +} diff --git a/crates/client/src/command/client/store/pull.rs b/crates/client/src/command/client/store/pull.rs new file mode 100644 index 00000000..3a0865be --- /dev/null +++ b/crates/client/src/command/client/store/pull.rs @@ -0,0 +1,95 @@ +use clap::Args; +use eyre::Result; + +use crate::atuin_client::{ + database::ClientSqlite, + encryption::load_key, + record::{ + sqlite_store::SqliteStore, + sync::{self, Operation}, + }, + settings::Settings, +}; + +#[derive(Args, Debug)] +pub(crate) struct Pull { + /// The tag to push (eg, 'history'). Defaults to all tags + #[arg(long, short)] + pub(crate) tag: Option<String>, + + /// Force push records + /// This will first wipe the local store, and then download all records from the remote + #[arg(long, default_value = "false")] + pub(crate) force: bool, + + /// Page Size + /// How many records to download at once. Defaults to 100 + #[arg(long, default_value = "100")] + pub(crate) page: u64, +} + +impl Pull { + pub(crate) async fn run( + &self, + settings: &Settings, + store: SqliteStore, + db: &ClientSqlite, + ) -> Result<()> { + if self.force { + println!("Forcing local overwrite!"); + println!("Clearing local store"); + + store.delete_all().await?; + } + + // We can actually just use the existing diff/etc to push + // 1. Diff + // 2. Get operations + // 3. Filter operations by + // a) are they a download op? + // b) are they for the host/tag we are pushing here? + let client = sync::build_client(settings)?; + let (diff, remote_index) = sync::diff(&client, &store).await?; + + // Skip on --force: local was already wiped above, mismatch is the user's call. + if !self.force { + let key: [u8; 32] = load_key(settings)?.into(); + sync::check_encryption_key(&client, &remote_index, &key) + .await + .map_err(crate::print_error::format_sync_error)?; + } + + let operations = sync::operations(diff, &store)?; + + let operations = operations + .into_iter() + .filter(|op| match op { + // No noops or downloads thx + Operation::Noop { .. } | Operation::Upload { .. } => false, + + // pull, so yes plz to downloads! + Operation::Download { tag, .. } => { + if self.force { + return true; + } + + if let Some(t) = self.tag.clone() + && t != *tag + { + return false; + } + + true + } + }) + .collect(); + + let (_, downloaded) = sync::sync_remote(&client, operations, &store, self.page).await?; + + println!("Downloaded {} records", downloaded.len()); + + crate::sync::build(settings, &store, db, Some(&downloaded)).await?; + + Ok(()) + } +} diff --git a/crates/client/src/command/client/store/purge.rs b/crates/client/src/command/client/store/purge.rs new file mode 100644 index 00000000..a23f1886 --- /dev/null +++ b/crates/client/src/command/client/store/purge.rs @@ -0,0 +1,24 @@ +use clap::Args; +use eyre::Result; + +use crate::atuin_client::{ + encryption::load_key, record::sqlite_store::SqliteStore, settings::Settings, +}; + +#[derive(Args, Debug)] +pub(crate) struct Purge {} + +impl Purge { + pub(crate) async fn run(&self, settings: &Settings, store: SqliteStore) -> Result<()> { + println!("Purging local records that cannot be decrypted"); + + let key = load_key(settings)?; + + match store.purge(&key.into()).await { + Ok(()) => println!("Local store purge completed OK"), + Err(e) => println!("Failed to purge local store: {e:?}"), + } + + Ok(()) + } +} diff --git a/crates/client/src/command/client/store/push.rs b/crates/client/src/command/client/store/push.rs new file mode 100644 index 00000000..9d66b5b2 --- /dev/null +++ b/crates/client/src/command/client/store/push.rs @@ -0,0 +1,113 @@ +use crate::atuin_common::record::HostId; +use clap::Args; +use eyre::{OptionExt, Result}; +use uuid::Uuid; + +use crate::atuin_client::{ + api_client::Client, + encryption::load_key, + record::sync::Operation, + record::{sqlite_store::SqliteStore, sync}, + settings::Settings, +}; + +#[derive(Args, Debug)] +pub(crate) struct Push { + /// The tag to push (eg, 'history'). Defaults to all tags + #[arg(long, short)] + pub(crate) tag: Option<String>, + + /// The host to push, in the form of a UUID host ID. Defaults to the current host. + #[arg(long)] + pub(crate) host: Option<Uuid>, + + /// Force push records + /// This will override both host and tag, to be all hosts and all tags. First clear the remote store, then upload all of the + /// local store + #[arg(long, default_value = "false")] + pub(crate) force: bool, + + /// Page Size + /// How many records to upload at once. Defaults to 100 + #[arg(long, default_value = "100")] + pub(crate) page: u64, +} + +impl Push { + pub(crate) async fn run(&self, settings: &Settings, store: SqliteStore) -> Result<()> { + let host_id = Settings::host_id().await?; + + if self.force { + println!("Forcing remote store overwrite!"); + println!("Clearing remote store"); + + let client = Client::new( + &settings.sync.address, + settings.network_connect_timeout, + // we may be deleting a lot of data... so increase the + // timeout + settings.network_timeout * 10, + settings.sync.user_id()?.ok_or_eyre("no sync user-id")?, + ) + .expect("failed to create client"); + + client.delete_store().await?; + } + + // We can actually just use the existing diff/etc to push + // 1. Diff + // 2. Get operations + // 3. Filter operations by + // a) are they an upload op? + // b) are they for the host/tag we are pushing here? + let client = sync::build_client(settings)?; + let (diff, remote_index) = sync::diff(&client, &store).await?; + + // Skip on --force: that path intentionally replaces remote with local. + if !self.force { + let key: [u8; 32] = load_key(settings)?.into(); + sync::check_encryption_key(&client, &remote_index, &key) + .await + .map_err(crate::print_error::format_sync_error)?; + } + + let operations = sync::operations(diff, &store)?; + + let operations = operations + .into_iter() + .filter(|op| match op { + // No noops or downloads thx + Operation::Noop { .. } | Operation::Download { .. } => false, + + // push, so yes plz to uploads! + Operation::Upload { host, tag, .. } => { + if self.force { + return true; + } + + if let Some(h) = self.host { + if HostId(h) != *host { + return false; + } + } else if *host != host_id { + return false; + } + + if let Some(t) = self.tag.clone() + && t != *tag + { + return false; + } + + true + } + }) + .collect(); + + let (uploaded, _) = sync::sync_remote(&client, operations, &store, self.page).await?; + + println!("Uploaded {uploaded} records"); + + Ok(()) + } +} diff --git a/crates/client/src/command/client/store/rebuild.rs b/crates/client/src/command/client/store/rebuild.rs new file mode 100644 index 00000000..6be67cd0 --- /dev/null +++ b/crates/client/src/command/client/store/rebuild.rs @@ -0,0 +1,56 @@ +use clap::Args; +use eyre::{Result, bail}; + +use crate::command::client::daemon as daemon_cmd; + +use crate::atuin_client::{ + database::ClientSqlite, encryption, history::store::HistoryStore, + record::sqlite_store::SqliteStore, settings::Settings, +}; + +#[derive(Args, Debug)] +pub(crate) struct Rebuild { + pub(crate) tag: String, +} + +impl Rebuild { + pub(crate) async fn run( + &self, + settings: &Settings, + store: SqliteStore, + database: &ClientSqlite, + ) -> Result<()> { + // keep it as a string and not an enum atm + // would be super cool to build this dynamically in the future + // eg register handles for rebuilding various tags without having to make this part of the + // binary big + match self.tag.as_str() { + "history" => { + self.rebuild_history(settings, store.clone(), database) + .await?; + } + + tag => bail!("unknown tag: {tag}"), + } + + Ok(()) + } + + async fn rebuild_history( + &self, + settings: &Settings, + store: SqliteStore, + database: &ClientSqlite, + ) -> Result<()> { + let encryption_key: [u8; 32] = encryption::load_key(settings)?.into(); + + let host_id = Settings::host_id().await?; + let history_store = HistoryStore::new(store, host_id, encryption_key); + + history_store.build(database).await?; + + daemon_cmd::emit_event(settings, crate::atuin_daemon::DaemonEvent::HistoryRebuilt).await; + + Ok(()) + } +} diff --git a/crates/client/src/command/client/store/rekey.rs b/crates/client/src/command/client/store/rekey.rs new file mode 100644 index 00000000..2b379327 --- /dev/null +++ b/crates/client/src/command/client/store/rekey.rs @@ -0,0 +1,46 @@ +use clap::Args; +use eyre::Result; +use tokio::{fs::File, io::AsyncWriteExt}; + +use crate::atuin_client::{ + encryption::{decode_key, generate_encoded_key, load_key}, + record::sqlite_store::SqliteStore, + settings::Settings, +}; + +#[derive(Args, Debug)] +pub(crate) struct Rekey { + /// The new key to use for encryption. Omit for a randomly-generated key + key: Option<String>, +} + +impl Rekey { + pub(crate) async fn run(&self, settings: &Settings, store: SqliteStore) -> Result<()> { + let key = if let Some(key) = self.key.clone() { + println!("Re-encrypting store with specified key"); + + key + } else { + println!("Re-encrypting store with freshly-generated key"); + let (_, encoded) = generate_encoded_key()?; + encoded + }; + + let current_key: [u8; 32] = load_key(settings)?.into(); + let new_key: [u8; 32] = decode_key(&key)?.into(); + + store.re_encrypt(¤t_key, &new_key).await?; + + if let Some(key_path) = settings.sync.encryption_key_path.as_ref() { + println!("Store rewritten. Saving new key"); + let mut file = File::create(key_path).await?; + file.write_all(key.as_bytes()).await?; + } else { + println!( + "No key-path (settings.sync.encryption_key_path) set in config, will not save new key." + ); + } + + Ok(()) + } +} diff --git a/crates/client/src/command/client/store/verify.rs b/crates/client/src/command/client/store/verify.rs new file mode 100644 index 00000000..a39227f9 --- /dev/null +++ b/crates/client/src/command/client/store/verify.rs @@ -0,0 +1,24 @@ +use clap::Args; +use eyre::Result; + +use crate::atuin_client::{ + encryption::load_key, record::sqlite_store::SqliteStore, settings::Settings, +}; + +#[derive(Args, Debug)] +pub(crate) struct Verify {} + +impl Verify { + pub(crate) async fn run(&self, settings: &Settings, store: SqliteStore) -> Result<()> { + println!("Verifying local store can be decrypted with the current key"); + + let key = load_key(settings)?; + + match store.verify(&key.into()).await { + Ok(()) => println!("Local store encryption verified OK"), + Err(e) => println!("Failed to verify local store encryption: {e:?}"), + } + + Ok(()) + } +} diff --git a/crates/client/src/command/client/sync.rs b/crates/client/src/command/client/sync.rs new file mode 100644 index 00000000..d03dd926 --- /dev/null +++ b/crates/client/src/command/client/sync.rs @@ -0,0 +1,101 @@ +use clap::Subcommand; +use eyre::{Result, bail}; + +use tracing::info; +use turtle_api::client::{Probe, probe}; + +use crate::atuin_client::settings::Settings; + +#[derive(Subcommand, Debug)] +#[command(infer_subcommands = true)] +pub(crate) enum Cmd { + /// Sync with the configured server + Perform {}, + + /// Print (or generate) the encryption key and user id for transfer to another machine + KeyAndId {}, + + /// Display the sync status + Status, +} + +impl Cmd { + pub(crate) async fn run(self, settings: Settings) -> Result<()> { + match self { + Self::Perform {} => perform_cmd(&settings).await, + Self::Status => status_cmd(&settings).await, + Self::KeyAndId {} => { + todo!() + // use crate::atuin_client::encryption::{encode_key, load_key}; + // + // let key = load_key(&settings).wrap_err("could not load encryption key")?; + // let user_id = settings + // .sync + // .user_id() + // .wrap_err("Failed to load user-id")? + // .unwrap_or_else(utils::uuid_v7); + // + // let key = encode_key(&key).wrap_err("could not encode encryption key")?; + // + // let json = serde_json::to_string_pretty(&json!({ "key": key, "user_id": user_id })) + // .expect("Will always be formattable"); + // + // println!("{json}"); + // + // Ok(()) + } + } + } +} + +async fn status_cmd(settings: &Settings) -> Result<()> { + todo!(); + + // if let Some(me) = settings.sync.user_id()? { + // let last_sync = Settings::last_sync().await?; + // + // println!("Atuin v{VERSION} - Build rev {SHA}\n"); + // + // println!("{}", "[Local]".green()); + // println!("Sync frequency: {}", settings.sync.frequency); + // println!("Last sync: {}", last_sync.to_offset(settings.timezone.0)); + // println!("Auto sync: {}", settings.sync.auto); + // + // println!("{}", "[Remote]".green()); + // println!("Address: {}", settings.sync.address); + // println!("User id: {me}"); + // } else { + // bail!("You are not logged in to a sync server - cannot show sync status"); + // } + // + // Ok(()) +} + +async fn perform_cmd(settings: &Settings) -> Result<()> { + match probe(settings.daemon.socket_path.clone()).await { + Probe::Ready(mut control_client) => { + let reply = control_client.force_sync().await?; + + match reply.error { + Some(err) => { + bail!("Daemon failed to sync: {err}"); + } + None => { + info!( + down = reply.downloaded, + up = reply.uploaded, + "Sync completed." + ); + } + } + } + Probe::NeedsRestart(msg) => { + bail!("Daemon version mis-match, needs restart: {msg}"); + } + Probe::Unreachable(report) => { + bail!("Daemon unreachable: {report}"); + } + } + + Ok(()) +} diff --git a/crates/client/src/command/client/wrapped.rs b/crates/client/src/command/client/wrapped.rs new file mode 100644 index 00000000..4219aa2d --- /dev/null +++ b/crates/client/src/command/client/wrapped.rs @@ -0,0 +1,329 @@ +use crossterm::style::{ResetColor, SetAttribute}; +use eyre::Result; +use std::collections::{HashMap, HashSet}; +use time::{Date, Duration, Month, OffsetDateTime, Time}; +use turtle_api::{ + client::{HistoryClient, Range}, + history::History, +}; + +use crate::{ + atuin_client::settings::Settings, + atuin_history::stats::{Stats, compute}, +}; + +#[derive(Debug)] +struct WrappedStats { + nav_commands: usize, + pkg_commands: usize, + error_rate: f64, + first_half_commands: Vec<(String, usize)>, + second_half_commands: Vec<(String, usize)>, + git_percentage: f64, + busiest_hour: Option<(String, usize)>, +} + +impl WrappedStats { + #[expect(clippy::too_many_lines, clippy::cast_precision_loss)] + fn new(settings: &Settings, stats: &Stats, history: &[History]) -> Self { + let nav_commands = stats + .top + .iter() + .filter(|(cmd, _)| { + let cmd = &cmd[0]; + cmd == "cd" + || cmd == "ls" + || cmd == "ll" + || cmd == "pwd" + || cmd == "pushd" + || cmd == "popd" + }) + .map(|(_, count)| count) + .sum(); + + let pkg_managers = [ + "cargo", + "npm", + "pnpm", + "yarn", + "pip", + "pip3", + "pipenv", + "poetry", + "pipx", + "uv", + "brew", + "apt", + "apt-get", + "apk", + "pacman", + "yay", + "paru", + "yum", + "dnf", + "dnf5", + "rpm", + "rpm-ostree", + "zypper", + "pkg", + "chocolatey", + "choco", + "scoop", + "winget", + "gem", + "bundle", + "shards", + "composer", + "gradle", + "maven", + "mvn", + "go get", + "nuget", + "dotnet", + "mix", + "hex", + "rebar3", + "nix", + "nix-env", + "cabal", + "opam", + ]; + + let pkg_commands = history + .iter() + .filter(|h| { + let cmd = h.command.clone(); + pkg_managers.iter().any(|pm| cmd.starts_with(pm)) + }) + .count(); + + // Error analysis + let mut command_errors: HashMap<String, (usize, usize)> = HashMap::new(); // (total_uses, errors) + let midyear = history[0].timestamp + Duration::days(182); // Split year in half + + let mut first_half_commands: HashMap<String, usize> = HashMap::new(); + let mut second_half_commands: HashMap<String, usize> = HashMap::new(); + let mut hours: HashMap<String, usize> = HashMap::new(); + + for entry in history { + let cmd = entry + .command + .split_whitespace() + .next() + .unwrap_or("") + .to_string(); + let (total, errors) = command_errors.entry(cmd.clone()).or_insert((0, 0)); + *total += 1; + if entry.exit != 0 { + *errors += 1; + } + + // Track command evolution + if entry.timestamp < midyear { + *first_half_commands.entry(cmd.clone()).or_default() += 1; + } else { + *second_half_commands.entry(cmd).or_default() += 1; + } + + // Track hourly distribution + let local_time = entry + .timestamp + .to_offset(time::UtcOffset::current_local_offset().unwrap_or(settings.timezone.0)); + let hour = format!("{:02}:00", local_time.time().hour()); + *hours.entry(hour).or_default() += 1; + } + + let total_errors: usize = command_errors.values().map(|(_, errors)| errors).sum(); + let total_commands: usize = command_errors.values().map(|(total, _)| total).sum(); + let error_rate = total_errors as f64 / total_commands as f64; + + // Process command evolution data + let mut first_half: Vec<_> = first_half_commands.into_iter().collect(); + let mut second_half: Vec<_> = second_half_commands.into_iter().collect(); + first_half.sort_by_key(|(_, count)| std::cmp::Reverse(*count)); + second_half.sort_by_key(|(_, count)| std::cmp::Reverse(*count)); + first_half.truncate(5); + second_half.truncate(5); + + // Calculate git percentage + let git_commands: usize = stats + .top + .iter() + .filter(|(cmd, _)| cmd[0].starts_with("git")) + .map(|(_, count)| count) + .sum(); + let git_percentage = git_commands as f64 / stats.total_commands as f64; + + // Find busiest hour + let busiest_hour = hours.into_iter().max_by_key(|(_, count)| *count); + + Self { + nav_commands, + pkg_commands, + error_rate, + first_half_commands: first_half, + second_half_commands: second_half, + git_percentage, + busiest_hour, + } + } +} + +pub(crate) fn print_wrapped_header(year: i32) { + let reset = ResetColor; + let bold = SetAttribute(crossterm::style::Attribute::Bold); + + println!("{bold}╭────────────────────────────────────╮{reset}"); + println!("{bold}│ ATUIN WRAPPED {year} │{reset}"); + println!("{bold}│ Your Year in Shell History │{reset}"); + println!("{bold}╰────────────────────────────────────╯{reset}"); + println!(); +} + +#[expect(clippy::cast_precision_loss)] +fn print_fun_facts(wrapped_stats: &WrappedStats, stats: &Stats, year: i32) { + let reset = ResetColor; + let bold = SetAttribute(crossterm::style::Attribute::Bold); + + if wrapped_stats.git_percentage > 0.05 { + println!( + "{bold}🌟 You're a Git Power User!{reset} {bold}{:.1}%{reset} of your commands were Git operations\n", + wrapped_stats.git_percentage * 100.0 + ); + } + // Navigation patterns + let nav_percentage = wrapped_stats.nav_commands as f64 / stats.total_commands as f64 * 100.0; + if nav_percentage > 0.05 { + println!( + "{bold}🚀 You're a Navigator!{reset} {bold}{nav_percentage:.1}%{reset} of your time was spent navigating directories\n", + ); + } + + // Command vocabulary + println!( + "{bold}📚 Command Vocabulary{reset}: You know {bold}{}{reset} unique commands\n", + stats.unique_commands + ); + + // Package management + println!( + "{bold}📦 Package Management{reset}: You ran {bold}{}{reset} package-related commands\n", + wrapped_stats.pkg_commands + ); + + // Error patterns + let error_percentage = wrapped_stats.error_rate * 100.0; + println!( + "{bold}🚨 Error Analysis{reset}: Your commands failed {bold}{error_percentage:.1}%{reset} of the time\n", + ); + + // Command evolution + println!("🔍 Command Evolution:"); + + // print stats for each half and compare + println!(" {bold}Top Commands{reset} in the first half of {year}:"); + for (cmd, count) in wrapped_stats.first_half_commands.iter().take(3) { + println!(" {bold}{cmd}{reset} ({count} times)"); + } + + println!(" {bold}Top Commands{reset} in the second half of {year}:"); + for (cmd, count) in wrapped_stats.second_half_commands.iter().take(3) { + println!(" {bold}{cmd}{reset} ({count} times)"); + } + + // Find new favorite commands (in top 5 of second half but not in first half) + let first_half_set: HashSet<_> = wrapped_stats + .first_half_commands + .iter() + .map(|(cmd, _)| cmd) + .collect(); + let new_favorites: Vec<_> = wrapped_stats + .second_half_commands + .iter() + .filter(|(cmd, _)| !first_half_set.contains(cmd)) + .take(2) + .collect(); + + if !new_favorites.is_empty() { + println!(" {bold}New favorites{reset} in the second half:"); + for (cmd, count) in new_favorites { + println!(" {bold}{cmd}{reset} ({count} times)"); + } + } + + // Time patterns + if let Some((hour, count)) = &wrapped_stats.busiest_hour { + println!("\n🕘 Most Productive Hour: {bold}{hour}{reset} ({count} commands)"); + + // Night owl or early bird + let hour_num = hour + .split(':') + .next() + .unwrap_or("0") + .parse::<u32>() + .unwrap_or(0); + if hour_num >= 22 || hour_num <= 4 { + println!(" You're quite the night owl! 🦉"); + } else if (5..=7).contains(&hour_num) { + println!(" Early bird gets the worm! 🐦"); + } + } + + println!(); +} + +pub(crate) async fn run(year: Option<i32>, settings: &Settings) -> Result<()> { + let mut client = HistoryClient::new(settings.daemon.socket_path.clone()).await?; + + let now = OffsetDateTime::now_utc().to_offset(settings.timezone.0); + let month = now.month(); + + // If we're in December, then wrapped is for the current year. If not, it's for the previous year + let year = year.unwrap_or_else(|| { + if month == Month::December { + now.year() + } else { + now.year() - 1 + } + }); + + let start = OffsetDateTime::new_in_offset( + Date::from_calendar_date(year, Month::January, 1).unwrap(), + Time::MIDNIGHT, + now.offset(), + ); + let end = OffsetDateTime::new_in_offset( + Date::from_calendar_date(year, Month::December, 31).unwrap(), + Time::MIDNIGHT + Duration::days(1) - Duration::nanoseconds(1), + now.offset(), + ); + + let history = client.history(Some(Range { start, end })).await?; + if history.is_empty() { + println!( + "Your history for {year} is empty!\nMaybe 'atuin import' could help you import your previous history 🪄" + ); + return Ok(()); + } + + // Compute overall stats using existing functionality + let stats = compute(settings, &history, 10, 1).expect("Failed to compute stats"); + let wrapped_stats = WrappedStats::new(settings, &stats, &history); + + // Print wrapped format + print_wrapped_header(year); + + println!("🎉 In {year}, you typed {} commands!", stats.total_commands); + println!( + " That's ~{} commands every day\n", + stats.total_commands / 365 + ); + + println!("Your Top Commands:"); + crate::atuin_history::stats::pretty_print(stats.clone(), 1); + println!(); + + print_fun_facts(&wrapped_stats, &stats, year); + + Ok(()) +} diff --git a/crates/client/src/command/contributors.rs b/crates/client/src/command/contributors.rs new file mode 100644 index 00000000..b2a41522 --- /dev/null +++ b/crates/client/src/command/contributors.rs @@ -0,0 +1,5 @@ +static CONTRIBUTORS: &str = include_str!("CONTRIBUTORS"); + +pub(crate) fn run() { + println!("\n{CONTRIBUTORS}"); +} diff --git a/crates/client/src/command/gen_completions.rs b/crates/client/src/command/gen_completions.rs new file mode 100644 index 00000000..9f13bffc --- /dev/null +++ b/crates/client/src/command/gen_completions.rs @@ -0,0 +1,84 @@ +use clap::{CommandFactory, Parser, ValueEnum}; +use clap_complete::{Generator, Shell, generate, generate_to}; +use clap_complete_nushell::Nushell; +use eyre::Result; + +// clap put nushell completions into a separate package due to the maintainers +// being a little less committed to support them. +// This means we have to do a tiny bit of legwork to combine these completions +// into one command. +#[derive(Debug, Clone, ValueEnum)] +#[value(rename_all = "lower")] +pub(crate) enum GenShell { + Bash, + Elvish, + Fish, + Nushell, + PowerShell, + Zsh, +} + +impl Generator for GenShell { + fn file_name(&self, name: &str) -> String { + match self { + // clap_complete + Self::Bash => Shell::Bash.file_name(name), + Self::Elvish => Shell::Elvish.file_name(name), + Self::Fish => Shell::Fish.file_name(name), + Self::PowerShell => Shell::PowerShell.file_name(name), + Self::Zsh => Shell::Zsh.file_name(name), + + // clap_complete_nushell + Self::Nushell => Nushell.file_name(name), + } + } + + fn generate(&self, cmd: &clap::Command, buf: &mut dyn std::io::prelude::Write) { + match self { + // clap_complete + Self::Bash => Shell::Bash.generate(cmd, buf), + Self::Elvish => Shell::Elvish.generate(cmd, buf), + Self::Fish => Shell::Fish.generate(cmd, buf), + Self::PowerShell => Shell::PowerShell.generate(cmd, buf), + Self::Zsh => Shell::Zsh.generate(cmd, buf), + + // clap_complete_nushell + Self::Nushell => Nushell.generate(cmd, buf), + } + } +} + +#[derive(Debug, Parser)] +pub(crate) struct Cmd { + /// Set the shell for generating completions + #[arg(long, short)] + shell: GenShell, + + /// Set the output directory + #[arg(long, short)] + out_dir: Option<String>, +} + +impl Cmd { + pub(crate) fn run(self) -> Result<()> { + let Self { shell, out_dir } = self; + + let mut cli = crate::Atuin::command(); + + match out_dir { + Some(out_dir) => { + generate_to(shell, &mut cli, env!("CARGO_PKG_NAME"), &out_dir)?; + } + None => { + generate( + shell, + &mut cli, + env!("CARGO_PKG_NAME"), + &mut std::io::stdout(), + ); + } + } + + Ok(()) + } +} diff --git a/crates/client/src/command/mod.rs b/crates/client/src/command/mod.rs new file mode 100644 index 00000000..a2e75034 --- /dev/null +++ b/crates/client/src/command/mod.rs @@ -0,0 +1,56 @@ +use clap::Subcommand; +use eyre::Result; + +#[cfg(not(windows))] +use rustix::{fs::Mode, process::umask}; + +mod client; +mod contributors; +mod gen_completions; + +#[derive(Subcommand)] +#[command(infer_subcommands = true)] +pub(crate) enum AtuinCmd { + #[command(flatten)] + Client(client::Cmd), + + /// Generate a UUID + Uuid, + + Contributors, + + /// Generate shell completions + GenCompletions(gen_completions::Cmd), +} + +impl AtuinCmd { + pub(crate) fn run(self) -> Result<()> { + #[cfg(not(windows))] + { + // set umask before we potentially open/create files + // or in other words, 077. Do not allow any access to any other user + let mode = Mode::RWXG | Mode::RWXO; + umask(mode); + } + + match self { + Self::Client(client) => client.run(), + + Self::Contributors => { + contributors::run(); + Ok(()) + } + Self::Uuid => { + println!("{}", turtle_common::utils::uuid_v7().as_simple()); + Ok(()) + } + Self::GenCompletions(gen_completions) => gen_completions.run(), + } + } +} + +pub(crate) fn current_session() -> Result<String> { + std::env::var("ATUIN_SESSION").map_err(|_| { + eyre::eyre!("Failed to find $ATUIN_SESSION in the environment. Check that you have correctly set up your shell.") + }) +} |
