diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-13 18:36:47 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-13 17:36:47 +0200 |
| commit | fc1cb77f0572948552377b23f7754a3016ac7dfa (patch) | |
| tree | 5412b796aaaa00894fe628b2ffed8d747e6f452d /src/integration | |
| parent | 3be35231497f33f38aeffd84df02642479995bc6 (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.rs | 61 | ||||
| -rw-r--r-- | src/integration/assets/letta/herdr-agent-session.ps1 | 51 | ||||
| -rw-r--r-- | src/integration/assets/letta/herdr-agent-session.sh | 56 | ||||
| -rw-r--r-- | src/integration/env.rs | 4 | ||||
| -rw-r--r-- | src/integration/mod.rs | 30 | ||||
| -rw-r--r-- | src/integration/registry.rs | 50 | ||||
| -rw-r--r-- | src/integration/targets.rs | 256 | ||||
| -rw-r--r-- | src/integration/tests.rs | 188 | ||||
| -rw-r--r-- | src/integration/types.rs | 25 |
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, |
