aboutsummaryrefslogtreecommitdiffstats
path: root/crates/client/src
diff options
context:
space:
mode:
Diffstat (limited to 'crates/client/src')
-rw-r--r--crates/client/src/atuin_client/mod.rs16
-rw-r--r--crates/client/src/atuin_client/settings/meta.rs0
-rw-r--r--crates/client/src/atuin_client/settings/mod.rs596
-rw-r--r--crates/client/src/atuin_history/mod.rs1
-rw-r--r--crates/client/src/atuin_history/stats.rs505
l---------crates/client/src/command/CONTRIBUTORS1
-rw-r--r--crates/client/src/command/client.rs121
-rw-r--r--crates/client/src/command/client/config.rs352
-rw-r--r--crates/client/src/command/client/daemon.rs46
-rw-r--r--crates/client/src/command/client/default_config.rs4
-rw-r--r--crates/client/src/command/client/history/end.rs44
-rw-r--r--crates/client/src/command/client/history/list.rs283
-rw-r--r--crates/client/src/command/client/history/mod.rs261
-rw-r--r--crates/client/src/command/client/history/start.rs80
-rw-r--r--crates/client/src/command/client/history/tail.rs322
-rw-r--r--crates/client/src/command/client/info.rs46
-rw-r--r--crates/client/src/command/client/stats.rs85
-rw-r--r--crates/client/src/command/client/store/mod.rs108
-rw-r--r--crates/client/src/command/client/store/pull.rs95
-rw-r--r--crates/client/src/command/client/store/purge.rs24
-rw-r--r--crates/client/src/command/client/store/push.rs113
-rw-r--r--crates/client/src/command/client/store/rebuild.rs56
-rw-r--r--crates/client/src/command/client/store/rekey.rs46
-rw-r--r--crates/client/src/command/client/store/verify.rs24
-rw-r--r--crates/client/src/command/client/sync.rs101
-rw-r--r--crates/client/src/command/client/wrapped.rs329
-rw-r--r--crates/client/src/command/contributors.rs5
-rw-r--r--crates/client/src/command/gen_completions.rs84
-rw-r--r--crates/client/src/command/mod.rs56
-rw-r--r--crates/client/src/main.rs79
30 files changed, 3883 insertions, 0 deletions
diff --git a/crates/client/src/atuin_client/mod.rs b/crates/client/src/atuin_client/mod.rs
new file mode 100644
index 00000000..ed9f57b6
--- /dev/null
+++ b/crates/client/src/atuin_client/mod.rs
@@ -0,0 +1,16 @@
+pub(crate) mod settings;
+
+// pub async fn current_context(session: String) -> eyre::Result<Context> {
+// let hostname = get_host_user();
+// let cwd = utils::get_current_dir();
+// let host_id = Settings::host_id().await?;
+// let git_root = utils::in_git_repo(cwd.as_str());
+//
+// Ok(Context {
+// session,
+// hostname,
+// cwd,
+// git_root,
+// host_id: host_id.0.as_simple().to_string(),
+// })
+// }
diff --git a/crates/client/src/atuin_client/settings/meta.rs b/crates/client/src/atuin_client/settings/meta.rs
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/crates/client/src/atuin_client/settings/meta.rs
diff --git a/crates/client/src/atuin_client/settings/mod.rs b/crates/client/src/atuin_client/settings/mod.rs
new file mode 100644
index 00000000..21ac3778
--- /dev/null
+++ b/crates/client/src/atuin_client/settings/mod.rs
@@ -0,0 +1,596 @@
+use std::{collections::HashMap, fmt, path::PathBuf, str::FromStr, sync::OnceLock};
+
+use clap::ValueEnum;
+use config::{
+ Config, ConfigBuilder, Environment, File as ConfigFile, FileFormat, builder::DefaultState,
+};
+use eyre::{Context, Error, Result, bail, eyre};
+use fs_err::create_dir_all;
+use regex::RegexSet;
+use serde::{Deserialize, Serialize};
+use serde_with::DeserializeFromStr;
+use time::{UtcOffset, format_description::FormatItem, macros::format_description};
+use tracing::info;
+use turtle_common::utils;
+
+static DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
+
+/// Type wrapper around `time::UtcOffset` to support a wider variety of timezone formats.
+///
+/// Note that the parsing of this struct needs to be done before starting any
+/// multithreaded runtime, otherwise it will fail on most Unix systems.
+///
+/// See: <https://github.com/atuinsh/atuin/pull/1517#discussion_r1447516426>
+#[derive(Clone, Copy, Debug, Eq, PartialEq, DeserializeFromStr, Serialize)]
+pub(crate) struct Timezone(pub(crate) UtcOffset);
+impl fmt::Display for Timezone {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ self.0.fmt(f)
+ }
+}
+/// format: <+|-><hour>[:<minute>[:<second>]]
+static OFFSET_FMT: &[FormatItem<'_>] = format_description!(
+ "[offset_hour sign:mandatory padding:none][optional [:[offset_minute padding:none][optional [:[offset_second padding:none]]]]]"
+);
+impl FromStr for Timezone {
+ type Err = Error;
+
+ fn from_str(s: &str) -> Result<Self> {
+ // local timezone
+ if matches!(s.to_lowercase().as_str(), "l" | "local") {
+ // There have been some timezone issues, related to errors fetching it on some
+ // platforms
+ // Rather than fail to start, fallback to UTC. The user should still be able to specify
+ // their timezone manually in the config file.
+ let offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);
+ return Ok(Self(offset));
+ }
+
+ if matches!(s.to_lowercase().as_str(), "0" | "utc") {
+ let offset = UtcOffset::UTC;
+ return Ok(Self(offset));
+ }
+
+ // offset from UTC
+ if let Ok(offset) = UtcOffset::parse(s, OFFSET_FMT) {
+ return Ok(Self(offset));
+ }
+
+ // IDEA: Currently named timezones are not supported, because the well-known crate
+ // for this is `chrono_tz`, which is not really interoperable with the datetime crate
+ // that we currently use - `time`. If ever we migrate to using `chrono`, this would
+ // be a good feature to add.
+
+ bail!(r#""{s}" is not a valid timezone spec"#)
+ }
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub(crate) struct Stats {
+ /// sudo, etc. commands we want to strip off
+ #[serde(default = "Stats::common_prefix_default")]
+ pub(crate) common_prefix: Vec<String>,
+
+ /// kubectl, commands we should consider subcommands for
+ #[serde(default = "Stats::common_subcommands_default")]
+ pub(crate) common_subcommands: Vec<String>,
+
+ /// cd, ls, etc. commands we want to completely hide from stats
+ #[serde(default = "Stats::ignored_commands_default")]
+ pub(crate) ignored_commands: Vec<String>,
+}
+
+impl Stats {
+ fn common_prefix_default() -> Vec<String> {
+ vec!["sudo", "doas"].into_iter().map(String::from).collect()
+ }
+
+ fn common_subcommands_default() -> Vec<String> {
+ vec![
+ "apt",
+ "cargo",
+ "composer",
+ "dnf",
+ "docker",
+ "dotnet",
+ "git",
+ "go",
+ "ip",
+ "jj",
+ "kubectl",
+ "nix",
+ "nmcli",
+ "npm",
+ "pecl",
+ "pnpm",
+ "podman",
+ "port",
+ "systemctl",
+ "tmux",
+ "yarn",
+ ]
+ .into_iter()
+ .map(String::from)
+ .collect()
+ }
+
+ fn ignored_commands_default() -> Vec<String> {
+ vec![]
+ }
+}
+
+impl Default for Stats {
+ fn default() -> Self {
+ Self {
+ common_prefix: Self::common_prefix_default(),
+ common_subcommands: Self::common_subcommands_default(),
+ ignored_commands: Self::ignored_commands_default(),
+ }
+ }
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize, Default)]
+pub(crate) struct Daemon {
+ /// The path to the unix socket used by the daemon
+ pub(crate) socket_path: String,
+
+ /// Use a socket passed via systemd's socket activation protocol, instead of the path
+ pub(crate) systemd_socket: bool,
+}
+
+// The preview height strategy also takes max_preview_height into account.
+#[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum, Serialize)]
+pub(crate) enum PreviewStrategy {
+ // Preview height is calculated for the length of the selected command.
+ #[serde(rename = "auto")]
+ Auto,
+
+ // Preview height is calculated for the length of the longest command stored in the history.
+ #[serde(rename = "static")]
+ Static,
+
+ // max_preview_height is used as fixed height.
+ #[serde(rename = "fixed")]
+ Fixed,
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub(crate) struct Settings {
+ data_dir: Option<String>,
+ pub(crate) timezone: Timezone,
+
+ pub(crate) strip_trailing_whitespace: bool,
+
+ #[serde(with = "serde_regex", default = "RegexSet::empty", skip_serializing)]
+ pub(crate) history_filter: RegexSet,
+
+ #[serde(with = "serde_regex", default = "RegexSet::empty", skip_serializing)]
+ pub(crate) cwd_filter: RegexSet,
+
+ pub(crate) secrets_filter: bool,
+
+ #[serde(default)]
+ pub(crate) stats: Stats,
+
+ #[serde(default)]
+ pub(crate) daemon: Daemon,
+}
+
+impl Settings {
+ fn builder() -> Result<ConfigBuilder<DefaultState>> {
+ Self::builder_with_data_dir(&utils::data_dir())
+ }
+
+ #[expect(clippy::too_many_lines)]
+ fn builder_with_data_dir(data_dir: &std::path::Path) -> Result<ConfigBuilder<DefaultState>> {
+ let db_path = data_dir.join("history.db");
+ let record_store_path = data_dir.join("records.db");
+ let kv_path = data_dir.join("kv.db");
+ let scripts_path = data_dir.join("scripts.db");
+ let socket_path = utils::daemon_socket_path();
+ let pidfile_path = data_dir.join("atuin-daemon.pid");
+
+ let key_path = data_dir.join("key");
+
+ Ok(Config::builder()
+ .set_default("history_format", "{time}\t{command}\t{duration}")?
+ .set_default("db_path", db_path.to_str())?
+ .set_default("record_store_path", record_store_path.to_str())?
+ .set_default("key_path", key_path.to_str())?
+ .set_default("dialect", "us")?
+ .set_default("timezone", "local")?
+ .set_default("auto_sync", true)?
+ .set_default("sync.address", "https://api.atuin.sh")?
+ .set_default("sync_frequency", "5m")?
+ .set_default("search_mode", "fuzzy")?
+ .set_default("filter_mode", None::<String>)?
+ .set_default("style", "compact")?
+ .set_default("inline_height", 40)?
+ .set_default("show_preview", true)?
+ .set_default("preview.strategy", "auto")?
+ .set_default("max_preview_height", 4)?
+ .set_default("show_help", true)?
+ .set_default("show_tabs", true)?
+ .set_default("show_numeric_shortcuts", true)?
+ .set_default("auto_hide_height", 8)?
+ .set_default("invert", false)?
+ .set_default("exit_mode", "return-original")?
+ .set_default("word_jump_mode", "emacs")?
+ .set_default(
+ "word_chars",
+ "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
+ )?
+ .set_default("scroll_context_lines", 1)?
+ .set_default("shell_up_key_binding", false)?
+ .set_default("workspaces", false)?
+ .set_default("ctrl_n_shortcuts", false)?
+ .set_default("secrets_filter", true)?
+ .set_default("strip_trailing_whitespace", true)?
+ .set_default("network_connect_timeout", 5)?
+ .set_default("network_timeout", 30)?
+ .set_default("local_timeout", 2.0)?
+ // enter_accept defaults to false here, but true in the default config file. The dissonance is
+ // intentional!
+ // Existing users will get the default "False", so we don't mess with any potential
+ // muscle memory.
+ // New users will get the new default, that is more similar to what they are used to.
+ .set_default("enter_accept", false)?
+ .set_default("keys.scroll_exits", true)?
+ .set_default("keys.accept_past_line_end", true)?
+ .set_default("keys.exit_past_line_start", true)?
+ .set_default("keys.accept_past_line_start", false)?
+ .set_default("keys.accept_with_backspace", false)?
+ .set_default("keys.prefix", "a")?
+ .set_default("keymap_mode", "emacs")?
+ .set_default("keymap_mode_shell", "auto")?
+ .set_default("keymap_cursor", HashMap::<String, String>::new())?
+ .set_default("smart_sort", false)?
+ .set_default("command_chaining", false)?
+ .set_default("store_failed", true)?
+ .set_default("daemon.sync_frequency", 300)?
+ .set_default("daemon.socket_path", socket_path.to_str())?
+ .set_default("daemon.pidfile_path", pidfile_path.to_str())?
+ .set_default("daemon.systemd_socket", false)?
+ .set_default("daemon.tcp_port", 8889)?
+ .set_default("logs.enabled", true)?
+ .set_default("logs.level", "info")?
+ .set_default("logs.search.file", "search.log")?
+ .set_default("logs.daemon.file", "daemon.log")?
+ .set_default("logs.ai.file", "ai.log")?
+ .set_default("kv.db_path", kv_path.to_str())?
+ .set_default("scripts.db_path", scripts_path.to_str())?
+ .set_default("search.recency_score_multiplier", 1.0)?
+ .set_default("search.frequency_score_multiplier", 1.0)?
+ .set_default("search.frecency_score_multiplier", 1.0)?
+ .set_default("ai.session_continue_minutes", 60)?
+ .set_default("ai.send_cwd", false)?
+ .set_default("ai.opening.send_cwd", false)?
+ .set_default("ai.opening.send_last_command", false)?
+ .set_default(
+ "search.filters",
+ vec![
+ "global",
+ "host",
+ "session",
+ "workspace",
+ "directory",
+ "session-preload",
+ ],
+ )?
+ .set_default("theme.name", "default")?
+ .set_default("theme.debug", None::<bool>)?
+ .set_default("tmux.enabled", false)?
+ .set_default("tmux.width", "80%")?
+ .set_default("tmux.height", "60%")?
+ .set_default(
+ "prefers_reduced_motion",
+ std::env::var("NO_MOTION").ok().map_or_else(
+ || config::Value::new(None, config::ValueKind::Boolean(false)),
+ |_| config::Value::new(None, config::ValueKind::Boolean(true)),
+ ),
+ )?
+ .set_default("no_mouse", false)?
+ .add_source(
+ Environment::with_prefix("atuin")
+ .prefix_separator("_")
+ .separator("__"),
+ ))
+ }
+
+ pub(crate) fn get_config_path() -> Result<PathBuf> {
+ let config_dir = utils::config_dir();
+
+ create_dir_all(&config_dir)
+ .wrap_err_with(|| format!("could not create dir {}", config_dir.display()))?;
+
+ let mut config_file = std::env::var("ATUIN_CONFIG_DIR").map_or_else(
+ |_| {
+ let mut config_file = PathBuf::new();
+ config_file.push(config_dir);
+ config_file
+ },
+ PathBuf::from,
+ );
+
+ config_file.push("config.toml");
+
+ Ok(config_file)
+ }
+
+ /// Build a merged `Config` from defaults, config file, and environment.
+ ///
+ /// This resolves `data_dir`, initializes the data directory on disk,
+ /// and layers defaults → config file → env overrides. Both `new()` and
+ /// `get_config_value()` use this so the resolution logic lives in one place.
+ fn build_config() -> Result<Config> {
+ let config_file = Self::get_config_path()?;
+
+ // extract data_dir first so we can use it as the base for other path defaults
+ let effective_data_dir = if config_file.exists() {
+ #[derive(Deserialize, Default)]
+ struct DataDirOnly {
+ data_dir: Option<String>,
+ }
+
+ let config_file_str = config_file
+ .to_str()
+ .ok_or_else(|| eyre!("config file path is not valid UTF-8"))?;
+
+ let partial_config = Config::builder()
+ .add_source(ConfigFile::new(config_file_str, FileFormat::Toml))
+ .add_source(
+ Environment::with_prefix("atuin")
+ .prefix_separator("_")
+ .separator("__"),
+ )
+ .build()
+ .ok();
+
+ let custom_data_dir = partial_config
+ .and_then(|c| c.try_deserialize::<DataDirOnly>().ok())
+ .and_then(|d| d.data_dir);
+
+ match custom_data_dir {
+ Some(dir) => {
+ let expanded = shellexpand::full(&dir)
+ .map_err(|e| eyre!("failed to expand data_dir path: {}", e))?;
+ PathBuf::from(expanded.as_ref())
+ }
+ None => utils::data_dir(),
+ }
+ } else {
+ utils::data_dir()
+ };
+
+ DATA_DIR.set(effective_data_dir.clone()).ok();
+
+ create_dir_all(&effective_data_dir)
+ .wrap_err_with(|| format!("could not create dir {}", effective_data_dir.display()))?;
+
+ let mut config_builder = Self::builder_with_data_dir(&effective_data_dir)?;
+
+ config_builder = if config_file.exists() {
+ let config_file_str = config_file
+ .to_str()
+ .ok_or_else(|| eyre!("config file path is not valid UTF-8"))?;
+ config_builder.add_source(ConfigFile::new(config_file_str, FileFormat::Toml))
+ } else {
+ // TODO(@bpeetz): Rework the config handling, so that we can actually auto-write a
+ // file with defaults. <2026-06-13>
+ create_dir_all(config_file.parent().unwrap())?;
+
+ info!(
+ "No config file at: `{}`. Not adding one.",
+ config_file.display()
+ );
+
+ config_builder
+ };
+
+ // all paths should be expanded
+ let built = config_builder.build_cloned()?;
+ config_builder = [
+ "db_path",
+ "record_store_path",
+ "key_path",
+ "daemon.socket_path",
+ "daemon.pidfile_path",
+ "logs.dir",
+ "logs.search.file",
+ "logs.daemon.file",
+ ]
+ .iter()
+ .map(|key| (key, built.get_string(key).unwrap_or_default()))
+ .filter_map(|(key, value)| match Self::expand_path(&value) {
+ Ok(expanded) => Some((key, expanded)),
+ Err(e) => {
+ log::warn!("failed to expand path for {key}: {e}");
+ None
+ }
+ })
+ .fold(config_builder, |builder, (key, value)| {
+ builder
+ .set_override(key, value)
+ .unwrap_or_else(|_| panic!("failed to set absolute path override for {key}"))
+ });
+
+ config_builder.build().map_err(Into::into)
+ }
+
+ /// Look up a single config value by dotted key (e.g. `"daemon.sync_frequency"`).
+ ///
+ /// Returns the effective value after merging defaults, config file, and
+ /// environment — without the side-effects of full `Settings` construction
+ /// (meta store init, path expansion, etc.).
+ pub(crate) fn get_config_value(key: &str) -> Result<String> {
+ let config = Self::build_config()?;
+ let value: config::Value = config
+ .get(key)
+ .map_err(|e| eyre!("failed to get config value '{}': {}", key, e))?;
+ Ok(Self::format_resolved_value(&value, key))
+ }
+
+ fn format_resolved_value(value: &config::Value, prefix: &str) -> String {
+ use config::ValueKind;
+
+ match &value.kind {
+ ValueKind::Nil => String::new(),
+ ValueKind::Boolean(b) => b.to_string(),
+ ValueKind::I64(i) => i.to_string(),
+ ValueKind::I128(i) => i.to_string(),
+ ValueKind::U64(u) => u.to_string(),
+ ValueKind::U128(u) => u.to_string(),
+ ValueKind::Float(f) => f.to_string(),
+ ValueKind::String(s) => s.clone(),
+ ValueKind::Array(arr) => {
+ let items: Vec<String> = arr
+ .iter()
+ .map(|v| Self::format_resolved_value(v, ""))
+ .collect();
+ format!("[{}]", items.join(", "))
+ }
+ ValueKind::Table(map) => {
+ let mut lines = Vec::new();
+ let mut keys: Vec<_> = map.keys().collect();
+ keys.sort();
+
+ for k in keys {
+ let v = &map[k];
+ let full_key = if prefix.is_empty() {
+ k.clone()
+ } else {
+ format!("{prefix}.{k}")
+ };
+
+ match &v.kind {
+ ValueKind::Table(_) => {
+ lines.push(Self::format_resolved_value(v, &full_key));
+ }
+ _ => {
+ lines.push(format!(
+ "{} = {}",
+ full_key,
+ Self::format_resolved_value(v, "")
+ ));
+ }
+ }
+ }
+
+ lines.join("\n")
+ }
+ }
+ }
+
+ pub(crate) fn new() -> Result<Self> {
+ let config = Self::build_config()?;
+ let settings: Self = config
+ .try_deserialize()
+ .map_err(|e| eyre!("failed to deserialize: {}", e))?;
+
+ Ok(settings)
+ }
+
+ fn expand_path(path: &str) -> Result<String> {
+ shellexpand::full(&path)
+ .map(|p| p.to_string())
+ .map_err(|e| eyre!("failed to expand path: {}", e))
+ }
+}
+
+impl Default for Settings {
+ fn default() -> Self {
+ // if this panics something is very wrong, as the default config
+ // does not build or deserialize into the settings struct
+ Self::builder()
+ .expect("Could not build default")
+ .build()
+ .expect("Could not build config")
+ .try_deserialize()
+ .expect("Could not deserialize config")
+ }
+}
+
+#[cfg(test)]
+pub(crate) fn test_local_timeout() -> f64 {
+ std::env::var("ATUIN_TEST_LOCAL_TIMEOUT")
+ .ok()
+ .and_then(|x| x.parse().ok())
+ // this hardcoded value should be replaced by a simple way to get the
+ // default local_timeout of Settings if possible
+ .unwrap_or(2.0)
+}
+
+#[cfg(test)]
+mod tests {
+ use std::str::FromStr;
+
+ use eyre::Result;
+
+ use super::Timezone;
+
+ #[test]
+ fn can_parse_offset_timezone_spec() -> Result<()> {
+ assert_eq!(Timezone::from_str("+02")?.0.as_hms(), (2, 0, 0));
+ assert_eq!(Timezone::from_str("-04")?.0.as_hms(), (-4, 0, 0));
+ assert_eq!(Timezone::from_str("+05:30")?.0.as_hms(), (5, 30, 0));
+ assert_eq!(Timezone::from_str("-09:30")?.0.as_hms(), (-9, -30, 0));
+
+ // single digit hours are allowed
+ assert_eq!(Timezone::from_str("+2")?.0.as_hms(), (2, 0, 0));
+ assert_eq!(Timezone::from_str("-4")?.0.as_hms(), (-4, 0, 0));
+ assert_eq!(Timezone::from_str("+5:30")?.0.as_hms(), (5, 30, 0));
+ assert_eq!(Timezone::from_str("-9:30")?.0.as_hms(), (-9, -30, 0));
+
+ // fully qualified form
+ assert_eq!(Timezone::from_str("+09:30:00")?.0.as_hms(), (9, 30, 0));
+ assert_eq!(Timezone::from_str("-09:30:00")?.0.as_hms(), (-9, -30, 0));
+
+ // these offsets don't really exist but are supported anyway
+ assert_eq!(Timezone::from_str("+0:5")?.0.as_hms(), (0, 5, 0));
+ assert_eq!(Timezone::from_str("-0:5")?.0.as_hms(), (0, -5, 0));
+ assert_eq!(Timezone::from_str("+01:23:45")?.0.as_hms(), (1, 23, 45));
+ assert_eq!(Timezone::from_str("-01:23:45")?.0.as_hms(), (-1, -23, -45));
+
+ // require a leading sign for clarity
+ assert!(Timezone::from_str("5").is_err());
+ assert!(Timezone::from_str("10:30").is_err());
+
+ Ok(())
+ }
+
+ #[test]
+ fn builder_with_data_dir_uses_custom_paths() -> Result<()> {
+ use std::path::PathBuf;
+
+ let custom_dir = PathBuf::from("/custom/data/dir");
+ let builder = super::Settings::builder_with_data_dir(&custom_dir)?;
+ let config = builder.build()?;
+
+ let db_path: String = config.get("db_path")?;
+ let key_path: String = config.get("key_path")?;
+ let record_store_path: String = config.get("record_store_path")?;
+ let kv_db_path: String = config.get("kv.db_path")?;
+ let scripts_db_path: String = config.get("scripts.db_path")?;
+ let daemon_socket_path: String = config.get("daemon.socket_path")?;
+ let daemon_pidfile_path: String = config.get("daemon.pidfile_path")?;
+
+ assert_eq!(db_path, custom_dir.join("history.db").to_str().unwrap());
+ assert_eq!(key_path, custom_dir.join("key").to_str().unwrap());
+ assert_eq!(
+ record_store_path,
+ custom_dir.join("records.db").to_str().unwrap()
+ );
+ assert_eq!(kv_db_path, custom_dir.join("kv.db").to_str().unwrap());
+ assert_eq!(
+ scripts_db_path,
+ custom_dir.join("scripts.db").to_str().unwrap()
+ );
+
+ assert_eq!(
+ daemon_pidfile_path,
+ custom_dir.join("atuin-daemon.pid").to_str().unwrap()
+ );
+
+ Ok(())
+ }
+}
diff --git a/crates/client/src/atuin_history/mod.rs b/crates/client/src/atuin_history/mod.rs
new file mode 100644
index 00000000..b3ca0d2f
--- /dev/null
+++ b/crates/client/src/atuin_history/mod.rs
@@ -0,0 +1 @@
+pub(crate) mod stats;
diff --git a/crates/client/src/atuin_history/stats.rs b/crates/client/src/atuin_history/stats.rs
new file mode 100644
index 00000000..40be14f9
--- /dev/null
+++ b/crates/client/src/atuin_history/stats.rs
@@ -0,0 +1,505 @@
+use std::collections::{HashMap, HashSet};
+
+use crossterm::style::{Color, ResetColor, SetAttribute, SetForegroundColor};
+use serde::{Deserialize, Serialize};
+use turtle_api::history::History;
+use unicode_segmentation::UnicodeSegmentation;
+
+use crate::atuin_client::settings::Settings;
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub(crate) struct Stats {
+ pub(crate) total_commands: usize,
+ pub(crate) unique_commands: usize,
+ pub(crate) top: Vec<(Vec<String>, usize)>,
+}
+
+fn first_non_whitespace(s: &str) -> Option<usize> {
+ s.char_indices()
+ // find the first non whitespace char
+ .find(|(_, c)| !c.is_ascii_whitespace())
+ // return the index of that char
+ .map(|(i, _)| i)
+}
+
+fn first_whitespace(s: &str) -> usize {
+ s.char_indices()
+ // find the first whitespace char
+ .find(|(_, c)| c.is_ascii_whitespace())
+ // return the index of that char, (or the max length of the string)
+ .map_or(s.len(), |(i, _)| i)
+}
+
+fn interesting_command<'a>(settings: &Settings, mut command: &'a str) -> &'a str {
+ // Sort by length so that we match the longest prefix first
+ let mut common_prefix = settings.stats.common_prefix.clone();
+ common_prefix.sort_by_key(|b| std::cmp::Reverse(b.len()));
+
+ // Trim off the common prefix, if it exists
+ for p in &common_prefix {
+ if command.starts_with(p) {
+ let i = p.len();
+ let prefix = &command[..i];
+ command = command[i..].trim_start();
+ if command.is_empty() {
+ // no commands following, just use the prefix
+ return prefix;
+ }
+ break;
+ }
+ }
+
+ // Sort the common_subcommands by length so that we match the longest subcommand first
+ let mut common_subcommands = settings.stats.common_subcommands.clone();
+ common_subcommands.sort_by_key(|b| std::cmp::Reverse(b.len()));
+
+ // Check for a common subcommand
+ for p in &common_subcommands {
+ if command.starts_with(p) {
+ // if the subcommand is the same length as the command, then we just use the subcommand
+ if p.len() == command.len() {
+ return command;
+ }
+ // otherwise we need to use the subcommand + the next word
+ let non_whitespace = first_non_whitespace(&command[p.len()..]).unwrap_or(0);
+ let j =
+ p.len() + non_whitespace + first_whitespace(&command[p.len() + non_whitespace..]);
+ return &command[..j];
+ }
+ }
+ // Return the first word if there is no subcommand
+ &command[..first_whitespace(command)]
+}
+
+fn split_at_pipe(command: &str) -> Vec<&str> {
+ let mut result = vec![];
+ let mut quoted = false;
+ let mut start = 0;
+ let mut graphemes = UnicodeSegmentation::grapheme_indices(command, true);
+
+ while let Some((i, c)) = graphemes.next() {
+ let current = i;
+ match c {
+ "\"" if command[start..current] != *"\"" => {
+ quoted = !quoted;
+ }
+ "'" if command[start..current] != *"'" => {
+ quoted = !quoted;
+ }
+ "\\" if graphemes.next().is_some() => {}
+ "|" if !quoted => {
+ if current > start && command[start..].starts_with('|') {
+ start += 1;
+ }
+ result.push(&command[start..current]);
+ start = current;
+ }
+ _ => {}
+ }
+ }
+ if command[start..].starts_with('|') {
+ start += 1;
+ }
+ result.push(&command[start..]);
+ result
+}
+
+fn strip_leading_env_vars(command: &str) -> &str {
+ // fast path: no equals sign, no environment variable
+ if !command.contains('=') {
+ return command;
+ }
+
+ let mut in_token = false;
+ let mut token_start_pos = 0;
+ let mut in_single_quotes = false;
+ let mut in_double_quotes = false;
+ let mut escape_next = false;
+ let mut has_equals_outside_quotes = false;
+
+ for (i, g) in UnicodeSegmentation::grapheme_indices(command, true) {
+ if escape_next {
+ escape_next = false;
+ continue;
+ }
+
+ if !in_token {
+ token_start_pos = i;
+ }
+
+ match g {
+ "\\" => {
+ escape_next = true;
+ in_token = true;
+ }
+ "'" if !in_double_quotes => {
+ in_single_quotes = !in_single_quotes;
+ in_token = true;
+ }
+ "\"" if !in_single_quotes => {
+ in_double_quotes = !in_double_quotes;
+ in_token = true;
+ }
+ "=" if !in_single_quotes && !in_double_quotes => {
+ has_equals_outside_quotes = true;
+ in_token = true;
+ }
+ " " | "\t" if !in_single_quotes && !in_double_quotes => {
+ if in_token {
+ if !has_equals_outside_quotes {
+ // if we're not in an env var, we can break early
+ break;
+ }
+ in_token = false;
+ has_equals_outside_quotes = false;
+ }
+ }
+ _ => {
+ in_token = true;
+ }
+ }
+ }
+
+ command[token_start_pos..].trim()
+}
+
+pub(crate) fn pretty_print(stats: Stats, ngram_size: usize) {
+ let max = stats.top.iter().map(|x| x.1).max().unwrap();
+ let num_pad = max.ilog10() as usize + 1;
+
+ // Find the length of the longest command name for each column
+ let column_widths = stats
+ .top
+ .iter()
+ .map(|(commands, _)| commands.iter().map(String::len).collect::<Vec<usize>>())
+ .fold(vec![0; ngram_size], |acc, item| {
+ acc.iter()
+ .zip(item.iter())
+ .map(|(a, i)| *std::cmp::max(a, i))
+ .collect()
+ });
+
+ for (command, count) in stats.top {
+ let gray = SetForegroundColor(Color::Grey);
+ let bold = SetAttribute(crossterm::style::Attribute::Bold);
+
+ let in_ten = 10 * count / max;
+
+ print!("[");
+ print!("{}", SetForegroundColor(Color::Red));
+
+ for i in 0..in_ten {
+ if i == 2 {
+ print!("{}", SetForegroundColor(Color::Yellow));
+ }
+
+ if i == 5 {
+ print!("{}", SetForegroundColor(Color::Green));
+ }
+
+ print!("▮");
+ }
+
+ for _ in in_ten..10 {
+ print!(" ");
+ }
+
+ let formatted_command = command
+ .iter()
+ .zip(column_widths.iter())
+ .map(|(cmd, width)| format!("{cmd:width$}"))
+ .collect::<Vec<_>>()
+ .join(" | ");
+
+ println!(
+ "{ResetColor}] {gray}{count:num_pad$}{ResetColor} {bold}{formatted_command}{ResetColor}"
+ );
+ }
+ println!("Total commands: {}", stats.total_commands);
+ println!("Unique commands: {}", stats.unique_commands);
+}
+
+pub(crate) fn compute(
+ settings: &Settings,
+ history: &[History],
+ count: usize,
+ ngram_size: usize,
+) -> Option<Stats> {
+ let mut commands = HashSet::<&str>::with_capacity(history.len());
+ let mut total_unignored = 0;
+ let mut prefixes = HashMap::<Vec<&str>, usize>::with_capacity(history.len());
+
+ for i in history {
+ // just in case it somehow has a leading tab or space or something (legacy atuin didn't ignore space prefixes)
+ let command = strip_leading_env_vars(i.command.trim());
+ let prefix = interesting_command(settings, command);
+
+ if settings.stats.ignored_commands.iter().any(|c| c == prefix) {
+ continue;
+ }
+
+ total_unignored += 1;
+ commands.insert(command);
+
+ split_at_pipe(command)
+ .iter()
+ .map(|l| {
+ let command = l.trim();
+ commands.insert(command);
+ command
+ })
+ .collect::<Vec<_>>()
+ .windows(ngram_size)
+ .for_each(|w| {
+ *prefixes
+ .entry(w.iter().map(|c| interesting_command(settings, c)).collect())
+ .or_default() += 1;
+ });
+ }
+
+ let unique = commands.len();
+ let mut top = prefixes.into_iter().collect::<Vec<_>>();
+
+ top.sort_unstable_by_key(|x| std::cmp::Reverse(x.1));
+ top.truncate(count);
+
+ if top.is_empty() {
+ return None;
+ }
+
+ Some(Stats {
+ unique_commands: unique,
+ total_commands: total_unignored,
+ top: top
+ .into_iter()
+ .map(|t| (t.0.into_iter().map(ToString::to_string).collect(), t.1))
+ .collect(),
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use crate::atuin_client::settings::Settings;
+ use time::OffsetDateTime;
+ use turtle_daemon::aclient::history::History;
+
+ use super::compute;
+ use super::{interesting_command, split_at_pipe, strip_leading_env_vars};
+
+ #[test]
+ fn ignored_env_vars() {
+ let settings = Settings::new().unwrap();
+
+ let history: History = History::capture()
+ .timestamp(OffsetDateTime::now_utc())
+ .command("FOO='BAR=🚀' echo foo")
+ .cwd("/")
+ .build()
+ .into();
+
+ let stats = compute(&settings, &[history], 10, 1).expect("failed to compute stats");
+ assert_eq!(stats.top.first().unwrap().0, vec!["echo"]);
+ }
+
+ #[test]
+ fn interesting_commands() {
+ let settings = Settings::new().unwrap();
+
+ assert_eq!(interesting_command(&settings, "cargo"), "cargo");
+ assert_eq!(
+ interesting_command(&settings, "cargo build foo bar"),
+ "cargo build"
+ );
+ assert_eq!(
+ interesting_command(&settings, "sudo cargo build foo bar"),
+ "cargo build"
+ );
+ assert_eq!(interesting_command(&settings, "sudo"), "sudo");
+ }
+
+ // Test with spaces in the common_prefix
+ #[test]
+ fn interesting_commands_spaces() {
+ let mut settings = Settings::new().unwrap();
+ settings.stats.common_prefix.push("sudo test".to_string());
+
+ assert_eq!(interesting_command(&settings, "sudo test"), "sudo test");
+ assert_eq!(interesting_command(&settings, "sudo test "), "sudo test");
+ assert_eq!(interesting_command(&settings, "sudo test foo bar"), "foo");
+ assert_eq!(
+ interesting_command(&settings, "sudo test foo bar"),
+ "foo"
+ );
+
+ // Works with a common_subcommand as well
+ assert_eq!(
+ interesting_command(&settings, "sudo test cargo build foo bar"),
+ "cargo build"
+ );
+
+ // We still match on just the sudo prefix
+ assert_eq!(interesting_command(&settings, "sudo"), "sudo");
+ assert_eq!(interesting_command(&settings, "sudo foo"), "foo");
+ }
+
+ // Test with spaces in the common_subcommand
+ #[test]
+ fn interesting_commands_spaces_subcommand() {
+ let mut settings = Settings::new().unwrap();
+ settings
+ .stats
+ .common_subcommands
+ .push("cargo build".to_string());
+
+ assert_eq!(interesting_command(&settings, "cargo build"), "cargo build");
+ assert_eq!(
+ interesting_command(&settings, "cargo build "),
+ "cargo build"
+ );
+ assert_eq!(
+ interesting_command(&settings, "cargo build foo bar"),
+ "cargo build foo"
+ );
+
+ // Works with a common_prefix as well
+ assert_eq!(
+ interesting_command(&settings, "sudo cargo build foo bar"),
+ "cargo build foo"
+ );
+
+ // We still match on just cargo as a subcommand
+ assert_eq!(interesting_command(&settings, "cargo"), "cargo");
+ assert_eq!(interesting_command(&settings, "cargo foo"), "cargo foo");
+ }
+
+ // Test with spaces in the common_prefix and common_subcommand
+ #[test]
+ fn interesting_commands_spaces_both() {
+ let mut settings = Settings::new().unwrap();
+ settings.stats.common_prefix.push("sudo test".to_string());
+ settings
+ .stats
+ .common_subcommands
+ .push("cargo build".to_string());
+
+ assert_eq!(
+ interesting_command(&settings, "sudo test cargo build"),
+ "cargo build"
+ );
+ assert_eq!(
+ interesting_command(&settings, "sudo test cargo build"),
+ "cargo build"
+ );
+ assert_eq!(
+ interesting_command(&settings, "sudo test cargo build "),
+ "cargo build"
+ );
+ assert_eq!(
+ interesting_command(&settings, "sudo test cargo build foo bar"),
+ "cargo build foo"
+ );
+ }
+
+ #[test]
+ fn split_simple() {
+ assert_eq!(split_at_pipe("fd | rg"), ["fd ", " rg"]);
+ }
+
+ #[test]
+ fn split_multi() {
+ assert_eq!(
+ split_at_pipe("kubectl | jq | rg"),
+ ["kubectl ", " jq ", " rg"]
+ );
+ }
+
+ #[test]
+ fn split_simple_quoted() {
+ assert_eq!(
+ split_at_pipe("foo | bar 'baz {} | quux' | xyzzy"),
+ ["foo ", " bar 'baz {} | quux' ", " xyzzy"]
+ );
+ }
+
+ #[test]
+ fn split_multi_quoted() {
+ assert_eq!(
+ split_at_pipe("foo | bar 'baz \"{}\" | quux' | xyzzy"),
+ ["foo ", " bar 'baz \"{}\" | quux' ", " xyzzy"]
+ );
+ }
+
+ #[test]
+ fn escaped_pipes() {
+ assert_eq!(
+ split_at_pipe("foo | bar baz \\| quux"),
+ ["foo ", " bar baz \\| quux"]
+ );
+ }
+
+ #[test]
+ fn emoji() {
+ assert_eq!(
+ split_at_pipe("git commit -m \"🚀\""),
+ ["git commit -m \"🚀\""]
+ );
+ }
+
+ #[test]
+ fn starts_with_pipe() {
+ assert_eq!(
+ split_at_pipe("| sed 's/[0-9a-f]//g'"),
+ ["", " sed 's/[0-9a-f]//g'"]
+ );
+ }
+
+ #[test]
+ fn starts_with_spaces_and_pipe() {
+ assert_eq!(
+ split_at_pipe(" | sed 's/[0-9a-f]//g'"),
+ [" ", " sed 's/[0-9a-f]//g'"]
+ );
+ }
+
+ #[test]
+ fn strip_leading_env_vars_simple() {
+ assert_eq!(
+ strip_leading_env_vars("FOO=bar BAZ=quux echo foo"),
+ "echo foo"
+ );
+ }
+
+ #[test]
+ fn strip_leading_env_vars_quoted_single() {
+ assert_eq!(strip_leading_env_vars("FOO='BAR=baz' echo foo"), "echo foo");
+ }
+
+ #[test]
+ fn strip_leading_env_vars_quoted_double() {
+ assert_eq!(
+ strip_leading_env_vars("FOO=\"BAR=baz\" echo foo"),
+ "echo foo"
+ );
+ }
+
+ #[test]
+ fn strip_leading_env_vars_quoted_single_and_double() {
+ assert_eq!(
+ strip_leading_env_vars("FOO='BAR=\"baz\"' echo foo \"BAR=quux\""),
+ "echo foo \"BAR=quux\""
+ );
+ }
+
+ #[test]
+ fn strip_leading_env_vars_emojis() {
+ assert_eq!(
+ strip_leading_env_vars("FOO='BAR=🚀' echo foo \"BAR=quux\" foo"),
+ "echo foo \"BAR=quux\" foo"
+ );
+ }
+
+ #[test]
+ fn strip_leading_env_vars_name_same_as_command() {
+ assert_eq!(strip_leading_env_vars("FOO='bar' bar baz"), "bar baz");
+ }
+}
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(&current_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.")
+ })
+}
diff --git a/crates/client/src/main.rs b/crates/client/src/main.rs
new file mode 100644
index 00000000..45f09b1f
--- /dev/null
+++ b/crates/client/src/main.rs
@@ -0,0 +1,79 @@
+#![forbid(unsafe_code)]
+#![warn(clippy::pedantic, clippy::nursery, clippy::allow_attributes)]
+#![expect(
+ clippy::missing_const_for_fn, // not 100% reliable
+ clippy::redundant_pub_crate,
+)]
+
+use clap::Parser;
+use clap::builder::Styles;
+use clap::builder::styling::{AnsiColor, Effects};
+use eyre::Result;
+
+use command::AtuinCmd;
+use tracing_subscriber::EnvFilter;
+
+mod command;
+
+pub(crate) mod atuin_client;
+pub(crate) mod atuin_history;
+
+const VERSION: &str = env!("CARGO_PKG_VERSION");
+const SHA: &str = env!("GIT_HASH");
+
+const LONG_VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), " (", env!("GIT_HASH"), ")");
+
+static HELP_TEMPLATE: &str = "\
+{before-help}{name} {version}
+{author}
+{about}
+
+{usage-heading}
+ {usage}
+
+{all-args}{after-help}";
+
+const STYLES: Styles = Styles::styled()
+ .header(AnsiColor::Yellow.on_default().effects(Effects::BOLD))
+ .usage(AnsiColor::Green.on_default().effects(Effects::BOLD))
+ .literal(AnsiColor::Green.on_default().effects(Effects::BOLD))
+ .placeholder(AnsiColor::Green.on_default());
+
+/// Magical shell history
+#[derive(Parser)]
+#[command(
+ author = "Ellie Huxtable <ellie@atuin.sh>",
+ version = VERSION,
+ long_version = LONG_VERSION,
+ help_template(HELP_TEMPLATE),
+ styles = STYLES,
+)]
+struct Atuin {
+ #[command(subcommand)]
+ atuin: AtuinCmd,
+}
+
+impl Atuin {
+ fn run(self) -> Result<()> {
+ self.atuin.run()
+ }
+}
+
+fn main() -> Result<()> {
+ if let Err(e) = tracing_subscriber::fmt()
+ .with_file(true)
+ .with_line_number(true)
+ .with_level(true)
+ .without_time()
+ .with_env_filter(
+ EnvFilter::builder()
+ .from_env_lossy()
+ .add_directive("turtle=debug".parse().unwrap()),
+ )
+ .try_init()
+ {
+ eprintln!("failed to initialize logging: {e}");
+ }
+
+ Atuin::parse().run()
+}