aboutsummaryrefslogtreecommitdiffstats
path: root/crates/client/src/command
diff options
context:
space:
mode:
Diffstat (limited to '')
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
24 files changed, 1361 insertions, 30 deletions
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.")
+ })
+}