aboutsummaryrefslogtreecommitdiffstats
path: root/crates/client/src/command
diff options
context:
space:
mode:
authorBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-07-20 17:54:34 +0200
committerBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-07-20 17:54:34 +0200
commit6bd2b80be51a8623640fbd77afa1da09289e40cc (patch)
tree8fa76fc78e27a4f3d6f57af07805a83e9877f0ad /crates/client/src/command
parentchore: Commit (diff)
downloadatuin-6bd2b80be51a8623640fbd77afa1da09289e40cc.zip
chore: All compiles
Diffstat (limited to '')
-rw-r--r--crates/client/src/command/client.rs28
-rw-r--r--crates/client/src/command/client/daemon.rs44
-rw-r--r--crates/client/src/command/client/history.rs1241
-rw-r--r--crates/client/src/command/client/history/end.rs38
-rw-r--r--crates/client/src/command/client/history/mod.rs753
-rw-r--r--crates/client/src/command/client/history/start.rs76
-rw-r--r--crates/client/src/command/client/history/tail.rs321
-rw-r--r--crates/client/src/command/client/info.rs24
-rw-r--r--crates/client/src/command/client/stats.rs18
-rw-r--r--crates/client/src/command/client/store/mod.rs (renamed from crates/client/src/command/client/store.rs)0
-rw-r--r--crates/client/src/command/client/sync.rs149
-rw-r--r--crates/client/src/command/client/sync/status.rs25
-rw-r--r--crates/client/src/command/client/wrapped.rs17
-rw-r--r--crates/client/src/command/mod.rs86
14 files changed, 1352 insertions, 1468 deletions
diff --git a/crates/client/src/command/client.rs b/crates/client/src/command/client.rs
index 42e72e21..9f45f53b 100644
--- a/crates/client/src/command/client.rs
+++ b/crates/client/src/command/client.rs
@@ -37,11 +37,12 @@ fn cleanup_old_logs(log_dir: &Path, prefix: &str, retention_days: u64) {
}
mod config;
+mod daemon;
mod default_config;
mod history;
mod info;
mod stats;
-mod store;
+// mod store;
mod sync;
mod wrapped;
@@ -52,14 +53,17 @@ pub(crate) enum Cmd {
#[command(subcommand)]
History(history::Cmd),
+ /// Interact with the daemon
#[command(subcommand)]
- /// Request a sync or view sync status
- Sync(sync::Cmd),
+ Daemon(daemon::Cmd),
- /// Manage the atuin data store
#[command(subcommand)]
- Store(store::Cmd),
+ /// Request a sync or view sync status
+ Sync(sync::Cmd),
+ // /// Manage the atuin data store
+ // #[command(subcommand)]
+ // Store(store::Cmd),
/// Information about dotfiles locations and ENV vars
#[command()]
Info,
@@ -97,7 +101,7 @@ impl Cmd {
res
}
- async fn run_inner(self, mut settings: Settings) -> Result<()> {
+ 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();
@@ -117,14 +121,14 @@ impl Cmd {
}
match self {
- Self::Stats(stats) => stats.run(&db, &settings).await,
- Self::Wrapped { year } => wrapped::run(year, &db, &settings).await,
-
- Self::Sync(sync) => sync.run(settings, &db, sqlite_store).await,
+ Self::Daemon(cmd) => cmd.run(&settings).await,
+ Self::Sync(sync) => sync.run(settings).await,
- Self::Store(store) => store.run(&settings, &db, sqlite_store).await,
+ Self::Stats(stats) => stats.run(&settings).await,
+ Self::Wrapped { year } => wrapped::run(year, &settings).await,
- Self::Info => info::run(&settings),
+ // Self::Store(store) => store.run(&settings, &db, sqlite_store).await,
+ Self::Info => info::run(&settings).await,
Self::DefaultConfig => {
default_config::run();
diff --git a/crates/client/src/command/client/daemon.rs b/crates/client/src/command/client/daemon.rs
new file mode 100644
index 00000000..83877cfd
--- /dev/null
+++ b/crates/client/src/command/client/daemon.rs
@@ -0,0 +1,44 @@
+use clap::Subcommand;
+use eyre::Result;
+
+use turtle_daemon::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 {
+ Cmd::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}");
+ }
+ Probe::Unreachable(_) => {
+ println!("Daemon is not running");
+ }
+ }
+
+ Ok(())
+}
diff --git a/crates/client/src/command/client/history.rs b/crates/client/src/command/client/history.rs
deleted file mode 100644
index 4d633b7d..00000000
--- a/crates/client/src/command/client/history.rs
+++ /dev/null
@@ -1,1241 +0,0 @@
-use std::{
- fmt::{self, Display},
- io::{self, IsTerminal, Write},
- path::PathBuf,
- time::Duration,
-};
-
-use crate::{
- atuin_common::utils::{self, Escapable as _},
- command::client::daemon,
-};
-use clap::Subcommand;
-use eyre::{Context, Result, bail};
-use runtime_format::{FormatKey, FormatKeyError, ParseSegment, ParsedFmt};
-
-use colored::Colorize;
-use serde::Serialize;
-
-use crate::atuin_daemon::generated::history::{HistoryEventKind, TailHistoryReply};
-
-use crate::atuin_client::{
- database::{ClientSqlite, current_context},
- encryption,
- history::{History, store::HistoryStore},
- record::sqlite_store::SqliteStore,
- settings::{
- FilterMode::{Directory, Global, Session},
- Settings, Timezone,
- },
-};
-
-use log::debug;
-use time::{OffsetDateTime, macros::format_description};
-
-use super::search::format_duration_into;
-
-#[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,
-
- #[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")]
- 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>,
- },
-
- /// Get the last command ran
- Last {
- #[arg(long)]
- human: bool,
-
- /// Show only the text of the command
- #[arg(long)]
- cmd_only: 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")]
- timezone: Option<Timezone>,
-
- /// Available variables: {command}, {directory}, {duration}, {user}, {host}, {author}, {intent}, {time}, {session}, {uuid} and {relativetime}.
- /// Example: --format "{time} - [{duration}] - {directory}$\t{command}"
- #[arg(long, short)]
- format: Option<String>,
- },
-
- InitStore,
-
- /// Delete history entries matching the configured exclusion filters
- Prune {
- /// List matching history lines without performing the actual deletion.
- #[arg(short = 'n', long)]
- dry_run: bool,
- },
-
- /// Delete duplicate history entries (that have the same command, cwd and hostname)
- Dedup {
- /// List matching history lines without performing the actual deletion.
- #[arg(short = 'n', long)]
- dry_run: bool,
-
- /// Only delete results added before this date
- #[arg(long, short)]
- before: String,
-
- /// How many recent duplicates to keep
- #[arg(long)]
- dupkeep: u32,
- },
-}
-
-#[derive(Clone, Copy, Debug)]
-pub(crate) enum ListMode {
- Human,
- CmdOnly,
- Regular,
-}
-
-impl ListMode {
- pub(crate) const fn from_flags(human: bool, cmd_only: bool) -> Self {
- if human {
- Self::Human
- } else if cmd_only {
- Self::CmdOnly
- } else {
- Self::Regular
- }
- }
-}
-
-pub(crate) 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)]
- 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());
- }
-}
-
-async fn connect_client(settings: &Settings) -> Result<HistoryClient> {
- HistoryClient::new(
- #[cfg(unix)]
- settings.daemon.socket_path.clone(),
- )
- .await
-}
-
-async fn probe(settings: &Settings) -> Probe {
- let mut client = match connect_client(settings).await {
- Ok(client) => client,
- Err(err) => return Probe::Unreachable(err),
- };
-
- match client.status().await {
- Ok(status) => {
- if daemon_matches_expected(&status.version, status.protocol) {
- Probe::Ready(client)
- } else {
- Probe::NeedsRestart(daemon_mismatch_message(&status.version, status.protocol))
- }
- }
- Err(err) => Probe::Unreachable(err),
- }
-}
-
-pub(crate) async fn start_history(settings: &Settings, history: History) -> Result<String> {
- match async {
- connect_client(settings)
- .await?
- .start_history(history.clone())
- .await
- }
- .await
- {
- Ok(resp) => {
- if daemon_matches_expected(&resp.version, resp.protocol) {
- return Ok(resp.id);
- }
-
- Err(eyre!(
- "{}. Restart the daemon manually",
- daemon_mismatch_message(&resp.version, resp.protocol)
- ))
- }
- Err(err) => Err(err),
- }
-}
-
-pub(crate) async fn end_history(
- settings: &Settings,
- id: String,
- duration: u64,
- exit: i64,
-) -> Result<()> {
- match async {
- connect_client(settings)
- .await?
- .end_history(id.clone(), duration, exit)
- .await
- }
- .await
- {
- Ok(resp) => {
- if daemon_matches_expected(&resp.version, resp.protocol) {
- return Ok(());
- }
-
- Err(eyre!(
- "{}. Restart the daemon manually",
- daemon_mismatch_message(&resp.version, resp.protocol)
- ))
- }
- Err(err) => Err(err),
- }
-}
-
-/// Emit a daemon event.
-pub(crate) async fn emit_event(settings: &Settings, event: DaemonEvent) {
- // Try to connect and send
- match ControlClient::from_settings(settings).await {
- Ok(mut client) => {
- if let Err(e) = client.send_event(event).await {
- tracing::debug!(?e, "failed to send event to daemon");
- }
- }
- Err(e) => {
- tracing::debug!(?e, "daemon not available, skipping event emission");
- }
- }
-}
-
-pub(crate) async fn tail_client(settings: &Settings) -> Result<HistoryClient> {
- match probe(settings).await {
- Probe::Ready(client) => Ok(client),
- Probe::NeedsRestart(reason) => {
- bail!("{reason}. Restart the daemon manually");
- }
- Probe::Unreachable(err) if is_legacy_daemon_error(&err) => {
- Err(err.wrap_err(LEGACY_DAEMON_RESTART_MESSAGE))
- }
- Probe::Unreachable(err) => Err(err),
- }
-}
-
-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);
- }
- }
-}
-
-/// 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<'_> {
- #[expect(clippy::cast_sign_loss)]
- fn fmt(&self, key: &str, f: &mut fmt::Formatter<'_>) -> Result<(), FormatKeyError> {
- match key {
- "command" => match self.cmd_format {
- CmdFormat::Literal => f.write_str(self.history.command.trim()),
- CmdFormat::Escaped => f.write_str(&self.history.command.trim().escape_control()),
- }?,
- "directory" => f.write_str(self.history.cwd.trim())?,
- "exit" => f.write_str(&self.history.exit.to_string())?,
- "duration" => {
- let dur = Duration::from_nanos(std::cmp::max(self.history.duration, 0) as u64);
- 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.0)?,
- _ => 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 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
- }
-}
-
-async fn handle_daemon_start(
- 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::capture()
- .timestamp(OffsetDateTime::now_utc())
- .command(command)
- .cwd(cwd)
- .build()
- .into();
- apply_start_metadata(&mut h, author, intent);
-
- if !h.should_save(settings) {
- 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 daemon::start_history(settings, h.clone()).await {
- Ok(id) => id,
- Err(e) => {
- debug!("failed to start history via daemon: {e}");
- h.id.0.clone()
- }
- };
-
- Ok(Some(resp))
-}
-
-async fn handle_daemon_end(
- settings: &Settings,
- id: &str,
- exit: i64,
- duration: Option<u64>,
-) -> Result<()> {
- daemon::end_history(settings, id.to_string(), duration.unwrap_or(0), exit).await?;
-
- Ok(())
-}
-
-#[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_ns: Option<i64>,
- #[serde(skip_serializing_if = "Option::is_none")]
- duration: Option<String>,
- #[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 timestamp = OffsetDateTime::from_unix_timestamp_nanos(i128::from(history.timestamp))
- .context("invalid daemon history timestamp")?;
- 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 {
- id: history.id.into(),
- timestamp,
- duration: history.duration,
- exit: history.exit,
- command: history.command,
- cwd: history.cwd,
- session: history.session,
- hostname: history.hostname,
- author: history.author,
- intent: normalize_optional_field(&history.intent),
- deleted_at: None,
- },
- })
- }
-
- 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.0,
- 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_ns: self.duration_value(),
- duration: self.duration_value().map(format_duration_ns),
- 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.0);
- 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<i64> {
- 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 >= 0)
- .map(time::Duration::nanoseconds)
- .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 >= 0 => 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 format_history_time(timestamp: OffsetDateTime, tz: Timezone) -> Result<String> {
- Ok(timestamp.to_offset(tz.0).format(TIME_FMT)?)
-}
-
-fn format_duration_ns(duration_ns: i64) -> 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::from_nanos(duration_ns.max(0).cast_unsigned())).to_string()
-}
-
-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 normalize_optional_field(value: &str) -> Option<String> {
- let trimmed = value.trim();
- if trimmed.is_empty() {
- None
- } else {
- Some(trimmed.to_owned())
- }
-}
-
-impl Cmd {
- async fn handle_tail(settings: &Settings) -> Result<()> {
- let tty = io::stdout().is_terminal();
- let mut client = daemon::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(())
- }
-
- #[expect(clippy::too_many_arguments)]
- #[expect(clippy::fn_params_excessive_bools)]
- async fn handle_list(
- db: &ClientSqlite,
- settings: &Settings,
- context: crate::atuin_client::database::Context,
- session: bool,
- cwd: bool,
- mode: ListMode,
- format: Option<String>,
- include_deleted: bool,
- print0: bool,
- reverse: bool,
- tz: Timezone,
- ) -> Result<()> {
- let filters = match (session, cwd) {
- (true, true) => [Session, Directory],
- (true, false) => [Session, Global],
- (false, true) => [Global, Directory],
- (false, false) => [
- settings.default_filter_mode(context.git_root.is_some()),
- Global,
- ],
- };
-
- let history = db
- .list(&filters, &context, None, false, include_deleted)
- .await?;
-
- print_list(
- &history,
- mode,
- match format {
- None => Some(settings.history_format.as_str()),
- _ => format.as_deref(),
- },
- print0,
- reverse,
- tz,
- );
-
- Ok(())
- }
-
- async fn handle_prune(
- db: &ClientSqlite,
- settings: &Settings,
- store: SqliteStore,
- context: crate::atuin_client::database::Context,
- dry_run: bool,
- ) -> Result<()> {
- // Grab all executed commands and filter them using History::should_save.
- // We could iterate or paginate here if memory usage becomes an issue.
- let matches: Vec<History> = db
- .list(&[Global], &context, None, false, false)
- .await?
- .into_iter()
- .filter(|h| !h.should_save(settings))
- .collect();
-
- match matches.len() {
- 0 => {
- println!("No entries to prune.");
- return Ok(());
- }
- 1 => println!("Found 1 entry to prune."),
- n => println!("Found {n} entries to prune."),
- }
-
- if dry_run {
- print_list(
- &matches,
- ListMode::Human,
- Some(settings.history_format.as_str()),
- false,
- false,
- settings.timezone,
- );
- } else {
- let encryption_key: [u8; 32] = encryption::load_key(settings)
- .context("could not load encryption key")?
- .into();
- let host_id = Settings::host_id().await?;
- let history_store = HistoryStore::new(store.clone(), host_id, encryption_key);
-
- for entry in matches {
- eprintln!("deleting {}", entry.id);
- let (id, _) = history_store.delete(entry.id.clone()).await?;
- history_store.incremental_build(db, &[id]).await?;
- }
-
- daemon::emit_event(settings, crate::atuin_daemon::DaemonEvent::HistoryPruned).await;
- }
- Ok(())
- }
-
- async fn handle_dedup(
- db: &ClientSqlite,
- settings: &Settings,
- store: SqliteStore,
- before: i64,
- dupkeep: u32,
- dry_run: bool,
- ) -> Result<()> {
- if dupkeep == 0 {
- eprintln!(
- "\"--dupkeep 0\" would keep 0 copies of duplicate commands and thus delete all of them! Use \"atuin search --delete ...\" if you really want that."
- );
- std::process::exit(1);
- }
-
- let matches: Vec<History> = db.get_dups(before, dupkeep).await?;
-
- match matches.len() {
- 0 => {
- println!("No duplicates to delete.");
- return Ok(());
- }
- 1 => println!("Found 1 duplicate to delete."),
- n => println!("Found {n} duplicates to delete."),
- }
-
- if dry_run {
- print_list(
- &matches,
- ListMode::Human,
- Some(settings.history_format.as_str()),
- false,
- false,
- settings.timezone,
- );
- } else {
- let encryption_key: [u8; 32] = encryption::load_key(settings)
- .context("could not load encryption key")?
- .into();
- let host_id = Settings::host_id().await?;
- let history_store = HistoryStore::new(store.clone(), host_id, encryption_key);
-
- let ids = matches.iter().map(|h| h.id.clone()).collect::<Vec<_>>();
-
- for entry in matches {
- eprintln!("deleting {}", entry.id);
- let (id, _) = history_store.delete(entry.id).await?;
- history_store.incremental_build(db, &[id]).await?;
- }
-
- daemon::emit_event(
- settings,
- crate::atuin_daemon::DaemonEvent::HistoryDeleted { ids },
- )
- .await;
- }
- Ok(())
- }
-
- #[expect(clippy::too_many_lines)]
- 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) =
- handle_daemon_start(settings, &command, author.as_deref(), intent.as_deref())
- .await?
- {
- println!("{id}");
- }
-
- Ok(())
- }
- Self::End { id, exit, duration } => {
- handle_daemon_end(settings, &id, exit, duration).await
- }
- Self::Tail => {
- return Self::handle_tail(settings).await;
- }
- cmd => {
- let context = current_context().await?;
-
- let db_path = PathBuf::from(settings.db_path.as_str());
- let record_store_path = PathBuf::from(settings.record_store_path.as_str());
-
- let db = ClientSqlite::new(db_path, settings.local_timeout).await?;
- let store = SqliteStore::new(record_store_path, settings.local_timeout).await?;
-
- let encryption_key: [u8; 32] = encryption::load_key(settings)
- .context("could not load encryption key")?
- .into();
-
- let host_id = Settings::host_id().await?;
- let history_store = HistoryStore::new(store.clone(), host_id, encryption_key);
-
- match cmd {
- Self::List {
- session,
- cwd,
- human,
- cmd_only,
- print0,
- reverse,
- timezone,
- format,
- } => {
- let mode = ListMode::from_flags(human, cmd_only);
- let tz = timezone.unwrap_or(settings.timezone);
- Self::handle_list(
- &db, settings, context, session, cwd, mode, format, false, print0,
- reverse, tz,
- )
- .await
- }
-
- Self::Last {
- human,
- cmd_only,
- timezone,
- format,
- } => {
- let last = db.last().await?;
- let last = last.as_slice();
- let tz = timezone.unwrap_or(settings.timezone);
- print_list(
- last,
- ListMode::from_flags(human, cmd_only),
- match format {
- None => Some(settings.history_format.as_str()),
- _ => format.as_deref(),
- },
- false,
- true,
- tz,
- );
-
- Ok(())
- }
-
- Self::InitStore => history_store.init_store(&db).await,
-
- Self::Prune { dry_run } => {
- Self::handle_prune(&db, settings, store, context, dry_run).await
- }
-
- Self::Dedup {
- dry_run,
- before,
- dupkeep,
- } => {
- let before = i64::try_from(
- interim::parse_date_string(
- before.as_str(),
- OffsetDateTime::now_utc(),
- interim::Dialect::Uk,
- )?
- .unix_timestamp_nanos(),
- )?;
- Self::handle_dedup(&db, settings, store, before, dupkeep, dry_run).await
- }
-
- Self::Start { .. } | Self::End { .. } | Self::Tail => unreachable!(),
- }
- }
- }
- }
-}
-
-#[cfg(test)]
-mod tests {
- use time::macros::datetime;
-
- use super::{
- History, Settings, TailEvent, TailKind, Timezone, normalize_command_for_storage, parse_fmt,
- };
-
- #[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\\\\"
- );
- }
-
- #[test]
- fn test_format_string_no_panic() {
- // Don't panic but provide helpful output (issue #2776)
- let malformed_json = r#"{"command":"{command}","key":"value"}"#;
-
- let result = std::panic::catch_unwind(|| parse_fmt(malformed_json));
-
- assert!(result.is_ok());
- }
-
- #[test]
- fn test_valid_formats_still_work() {
- assert!(std::panic::catch_unwind(|| parse_fmt("{command}")).is_ok());
- assert!(std::panic::catch_unwind(|| parse_fmt("{time} - {command}")).is_ok());
- }
-
- fn sample_tail_event(kind: TailKind) -> TailEvent {
- TailEvent {
- kind,
- history: History {
- id: "history-id".to_owned().into(),
- timestamp: datetime!(2026-04-09 17:18:19 UTC),
- duration: 12_345_678,
- exit: 0,
- command: "git status".to_owned(),
- cwd: "/tmp/repo".to_owned(),
- session: "session-id".to_owned(),
- hostname: "host:ellie".to_owned(),
- author: "claude".to_owned(),
- intent: Some("inspect repository state".to_owned()),
- deleted_at: None,
- },
- }
- }
-
- #[test]
- fn test_tail_json_output_contains_history_fields() {
- let json = sample_tail_event(TailKind::Ended)
- .render(false, Timezone(time::UtcOffset::UTC))
- .unwrap();
- let value: serde_json::Value = serde_json::from_str(&json).unwrap();
-
- assert_eq!(value["event"], "ended");
- assert_eq!(value["history"]["id"], "history-id");
- assert_eq!(value["history"]["duration_ns"], 12_345_678);
- assert_eq!(value["history"]["success"], true);
- assert!(value.get("record").is_none());
- }
-
- #[test]
- fn test_tail_pretty_output_shows_pending_fields_for_started_events() {
- let rendered = sample_tail_event(TailKind::Started)
- .render(true, Timezone(time::UtcOffset::UTC))
- .unwrap();
- let plain = regex::Regex::new(r"\x1b\[[0-9;]*m")
- .unwrap()
- .replace_all(&rendered, "");
-
- assert!(plain.contains("STARTED git status"));
- assert!(plain.contains("exit:"));
- assert!(plain.contains("pending"));
- assert!(plain.contains("duration:"));
- assert!(plain.contains("running"));
- }
-}
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..bfec0aab
--- /dev/null
+++ b/crates/client/src/command/client/history/end.rs
@@ -0,0 +1,38 @@
+use crate::atuin_client::settings::Settings;
+
+use eyre::{Result, eyre};
+use turtle_daemon::api::client::{HistoryClient, daemon_matches_expected, daemon_mismatch_message};
+
+pub(super) async fn handle(
+ settings: &Settings,
+ id: &str,
+ exit: i64,
+ duration: Option<u64>,
+) -> Result<()> {
+ end_history(settings, id.to_string(), duration.unwrap_or(0), exit).await?;
+
+ Ok(())
+}
+
+async fn end_history(settings: &Settings, id: String, duration: u64, exit: i64) -> Result<()> {
+ match async {
+ HistoryClient::new(settings.daemon.socket_path.clone())
+ .await?
+ .end_history(id.clone(), duration, exit)
+ .await
+ }
+ .await
+ {
+ Ok(resp) => {
+ if daemon_matches_expected(&resp.version, resp.protocol) {
+ return Ok(());
+ }
+
+ Err(eyre!(
+ "{}. Restart the daemon manually",
+ daemon_mismatch_message(&resp.version, resp.protocol)
+ ))
+ }
+ Err(err) => Err(err),
+ }
+}
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..d71c653d
--- /dev/null
+++ b/crates/client/src/command/client/history/mod.rs
@@ -0,0 +1,753 @@
+use std::{
+ fmt::{self, Display},
+ io::{self, IsTerminal, Write},
+ ops::ControlFlow,
+ time::Duration,
+};
+
+use clap::Subcommand;
+use eyre::Result;
+use runtime_format::{FormatKey, FormatKeyError, ParseSegment, ParsedFmt};
+use time::{OffsetDateTime, macros::format_description};
+use turtle_common::utils::Escapable;
+use turtle_daemon::aclient::history::History;
+
+use crate::atuin_client::settings::{Settings, Timezone};
+
+mod end;
+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,
+
+ #[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")]
+ 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>,
+ },
+
+ /// Get the last command ran
+ Last {
+ #[arg(long)]
+ human: bool,
+
+ /// Show only the text of the command
+ #[arg(long)]
+ cmd_only: 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")]
+ timezone: Option<Timezone>,
+
+ /// Available variables: {command}, {directory}, {duration}, {user}, {host}, {author}, {intent}, {time}, {session}, {uuid} and {relativetime}.
+ /// Example: --format "{time} - [{duration}] - {directory}$\t{command}"
+ #[arg(long, short)]
+ format: Option<String>,
+ },
+
+ InitStore,
+
+ /// Delete history entries matching the configured exclusion filters
+ Prune {
+ /// List matching history lines without performing the actual deletion.
+ #[arg(short = 'n', long)]
+ dry_run: bool,
+ },
+
+ /// Delete duplicate history entries (that have the same command, cwd and hostname)
+ Dedup {
+ /// List matching history lines without performing the actual deletion.
+ #[arg(short = 'n', long)]
+ dry_run: bool,
+
+ /// Only delete results added before this date
+ #[arg(long, short)]
+ before: String,
+
+ /// How many recent duplicates to keep
+ #[arg(long)]
+ dupkeep: u32,
+ },
+}
+
+impl Cmd {
+ // #[expect(clippy::too_many_arguments)]
+ // #[expect(clippy::fn_params_excessive_bools)]
+ // async fn handle_list(
+ // settings: &Settings,
+ // session: bool,
+ // cwd: bool,
+ // mode: ListMode,
+ // format: Option<String>,
+ // include_deleted: bool,
+ // print0: bool,
+ // reverse: bool,
+ // tz: Timezone,
+ // ) -> Result<()> {
+ // let filters = match (session, cwd) {
+ // (true, true) => [Session, Directory],
+ // (true, false) => [Session, Global],
+ // (false, true) => [Global, Directory],
+ // (false, false) => [
+ // settings.default_filter_mode(context.git_root.is_some()),
+ // Global,
+ // ],
+ // };
+ //
+ // let history = db
+ // .list(&filters, &context, None, false, include_deleted)
+ // .await?;
+ //
+ // print_list(
+ // &history,
+ // mode,
+ // match format {
+ // None => Some(settings.history_format.as_str()),
+ // _ => format.as_deref(),
+ // },
+ // print0,
+ // reverse,
+ // tz,
+ // );
+ //
+ // Ok(())
+ // }
+
+ // async fn handle_prune(
+ // db: &ClientSqlite,
+ // settings: &Settings,
+ // store: SqliteStore,
+ // context: crate::atuin_client::database::Context,
+ // dry_run: bool,
+ // ) -> Result<()> {
+ // // Grab all executed commands and filter them using History::should_save.
+ // // We could iterate or paginate here if memory usage becomes an issue.
+ // let matches: Vec<History> = db
+ // .list(&[Global], &context, None, false, false)
+ // .await?
+ // .into_iter()
+ // .filter(|h| !h.should_save(settings))
+ // .collect();
+ //
+ // match matches.len() {
+ // 0 => {
+ // println!("No entries to prune.");
+ // return Ok(());
+ // }
+ // 1 => println!("Found 1 entry to prune."),
+ // n => println!("Found {n} entries to prune."),
+ // }
+ //
+ // if dry_run {
+ // print_list(
+ // &matches,
+ // ListMode::Human,
+ // Some(settings.history_format.as_str()),
+ // false,
+ // false,
+ // settings.timezone,
+ // );
+ // } else {
+ // let encryption_key: [u8; 32] = encryption::load_key(settings)
+ // .context("could not load encryption key")?
+ // .into();
+ // let host_id = Settings::host_id().await?;
+ // let history_store = HistoryStore::new(store.clone(), host_id, encryption_key);
+ //
+ // for entry in matches {
+ // eprintln!("deleting {}", entry.id);
+ // let (id, _) = history_store.delete(entry.id.clone()).await?;
+ // history_store.incremental_build(db, &[id]).await?;
+ // }
+ //
+ // daemon::emit_event(settings, crate::atuin_daemon::DaemonEvent::HistoryPruned).await;
+ // }
+ // Ok(())
+ // }
+ //
+ // async fn handle_dedup(
+ // db: &ClientSqlite,
+ // settings: &Settings,
+ // store: SqliteStore,
+ // before: i64,
+ // dupkeep: u32,
+ // dry_run: bool,
+ // ) -> Result<()> {
+ // if dupkeep == 0 {
+ // eprintln!(
+ // "\"--dupkeep 0\" would keep 0 copies of duplicate commands and thus delete all of them! Use \"atuin search --delete ...\" if you really want that."
+ // );
+ // std::process::exit(1);
+ // }
+ //
+ // let matches: Vec<History> = db.get_dups(before, dupkeep).await?;
+ //
+ // match matches.len() {
+ // 0 => {
+ // println!("No duplicates to delete.");
+ // return Ok(());
+ // }
+ // 1 => println!("Found 1 duplicate to delete."),
+ // n => println!("Found {n} duplicates to delete."),
+ // }
+ //
+ // if dry_run {
+ // print_list(
+ // &matches,
+ // ListMode::Human,
+ // Some(settings.history_format.as_str()),
+ // false,
+ // false,
+ // settings.timezone,
+ // );
+ // } else {
+ // let encryption_key: [u8; 32] = encryption::load_key(settings)
+ // .context("could not load encryption key")?
+ // .into();
+ // let host_id = Settings::host_id().await?;
+ // let history_store = HistoryStore::new(store.clone(), host_id, encryption_key);
+ //
+ // let ids = matches.iter().map(|h| h.id.clone()).collect::<Vec<_>>();
+ //
+ // for entry in matches {
+ // eprintln!("deleting {}", entry.id);
+ // let (id, _) = history_store.delete(entry.id).await?;
+ // history_store.incremental_build(db, &[id]).await?;
+ // }
+ //
+ // daemon::emit_event(
+ // settings,
+ // crate::atuin_daemon::DaemonEvent::HistoryDeleted { ids },
+ // )
+ // .await;
+ // }
+ // Ok(())
+ // }
+
+ #[expect(clippy::too_many_lines)]
+ 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).await,
+ Self::Tail => {
+ return tail::handle(settings).await;
+ }
+ cmd => {
+ todo!()
+ // let context = current_context().await?;
+ //
+ // let db_path = PathBuf::from(settings.db_path.as_str());
+ // let record_store_path = PathBuf::from(settings.record_store_path.as_str());
+ //
+ // let db = ClientSqlite::new(db_path, settings.local_timeout).await?;
+ // let store = SqliteStore::new(record_store_path, settings.local_timeout).await?;
+ //
+ // let encryption_key: [u8; 32] = encryption::load_key(settings)
+ // .context("could not load encryption key")?
+ // .into();
+ //
+ // let host_id = Settings::host_id().await?;
+ // let history_store = HistoryStore::new(store.clone(), host_id, encryption_key);
+ //
+ // match cmd {
+ // Self::List {
+ // session,
+ // cwd,
+ // human,
+ // cmd_only,
+ // print0,
+ // reverse,
+ // timezone,
+ // format,
+ // } => {
+ // let mode = ListMode::from_flags(human, cmd_only);
+ // let tz = timezone.unwrap_or(settings.timezone);
+ // Self::handle_list(
+ // &db, settings, context, session, cwd, mode, format, false, print0,
+ // reverse, tz,
+ // )
+ // .await
+ // }
+ //
+ // Self::Last {
+ // human,
+ // cmd_only,
+ // timezone,
+ // format,
+ // } => {
+ // let last = db.last().await?;
+ // let last = last.as_slice();
+ // let tz = timezone.unwrap_or(settings.timezone);
+ // print_list(
+ // last,
+ // ListMode::from_flags(human, cmd_only),
+ // match format {
+ // None => Some(settings.history_format.as_str()),
+ // _ => format.as_deref(),
+ // },
+ // false,
+ // true,
+ // tz,
+ // );
+ //
+ // Ok(())
+ // }
+ //
+ // Self::InitStore => history_store.init_store(&db).await,
+ //
+ // Self::Prune { dry_run } => {
+ // Self::handle_prune(&db, settings, store, context, dry_run).await
+ // }
+ //
+ // Self::Dedup {
+ // dry_run,
+ // before,
+ // dupkeep,
+ // } => {
+ // let before = i64::try_from(
+ // interim::parse_date_string(
+ // before.as_str(),
+ // OffsetDateTime::now_utc(),
+ // interim::Dialect::Uk,
+ // )?
+ // .unix_timestamp_nanos(),
+ // )?;
+ // Self::handle_dedup(&db, settings, store, before, dupkeep, dry_run).await
+ // }
+ //
+ // Self::Start { .. } | Self::End { .. } | Self::Tail => unreachable!(),
+ // }
+ }
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug)]
+pub(crate) enum ListMode {
+ Human,
+ CmdOnly,
+ Regular,
+}
+
+impl ListMode {
+ pub(crate) const fn from_flags(human: bool, cmd_only: bool) -> Self {
+ if human {
+ Self::Human
+ } else if cmd_only {
+ Self::CmdOnly
+ } else {
+ Self::Regular
+ }
+ }
+}
+
+pub(crate) 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)]
+ 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);
+ }
+ }
+}
+
+/// Type wrapper around `History` with formatting settings.
+#[derive(Clone, Copy, Debug)]
+struct FmtHistory<'a> {
+ history: &'a History,
+ cmd_format: CmdFormat,
+ tz: &'a Timezone,
+}
+/// defines how to format the history
+impl FormatKey for FmtHistory<'_> {
+ #[expect(clippy::cast_sign_loss)]
+ fn fmt(&self, key: &str, f: &mut fmt::Formatter<'_>) -> Result<(), FormatKeyError> {
+ match key {
+ "command" => match self.cmd_format {
+ CmdFormat::Literal => f.write_str(self.history.command.trim()),
+ CmdFormat::Escaped => f.write_str(&self.history.command.trim().escape_control()),
+ }?,
+ "directory" => f.write_str(self.history.cwd.trim())?,
+ "exit" => f.write_str(&self.history.exit.to_string())?,
+ "duration" => {
+ let dur = Duration::from_nanos(std::cmp::max(self.history.duration, 0) as u64);
+ 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.0)?,
+ _ => return Err(FormatKeyError::UnknownKey),
+ }
+ Ok(())
+ }
+}
+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"),
+ }
+}
+
+#[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]");
+
+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 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, parse_fmt};
+
+ #[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\\\\"
+ );
+ }
+
+ #[test]
+ fn test_format_string_no_panic() {
+ // Don't panic but provide helpful output (issue #2776)
+ let malformed_json = r#"{"command":"{command}","key":"value"}"#;
+
+ let result = std::panic::catch_unwind(|| parse_fmt(malformed_json));
+
+ assert!(result.is_ok());
+ }
+
+ #[test]
+ fn test_valid_formats_still_work() {
+ assert!(std::panic::catch_unwind(|| parse_fmt("{command}")).is_ok());
+ assert!(std::panic::catch_unwind(|| parse_fmt("{time} - {command}")).is_ok());
+ }
+}
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..81aaa904
--- /dev/null
+++ b/crates/client/src/command/client/history/start.rs
@@ -0,0 +1,76 @@
+use crate::{
+ atuin_client::settings::Settings,
+ command::client::history::{apply_start_metadata, normalize_command_for_storage},
+};
+
+use eyre::{Result, eyre};
+use time::OffsetDateTime;
+use tracing::debug;
+use turtle_common::utils;
+use turtle_daemon::{
+ aclient::history::{History, SettingsFilter},
+ api::client::{HistoryClient, daemon_matches_expected, daemon_mismatch_message},
+};
+
+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::capture()
+ .timestamp(OffsetDateTime::now_utc())
+ .command(command)
+ .cwd(cwd)
+ .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.0.clone()
+ }
+ };
+
+ Ok(Some(resp))
+}
+
+async fn start_history(settings: &Settings, history: History) -> Result<String> {
+ match async {
+ HistoryClient::new(settings.daemon.socket_path.clone())
+ .await?
+ .start_history(history.clone())
+ .await
+ }
+ .await
+ {
+ Ok(resp) => {
+ if daemon_matches_expected(&resp.version, resp.protocol) {
+ return Ok(resp.id);
+ }
+
+ Err(eyre!(
+ "{}. Restart the daemon manually",
+ daemon_mismatch_message(&resp.version, 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..595fe3a0
--- /dev/null
+++ b/crates/client/src/command/client/history/tail.rs
@@ -0,0 +1,321 @@
+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_common::utils::Escapable;
+use turtle_daemon::{
+ aclient::history::History,
+ api::client::{
+ HistoryClient, HistoryEventKind, Probe, TailHistoryReply, history_entry_to_history, probe,
+ },
+};
+
+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_ns: Option<i64>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ duration: Option<String>,
+ #[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.0,
+ 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_ns: self.duration_value(),
+ duration: self.duration_value().map(format_duration_ns),
+ 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.0);
+ 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<i64> {
+ 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 >= 0)
+ .map(time::Duration::nanoseconds)
+ .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 >= 0 => 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_ns: i64) -> 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::from_nanos(duration_ns.max(0).cast_unsigned())).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 = HistoryClient::new(settings.daemon.socket_path.clone()).await?;
+ let mut stream = client.tail_history().await?;
+ let stdout = io::stdout();
+
+ while let Some(reply) = stream.message().await? {
+ let event = TailEvent::from_proto(reply)?;
+ let rendered = event.render(tty, settings.timezone)?;
+ let mut out = stdout.lock();
+
+ match out.write_all(rendered.as_bytes()) {
+ Ok(()) => out.flush()?,
+ Err(err) if err.kind() == io::ErrorKind::BrokenPipe => break,
+ Err(err) => return Err(err.into()),
+ }
+ }
+
+ Ok(())
+}
diff --git a/crates/client/src/command/client/info.rs b/crates/client/src/command/client/info.rs
index 49c92193..77c7064c 100644
--- a/crates/client/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_daemon::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/client/src/command/client/stats.rs b/crates/client/src/command/client/stats.rs
index 9b8ebdff..ec235f36 100644
--- a/crates/client/src/command/client/stats.rs
+++ b/crates/client/src/command/client/stats.rs
@@ -2,10 +2,12 @@ use clap::Parser;
use eyre::Result;
use interim::parse_date_string;
use time::{Duration, OffsetDateTime, Time};
+use turtle_daemon::api::client::{HistoryClient, Range};
use crate::atuin_client::settings::Settings;
use crate::atuin_history::stats::{compute, pretty_print};
+use crate::command::current_session;
fn parse_ngram_size(s: &str) -> Result<usize, String> {
let value = s
@@ -36,7 +38,9 @@ pub(crate) struct Cmd {
impl Cmd {
pub(crate) async fn run(&self, settings: &Settings) -> Result<()> {
- let context = current_context().await?;
+ let session = current_session()?;
+ let mut client = HistoryClient::new(settings.daemon.socket_path.clone()).await?;
+
let words = if self.period.is_empty() {
String::from("all")
} else {
@@ -47,27 +51,27 @@ impl Cmd {
let last_night = now.replace_time(Time::MIDNIGHT);
let history = if words.as_str() == "all" {
- db.list(&[], &context, None, false, false).await?
+ client.history(session, None).await?
} else if words.trim() == "today" {
let start = last_night;
let end = start + Duration::days(1);
- db.range(start, end).await?
+ client.history(session, Some(Range { start, end })).await?
} else if words.trim() == "month" {
let end = last_night;
let start = end - Duration::days(31);
- db.range(start, end).await?
+ client.history(session, Some(Range { start, end })).await?
} else if words.trim() == "week" {
let end = last_night;
let start = end - Duration::days(7);
- db.range(start, end).await?
+ client.history(session, Some(Range { start, end })).await?
} else if words.trim() == "year" {
let end = last_night;
let start = end - Duration::days(365);
- db.range(start, end).await?
+ client.history(session, Some(Range { start, end })).await?
} else {
let start = parse_date_string(&words, now, settings.dialect.into())?;
let end = start + Duration::days(1);
- db.range(start, end).await?
+ client.history(session, Some(Range { start, end })).await?
};
let stats = compute(settings, &history, self.count, self.ngram_size);
diff --git a/crates/client/src/command/client/store.rs b/crates/client/src/command/client/store/mod.rs
index bc57488d..bc57488d 100644
--- a/crates/client/src/command/client/store.rs
+++ b/crates/client/src/command/client/store/mod.rs
diff --git a/crates/client/src/command/client/sync.rs b/crates/client/src/command/client/sync.rs
index c29a82fc..86228e47 100644
--- a/crates/client/src/command/client/sync.rs
+++ b/crates/client/src/command/client/sync.rs
@@ -1,29 +1,18 @@
use clap::Subcommand;
-use eyre::{Result, WrapErr};
+use colored::Colorize;
+use eyre::{Result, WrapErr, bail};
use serde_json::json;
-use crate::{
- atuin_client::{
- database::ClientSqlite,
- encryption,
- history::store::HistoryStore,
- record::{sqlite_store::SqliteStore, sync},
- settings::Settings,
- },
- atuin_common::utils,
-};
+use turtle_common::utils;
+use turtle_daemon::api::client::{Probe, probe};
-mod status;
+use crate::{SHA, VERSION, atuin_client::settings::Settings};
#[derive(Subcommand, Debug)]
#[command(infer_subcommands = true)]
pub(crate) enum Cmd {
/// Sync with the configured server
- Perform {
- /// Force re-download everything
- #[arg(long, short)]
- force: bool,
- },
+ Perform {},
/// Print (or generate) the encryption key and user id for transfer to another machine
KeyAndId {},
@@ -33,88 +22,72 @@ pub(crate) enum Cmd {
}
impl Cmd {
- pub(crate) async fn run(
- self,
- settings: Settings,
- db: &ClientSqlite,
- store: SqliteStore,
- ) -> Result<()> {
+ pub(crate) async fn run(self, settings: Settings) -> Result<()> {
match self {
- Self::Perform { force } => run(&settings, force, db, store).await,
- Self::Status => status::run(&settings).await,
+ Self::Perform {} => perform_cmd(&settings).await,
+ Self::Status => status_cmd(&settings).await,
Self::KeyAndId {} => {
- 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(())
+ 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 run(
- settings: &Settings,
- force: bool,
- db: &ClientSqlite,
- store: SqliteStore,
-) -> Result<()> {
- let encryption_key: [u8; 32] = encryption::load_key(settings)
- .context("could not load encryption key")?
- .into();
-
- let host_id = Settings::host_id().await?;
- let history_store = HistoryStore::new(store.clone(), host_id, encryption_key);
-
- let (uploaded, downloaded) = sync::sync(settings, &store, &encryption_key)
- .await
- .map_err(crate::print_error::format_sync_error)?;
-
- crate::sync::build(settings, &store, db, Some(&downloaded)).await?;
-
- println!("{uploaded}/{} up/down to record store", downloaded.len());
-
- let history_length = db.history_count(true).await?;
- let store_history_length = store.len_tag("history").await?;
+async fn status_cmd(settings: &Settings) -> Result<()> {
+ todo!();
- #[expect(clippy::cast_sign_loss)]
- if history_length as u64 > store_history_length {
- println!("{history_length} in history index, but {store_history_length} in history store");
- println!("Running automatic history store init...");
+ // 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");
+ // }
- // Internally we use the global filter mode, so this context is ignored.
- // don't recurse or loop here.
- history_store.init_store(db).await?;
-
- println!("Re-running sync due to new records locally");
-
- // we'll want to run sync once more, as there will now be stuff to upload
- let (uploaded, downloaded) = sync::sync(settings, &store, &encryption_key)
- .await
- .map_err(crate::print_error::format_sync_error)?;
-
- crate::sync::build(settings, &store, db, Some(&downloaded)).await?;
+ Ok(())
+}
- println!("{uploaded}/{} up/down to record store", downloaded.len());
+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?;
+ if !reply.accepted {
+ bail!("Daemon refused to accept sync request");
+ }
+ }
+ Probe::NeedsRestart(msg) => {
+ bail!("Daemon version mis-match, needs restart: {msg}");
+ }
+ Probe::Unreachable(report) => {
+ bail!("Daemon unreachable: {report}");
+ }
}
- println!(
- "Sync complete! {} items in history database, force: {}",
- db.history_count(true).await?,
- force
- );
-
Ok(())
}
diff --git a/crates/client/src/command/client/sync/status.rs b/crates/client/src/command/client/sync/status.rs
deleted file mode 100644
index caf3b90f..00000000
--- a/crates/client/src/command/client/sync/status.rs
+++ /dev/null
@@ -1,25 +0,0 @@
-use crate::atuin_client::settings::Settings;
-use crate::{SHA, VERSION};
-use colored::Colorize;
-use eyre::{Result, bail};
-
-pub(crate) async fn run(settings: &Settings) -> Result<()> {
- 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(())
-}
diff --git a/crates/client/src/command/client/wrapped.rs b/crates/client/src/command/client/wrapped.rs
index 2ce19bf7..64e5b718 100644
--- a/crates/client/src/command/client/wrapped.rs
+++ b/crates/client/src/command/client/wrapped.rs
@@ -2,11 +2,13 @@ use crossterm::style::{ResetColor, SetAttribute};
use eyre::Result;
use std::collections::{HashMap, HashSet};
use time::{Date, Duration, Month, OffsetDateTime, Time};
+use turtle_daemon::aclient::history::History;
+use turtle_daemon::api::client::{HistoryClient, Range};
-use crate::atuin_client::database::ClientSqlite;
use crate::atuin_client::settings::Settings;
use crate::atuin_history::stats::{Stats, compute};
+use crate::command::current_session;
#[derive(Debug)]
struct WrappedStats {
@@ -21,11 +23,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 +270,10 @@ 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 session = current_session()?;
+ 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 +297,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(session, 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/client/src/command/mod.rs b/crates/client/src/command/mod.rs
index 9a648254..2e51a1e2 100644
--- a/crates/client/src/command/mod.rs
+++ b/crates/client/src/command/mod.rs
@@ -15,10 +15,6 @@ pub(crate) enum AtuinCmd {
#[command(flatten)]
Client(client::Cmd),
- /// PTY proxy for atuin
- #[command(alias = "hex")]
- PtyProxy(crate::atuin_pty_proxy::PtyProxy),
-
/// Generate a UUID
Uuid,
@@ -41,17 +37,12 @@ impl AtuinCmd {
match self {
Self::Client(client) => client.run(),
- Self::PtyProxy(proxy) => {
- run_pty_proxy(proxy);
- Ok(())
- }
-
Self::Contributors => {
contributors::run();
Ok(())
}
Self::Uuid => {
- println!("{}", crate::atuin_common::utils::uuid_v7().as_simple());
+ println!("{}", turtle_common::utils::uuid_v7().as_simple());
Ok(())
}
Self::GenCompletions(gen_completions) => gen_completions.run(),
@@ -60,52 +51,6 @@ impl AtuinCmd {
}
#[cfg(unix)]
-fn run_pty_proxy(proxy: crate::atuin_pty_proxy::PtyProxy) {
- proxy.run(semantic_command_capture_sink());
-}
-
-#[cfg(unix)]
-fn semantic_command_capture_sink() -> Option<crate::atuin_pty_proxy::CommandCaptureSink> {
- use std::sync::mpsc;
- use std::time::Duration;
-
- if is_truthy_env("ATUIN_TERMINAL") {
- return None;
- }
-
- let settings = crate::atuin_client::settings::Settings::new().ok()?;
- let (tx, rx) = mpsc::sync_channel::<crate::atuin_pty_proxy::CommandCapture>(128);
-
- std::thread::spawn(move || {
- let Ok(runtime) = tokio::runtime::Builder::new_current_thread()
- .enable_all()
- .build()
- else {
- return;
- };
-
- while let Ok(first) = rx.recv() {
- let mut batch = vec![first];
-
- while batch.len() < 64 {
- match rx.recv_timeout(Duration::from_millis(25)) {
- Ok(capture) => batch.push(capture),
- Err(mpsc::RecvTimeoutError::Timeout | mpsc::RecvTimeoutError::Disconnected) => {
- break;
- }
- }
- }
-
- runtime.block_on(send_semantic_command_captures(&settings, batch));
- }
- });
-
- Some(Box::new(move |capture| {
- drop(tx.try_send(capture));
- }))
-}
-
-#[cfg(unix)]
#[inline]
fn is_truthy_env(name: &str) -> bool {
std::env::var(name)
@@ -113,29 +58,8 @@ fn is_truthy_env(name: &str) -> bool {
.as_ref()
.is_some_and(|value| !value.trim().is_empty() && value.trim() != "false")
}
-
-#[cfg(unix)]
-async fn send_semantic_command_captures(
- settings: &crate::atuin_client::settings::Settings,
- batch: Vec<crate::atuin_pty_proxy::CommandCapture>,
-) {
- use crate::atuin_daemon::generated;
-
- let captures = batch
- .into_iter()
- .map(|capture| generated::semantic::CommandCapture {
- prompt: capture.prompt,
- command: capture.command,
- output: capture.output,
- exit_code: capture.exit_code,
- history_id: capture.history_id,
- session_id: capture.session_id,
- output_truncated: capture.output_truncated,
- output_observed_bytes: capture.output_observed_bytes,
- })
- .collect();
-
- if let Ok(mut client) = crate::atuin_daemon::SemanticClient::from_settings(settings).await {
- drop(client.record_commands(captures).await);
- }
+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.")
+ })
}