aboutsummaryrefslogtreecommitdiffstats
path: root/crates/client
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--crates/client/Cargo.toml51
-rw-r--r--crates/client/build.rs12
-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.rs (renamed from crates/turtle/src/atuin_history/mod.rs)1
-rw-r--r--crates/client/src/atuin_history/stats.rs (renamed from crates/turtle/src/atuin_history/stats.rs)28
l---------crates/client/src/command/CONTRIBUTORS (renamed from crates/turtle/src/command/CONTRIBUTORS)0
-rw-r--r--crates/client/src/command/client.rs121
-rw-r--r--crates/client/src/command/client/config.rs (renamed from crates/turtle/src/command/client/config.rs)0
-rw-r--r--crates/client/src/command/client/daemon.rs46
-rw-r--r--crates/client/src/command/client/default_config.rs (renamed from crates/turtle/src/command/client/default_config.rs)0
-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.rs (renamed from crates/turtle/src/command/client/info.rs)24
-rw-r--r--crates/client/src/command/client/stats.rs (renamed from crates/turtle/src/command/client/stats.rs)29
-rw-r--r--crates/client/src/command/client/store/mod.rs (renamed from crates/turtle/src/command/client/store.rs)0
-rw-r--r--crates/client/src/command/client/store/pull.rs (renamed from crates/turtle/src/command/client/store/pull.rs)0
-rw-r--r--crates/client/src/command/client/store/purge.rs (renamed from crates/turtle/src/command/client/store/purge.rs)0
-rw-r--r--crates/client/src/command/client/store/push.rs (renamed from crates/turtle/src/command/client/store/push.rs)0
-rw-r--r--crates/client/src/command/client/store/rebuild.rs (renamed from crates/turtle/src/command/client/store/rebuild.rs)0
-rw-r--r--crates/client/src/command/client/store/rekey.rs (renamed from crates/turtle/src/command/client/store/rekey.rs)0
-rw-r--r--crates/client/src/command/client/store/verify.rs (renamed from crates/turtle/src/command/client/store/verify.rs)0
-rw-r--r--crates/client/src/command/client/sync.rs101
-rw-r--r--crates/client/src/command/client/wrapped.rs (renamed from crates/turtle/src/command/client/wrapped.rs)24
-rw-r--r--crates/client/src/command/contributors.rs (renamed from crates/turtle/src/command/contributors.rs)0
-rw-r--r--crates/client/src/command/gen_completions.rs (renamed from crates/turtle/src/command/gen_completions.rs)0
-rw-r--r--crates/client/src/command/mod.rs56
-rw-r--r--crates/client/src/main.rs (renamed from crates/turtle/src/main.rs)29
32 files changed, 2055 insertions, 69 deletions
diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml
new file mode 100644
index 00000000..957476fb
--- /dev/null
+++ b/crates/client/Cargo.toml
@@ -0,0 +1,51 @@
+[package]
+name = "turtle-client"
+description = "turtle - atuin shell history fork"
+edition.workspace = true
+version.workspace = true
+authors.workspace = true
+rust-version.workspace = true
+license.workspace = true
+repository.workspace = true
+
+[[bin]]
+name = "turtle"
+path = "src/main.rs"
+
+[dependencies]
+clap = { workspace = true }
+clap_complete = { workspace = true }
+clap_complete_nushell = { workspace = true }
+colored = { workspace = true }
+config = { workspace = true }
+crossterm = { workspace = true }
+eyre = { workspace = true }
+fs-err = { workspace = true }
+interim = { workspace = true }
+log = { workspace = true }
+regex = { workspace = true }
+runtime-format = { workspace = true }
+rustix = { workspace = true }
+serde = { workspace = true }
+serde_json = { workspace = true }
+serde_regex = { workspace = true }
+serde_with = { workspace = true }
+shellexpand = { workspace = true }
+time = { workspace = true }
+tokio = { workspace = true }
+toml_edit = { workspace = true }
+tracing = { workspace = true }
+tracing-subscriber = { workspace = true }
+turtle-api = { workspace = true }
+turtle-common = { workspace = true }
+unicode-segmentation = { workspace = true }
+
+[dev-dependencies]
+
+[build-dependencies]
+
+[package.metadata.docs.rs]
+all-features = true
+
+[lints]
+workspace = true
diff --git a/crates/client/build.rs b/crates/client/build.rs
new file mode 100644
index 00000000..939d6405
--- /dev/null
+++ b/crates/client/build.rs
@@ -0,0 +1,12 @@
+use std::process::Command;
+
+fn main() {
+ let output = Command::new("git").args(["rev-parse", "HEAD"]).output();
+
+ let sha = match output {
+ Ok(sha) => String::from_utf8(sha.stdout).unwrap(),
+ Err(_) => String::from("NO_GIT"),
+ };
+
+ println!("cargo:rustc-env=GIT_HASH={sha}");
+}
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/turtle/src/atuin_history/mod.rs b/crates/client/src/atuin_history/mod.rs
index 41336a14..b3ca0d2f 100644
--- a/crates/turtle/src/atuin_history/mod.rs
+++ b/crates/client/src/atuin_history/mod.rs
@@ -1,2 +1 @@
-pub(crate) mod sort;
pub(crate) mod stats;
diff --git a/crates/turtle/src/atuin_history/stats.rs b/crates/client/src/atuin_history/stats.rs
index c53dafb2..40be14f9 100644
--- a/crates/turtle/src/atuin_history/stats.rs
+++ b/crates/client/src/atuin_history/stats.rs
@@ -2,9 +2,10 @@ 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::{history::History, settings::Settings};
+use crate::atuin_client::settings::Settings;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct Stats {
@@ -278,9 +279,9 @@ pub(crate) fn compute(
#[cfg(test)]
mod tests {
- use crate::atuin_client::history::History;
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};
@@ -301,29 +302,6 @@ mod tests {
}
#[test]
- fn ignored_commands() {
- let mut settings = Settings::new().unwrap();
- settings.stats.ignored_commands.push("cd".to_string());
-
- let history = [
- History::import()
- .timestamp(OffsetDateTime::now_utc())
- .command("cd foo")
- .build()
- .into(),
- History::import()
- .timestamp(OffsetDateTime::now_utc())
- .command("cargo build stuff")
- .build()
- .into(),
- ];
-
- let stats = compute(&settings, &history, 10, 1).expect("failed to compute stats");
- assert_eq!(stats.total_commands, 1);
- assert_eq!(stats.unique_commands, 1);
- }
-
- #[test]
fn interesting_commands() {
let settings = Settings::new().unwrap();
diff --git a/crates/turtle/src/command/CONTRIBUTORS b/crates/client/src/command/CONTRIBUTORS
index 1ca4115a..1ca4115a 120000
--- a/crates/turtle/src/command/CONTRIBUTORS
+++ b/crates/client/src/command/CONTRIBUTORS
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/turtle/src/command/client/config.rs b/crates/client/src/command/client/config.rs
index 73d1c35e..73d1c35e 100644
--- a/crates/turtle/src/command/client/config.rs
+++ b/crates/client/src/command/client/config.rs
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/turtle/src/command/client/default_config.rs b/crates/client/src/command/client/default_config.rs
index 4b03c909..4b03c909 100644
--- a/crates/turtle/src/command/client/default_config.rs
+++ b/crates/client/src/command/client/default_config.rs
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/turtle/src/command/client/info.rs b/crates/client/src/command/client/info.rs
index 49c92193..1af8ee39 100644
--- a/crates/turtle/src/command/client/info.rs
+++ b/crates/client/src/command/client/info.rs
@@ -2,21 +2,33 @@ 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?;
-pub(crate) fn run(settings: &Settings) -> Result<()> {
- let config = crate::atuin_common::utils::config_dir();
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:\nclient config: {:?}\nserver config: {:?}\nclient db path: {:?}\nkey path: {:?}\nmeta db path: {:?}",
+ "\
+ Config files:
+ client config: {:?}
+ server config: {:?}
+ deamon config: {:?}
+ deamon db path: {:?}
+ deamon socket path: {:?}\
+ ",
config_file.to_string_lossy(),
sever_config.to_string_lossy(),
- settings.db_path,
- settings.sync.encryption_key()?,
- settings.meta.db_path
+ paths.config,
+ paths.db,
+ paths.socket,
);
let env_vars = format!(
diff --git a/crates/turtle/src/command/client/stats.rs b/crates/client/src/command/client/stats.rs
index 9ea5e283..9f74ecc3 100644
--- a/crates/turtle/src/command/client/stats.rs
+++ b/crates/client/src/command/client/stats.rs
@@ -1,10 +1,10 @@
use clap::Parser;
use eyre::Result;
-use interim::parse_date_string;
+use interim::{Dialect, parse_date_string};
use time::{Duration, OffsetDateTime, Time};
+use turtle_api::client::{HistoryClient, Range};
-use crate::atuin_client::database::ClientSqlite;
-use crate::atuin_client::{database::current_context, settings::Settings};
+use crate::atuin_client::settings::Settings;
use crate::atuin_history::stats::{compute, pretty_print};
@@ -36,8 +36,9 @@ pub(crate) struct Cmd {
}
impl Cmd {
- pub(crate) async fn run(&self, db: &ClientSqlite, settings: &Settings) -> Result<()> {
- let context = current_context().await?;
+ 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 {
@@ -47,30 +48,32 @@ impl Cmd {
let now = OffsetDateTime::now_utc().to_offset(settings.timezone.0);
let last_night = now.replace_time(Time::MIDNIGHT);
- let history = if words.as_str() == "all" {
- db.list(&[], &context, None, false, false).await?
+ let range = if words.as_str() == "all" {
+ None
} else if words.trim() == "today" {
let start = last_night;
let end = start + Duration::days(1);
- db.range(start, end).await?
+ Some(Range { start, end })
} else if words.trim() == "month" {
let end = last_night;
let start = end - Duration::days(31);
- db.range(start, end).await?
+ Some(Range { start, end })
} else if words.trim() == "week" {
let end = last_night;
let start = end - Duration::days(7);
- db.range(start, end).await?
+ Some(Range { start, end })
} else if words.trim() == "year" {
let end = last_night;
let start = end - Duration::days(365);
- db.range(start, end).await?
+ Some(Range { start, end })
} else {
- let start = parse_date_string(&words, now, settings.dialect.into())?;
+ let start = parse_date_string(&words, now, Dialect::Uk)?;
let end = start + Duration::days(1);
- db.range(start, end).await?
+ 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 {
diff --git a/crates/turtle/src/command/client/store.rs b/crates/client/src/command/client/store/mod.rs
index bc57488d..bc57488d 100644
--- a/crates/turtle/src/command/client/store.rs
+++ b/crates/client/src/command/client/store/mod.rs
diff --git a/crates/turtle/src/command/client/store/pull.rs b/crates/client/src/command/client/store/pull.rs
index 3a0865be..3a0865be 100644
--- a/crates/turtle/src/command/client/store/pull.rs
+++ b/crates/client/src/command/client/store/pull.rs
diff --git a/crates/turtle/src/command/client/store/purge.rs b/crates/client/src/command/client/store/purge.rs
index a23f1886..a23f1886 100644
--- a/crates/turtle/src/command/client/store/purge.rs
+++ b/crates/client/src/command/client/store/purge.rs
diff --git a/crates/turtle/src/command/client/store/push.rs b/crates/client/src/command/client/store/push.rs
index 9d66b5b2..9d66b5b2 100644
--- a/crates/turtle/src/command/client/store/push.rs
+++ b/crates/client/src/command/client/store/push.rs
diff --git a/crates/turtle/src/command/client/store/rebuild.rs b/crates/client/src/command/client/store/rebuild.rs
index 6be67cd0..6be67cd0 100644
--- a/crates/turtle/src/command/client/store/rebuild.rs
+++ b/crates/client/src/command/client/store/rebuild.rs
diff --git a/crates/turtle/src/command/client/store/rekey.rs b/crates/client/src/command/client/store/rekey.rs
index 2b379327..2b379327 100644
--- a/crates/turtle/src/command/client/store/rekey.rs
+++ b/crates/client/src/command/client/store/rekey.rs
diff --git a/crates/turtle/src/command/client/store/verify.rs b/crates/client/src/command/client/store/verify.rs
index a39227f9..a39227f9 100644
--- a/crates/turtle/src/command/client/store/verify.rs
+++ b/crates/client/src/command/client/store/verify.rs
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/turtle/src/command/client/wrapped.rs b/crates/client/src/command/client/wrapped.rs
index 2ce19bf7..4219aa2d 100644
--- a/crates/turtle/src/command/client/wrapped.rs
+++ b/crates/client/src/command/client/wrapped.rs
@@ -2,11 +2,15 @@ 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::database::ClientSqlite;
-use crate::atuin_client::settings::Settings;
-
-use crate::atuin_history::stats::{Stats, compute};
+use crate::{
+ atuin_client::settings::Settings,
+ atuin_history::stats::{Stats, compute},
+};
#[derive(Debug)]
struct WrappedStats {
@@ -21,11 +25,7 @@ struct WrappedStats {
impl WrappedStats {
#[expect(clippy::too_many_lines, clippy::cast_precision_loss)]
- fn new(
- settings: &Settings,
- stats: &Stats,
- history: &[crate::atuin_client::history::History],
- ) -> Self {
+ fn new(settings: &Settings, stats: &Stats, history: &[History]) -> Self {
let nav_commands = stats
.top
.iter()
@@ -272,7 +272,9 @@ fn print_fun_facts(wrapped_stats: &WrappedStats, stats: &Stats, year: i32) {
println!();
}
-pub(crate) async fn run(year: Option<i32>, db: &ClientSqlite, settings: &Settings) -> Result<()> {
+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();
@@ -296,7 +298,7 @@ pub(crate) async fn run(year: Option<i32>, db: &ClientSqlite, settings: &Setting
now.offset(),
);
- let history = db.range(start, end).await?;
+ 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 🪄"
diff --git a/crates/turtle/src/command/contributors.rs b/crates/client/src/command/contributors.rs
index b2a41522..b2a41522 100644
--- a/crates/turtle/src/command/contributors.rs
+++ b/crates/client/src/command/contributors.rs
diff --git a/crates/turtle/src/command/gen_completions.rs b/crates/client/src/command/gen_completions.rs
index 9f13bffc..9f13bffc 100644
--- a/crates/turtle/src/command/gen_completions.rs
+++ b/crates/client/src/command/gen_completions.rs
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/turtle/src/main.rs b/crates/client/src/main.rs
index 12858140..45f09b1f 100644
--- a/crates/turtle/src/main.rs
+++ b/crates/client/src/main.rs
@@ -4,12 +4,6 @@
clippy::missing_const_for_fn, // not 100% reliable
clippy::redundant_pub_crate,
)]
-#![expect(
- clippy::cast_possible_wrap,
- clippy::cast_sign_loss,
- clippy::cast_possible_truncation,
- reason = "We should remove all of these. But it's just a lot of work in this code-base"
-)]
use clap::Parser;
use clap::builder::Styles;
@@ -17,18 +11,12 @@ 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_common;
-pub(crate) mod atuin_daemon;
pub(crate) mod atuin_history;
-pub(crate) mod atuin_pty_proxy;
-pub(crate) mod atuin_server;
-
-mod print_error;
-mod sync;
const VERSION: &str = env!("CARGO_PKG_VERSION");
const SHA: &str = env!("GIT_HASH");
@@ -72,5 +60,20 @@ impl Atuin {
}
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()
}