aboutsummaryrefslogtreecommitdiff
path: root/src/integration
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-13 18:36:47 +0300
committerGitHub <noreply@github.com>2026-09-13 17:36:47 +0200
commitfc1cb77f0572948552377b23f7754a3016ac7dfa (patch)
tree5412b796aaaa00894fe628b2ffed8d747e6f452d /src/integration
parent3be35231497f33f38aeffd84df02642479995bc6 (diff)
feat: add Letta Code detection and restore (#3107)
refs #3106 Co-authored-by: Cameron <cameron@pfiffer.org>
Diffstat (limited to 'src/integration')
-rw-r--r--src/integration/actions.rs61
-rw-r--r--src/integration/assets/letta/herdr-agent-session.ps151
-rw-r--r--src/integration/assets/letta/herdr-agent-session.sh56
-rw-r--r--src/integration/env.rs4
-rw-r--r--src/integration/mod.rs30
-rw-r--r--src/integration/registry.rs50
-rw-r--r--src/integration/targets.rs256
-rw-r--r--src/integration/tests.rs188
-rw-r--r--src/integration/types.rs25
9 files changed, 693 insertions, 28 deletions
diff --git a/src/integration/actions.rs b/src/integration/actions.rs
index e8b3ab63..d51682f8 100644
--- a/src/integration/actions.rs
+++ b/src/integration/actions.rs
@@ -4,11 +4,11 @@ use super::registry::{integration_target_label, integration_target_supported};
use super::targets::{
install_antigravity_cli, install_claude, install_codex, install_copilot, install_cursor,
install_devin, install_droid, install_grok, install_hermes, install_kilo, install_kimi,
- install_mastracode, install_omp, install_opencode, install_pi, install_qodercli, install_qwen,
- uninstall_antigravity_cli, uninstall_claude, uninstall_codex, uninstall_copilot,
+ install_letta, install_mastracode, install_omp, install_opencode, install_pi, install_qodercli,
+ install_qwen, uninstall_antigravity_cli, uninstall_claude, uninstall_codex, uninstall_copilot,
uninstall_cursor, uninstall_devin, uninstall_droid, uninstall_grok, uninstall_hermes,
- uninstall_kilo, uninstall_kimi, uninstall_mastracode, uninstall_omp, uninstall_opencode,
- uninstall_pi, uninstall_qodercli, uninstall_qwen,
+ uninstall_kilo, uninstall_kimi, uninstall_letta, uninstall_mastracode, uninstall_omp,
+ uninstall_opencode, uninstall_pi, uninstall_qodercli, uninstall_qwen,
};
use super::version::{agent_version_requirement, enforce_agent_version};
use super::{KIMI_MIN_VERSION, PI_EXTENSION_INSTALL_NAME};
@@ -22,6 +22,59 @@ pub(crate) fn install_target(
result
}
+/// Experimental Letta install that bypasses the frozen client endpoint
+/// `IntegrationTarget` enum. Fold into the agent registry when it lands.
+pub(crate) fn install_experimental_letta() -> io::Result<Vec<String>> {
+ let result = install_letta().map(|installed| {
+ vec![
+ format!(
+ "installed letta integration hook to {}",
+ installed.hook_path.display()
+ ),
+ format!(
+ "ensured letta settings at {}",
+ installed.settings_path.display()
+ ),
+ ]
+ });
+ let outcome = if result.is_ok() { "ok" } else { "error" };
+ crate::logging::integration_action("install", "letta", outcome);
+ result
+}
+
+/// Experimental Letta uninstall counterpart.
+pub(crate) fn uninstall_experimental_letta() -> io::Result<Vec<String>> {
+ let result = uninstall_letta().map(|result| {
+ let mut messages = Vec::new();
+ if result.removed_hook_file {
+ messages.push(format!(
+ "removed letta hook at {}",
+ result.hook_path.display()
+ ));
+ } else {
+ messages.push(format!(
+ "no letta hook found at {}",
+ result.hook_path.display()
+ ));
+ }
+ if result.updated_settings {
+ messages.push(format!(
+ "removed herdr letta hook entry from {}",
+ result.settings_path.display()
+ ));
+ } else {
+ messages.push(format!(
+ "no herdr letta hook entry found in {}",
+ result.settings_path.display()
+ ));
+ }
+ messages
+ });
+ let outcome = if result.is_ok() { "ok" } else { "error" };
+ crate::logging::integration_action("uninstall", "letta", outcome);
+ result
+}
+
fn install_target_inner(target: crate::api::schema::IntegrationTarget) -> io::Result<Vec<String>> {
if !integration_target_supported(target) {
return Err(io::Error::other(format!(
diff --git a/src/integration/assets/letta/herdr-agent-session.ps1 b/src/integration/assets/letta/herdr-agent-session.ps1
new file mode 100644
index 00000000..27f40e9b
--- /dev/null
+++ b/src/integration/assets/letta/herdr-agent-session.ps1
@@ -0,0 +1,51 @@
+# managed by herdr; reinstalling the integration replaces this file.
+# HERDR_INTEGRATION_ID=letta
+# HERDR_INTEGRATION_VERSION=1
+
+param([string]$Action = "")
+
+if ($Action -ne "session") { exit 0 }
+if ($env:HERDR_ENV -ne "1") { exit 0 }
+if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { exit 0 }
+if ([string]::IsNullOrWhiteSpace($env:HERDR_SOCKET_PATH)) { exit 0 }
+
+$inputText = [Console]::In.ReadToEnd()
+try {
+ $payload = if ([string]::IsNullOrWhiteSpace($inputText)) { $null } else { $inputText | ConvertFrom-Json }
+} catch {
+ $payload = $null
+}
+if ($null -eq $payload -or [string]::IsNullOrWhiteSpace($payload.conversation_id)) { exit 0 }
+
+$sessionId = [string]$payload.conversation_id
+if ($sessionId -eq "default") {
+ if ([string]::IsNullOrWhiteSpace($payload.agent_id)) { exit 0 }
+ $sessionId = "default:" + [string]$payload.agent_id
+}
+
+$herdr = if ([string]::IsNullOrWhiteSpace($env:HERDR_BIN_PATH)) { "herdr" } else { $env:HERDR_BIN_PATH }
+$source = if ($payload.is_new_session -eq $true) { "new" } else { "resume" }
+$commandArgs = @(
+ "pane", "report-agent-session", $env:HERDR_PANE_ID,
+ "--source", "herdr:letta", "--agent", "letta",
+ "--agent-session-id", $sessionId,
+ "--seq", [string][DateTime]::UtcNow.Ticks,
+ "--session-start-source", $source
+)
+$job = $null
+try {
+ $job = Start-Job -ScriptBlock {
+ param($Executable, [object[]]$Arguments)
+ & $Executable @Arguments *> $null
+ } -ArgumentList $herdr, (,$commandArgs)
+ if ($null -eq (Wait-Job -Job $job -Timeout 1)) {
+ Stop-Job -Job $job
+ }
+} catch {
+ # Hook failures must stay silent because Letta injects output into the next prompt.
+ $null = $_
+} finally {
+ if ($null -ne $job) {
+ Remove-Job -Job $job -Force -ErrorAction SilentlyContinue
+ }
+}
diff --git a/src/integration/assets/letta/herdr-agent-session.sh b/src/integration/assets/letta/herdr-agent-session.sh
new file mode 100644
index 00000000..d6e052d6
--- /dev/null
+++ b/src/integration/assets/letta/herdr-agent-session.sh
@@ -0,0 +1,56 @@
+#!/bin/sh
+# managed by herdr; reinstalling the integration replaces this file.
+# HERDR_INTEGRATION_ID=letta
+# HERDR_INTEGRATION_VERSION=1
+
+[ "${1:-}" = "session" ] || exit 0
+[ "${HERDR_ENV:-}" = "1" ] || exit 0
+[ -n "${HERDR_PANE_ID:-}" ] || exit 0
+[ -n "${HERDR_SOCKET_PATH:-}" ] || exit 0
+if [ -n "${HERDR_BIN_PATH:-}" ]; then
+ [ -x "$HERDR_BIN_PATH" ] || exit 0
+else
+ command -v herdr >/dev/null 2>&1 || exit 0
+fi
+command -v python3 >/dev/null 2>&1 || exit 0
+
+# SessionStart stdout is injected into Letta's next user message. Keep every
+# path silent, including successful reports and malformed hook input.
+python3 -c '
+import json
+import os
+import subprocess
+import sys
+import time
+
+try:
+ payload = json.load(sys.stdin)
+ conversation_id = payload.get("conversation_id")
+ agent_id = payload.get("agent_id")
+ if not isinstance(conversation_id, str) or not conversation_id:
+ raise ValueError
+ if conversation_id == "default":
+ if not isinstance(agent_id, str) or not agent_id:
+ raise ValueError
+ session_id = "default:" + agent_id
+ else:
+ session_id = conversation_id
+
+ command = os.environ.get("HERDR_BIN_PATH") or "herdr"
+ args = [
+ command, "pane", "report-agent-session", os.environ["HERDR_PANE_ID"],
+ "--source", "herdr:letta", "--agent", "letta",
+ "--agent-session-id", session_id, "--seq", str(time.time_ns()),
+ "--session-start-source", "new" if payload.get("is_new_session") else "resume",
+ ]
+ subprocess.run(
+ args,
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ timeout=1,
+ check=False,
+ )
+except Exception:
+ pass
+' 2>/dev/null || true
diff --git a/src/integration/env.rs b/src/integration/env.rs
index 48320c72..548c1386 100644
--- a/src/integration/env.rs
+++ b/src/integration/env.rs
@@ -175,6 +175,10 @@ pub(crate) fn qwen_dir() -> io::Result<PathBuf> {
config_dir_from_env_or_home(QWEN_HOME_ENV_VAR, &[".qwen"])
}
+pub(crate) fn letta_dir() -> io::Result<PathBuf> {
+ Ok(home_dir()?.join(".letta"))
+}
+
pub(crate) fn cursor_dir() -> io::Result<PathBuf> {
config_dir_from_env_or_home(CURSOR_CONFIG_DIR_ENV_VAR, &[".cursor"])
}
diff --git a/src/integration/mod.rs b/src/integration/mod.rs
index a40ad970..97163da5 100644
--- a/src/integration/mod.rs
+++ b/src/integration/mod.rs
@@ -10,17 +10,27 @@ mod targets;
mod types;
mod version;
-pub(crate) use actions::{install_target, uninstall_target};
+pub(crate) use actions::{
+ install_experimental_letta, install_target, uninstall_experimental_letta, uninstall_target,
+};
#[cfg(test)]
pub(crate) use env::integration_env_lock;
pub(crate) use env::{
apply_pane_base_env, HERDR_PANE_ID_ENV_VAR, HERDR_TAB_ID_ENV_VAR, HERDR_WORKSPACE_ID_ENV_VAR,
};
pub(crate) use registry::{
- installed_integration_statuses, integration_recommendations, integration_target_label,
- print_outdated_update_notice,
+ experimental_letta_integration_status, installed_integration_statuses,
+ integration_recommendations, integration_target_label, print_outdated_update_notice,
+};
+pub(crate) use types::{
+ ExperimentalIntegrationStatus, IntegrationRecommendation, IntegrationStatus,
+ IntegrationStatusKind,
};
-pub(crate) use types::{IntegrationRecommendation, IntegrationStatus, IntegrationStatusKind};
+
+/// CLI labels for experimental integrations that are intentionally not part of
+/// the frozen client endpoint `IntegrationTarget` enum. Empty this list once the
+/// agent registry provides first-class target registration.
+pub(crate) const EXPERIMENTAL_INTEGRATION_TARGET_LABELS: &[&str] = &["letta"];
const PI_EXTENSION_INSTALL_NAME: &str = "herdr-agent-state.ts";
const PI_EXTENSION_ASSET: &str = include_str!("assets/pi/herdr-agent-state.ts");
@@ -207,6 +217,18 @@ const QWEN_HOOK_ASSET: &str = if cfg!(windows) {
};
const QWEN_INTEGRATION_VERSION: u32 = 1;
const QWEN_HOOK_EVENTS: [(&str, &str); 1] = [("SessionStart", "session")];
+const LETTA_HOOK_INSTALL_NAME: &str = if cfg!(windows) {
+ "herdr-agent-session.ps1"
+} else {
+ "herdr-agent-session.sh"
+};
+const LETTA_HOOK_ASSET: &str = if cfg!(windows) {
+ include_str!("assets/letta/herdr-agent-session.ps1")
+} else {
+ include_str!("assets/letta/herdr-agent-session.sh")
+};
+const LETTA_INTEGRATION_VERSION: u32 = 1;
+const LETTA_HOOK_TIMEOUT_MS: u64 = 10_000;
const QODERCLI_REMOVED_LIFECYCLE_HOOK_EVENTS: [(&str, &str); 12] = [
("SessionStart", "idle"),
("UserPromptSubmit", "working"),
diff --git a/src/integration/registry.rs b/src/integration/registry.rs
index f3bb8c04..9185c96f 100644
--- a/src/integration/registry.rs
+++ b/src/integration/registry.rs
@@ -441,30 +441,33 @@ fn opencode_tui_integration_is_valid(plugin_path: &Path, expected_version: u32)
.is_some_and(|version| version >= expected_version)))
}
-pub(crate) fn integration_status_at(
- target: crate::api::schema::IntegrationTarget,
- path: PathBuf,
+fn integration_state_for_path(
+ path: &Path,
expected_version: u32,
-) -> super::IntegrationStatus {
+) -> (super::IntegrationStatusKind, Option<u32>) {
if !path.is_file() {
- return super::IntegrationStatus {
- target,
- path,
- state: super::IntegrationStatusKind::NotInstalled,
- installed_version: None,
- expected_version,
- };
+ return (super::IntegrationStatusKind::NotInstalled, None);
}
- let installed_version = fs::read_to_string(&path)
+ let installed_version = fs::read_to_string(path)
.ok()
.and_then(|content| parse_integration_version(&content));
- let mut state = if installed_version.is_some_and(|version| version >= expected_version) {
+ let state = if installed_version.is_some_and(|version| version >= expected_version) {
super::IntegrationStatusKind::Current
} else {
super::IntegrationStatusKind::Outdated
};
+ (state, installed_version)
+}
+
+pub(crate) fn integration_status_at(
+ target: crate::api::schema::IntegrationTarget,
+ path: PathBuf,
+ expected_version: u32,
+) -> super::IntegrationStatus {
+ let (mut state, installed_version) = integration_state_for_path(&path, expected_version);
+
// Grok only invokes the hook when the herdr-owned `hooks/herdr.json`
// registers it, so a current hook script with a missing or broken config
// is a nonfunctional install: report it as outdated so `herdr integration
@@ -491,6 +494,27 @@ pub(crate) fn integration_status_at(
}
}
+/// Letta is intentionally kept out of the frozen client endpoint
+/// `IntegrationTarget` enum so published generation-1 clients never receive an
+/// unknown variant. It is installable and reportable as an experimental
+/// CLI-only target until the agent registry replaces the enum-keyed registry.
+pub(crate) fn experimental_letta_integration_status() -> Option<super::ExperimentalIntegrationStatus>
+{
+ let path = letta_dir()
+ .ok()?
+ .join("hooks")
+ .join(super::LETTA_HOOK_INSTALL_NAME);
+ let (state, installed_version) =
+ integration_state_for_path(&path, super::LETTA_INTEGRATION_VERSION);
+ Some(super::ExperimentalIntegrationStatus {
+ label: "letta",
+ path,
+ state,
+ installed_version,
+ expected_version: super::LETTA_INTEGRATION_VERSION,
+ })
+}
+
pub(crate) fn parse_integration_version(content: &str) -> Option<u32> {
content.lines().find_map(|line| {
let marker_line = line
diff --git a/src/integration/targets.rs b/src/integration/targets.rs
index d204c552..cab8f7ee 100644
--- a/src/integration/targets.rs
+++ b/src/integration/targets.rs
@@ -21,8 +21,8 @@ use super::config_edit::{
};
use super::env::{
antigravity_cli_dir, claude_dir, codex_dir, copilot_dir, cursor_dir, devin_dir, droid_dir,
- grok_dir, hermes_dir, hermes_plugin_dir, kilo_dir, kimi_dir, mastracode_dir, omp_extension_dir,
- opencode_dir, opencode_state_dir, pi_extension_dir, qodercli_dir, qwen_dir,
+ grok_dir, hermes_dir, hermes_plugin_dir, kilo_dir, kimi_dir, letta_dir, mastracode_dir,
+ omp_extension_dir, opencode_dir, opencode_state_dir, pi_extension_dir, qodercli_dir, qwen_dir,
};
use super::file_ops::{
make_executable, remove_dir_all_if_exists, remove_file_if_exists, remove_legacy_bash_hook_file,
@@ -37,10 +37,10 @@ use super::types::{
CopilotUninstallResult, CursorInstallPaths, CursorUninstallResult, DevinInstallPaths,
DevinUninstallResult, DroidInstallPaths, DroidUninstallResult, GrokInstallPaths,
GrokUninstallResult, HermesInstallPaths, HermesUninstallResult, KiloInstallPaths,
- KiloUninstallResult, KimiInstallPaths, KimiUninstallResult, MastracodeInstallPaths,
- MastracodeUninstallResult, OmpInstallPaths, OmpUninstallResult, OpenCodeInstallPaths,
- OpenCodeUninstallResult, PiUninstallResult, QodercliInstallPaths, QodercliUninstallResult,
- QwenInstallPaths, QwenUninstallResult,
+ KiloUninstallResult, KimiInstallPaths, KimiUninstallResult, LettaInstallPaths,
+ LettaUninstallResult, MastracodeInstallPaths, MastracodeUninstallResult, OmpInstallPaths,
+ OmpUninstallResult, OpenCodeInstallPaths, OpenCodeUninstallResult, PiUninstallResult,
+ QodercliInstallPaths, QodercliUninstallResult, QwenInstallPaths, QwenUninstallResult,
};
use super::{
ANTIGRAVITY_CLI_HOOK_ASSET, ANTIGRAVITY_CLI_HOOK_BLOCK_NAME, ANTIGRAVITY_CLI_HOOK_EVENTS,
@@ -53,7 +53,8 @@ use super::{
GROK_HOOK_ASSET, GROK_HOOK_CONFIG_INSTALL_NAME, GROK_HOOK_INSTALL_NAME,
HERMES_PLUGIN_INIT_ASSET, HERMES_PLUGIN_INIT_INSTALL_NAME, HERMES_PLUGIN_MANIFEST_ASSET,
HERMES_PLUGIN_MANIFEST_INSTALL_NAME, KILO_PLUGIN_ASSET, KILO_PLUGIN_INSTALL_NAME,
- KIMI_HOOK_ASSET, KIMI_HOOK_INSTALL_NAME, MASTRACODE_HOOK_ASSET, MASTRACODE_HOOK_EVENTS,
+ KIMI_HOOK_ASSET, KIMI_HOOK_INSTALL_NAME, LETTA_HOOK_ASSET, LETTA_HOOK_INSTALL_NAME,
+ LETTA_HOOK_TIMEOUT_MS, MASTRACODE_HOOK_ASSET, MASTRACODE_HOOK_EVENTS,
MASTRACODE_HOOK_INSTALL_NAME, MASTRACODE_HOOK_TIMEOUT_MS, MASTRACODE_REMOVED_HOOK_EVENTS,
OMP_EXTENSION_ASSET, OMP_EXTENSION_INSTALL_NAME, OPENCODE_PLUGIN_ASSET,
OPENCODE_PLUGIN_INSTALL_NAME, OPENCODE_TUI_PLUGIN_ASSET, OPENCODE_TUI_PLUGIN_INSTALL_NAME,
@@ -1021,6 +1022,207 @@ pub(crate) fn install_qwen() -> io::Result<QwenInstallPaths> {
})
}
+fn letta_install_artifact_path(path: &Path, role: &str) -> io::Result<PathBuf> {
+ let file_name = path
+ .file_name()
+ .ok_or_else(|| io::Error::other(format!("invalid install path: {}", path.display())))?;
+ let mut artifact_name = file_name.to_os_string();
+ artifact_name.push(format!(".herdr-install-{}-{role}", std::process::id()));
+ Ok(path.with_file_name(artifact_name))
+}
+
+pub(super) fn prepare_letta_install_file(
+ target: &Path,
+ contents: &[u8],
+ executable: bool,
+ preserve_permissions: bool,
+) -> io::Result<(PathBuf, PathBuf)> {
+ if target.try_exists()? && !target.is_file() {
+ return Err(io::Error::other(format!(
+ "install target is not a file: {}",
+ target.display()
+ )));
+ }
+
+ let staged = letta_install_artifact_path(target, "staged")?;
+ let backup = letta_install_artifact_path(target, "backup")?;
+ if staged.try_exists()? || backup.try_exists()? {
+ return Err(io::Error::new(
+ io::ErrorKind::AlreadyExists,
+ format!("stale install artifact exists for {}", target.display()),
+ ));
+ }
+
+ let prepare_result = (|| {
+ fs::write(&staged, contents)?;
+ if preserve_permissions && target.is_file() {
+ fs::set_permissions(&staged, fs::metadata(target)?.permissions())?;
+ }
+ if executable {
+ make_executable(&staged)?;
+ }
+ Ok(())
+ })();
+ if let Err(err) = prepare_result {
+ let _ = remove_file_if_exists(&staged);
+ return Err(err);
+ }
+
+ Ok((staged, backup))
+}
+
+fn combine_letta_install_errors(primary: io::Error, rollback: io::Result<()>) -> io::Error {
+ match rollback {
+ Ok(()) => primary,
+ Err(rollback_err) => io::Error::new(
+ primary.kind(),
+ format!("{primary}; rollback failed: {rollback_err}"),
+ ),
+ }
+}
+
+pub(super) fn publish_letta_install_file(
+ target: &Path,
+ staged: &Path,
+ backup: &Path,
+) -> io::Result<bool> {
+ let had_original = target.try_exists()?;
+ if had_original {
+ fs::rename(target, backup)?;
+ }
+
+ if let Err(err) = fs::rename(staged, target) {
+ let rollback = if had_original {
+ fs::rename(backup, target)
+ } else {
+ Ok(())
+ };
+ return Err(combine_letta_install_errors(err, rollback));
+ }
+
+ Ok(had_original)
+}
+
+pub(super) fn rollback_letta_install_file(
+ target: &Path,
+ backup: &Path,
+ had_original: bool,
+) -> io::Result<()> {
+ remove_file_if_exists(target)?;
+ if had_original {
+ fs::rename(backup, target)?;
+ }
+ Ok(())
+}
+
+fn cleanup_letta_install_artifact(path: &Path) {
+ if let Err(err) = remove_file_if_exists(path) {
+ tracing::warn!(path = %path.display(), %err, "failed to remove Letta install artifact");
+ }
+}
+
+fn ensure_letta_session_hook(hooks: &mut Map<String, Value>, command: String) -> io::Result<()> {
+ let entries = hooks
+ .entry("SessionStart".to_string())
+ .or_insert_with(|| Value::Array(Vec::new()))
+ .as_array_mut()
+ .ok_or_else(|| io::Error::other("hook entries for SessionStart must be an array"))?;
+
+ entries.push(json!({
+ "hooks": [{
+ "type": "command",
+ "command": command,
+ "timeout": LETTA_HOOK_TIMEOUT_MS,
+ "quiet": true,
+ }],
+ }));
+ Ok(())
+}
+
+pub(crate) fn install_letta() -> io::Result<LettaInstallPaths> {
+ let dir = letta_dir()?;
+ if !dir.is_dir() {
+ return Err(io::Error::other(format!(
+ "letta code config directory not found at {}. install letta code first",
+ dir.display()
+ )));
+ }
+
+ let hooks_dir = dir.join("hooks");
+ fs::create_dir_all(&hooks_dir)?;
+
+ let hook_path = hooks_dir.join(LETTA_HOOK_INSTALL_NAME);
+
+ let settings_path = dir.join("settings.json");
+ let mut settings = if settings_path.is_file() {
+ serde_json::from_str::<Value>(&fs::read_to_string(&settings_path)?).map_err(|err| {
+ io::Error::other(format!(
+ "failed to parse {}: {err}",
+ settings_path.display()
+ ))
+ })?
+ } else {
+ json!({})
+ };
+
+ let hooks = ensure_hooks_object(
+ &mut settings,
+ &settings_path,
+ "letta settings",
+ "letta settings hooks",
+ )?;
+ remove_hook_commands(hooks, "SessionStart", &hook_path, Some("session"))?;
+ ensure_letta_session_hook(hooks, hook_command(&hook_path, Some("session")))?;
+
+ let settings_contents = serde_json::to_string_pretty(&settings)?;
+ let (hook_staged, hook_backup) =
+ prepare_letta_install_file(&hook_path, LETTA_HOOK_ASSET.as_bytes(), true, false)?;
+ let (settings_staged, settings_backup) =
+ match prepare_letta_install_file(&settings_path, settings_contents.as_bytes(), false, true)
+ {
+ Ok(paths) => paths,
+ Err(err) => {
+ cleanup_letta_install_artifact(&hook_staged);
+ return Err(err);
+ }
+ };
+
+ let hook_had_original = match publish_letta_install_file(&hook_path, &hook_staged, &hook_backup)
+ {
+ Ok(had_original) => had_original,
+ Err(err) => {
+ cleanup_letta_install_artifact(&hook_staged);
+ cleanup_letta_install_artifact(&settings_staged);
+ return Err(err);
+ }
+ };
+
+ let settings_had_original =
+ match publish_letta_install_file(&settings_path, &settings_staged, &settings_backup) {
+ Ok(had_original) => had_original,
+ Err(err) => {
+ let err = combine_letta_install_errors(
+ err,
+ rollback_letta_install_file(&hook_path, &hook_backup, hook_had_original),
+ );
+ cleanup_letta_install_artifact(&settings_staged);
+ return Err(err);
+ }
+ };
+
+ if hook_had_original {
+ cleanup_letta_install_artifact(&hook_backup);
+ }
+ if settings_had_original {
+ cleanup_letta_install_artifact(&settings_backup);
+ }
+
+ Ok(LettaInstallPaths {
+ hook_path,
+ settings_path,
+ })
+}
+
pub(crate) fn install_cursor() -> io::Result<CursorInstallPaths> {
let dir = cursor_dir()?;
if !dir.is_dir() {
@@ -1163,6 +1365,46 @@ pub(crate) fn uninstall_qwen() -> io::Result<QwenUninstallResult> {
})
}
+pub(crate) fn uninstall_letta() -> io::Result<LettaUninstallResult> {
+ let dir = letta_dir()?;
+ let hook_path = dir.join("hooks").join(LETTA_HOOK_INSTALL_NAME);
+ let settings_path = dir.join("settings.json");
+ let mut updated_settings = false;
+
+ if settings_path.is_file() {
+ let mut settings = serde_json::from_str::<Value>(&fs::read_to_string(&settings_path)?)
+ .map_err(|err| {
+ io::Error::other(format!(
+ "failed to parse {}: {err}",
+ settings_path.display()
+ ))
+ })?;
+
+ if let Some(hooks) = hooks_object_if_present(
+ &mut settings,
+ &settings_path,
+ "letta settings",
+ "letta settings hooks",
+ )? {
+ updated_settings |=
+ remove_hook_commands(hooks, "SessionStart", &hook_path, Some("session"))?;
+ }
+
+ if updated_settings {
+ fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
+ }
+ }
+
+ let removed_hook_file = remove_file_if_exists(&hook_path)?;
+
+ Ok(LettaUninstallResult {
+ hook_path,
+ settings_path,
+ removed_hook_file,
+ updated_settings,
+ })
+}
+
pub(crate) fn uninstall_cursor() -> io::Result<CursorUninstallResult> {
let cursor_home = cursor_dir()?;
let hook_path = cursor_home.join(CURSOR_HOOK_INSTALL_NAME);
diff --git a/src/integration/tests.rs b/src/integration/tests.rs
index 4c2178cc..41a4e027 100644
--- a/src/integration/tests.rs
+++ b/src/integration/tests.rs
@@ -3343,6 +3343,194 @@ fn install_qwen_errors_when_config_dir_missing() {
}
#[test]
+fn install_and_uninstall_letta_preserve_unrelated_settings_and_hooks() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let letta_dir = home.join(".letta");
+ fs::create_dir_all(&letta_dir).unwrap();
+ let settings_path = letta_dir.join("settings.json");
+ fs::write(
+ &settings_path,
+ r#"{"theme":"dark","hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"echo user"}]}]}}"#,
+ )
+ .unwrap();
+ let previous_home = std::env::var_os("HOME");
+ std::env::set_var("HOME", &home);
+
+ let installed = install_letta().unwrap();
+ assert_eq!(
+ installed.hook_path,
+ letta_dir.join("hooks").join(LETTA_HOOK_INSTALL_NAME)
+ );
+ let first_install = fs::read_to_string(&settings_path).unwrap();
+ let settings: Value = serde_json::from_str(&first_install).unwrap();
+ let entries = settings["hooks"]["SessionStart"].as_array().unwrap();
+ assert_eq!(entries.len(), 2);
+ assert_eq!(entries[0]["hooks"][0]["command"], "echo user");
+ assert!(entries[1].get("matcher").is_none());
+ assert_eq!(entries[1]["hooks"][0]["timeout"], LETTA_HOOK_TIMEOUT_MS);
+ assert_eq!(entries[1]["hooks"][0]["quiet"], true);
+ assert!(entries[1]["hooks"][0]["command"]
+ .as_str()
+ .unwrap()
+ .ends_with("session"));
+ assert_eq!(settings["theme"], "dark");
+
+ install_letta().unwrap();
+ assert_eq!(fs::read_to_string(&settings_path).unwrap(), first_install);
+
+ let result = uninstall_letta().unwrap();
+ assert!(result.removed_hook_file);
+ assert!(result.updated_settings);
+ assert!(!installed.hook_path.exists());
+ let settings: Value =
+ serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
+ assert_eq!(settings["theme"], "dark");
+ let remaining = settings["hooks"]["SessionStart"].as_array().unwrap();
+ assert_eq!(remaining.len(), 1);
+ assert_eq!(remaining[0]["hooks"][0]["command"], "echo user");
+
+ if let Some(home) = previous_home {
+ std::env::set_var("HOME", home);
+ } else {
+ std::env::remove_var("HOME");
+ }
+ let _ = fs::remove_dir_all(base);
+}
+
+#[cfg(unix)]
+#[test]
+fn letta_session_hook_is_silent_and_encodes_default_conversation() {
+ use std::io::Write;
+ use std::os::unix::fs::PermissionsExt;
+ use std::process::{Command, Stdio};
+
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ fs::create_dir_all(home.join(".letta")).unwrap();
+ let previous_home = std::env::var_os("HOME");
+ std::env::set_var("HOME", &home);
+ let installed = install_letta().unwrap();
+
+ let capture = base.join("args.txt");
+ let fake_herdr = base.join("herdr");
+ fs::write(
+ &fake_herdr,
+ format!(
+ "#!/bin/sh\nprintf '%s\\n' \"$*\" > '{}'\n",
+ capture.display()
+ ),
+ )
+ .unwrap();
+ let mut permissions = fs::metadata(&fake_herdr).unwrap().permissions();
+ permissions.set_mode(0o755);
+ fs::set_permissions(&fake_herdr, permissions).unwrap();
+
+ let mut child = Command::new("sh")
+ .arg(&installed.hook_path)
+ .arg("session")
+ .env("HERDR_ENV", "1")
+ .env("HERDR_PANE_ID", "w1:p2")
+ .env("HERDR_SOCKET_PATH", "/tmp/herdr.sock")
+ .env("HERDR_BIN_PATH", &fake_herdr)
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .spawn()
+ .unwrap();
+ child
+ .stdin
+ .take()
+ .unwrap()
+ .write_all(
+ br#"{"event_type":"SessionStart","conversation_id":"default","agent_id":"agent-123","is_new_session":false}"#,
+ )
+ .unwrap();
+ let output = child.wait_with_output().unwrap();
+ assert!(output.status.success());
+ assert!(output.stdout.is_empty());
+ assert!(output.stderr.is_empty());
+ let args = fs::read_to_string(capture).unwrap();
+ assert!(args.contains("report-agent-session w1:p2"));
+ assert!(args.contains("--source herdr:letta --agent letta"));
+ assert!(args.contains("--agent-session-id default:agent-123"));
+ assert!(args.contains("--session-start-source resume"));
+
+ if let Some(home) = previous_home {
+ std::env::set_var("HOME", home);
+ } else {
+ std::env::remove_var("HOME");
+ }
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn install_letta_errors_when_config_dir_missing() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ fs::create_dir_all(&home).unwrap();
+ let previous_home = std::env::var_os("HOME");
+ std::env::set_var("HOME", &home);
+
+ let err = install_letta().unwrap_err().to_string();
+ assert!(err.contains("letta code config directory not found"));
+
+ if let Some(home) = previous_home {
+ std::env::set_var("HOME", home);
+ } else {
+ std::env::remove_var("HOME");
+ }
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn install_letta_does_not_publish_hook_when_settings_are_invalid() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let letta_dir = home.join(".letta");
+ fs::create_dir_all(&letta_dir).unwrap();
+ fs::write(letta_dir.join("settings.json"), "not json").unwrap();
+ let previous_home = std::env::var_os("HOME");
+ std::env::set_var("HOME", &home);
+
+ assert!(install_letta().is_err());
+ assert!(!letta_dir
+ .join("hooks")
+ .join(LETTA_HOOK_INSTALL_NAME)
+ .exists());
+
+ if let Some(home) = previous_home {
+ std::env::set_var("HOME", home);
+ } else {
+ std::env::remove_var("HOME");
+ }
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn letta_staged_install_can_restore_the_prior_file() {
+ let base = unique_base();
+ fs::create_dir_all(&base).unwrap();
+ let target = base.join("settings.json");
+ fs::write(&target, "old").unwrap();
+
+ let (staged, backup) = prepare_letta_install_file(&target, b"new", false, true).unwrap();
+ let had_original = publish_letta_install_file(&target, &staged, &backup).unwrap();
+ assert!(had_original);
+ assert_eq!(fs::read_to_string(&target).unwrap(), "new");
+
+ rollback_letta_install_file(&target, &backup, had_original).unwrap();
+ assert_eq!(fs::read_to_string(&target).unwrap(), "old");
+ assert!(!backup.exists());
+
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
fn install_cursor_writes_hook_and_updates_hooks_json() {
let _lock = integration_env_lock();
let base = unique_base();
diff --git a/src/integration/types.rs b/src/integration/types.rs
index 8169ab98..928675f6 100644
--- a/src/integration/types.rs
+++ b/src/integration/types.rs
@@ -77,6 +77,12 @@ pub(crate) struct QwenInstallPaths {
}
#[derive(Debug)]
+pub(crate) struct LettaInstallPaths {
+ pub hook_path: PathBuf,
+ pub settings_path: PathBuf,
+}
+
+#[derive(Debug)]
pub(crate) struct CursorInstallPaths {
pub hook_path: PathBuf,
pub hooks_path: PathBuf,
@@ -134,6 +140,14 @@ pub(crate) struct QwenUninstallResult {
pub updated_settings: bool,
}
+#[derive(Debug)]
+pub(crate) struct LettaUninstallResult {
+ pub hook_path: PathBuf,
+ pub settings_path: PathBuf,
+ pub removed_hook_file: bool,
+ pub updated_settings: bool,
+}
+
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct IntegrationStatus {
pub target: crate::api::schema::IntegrationTarget,
@@ -150,6 +164,17 @@ pub(crate) enum IntegrationStatusKind {
Outdated,
}
+/// Status for an experimental target that is deliberately not part of the
+/// frozen client endpoint `IntegrationTarget` enum.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) struct ExperimentalIntegrationStatus {
+ pub label: &'static str,
+ pub path: PathBuf,
+ pub state: IntegrationStatusKind,
+ pub installed_version: Option<u32>,
+ pub expected_version: u32,
+}
+
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct IntegrationRecommendation {
pub target: crate::api::schema::IntegrationTarget,