aboutsummaryrefslogtreecommitdiffstats
path: root/crates
diff options
context:
space:
mode:
Diffstat (limited to 'crates')
-rw-r--r--crates/atuin-client/migrations/20260224000100_history_author_intent.sql2
-rw-r--r--crates/atuin-client/src/database.rs23
-rw-r--r--crates/atuin-client/src/encryption.rs10
-rw-r--r--crates/atuin-client/src/history.rs237
-rw-r--r--crates/atuin-client/src/history/builder.rs22
-rw-r--r--crates/atuin-client/src/history/store.rs21
-rw-r--r--crates/atuin-daemon/proto/history.proto2
-rw-r--r--crates/atuin-daemon/src/client.rs2
-rw-r--r--crates/atuin-daemon/src/server.rs8
-rw-r--r--crates/atuin/src/command/client/history.rs62
-rw-r--r--crates/atuin/src/command/client/search/inspector.rs6
11 files changed, 336 insertions, 59 deletions
diff --git a/crates/atuin-client/migrations/20260224000100_history_author_intent.sql b/crates/atuin-client/migrations/20260224000100_history_author_intent.sql
new file mode 100644
index 00000000..2bed17e9
--- /dev/null
+++ b/crates/atuin-client/migrations/20260224000100_history_author_intent.sql
@@ -0,0 +1,2 @@
+alter table history add column author text;
+alter table history add column intent text;
diff --git a/crates/atuin-client/src/database.rs b/crates/atuin-client/src/database.rs
index 7aa095f7..5f292bec 100644
--- a/crates/atuin-client/src/database.rs
+++ b/crates/atuin-client/src/database.rs
@@ -200,8 +200,8 @@ impl Sqlite {
async fn save_raw(tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, h: &History) -> Result<()> {
sqlx::query(
- "insert or ignore into history(id, timestamp, duration, exit, command, cwd, session, hostname, deleted_at)
- values(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
+ "insert or ignore into history(id, timestamp, duration, exit, command, cwd, session, hostname, author, intent, deleted_at)
+ values(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
)
.bind(h.id.0.as_str())
.bind(h.timestamp.unix_timestamp_nanos() as i64)
@@ -211,6 +211,8 @@ impl Sqlite {
.bind(h.cwd.as_str())
.bind(h.session.as_str())
.bind(h.hostname.as_str())
+ .bind(h.author.as_str())
+ .bind(h.intent.as_deref())
.bind(h.deleted_at.map(|t|t.unix_timestamp_nanos() as i64))
.execute(&mut **tx)
.await?;
@@ -232,6 +234,13 @@ impl Sqlite {
fn query_history(row: SqliteRow) -> History {
let deleted_at: Option<i64> = row.get("deleted_at");
+ let hostname: String = row.get("hostname");
+ let author: Option<String> = row.try_get("author").ok().flatten();
+ let author = author
+ .filter(|author| !author.trim().is_empty())
+ .unwrap_or_else(|| History::author_from_hostname(hostname.as_str()));
+ let intent: Option<String> = row.try_get("intent").ok().flatten();
+ let intent = intent.filter(|intent| !intent.trim().is_empty());
History::from_db()
.id(row.get("id"))
@@ -244,7 +253,9 @@ impl Sqlite {
.command(row.get("command"))
.cwd(row.get("cwd"))
.session(row.get("session"))
- .hostname(row.get("hostname"))
+ .hostname(hostname)
+ .author(author)
+ .intent(intent)
.deleted_at(
deleted_at.and_then(|t| OffsetDateTime::from_unix_timestamp_nanos(t as i128).ok()),
)
@@ -295,7 +306,7 @@ impl Database for Sqlite {
sqlx::query(
"update history
- set timestamp = ?2, duration = ?3, exit = ?4, command = ?5, cwd = ?6, session = ?7, hostname = ?8, deleted_at = ?9
+ set timestamp = ?2, duration = ?3, exit = ?4, command = ?5, cwd = ?6, session = ?7, hostname = ?8, author = ?9, intent = ?10, deleted_at = ?11
where id = ?1",
)
.bind(h.id.0.as_str())
@@ -306,6 +317,8 @@ impl Database for Sqlite {
.bind(h.cwd.as_str())
.bind(h.session.as_str())
.bind(h.hostname.as_str())
+ .bind(h.author.as_str())
+ .bind(h.intent.as_deref())
.bind(h.deleted_at.map(|t|t.unix_timestamp_nanos() as i64))
.execute(&self.pool)
.await?;
@@ -612,6 +625,8 @@ impl Database for Sqlite {
"exit",
"command",
"deleted_at",
+ "null as author",
+ "null as intent",
"group_concat(cwd, ':') as cwd",
"group_concat(session) as session",
"group_concat(hostname, ',') as hostname",
diff --git a/crates/atuin-client/src/encryption.rs b/crates/atuin-client/src/encryption.rs
index a56dbd09..f2032482 100644
--- a/crates/atuin-client/src/encryption.rs
+++ b/crates/atuin-client/src/encryption.rs
@@ -253,6 +253,8 @@ fn decode(bytes: &[u8]) -> Result<History> {
cwd: cwd.to_owned(),
session: session.to_owned(),
hostname: hostname.to_owned(),
+ author: History::author_from_hostname(hostname),
+ intent: None,
deleted_at: deleted_at
.map(|t| OffsetDateTime::parse(t, &Rfc3339))
.transpose()?,
@@ -287,6 +289,8 @@ mod test {
.duration(1)
.session("beep boop".into())
.hostname("booop".into())
+ .author("booop".into())
+ .intent(None)
.deleted_at(None)
.build()
.into();
@@ -331,6 +335,8 @@ mod test {
cwd: "/Users/conrad.ludgate/Documents/code/atuin".to_owned(),
session: "b97d9a306f274473a203d2eba41f9457".to_owned(),
hostname: "fvfg936c0kpf:conrad.ludgate".to_owned(),
+ author: "conrad.ludgate".to_owned(),
+ intent: None,
deleted_at: None,
};
@@ -352,6 +358,8 @@ mod test {
cwd: "/Users/conrad.ludgate/Documents/code/atuin".to_owned(),
session: "b97d9a306f274473a203d2eba41f9457".to_owned(),
hostname: "fvfg936c0kpf:conrad.ludgate".to_owned(),
+ author: "conrad.ludgate".to_owned(),
+ intent: None,
deleted_at: Some(datetime!(2023-05-28 18:35:40.633872 +00:00)),
};
@@ -383,6 +391,8 @@ mod test {
cwd: "/Users/conrad.ludgate/Documents/code/atuin".to_owned(),
session: "b97d9a306f274473a203d2eba41f9457".to_owned(),
hostname: "fvfg936c0kpf:conrad.ludgate".to_owned(),
+ author: "conrad.ludgate".to_owned(),
+ intent: None,
deleted_at: None,
};
diff --git a/crates/atuin-client/src/history.rs b/crates/atuin-client/src/history.rs
index d9d5a203..a5adc233 100644
--- a/crates/atuin-client/src/history.rs
+++ b/crates/atuin-client/src/history.rs
@@ -1,4 +1,5 @@
use core::fmt::Formatter;
+use rmp::decode::DecodeStringError;
use rmp::decode::ValueReadError;
use rmp::{Marker, decode::Bytes};
use std::env;
@@ -17,8 +18,14 @@ use time::OffsetDateTime;
mod builder;
pub mod store;
-const HISTORY_VERSION: &str = "v0";
+pub(crate) const HISTORY_VERSION_V0: &str = "v0";
+pub(crate) const HISTORY_VERSION_V1: &str = "v1";
+const HISTORY_RECORD_VERSION_V0: u16 = 0;
+const HISTORY_RECORD_VERSION_V1: u16 = 1;
+pub(crate) const HISTORY_VERSION: &str = HISTORY_VERSION_V1;
pub const HISTORY_TAG: &str = "history";
+const HISTORY_AUTHOR_ENV: &str = "ATUIN_HISTORY_AUTHOR";
+const HISTORY_INTENT_ENV: &str = "ATUIN_HISTORY_INTENT";
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
pub struct HistoryId(pub String);
@@ -46,8 +53,8 @@ impl From<String> for HistoryId {
//
// ## Implementation Notes
//
-// New fields must should be added to `encryption::{encode, decode}` in a backwards
-// compatible way. (eg sensible defaults and updating the nfields parameter)
+// New fields must be added to `History::{serialize,deserialize}` in a backwards
+// compatible way (sensible defaults and careful `nfields` handling).
#[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)]
pub struct History {
/// A client-generated ID, used to identify the entry when syncing.
@@ -68,6 +75,10 @@ pub struct History {
pub session: String,
/// The hostname of the machine the command was run on.
pub hostname: String,
+ /// Who wrote this command (human user or automation/agent identity).
+ pub author: String,
+ /// Optional rationale for why the command was executed.
+ pub intent: Option<String>,
/// Timestamp, which is set when the entry is deleted, allowing a soft delete.
pub deleted_at: Option<OffsetDateTime>,
}
@@ -93,6 +104,23 @@ pub struct HistoryStats {
}
impl History {
+ pub(crate) fn author_from_hostname(hostname: &str) -> String {
+ hostname
+ .split_once(':')
+ .map_or_else(|| hostname.to_owned(), |(_, user)| user.to_owned())
+ }
+
+ fn normalize_optional_field(field: Option<String>) -> Option<String> {
+ field.and_then(|value| {
+ let trimmed = value.trim();
+ if trimmed.is_empty() {
+ None
+ } else {
+ Some(trimmed.to_owned())
+ }
+ })
+ }
+
#[allow(clippy::too_many_arguments)]
fn new(
timestamp: OffsetDateTime,
@@ -102,12 +130,19 @@ impl History {
duration: i64,
session: Option<String>,
hostname: Option<String>,
+ author: Option<String>,
+ intent: Option<String>,
deleted_at: Option<OffsetDateTime>,
) -> Self {
let session = session
.or_else(|| env::var("ATUIN_SESSION").ok())
.unwrap_or_else(|| uuid_v7().as_simple().to_string());
let hostname = hostname.unwrap_or_else(get_host_user);
+ let author = Self::normalize_optional_field(author)
+ .or_else(|| Self::normalize_optional_field(env::var(HISTORY_AUTHOR_ENV).ok()))
+ .unwrap_or_else(|| Self::author_from_hostname(hostname.as_str()));
+ let intent = Self::normalize_optional_field(intent)
+ .or_else(|| Self::normalize_optional_field(env::var(HISTORY_INTENT_ENV).ok()));
Self {
id: uuid_v7().as_simple().to_string().into(),
@@ -118,6 +153,8 @@ impl History {
duration,
session,
hostname,
+ author,
+ intent,
deleted_at,
}
}
@@ -131,9 +168,9 @@ impl History {
let mut output = vec![];
// write the version
- encode::write_u16(&mut output, 0)?;
- // INFO: ensure this is updated when adding new fields
- encode::write_array_len(&mut output, 9)?;
+ encode::write_u16(&mut output, HISTORY_RECORD_VERSION_V1)?;
+ let include_intent = self.intent.is_some();
+ encode::write_array_len(&mut output, 10 + u32::from(include_intent))?;
encode::write_str(&mut output, &self.id.0)?;
encode::write_u64(&mut output, self.timestamp.unix_timestamp_nanos() as u64)?;
@@ -149,9 +186,33 @@ impl History {
None => encode::write_nil(&mut output)?,
}
+ encode::write_str(&mut output, self.author.as_str())?;
+ if let Some(intent) = &self.intent {
+ encode::write_str(&mut output, intent.as_str())?;
+ }
+
Ok(DecryptedData(output))
}
+ fn read_optional_string(bytes: &[u8]) -> Result<(Option<String>, &[u8])> {
+ use rmp::decode;
+
+ fn error_report<E: std::fmt::Debug>(err: E) -> eyre::Report {
+ eyre!("{err:?}")
+ }
+
+ match decode::read_str_from_slice(bytes) {
+ Ok((value, bytes)) => Ok((Some(value.to_owned()), bytes)),
+ Err(DecodeStringError::TypeMismatch(Marker::Null)) => {
+ let mut cursor = Bytes::new(bytes);
+ decode::read_nil(&mut cursor).map_err(error_report)?;
+
+ Ok((None, cursor.remaining_slice()))
+ }
+ Err(err) => Err(error_report(err)),
+ }
+ }
+
fn deserialize_v0(bytes: &[u8]) -> Result<History> {
use rmp::decode;
@@ -163,7 +224,7 @@ impl History {
let version = decode::read_u16(&mut bytes).map_err(error_report)?;
- if version != 0 {
+ if version != HISTORY_RECORD_VERSION_V0 {
bail!("expected decoding v0 record, found v{version}");
}
@@ -187,7 +248,6 @@ impl History {
let (session, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
let (hostname, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
- // if we have more fields, try and get the deleted_at
let mut bytes = Bytes::new(bytes);
let (deleted_at, bytes) = match decode::read_u64(&mut bytes) {
@@ -196,6 +256,76 @@ impl History {
Err(ValueReadError::TypeMismatch(Marker::Null)) => (None, bytes.remaining_slice()),
Err(err) => return Err(error_report(err)),
};
+ if !bytes.is_empty() {
+ bail!("trailing bytes in encoded history. malformed")
+ }
+
+ Ok(History {
+ id: id.to_owned().into(),
+ timestamp: OffsetDateTime::from_unix_timestamp_nanos(timestamp as i128)?,
+ duration,
+ exit,
+ command: command.to_owned(),
+ cwd: cwd.to_owned(),
+ session: session.to_owned(),
+ hostname: hostname.to_owned(),
+ author: Self::author_from_hostname(hostname),
+ intent: None,
+ deleted_at: deleted_at
+ .map(|t| OffsetDateTime::from_unix_timestamp_nanos(t as i128))
+ .transpose()?,
+ })
+ }
+
+ fn deserialize_v1(bytes: &[u8]) -> Result<History> {
+ use rmp::decode;
+
+ fn error_report<E: std::fmt::Debug>(err: E) -> eyre::Report {
+ eyre!("{err:?}")
+ }
+
+ let mut bytes = Bytes::new(bytes);
+
+ let version = decode::read_u16(&mut bytes).map_err(error_report)?;
+
+ if version != HISTORY_RECORD_VERSION_V1 {
+ bail!("expected decoding v1 record, found v{version}");
+ }
+
+ let nfields = decode::read_array_len(&mut bytes).map_err(error_report)?;
+
+ if !(10..=11).contains(&nfields) {
+ bail!("cannot decrypt history from a different version of Atuin");
+ }
+
+ let bytes = bytes.remaining_slice();
+ let (id, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
+
+ let mut bytes = Bytes::new(bytes);
+ let timestamp = decode::read_u64(&mut bytes).map_err(error_report)?;
+ let duration = decode::read_int(&mut bytes).map_err(error_report)?;
+ let exit = decode::read_int(&mut bytes).map_err(error_report)?;
+
+ let bytes = bytes.remaining_slice();
+ let (command, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
+ let (cwd, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
+ let (session, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
+ let (hostname, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
+
+ let mut bytes = Bytes::new(bytes);
+
+ let (deleted_at, bytes) = match decode::read_u64(&mut bytes) {
+ Ok(unix) => (Some(unix), bytes.remaining_slice()),
+ // we accept null here
+ Err(ValueReadError::TypeMismatch(Marker::Null)) => (None, bytes.remaining_slice()),
+ Err(err) => return Err(error_report(err)),
+ };
+ let (author, bytes) = Self::read_optional_string(bytes)?;
+ let (intent, bytes) = if nfields > 10 {
+ Self::read_optional_string(bytes)?
+ } else {
+ (None, bytes)
+ };
if !bytes.is_empty() {
bail!("trailing bytes in encoded history. malformed")
@@ -210,6 +340,8 @@ impl History {
cwd: cwd.to_owned(),
session: session.to_owned(),
hostname: hostname.to_owned(),
+ author: author.unwrap_or_else(|| Self::author_from_hostname(hostname)),
+ intent,
deleted_at: deleted_at
.map(|t| OffsetDateTime::from_unix_timestamp_nanos(t as i128))
.transpose()?,
@@ -218,7 +350,8 @@ impl History {
pub fn deserialize(bytes: &[u8], version: &str) -> Result<History> {
match version {
- HISTORY_VERSION => Self::deserialize_v0(bytes),
+ HISTORY_VERSION_V0 => Self::deserialize_v0(bytes),
+ HISTORY_VERSION_V1 => Self::deserialize_v1(bytes),
_ => bail!("unknown version {version:?}"),
}
@@ -361,6 +494,8 @@ impl History {
/// .duration(100)
/// .session("somesession".to_string())
/// .hostname("localhost".to_string())
+ /// .author("user".to_string())
+ /// .intent(None)
/// .deleted_at(None)
/// .build()
/// .into();
@@ -469,18 +604,6 @@ mod tests {
#[test]
fn test_serialize_deserialize() {
- let bytes = [
- 205, 0, 0, 153, 217, 32, 54, 54, 100, 49, 54, 99, 98, 101, 101, 55, 99, 100, 52, 55,
- 53, 51, 56, 101, 53, 99, 53, 98, 56, 98, 52, 52, 101, 57, 48, 48, 54, 101, 207, 23, 99,
- 98, 117, 24, 210, 246, 128, 206, 2, 238, 210, 240, 0, 170, 103, 105, 116, 32, 115, 116,
- 97, 116, 117, 115, 217, 42, 47, 85, 115, 101, 114, 115, 47, 99, 111, 110, 114, 97, 100,
- 46, 108, 117, 100, 103, 97, 116, 101, 47, 68, 111, 99, 117, 109, 101, 110, 116, 115,
- 47, 99, 111, 100, 101, 47, 97, 116, 117, 105, 110, 217, 32, 98, 57, 55, 100, 57, 97,
- 51, 48, 54, 102, 50, 55, 52, 52, 55, 51, 97, 50, 48, 51, 100, 50, 101, 98, 97, 52, 49,
- 102, 57, 52, 53, 55, 187, 102, 118, 102, 103, 57, 51, 54, 99, 48, 107, 112, 102, 58,
- 99, 111, 110, 114, 97, 100, 46, 108, 117, 100, 103, 97, 116, 101, 192,
- ];
-
let history = History {
id: "66d16cbee7cd47538e5c5b8b44e9006e".to_owned().into(),
timestamp: datetime!(2023-05-28 18:35:40.633872 +00:00),
@@ -490,20 +613,21 @@ mod tests {
cwd: "/Users/conrad.ludgate/Documents/code/atuin".to_owned(),
session: "b97d9a306f274473a203d2eba41f9457".to_owned(),
hostname: "fvfg936c0kpf:conrad.ludgate".to_owned(),
+ author: "conrad.ludgate".to_owned(),
+ intent: None,
deleted_at: None,
};
let serialized = history.serialize().expect("failed to serialize history");
- assert_eq!(serialized.0, bytes);
+ assert_eq!(
+ &serialized.0[0..3],
+ [205, 0, 1],
+ "should encode as history v1"
+ );
let deserialized = History::deserialize(&serialized.0, HISTORY_VERSION)
.expect("failed to deserialize history");
assert_eq!(history, deserialized);
-
- // test the snapshot too
- let deserialized =
- History::deserialize(&bytes, HISTORY_VERSION).expect("failed to deserialize history");
- assert_eq!(history, deserialized);
}
#[test]
@@ -517,6 +641,8 @@ mod tests {
cwd: "/Users/conrad.ludgate/Documents/code/atuin".to_owned(),
session: "b97d9a306f274473a203d2eba41f9457".to_owned(),
hostname: "fvfg936c0kpf:conrad.ludgate".to_owned(),
+ author: "conrad.ludgate".to_owned(),
+ intent: None,
deleted_at: Some(datetime!(2023-11-19 20:18 +00:00)),
};
@@ -529,6 +655,29 @@ mod tests {
}
#[test]
+ fn test_serialize_deserialize_with_author_and_intent() {
+ let history = History {
+ id: "66d16cbee7cd47538e5c5b8b44e9006e".to_owned().into(),
+ timestamp: datetime!(2023-05-28 18:35:40.633872 +00:00),
+ duration: 49206000,
+ exit: 0,
+ command: "git status".to_owned(),
+ cwd: "/Users/conrad.ludgate/Documents/code/atuin".to_owned(),
+ session: "b97d9a306f274473a203d2eba41f9457".to_owned(),
+ hostname: "fvfg936c0kpf:conrad.ludgate".to_owned(),
+ author: "claude".to_owned(),
+ intent: Some("check repository status".to_owned()),
+ deleted_at: None,
+ };
+
+ let serialized = history.serialize().expect("failed to serialize history");
+ let deserialized = History::deserialize(&serialized.0, HISTORY_VERSION)
+ .expect("failed to deserialize history");
+
+ assert_eq!(history, deserialized);
+ }
+
+ #[test]
fn test_serialize_deserialize_version() {
// v0
let bytes_v0 = [
@@ -543,23 +692,31 @@ mod tests {
99, 111, 110, 114, 97, 100, 46, 108, 117, 100, 103, 97, 116, 101, 192,
];
- // some other version
- let bytes_v1 = [
- 205, 1, 0, 153, 217, 32, 54, 54, 100, 49, 54, 99, 98, 101, 101, 55, 99, 100, 52, 55,
- 53, 51, 56, 101, 53, 99, 53, 98, 56, 98, 52, 52, 101, 57, 48, 48, 54, 101, 207, 23, 99,
- 98, 117, 24, 210, 246, 128, 206, 2, 238, 210, 240, 0, 170, 103, 105, 116, 32, 115, 116,
- 97, 116, 117, 115, 217, 42, 47, 85, 115, 101, 114, 115, 47, 99, 111, 110, 114, 97, 100,
- 46, 108, 117, 100, 103, 97, 116, 101, 47, 68, 111, 99, 117, 109, 101, 110, 116, 115,
- 47, 99, 111, 100, 101, 47, 97, 116, 117, 105, 110, 217, 32, 98, 57, 55, 100, 57, 97,
- 51, 48, 54, 102, 50, 55, 52, 52, 55, 51, 97, 50, 48, 51, 100, 50, 101, 98, 97, 52, 49,
- 102, 57, 52, 53, 55, 187, 102, 118, 102, 103, 57, 51, 54, 99, 48, 107, 112, 102, 58,
- 99, 111, 110, 114, 97, 100, 46, 108, 117, 100, 103, 97, 116, 101, 192,
- ];
+ let deserialized = History::deserialize(&bytes_v0, "v0");
+ assert!(deserialized.is_ok());
let deserialized = History::deserialize(&bytes_v0, HISTORY_VERSION);
+ assert!(deserialized.is_err());
+
+ let current = History {
+ id: "66d16cbee7cd47538e5c5b8b44e9006e".to_owned().into(),
+ timestamp: datetime!(2023-05-28 18:35:40.633872 +00:00),
+ duration: 49206000,
+ exit: 0,
+ command: "git status".to_owned(),
+ cwd: "/Users/conrad.ludgate/Documents/code/atuin".to_owned(),
+ session: "b97d9a306f274473a203d2eba41f9457".to_owned(),
+ hostname: "fvfg936c0kpf:conrad.ludgate".to_owned(),
+ author: "conrad.ludgate".to_owned(),
+ intent: None,
+ deleted_at: None,
+ };
+
+ let bytes_v1 = current.serialize().expect("failed to serialize history");
+ let deserialized = History::deserialize(&bytes_v1.0, HISTORY_VERSION);
assert!(deserialized.is_ok());
- let deserialized = History::deserialize(&bytes_v1, HISTORY_VERSION);
+ let deserialized = History::deserialize(&bytes_v1.0, "v0");
assert!(deserialized.is_err());
}
}
diff --git a/crates/atuin-client/src/history/builder.rs b/crates/atuin-client/src/history/builder.rs
index 2b28339f..72a505fd 100644
--- a/crates/atuin-client/src/history/builder.rs
+++ b/crates/atuin-client/src/history/builder.rs
@@ -20,6 +20,10 @@ pub struct HistoryImported {
session: Option<String>,
#[builder(default, setter(strip_option, into))]
hostname: Option<String>,
+ #[builder(default, setter(strip_option, into))]
+ author: Option<String>,
+ #[builder(default, setter(strip_option, into))]
+ intent: Option<String>,
}
impl From<HistoryImported> for History {
@@ -32,6 +36,8 @@ impl From<HistoryImported> for History {
imported.duration,
imported.session,
imported.hostname,
+ imported.author,
+ imported.intent,
None,
)
}
@@ -49,6 +55,10 @@ pub struct HistoryCaptured {
command: String,
#[builder(setter(into))]
cwd: String,
+ #[builder(default, setter(strip_option, into))]
+ author: Option<String>,
+ #[builder(default, setter(strip_option, into))]
+ intent: Option<String>,
}
impl From<HistoryCaptured> for History {
@@ -61,6 +71,8 @@ impl From<HistoryCaptured> for History {
-1,
None,
None,
+ captured.author,
+ captured.intent,
None,
)
}
@@ -79,6 +91,8 @@ pub struct HistoryFromDb {
duration: i64,
session: String,
hostname: String,
+ author: String,
+ intent: Option<String>,
deleted_at: Option<time::OffsetDateTime>,
}
@@ -93,6 +107,8 @@ impl From<HistoryFromDb> for History {
duration: from_db.duration,
session: from_db.session,
hostname: from_db.hostname,
+ author: from_db.author,
+ intent: from_db.intent,
deleted_at: from_db.deleted_at,
}
}
@@ -114,6 +130,10 @@ pub struct HistoryDaemonCapture {
session: String,
#[builder(setter(into))]
hostname: String,
+ #[builder(default, setter(strip_option, into))]
+ author: Option<String>,
+ #[builder(default, setter(strip_option, into))]
+ intent: Option<String>,
}
impl From<HistoryDaemonCapture> for History {
@@ -126,6 +146,8 @@ impl From<HistoryDaemonCapture> for History {
-1,
Some(captured.session),
Some(captured.hostname),
+ captured.author,
+ captured.intent,
None,
)
}
diff --git a/crates/atuin-client/src/history/store.rs b/crates/atuin-client/src/history/store.rs
index 041d90ce..d166564f 100644
--- a/crates/atuin-client/src/history/store.rs
+++ b/crates/atuin-client/src/history/store.rs
@@ -10,7 +10,7 @@ use crate::{
};
use atuin_common::record::{DecryptedData, Host, HostId, Record, RecordId, RecordIdx};
-use super::{HISTORY_TAG, HISTORY_VERSION, History, HistoryId};
+use super::{HISTORY_TAG, HISTORY_VERSION, HISTORY_VERSION_V0, History, HistoryId};
#[derive(Debug, Clone)]
pub struct HistoryStore {
@@ -196,10 +196,11 @@ impl HistoryStore {
for record in records.into_iter() {
let hist = match record.version.as_str() {
- HISTORY_VERSION => {
+ HISTORY_VERSION_V0 | HISTORY_VERSION => {
+ let version = record.version.clone();
let decrypted = record.decrypt::<PASETO_V4>(&self.encryption_key)?;
- HistoryRecord::deserialize(&decrypted.data, HISTORY_VERSION)
+ HistoryRecord::deserialize(&decrypted.data, version.as_str())
}
version => bail!("unknown history version {version:?}"),
}?;
@@ -257,8 +258,14 @@ impl HistoryStore {
continue;
}
+ let version = record.version.clone();
let decrypted = record.decrypt::<PASETO_V4>(&self.encryption_key)?;
- let record = HistoryRecord::deserialize(&decrypted.data, HISTORY_VERSION)?;
+ let record = match version.as_str() {
+ HISTORY_VERSION_V0 | HISTORY_VERSION => {
+ HistoryRecord::deserialize(&decrypted.data, version.as_str())?
+ }
+ version => bail!("unknown history version {version:?}"),
+ };
match record {
HistoryRecord::Create(h) => {
@@ -350,14 +357,14 @@ mod tests {
#[test]
fn test_serialize_deserialize_create() {
let bytes = [
- 204, 0, 196, 141, 205, 0, 0, 153, 217, 32, 48, 49, 56, 99, 100, 52, 102, 101, 56, 49,
+ 204, 0, 196, 147, 205, 0, 1, 154, 217, 32, 48, 49, 56, 99, 100, 52, 102, 101, 56, 49,
55, 53, 55, 99, 100, 50, 97, 101, 101, 54, 53, 99, 100, 55, 56, 54, 49, 102, 57, 99,
56, 49, 207, 23, 166, 251, 212, 181, 82, 0, 0, 100, 0, 162, 108, 115, 217, 41, 47, 85,
115, 101, 114, 115, 47, 101, 108, 108, 105, 101, 47, 115, 114, 99, 47, 103, 105, 116,
104, 117, 98, 46, 99, 111, 109, 47, 97, 116, 117, 105, 110, 115, 104, 47, 97, 116, 117,
105, 110, 217, 32, 48, 49, 56, 99, 100, 52, 102, 101, 97, 100, 56, 57, 55, 53, 57, 55,
56, 53, 50, 53, 50, 55, 97, 51, 49, 99, 57, 57, 56, 48, 53, 57, 170, 98, 111, 111, 112,
- 58, 101, 108, 108, 105, 101, 192,
+ 58, 101, 108, 108, 105, 101, 192, 165, 101, 108, 108, 105, 101,
];
let history = History {
@@ -369,6 +376,8 @@ mod tests {
cwd: "/Users/ellie/src/github.com/atuinsh/atuin".to_owned(),
session: "018cd4fead897597852527a31c998059".to_owned(),
hostname: "boop:ellie".to_owned(),
+ author: "ellie".to_owned(),
+ intent: None,
deleted_at: None,
};
diff --git a/crates/atuin-daemon/proto/history.proto b/crates/atuin-daemon/proto/history.proto
index 9fbd3372..2a45b7cf 100644
--- a/crates/atuin-daemon/proto/history.proto
+++ b/crates/atuin-daemon/proto/history.proto
@@ -8,6 +8,8 @@ message StartHistoryRequest {
string cwd = 3;
string session = 4;
string hostname = 5;
+ string author = 6;
+ string intent = 7;
}
message EndHistoryRequest {
diff --git a/crates/atuin-daemon/src/client.rs b/crates/atuin-daemon/src/client.rs
index 05067bda..3b76a680 100644
--- a/crates/atuin-daemon/src/client.rs
+++ b/crates/atuin-daemon/src/client.rs
@@ -103,6 +103,8 @@ impl HistoryClient {
hostname: h.hostname,
session: h.session,
timestamp: h.timestamp.unix_timestamp_nanos() as u64,
+ author: h.author,
+ intent: h.intent.unwrap_or_default(),
};
Ok(self.client.start_history(req).await?.into_inner())
diff --git a/crates/atuin-daemon/src/server.rs b/crates/atuin-daemon/src/server.rs
index 9622d2b6..826d6191 100644
--- a/crates/atuin-daemon/src/server.rs
+++ b/crates/atuin-daemon/src/server.rs
@@ -69,7 +69,7 @@ impl HistorySvc for HistoryService {
)
})?;
- let h: History = History::daemon()
+ let mut h: History = History::daemon()
.timestamp(timestamp)
.command(req.command)
.cwd(req.cwd)
@@ -77,6 +77,12 @@ impl HistorySvc for HistoryService {
.hostname(req.hostname)
.build()
.into();
+ if !req.author.trim().is_empty() {
+ h.author = req.author;
+ }
+ if !req.intent.trim().is_empty() {
+ h.intent = Some(req.intent);
+ }
// The old behaviour had us inserting half-finished history records into the database
// The new behaviour no longer allows that.
diff --git a/crates/atuin/src/command/client/history.rs b/crates/atuin/src/command/client/history.rs
index 00b432a6..c20f64a3 100644
--- a/crates/atuin/src/command/client/history.rs
+++ b/crates/atuin/src/command/client/history.rs
@@ -41,6 +41,14 @@ pub enum Cmd {
#[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>,
},
@@ -87,7 +95,7 @@ pub enum Cmd {
#[arg(long, visible_alias = "tz")]
timezone: Option<Timezone>,
- /// Available variables: {command}, {directory}, {duration}, {user}, {host}, {exit}, {time}, {session}, and {uuid}
+ /// 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>,
@@ -110,7 +118,7 @@ pub enum Cmd {
#[arg(long, visible_alias = "tz")]
timezone: Option<Timezone>,
- /// Available variables: {command}, {directory}, {duration}, {user}, {host}, {time}, {session}, {uuid} and {relativetime}.
+ /// 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>,
@@ -316,6 +324,8 @@ impl FormatKey for FmtHistory<'_> {
.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
@@ -352,18 +362,37 @@ fn parse_fmt(format: &str) -> ParsedFmt<'_> {
}
impl Cmd {
+ 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;
+ }
+ }
+
#[allow(clippy::too_many_lines, clippy::cast_possible_truncation)]
- async fn handle_start(db: &impl Database, settings: &Settings, command: &str) -> Result<()> {
+ async fn handle_start(
+ db: &impl Database,
+ settings: &Settings,
+ command: &str,
+ author: Option<&str>,
+ intent: Option<&str>,
+ ) -> Result<()> {
// 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 h: History = History::capture()
+ let mut h: History = History::capture()
.timestamp(OffsetDateTime::now_utc())
.command(command)
.cwd(cwd)
.build()
.into();
+ Self::apply_start_metadata(&mut h, author, intent);
if !h.should_save(settings) {
return Ok(());
@@ -383,17 +412,23 @@ impl Cmd {
}
#[cfg(feature = "daemon")]
- async fn handle_daemon_start(settings: &Settings, command: &str) -> Result<()> {
+ async fn handle_daemon_start(
+ settings: &Settings,
+ command: &str,
+ author: Option<&str>,
+ intent: Option<&str>,
+ ) -> Result<()> {
// 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 h: History = History::capture()
+ let mut h: History = History::capture()
.timestamp(OffsetDateTime::now_utc())
.command(command)
.cwd(cwd)
.build()
.into();
+ Self::apply_start_metadata(&mut h, author, intent);
if !h.should_save(settings) {
return Ok(());
@@ -657,7 +692,8 @@ impl Cmd {
match self {
Self::Start { .. } => {
let command = self.get_start_command().unwrap_or_default();
- return Self::handle_daemon_start(settings, &command).await;
+ let (author, intent) = self.get_start_metadata().unwrap_or_default();
+ return Self::handle_daemon_start(settings, &command, author, intent).await;
}
Self::End { id, exit, duration } => {
@@ -684,7 +720,8 @@ impl Cmd {
match self {
Self::Start { .. } => {
let command = self.get_start_command().unwrap_or_default();
- Self::handle_start(&db, settings, &command).await
+ let (author, intent) = self.get_start_metadata().unwrap_or_default();
+ Self::handle_start(&db, settings, &command, author, intent).await
}
Self::End { id, exit, duration } => {
Self::handle_end(&db, store, history_store, settings, &id, exit, duration).await
@@ -766,6 +803,15 @@ impl Cmd {
_ => None,
}
}
+
+ /// Returns `(author, intent)` for the `Start` variant.
+ /// Returns `None` for any other variant.
+ fn get_start_metadata(&self) -> Option<(Option<&str>, Option<&str>)> {
+ match self {
+ Self::Start { author, intent, .. } => Some((author.as_deref(), intent.as_deref())),
+ _ => None,
+ }
+ }
}
#[cfg(test)]
diff --git a/crates/atuin/src/command/client/search/inspector.rs b/crates/atuin/src/command/client/search/inspector.rs
index 4c3fece1..151e1354 100644
--- a/crates/atuin/src/command/client/search/inspector.rs
+++ b/crates/atuin/src/command/client/search/inspector.rs
@@ -355,6 +355,8 @@ mod tests {
cwd: "/toot".to_string(),
session: "sesh1".to_string(),
hostname: "hostn".to_string(),
+ author: "hostn".to_string(),
+ intent: None,
deleted_at: None,
};
let next = History {
@@ -366,6 +368,8 @@ mod tests {
cwd: "/toot".to_string(),
session: "sesh1".to_string(),
hostname: "hostn".to_string(),
+ author: "hostn".to_string(),
+ intent: None,
deleted_at: None,
};
let prev = History {
@@ -377,6 +381,8 @@ mod tests {
cwd: "/toot".to_string(),
session: "sesh1".to_string(),
hostname: "hostn".to_string(),
+ author: "hostn".to_string(),
+ intent: None,
deleted_at: None,
};
let stats = HistoryStats {