From e29f580feb444d1d5ed785d0a7fc18adc56f8998 Mon Sep 17 00:00:00 2001 From: wendaining <182899058+wendaining@users.noreply.github.com> Date: Sat, 12 Sep 2026 13:12:02 +0800 Subject: [PATCH 01/15] feat(setup): add idempotent shell profile setup --- Cargo.toml | 2 +- src/cli.rs | 64 ++++- src/commands/mod.rs | 2 + src/commands/setup.rs | 582 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 648 insertions(+), 2 deletions(-) create mode 100644 src/commands/setup.rs diff --git a/Cargo.toml b/Cargo.toml index 28aa471..9b7da05 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,4 +39,4 @@ crossterm = { version = "0.29.0", features = ["use-dev-tty"] } [target."cfg(windows)".dependencies] sysinfo = { version = "=0.38.0", default-features = false, features = ["system"] } -windows-sys = { version = "0.60", features = ["Win32_Foundation", "Win32_System_Console"] } +windows-sys = { version = "0.60", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Console"] } diff --git a/src/cli.rs b/src/cli.rs index 9556c43..5fc950e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,4 +1,4 @@ -use clap::{Args, Parser, Subcommand}; +use clap::{Args, Parser, Subcommand, ValueEnum}; /// Shell command and output history viewer. #[derive(Debug, Parser)] @@ -18,6 +18,8 @@ pub enum Command { Proxy(ProxyArgs), /// Print shell integration code: `eval "$(recall init zsh)"`. Init(InitArgs), + /// Configure automatic output capture and shell integration. + Setup(SetupArgs), /// Record metadata only, without captured output. Record(RecordArgs), /// Import metadata from another history source. @@ -76,6 +78,29 @@ pub struct InitArgs { pub shell: String, } +#[derive(Debug, Args)] +pub struct SetupArgs { + /// Shell to configure (defaults to the login shell). + #[arg(value_parser = crate::shell::parse_integration_name)] + pub shell: Option, + /// Configure automatic capture or install hooks only. + #[arg(long, value_enum, default_value_t = SetupMode::Auto)] + pub mode: SetupMode, + /// Override the shell startup file to update. + #[arg(long)] + pub profile: Option, + /// Remove setup managed by recall from the startup file. + #[arg(long)] + pub remove: bool, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)] +pub enum SetupMode { + #[default] + Auto, + Hooks, +} + #[derive(Debug, Args)] pub struct RecordArgs { #[arg(long)] @@ -143,3 +168,40 @@ pub enum ConfigAction { /// Print a commented default configuration. Default, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn setup_defaults_to_automatic_capture() { + let cli = Cli::try_parse_from(["recall", "setup", "zsh"]).unwrap(); + let Some(Command::Setup(args)) = cli.command else { + panic!("expected setup command"); + }; + assert_eq!(args.mode, SetupMode::Auto); + assert!(!args.remove); + } + + #[test] + fn setup_accepts_hooks_and_custom_profile() { + let cli = Cli::try_parse_from([ + "recall", + "setup", + "pwsh", + "--mode", + "hooks", + "--profile", + "profile.ps1", + ]) + .unwrap(); + let Some(Command::Setup(args)) = cli.command else { + panic!("expected setup command"); + }; + assert_eq!(args.mode, SetupMode::Hooks); + assert_eq!( + args.profile.unwrap(), + std::path::PathBuf::from("profile.ps1") + ); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index e79315f..6aa52c5 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -3,6 +3,7 @@ mod import; mod init; mod proxy; mod record; +mod setup; mod shell; mod update; @@ -23,6 +24,7 @@ pub fn run(cli: Cli) -> Result<()> { Some(Command::Shell(args)) => shell::run(args), Some(Command::Proxy(args)) => proxy::run(args), Some(Command::Init(args)) => init::run(args), + Some(Command::Setup(args)) => setup::run(args), Some(Command::Record(args)) => record::run(args), Some(Command::Import(args)) => import::run(args), Some(Command::Doctor) => doctor::run(), diff --git a/src/commands/setup.rs b/src/commands/setup.rs new file mode 100644 index 0000000..2ca1bb7 --- /dev/null +++ b/src/commands/setup.rs @@ -0,0 +1,582 @@ +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, anyhow, bail}; + +use crate::cli::{SetupArgs, SetupMode}; +use crate::shell::Shell; +use crate::util; + +pub(crate) const BOOTSTRAP_START: &str = "# >>> recall setup bootstrap >>>"; +pub(crate) const BOOTSTRAP_END: &str = "# <<< recall setup bootstrap <<<"; +pub(crate) const INTEGRATION_START: &str = "# >>> recall setup integration >>>"; +pub(crate) const INTEGRATION_END: &str = "# <<< recall setup integration <<<"; +const LEGACY_START: &str = "# >>> recall installer >>>"; +const LEGACY_END: &str = "# <<< recall installer <<<"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum TextEncoding { + Utf8, + Utf8Bom, + Utf16Le, + Utf16Be, +} + +struct ProfileText { + text: String, + encoding: TextEncoding, + newline: &'static str, +} + +pub fn run(args: SetupArgs) -> Result<()> { + let shell = resolve_shell(args.shell.as_deref())?; + let profile = args + .profile + .or_else(|| shell.rc_path()) + .ok_or_else(|| anyhow!("could not determine the startup file for {}", shell.name))?; + + if args.remove { + let changed = update_profile(&profile, shell, args.mode, true)?; + println!( + "shell setup: {} ({})", + profile.display(), + if changed { "removed" } else { "not present" } + ); + return Ok(()); + } + + let changed = update_profile(&profile, shell, args.mode, false)?; + println!( + "shell setup: {} ({}; {})", + profile.display(), + match args.mode { + SetupMode::Auto => "automatic output capture", + SetupMode::Hooks => "hooks only", + }, + if changed { + "updated" + } else { + "already current" + } + ); + Ok(()) +} + +fn resolve_shell(name: Option<&str>) -> Result<&'static Shell> { + let detected; + let name = match name { + Some(name) => name, + None => { + detected = util::login_shell(); + &detected + } + }; + Shell::from_command(name) + .filter(|shell| shell.init_script().is_some()) + .ok_or_else(|| anyhow!("unsupported shell: {name}")) +} + +fn update_profile(path: &Path, shell: &Shell, mode: SetupMode, remove: bool) -> Result { + let write_path = resolve_profile_target(path)?; + let existing = read_profile(&write_path)?; + let cleaned = remove_managed_blocks(&existing.text)?; + let rendered = if remove { + cleaned + } else { + let binary_dir = std::env::current_exe() + .context("locating the recall executable")? + .parent() + .ok_or_else(|| anyhow!("the recall executable has no parent directory"))? + .to_path_buf(); + render_profile(&cleaned, shell.name, &binary_dir, mode) + }; + + if rendered == existing.text { + return Ok(false); + } + write_profile(&write_path, &existing, &rendered)?; + Ok(true) +} + +fn resolve_profile_target(path: &Path) -> Result { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() => fs::canonicalize(path) + .with_context(|| format!("resolving startup file symlink {}", path.display())), + Ok(_) => Ok(path.to_path_buf()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(path.to_path_buf()), + Err(err) => Err(err).with_context(|| format!("inspecting {}", path.display())), + } +} + +fn read_profile(path: &Path) -> Result { + let bytes = match fs::read(path) { + Ok(bytes) => bytes, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + return Ok(ProfileText { + text: String::new(), + encoding: TextEncoding::Utf8, + newline: if cfg!(windows) { "\r\n" } else { "\n" }, + }); + } + Err(err) => return Err(err).with_context(|| format!("reading {}", path.display())), + }; + + let (text, encoding) = decode_text(&bytes).with_context(|| { + format!( + "decoding {} (expected UTF-8 or BOM-marked UTF-16)", + path.display() + ) + })?; + let newline = if text.contains("\r\n") { "\r\n" } else { "\n" }; + Ok(ProfileText { + text: text.replace("\r\n", "\n"), + encoding, + newline, + }) +} + +fn decode_text(bytes: &[u8]) -> Result<(String, TextEncoding)> { + if let Some(bytes) = bytes.strip_prefix(&[0xef, 0xbb, 0xbf]) { + return Ok((String::from_utf8(bytes.to_vec())?, TextEncoding::Utf8Bom)); + } + if let Some(bytes) = bytes.strip_prefix(&[0xff, 0xfe]) { + return Ok((decode_utf16(bytes, true)?, TextEncoding::Utf16Le)); + } + if let Some(bytes) = bytes.strip_prefix(&[0xfe, 0xff]) { + return Ok((decode_utf16(bytes, false)?, TextEncoding::Utf16Be)); + } + Ok((String::from_utf8(bytes.to_vec())?, TextEncoding::Utf8)) +} + +fn decode_utf16(bytes: &[u8], little_endian: bool) -> Result { + if !bytes.len().is_multiple_of(2) { + bail!("UTF-16 input has an odd byte length"); + } + let (pairs, _) = bytes.as_chunks::<2>(); + let units = pairs.iter().map(|pair| { + if little_endian { + u16::from_le_bytes([pair[0], pair[1]]) + } else { + u16::from_be_bytes([pair[0], pair[1]]) + } + }); + String::from_utf16(&units.collect::>()).map_err(Into::into) +} + +fn encode_text(text: &str, encoding: TextEncoding, newline: &str) -> Vec { + let text = if newline == "\n" { + text.to_string() + } else { + text.replace('\n', newline) + }; + match encoding { + TextEncoding::Utf8 => text.into_bytes(), + TextEncoding::Utf8Bom => [vec![0xef, 0xbb, 0xbf], text.into_bytes()].concat(), + TextEncoding::Utf16Le => { + let mut bytes = vec![0xff, 0xfe]; + for unit in text.encode_utf16() { + bytes.extend_from_slice(&unit.to_le_bytes()); + } + bytes + } + TextEncoding::Utf16Be => { + let mut bytes = vec![0xfe, 0xff]; + for unit in text.encode_utf16() { + bytes.extend_from_slice(&unit.to_be_bytes()); + } + bytes + } + } +} + +fn remove_managed_blocks(text: &str) -> Result { + let mut output = Vec::new(); + let mut expected_end: Option<&str> = None; + + for line in text.split_inclusive('\n') { + let marker = line.trim_end_matches(['\r', '\n']).trim(); + if let Some(end) = expected_end { + if marker == end { + expected_end = None; + } else if is_start_marker(marker).is_some() || is_end_marker(marker) { + bail!("malformed recall setup markers"); + } + continue; + } + if let Some(end) = is_start_marker(marker) { + expected_end = Some(end); + } else if is_end_marker(marker) { + bail!("malformed recall setup markers"); + } else { + output.push(line); + } + } + if expected_end.is_some() { + bail!("malformed recall setup markers"); + } + Ok(output.concat()) +} + +fn is_start_marker(line: &str) -> Option<&'static str> { + match line { + BOOTSTRAP_START => Some(BOOTSTRAP_END), + INTEGRATION_START => Some(INTEGRATION_END), + LEGACY_START => Some(LEGACY_END), + _ => None, + } +} + +fn is_end_marker(line: &str) -> bool { + matches!(line, BOOTSTRAP_END | INTEGRATION_END | LEGACY_END) +} + +fn render_profile(text: &str, shell: &str, binary_dir: &Path, mode: SetupMode) -> String { + let mut output = String::new(); + output.push_str(&render_bootstrap(shell, binary_dir, mode)); + output.push_str(text); + if !output.is_empty() && !output.ends_with('\n') { + output.push('\n'); + } + if active_init_count(text) == 0 { + output.push_str(&render_integration(shell)); + } + output +} + +fn render_bootstrap(shell: &str, binary_dir: &Path, mode: SetupMode) -> String { + let path = binary_dir.to_string_lossy(); + match shell { + "zsh" | "bash" => { + let path = quote_posix(&path); + let interactive = if shell == "zsh" { + "[[ -o interactive ]]" + } else { + "[[ $- == *i* ]]" + }; + let mut block = format!( + "{BOOTSTRAP_START}\ncase \":$PATH:\" in\n *\":{path}:\"*) ;;\n *) export PATH={path}:\"$PATH\" ;;\nesac\n" + ); + if mode == SetupMode::Auto { + block.push_str(&format!( + "if {interactive} && [[ -t 0 && -t 1 && -z ${{RECALL_PROXY_ACTIVE:-}} && -z ${{RECALL_AUTO_LAUNCH:-}} && ${{RECALL_PROXY:-1}} != 0 ]]; then\n export RECALL_AUTO_LAUNCH=1\n exec recall shell\nfi\n" + )); + } + block.push_str(BOOTSTRAP_END); + block.push('\n'); + block + } + "fish" => { + let path = quote_fish(&path); + let mut block = format!( + "{BOOTSTRAP_START}\ncontains -- {path} $PATH; or set -gx PATH {path} $PATH\n" + ); + if mode == SetupMode::Auto { + block.push_str( + "if status is-interactive; and isatty stdin; and isatty stdout; and not set -q RECALL_PROXY_ACTIVE; and not set -q RECALL_AUTO_LAUNCH\n if not set -q RECALL_PROXY; or test \"$RECALL_PROXY\" != 0\n set -gx RECALL_AUTO_LAUNCH 1\n exec recall shell\n end\nend\n", + ); + } + block.push_str(BOOTSTRAP_END); + block.push('\n'); + block + } + "pwsh" | "powershell" => { + let path = quote_powershell(&path); + let mut block = format!( + "{BOOTSTRAP_START}\nif (($env:Path -split ';') -notcontains {path}) {{ $env:Path = {path} + ';' + $env:Path }}\n" + ); + if mode == SetupMode::Auto { + block.push_str( + "if (-not [Console]::IsInputRedirected -and -not [Console]::IsOutputRedirected -and -not $env:RECALL_PROXY_ACTIVE -and -not $env:RECALL_AUTO_LAUNCH -and $env:RECALL_PROXY -ne '0') {\n $env:RECALL_AUTO_LAUNCH = '1'\n & recall shell\n $recallExitCode = $LASTEXITCODE\n exit $recallExitCode\n}\n", + ); + } + block.push_str(BOOTSTRAP_END); + block.push('\n'); + block + } + _ => unreachable!("setup only accepts integrated shells"), + } +} + +fn render_integration(shell: &str) -> String { + let command = match shell { + "zsh" | "bash" => format!("eval \"$(command recall init {shell})\""), + "fish" => "command recall init fish | source".to_string(), + "pwsh" | "powershell" => { + format!("recall init {shell} | Out-String | Invoke-Expression") + } + _ => unreachable!("setup only accepts integrated shells"), + }; + format!("{INTEGRATION_START}\n{command}\n{INTEGRATION_END}\n") +} + +fn active_init_count(text: &str) -> usize { + text.lines() + .filter(|line| { + let line = line.trim_start(); + !line.starts_with('#') && line.contains("recall init") + }) + .count() +} + +fn quote_posix(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} + +fn quote_fish(value: &str) -> String { + format!("'{}'", value.replace('\\', "\\\\").replace('\'', "\\'")) +} + +fn quote_powershell(value: &str) -> String { + format!("'{}'", value.replace('\'', "''")) +} + +fn write_profile(path: &Path, original: &ProfileText, text: &str) -> Result<()> { + let parent = path + .parent() + .ok_or_else(|| anyhow!("startup file has no parent: {}", path.display()))?; + fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?; + let bytes = encode_text(text, original.encoding, original.newline); + let permissions = fs::metadata(path) + .ok() + .map(|metadata| metadata.permissions()); + + let mut temp_path = None; + for attempt in 0..100 { + let candidate = parent.join(format!( + ".recall-setup-{}-{attempt}.tmp", + std::process::id() + )); + match OpenOptions::new() + .write(true) + .create_new(true) + .open(&candidate) + { + Ok(mut file) => { + file.write_all(&bytes)?; + file.sync_all()?; + if let Some(permissions) = permissions.clone() { + fs::set_permissions(&candidate, permissions)?; + } + temp_path = Some(candidate); + break; + } + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(err) => return Err(err).context("creating a temporary startup file"), + } + } + let temp_path = + temp_path.ok_or_else(|| anyhow!("could not create a temporary startup file"))?; + replace_file(&temp_path, path).inspect_err(|_| { + let _ = fs::remove_file(&temp_path); + })?; + Ok(()) +} + +#[cfg(not(windows))] +fn replace_file(from: &Path, to: &Path) -> Result<()> { + fs::rename(from, to).with_context(|| format!("replacing {}", to.display())) +} + +#[cfg(windows)] +fn replace_file(from: &Path, to: &Path) -> Result<()> { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::{ + MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW, + }; + + let from: Vec = from.as_os_str().encode_wide().chain(Some(0)).collect(); + let to: Vec = to.as_os_str().encode_wide().chain(Some(0)).collect(); + let result = unsafe { + MoveFileExW( + from.as_ptr(), + to.as_ptr(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + }; + if result == 0 { + return Err(std::io::Error::last_os_error()).context("replacing the startup file"); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_profile(name: &str) -> (PathBuf, PathBuf) { + let dir = std::env::temp_dir().join(format!( + "recall-setup-test-{}-{}-{name}", + std::process::id(), + ulid::Ulid::generate() + )); + fs::create_dir_all(&dir).unwrap(); + (dir.join("profile"), dir) + } + + #[test] + fn renders_fast_bootstrap_before_user_config_and_hooks_after_it() { + let rendered = render_profile( + "echo user-config\n", + "zsh", + Path::new("/opt/recall/bin"), + SetupMode::Auto, + ); + assert!(rendered.starts_with(BOOTSTRAP_START)); + assert!(rendered.contains("[[ -t 0 && -t 1")); + assert!(rendered.contains("RECALL_PROXY_ACTIVE")); + assert!(rendered.contains("RECALL_AUTO_LAUNCH")); + assert!(rendered.contains("${RECALL_PROXY:-1} != 0")); + assert!( + rendered.find("echo user-config").unwrap() < rendered.find("recall init zsh").unwrap() + ); + } + + #[test] + fn hooks_mode_omits_proxy_launch() { + let rendered = render_profile("", "bash", Path::new("/bin"), SetupMode::Hooks); + assert!(!rendered.contains("exec recall shell")); + assert!(rendered.contains("recall init bash")); + } + + #[test] + fn renders_shell_specific_launch_and_integration() { + let fish = render_profile("", "fish", Path::new("/bin"), SetupMode::Auto); + assert!(fish.contains("isatty stdin")); + assert!(fish.contains("exec recall shell")); + assert!(fish.contains("recall init fish | source")); + + let pwsh = render_profile("", "pwsh", Path::new("C:\\Recall"), SetupMode::Auto); + assert!(pwsh.contains("[Console]::IsInputRedirected")); + assert!(pwsh.contains("$recallExitCode = $LASTEXITCODE")); + assert!(pwsh.contains("exit $recallExitCode")); + assert!(pwsh.contains("recall init pwsh | Out-String | Invoke-Expression")); + } + + #[test] + fn keeps_an_existing_unmanaged_integration() { + let original = "# custom\neval \"$(recall init zsh)\"\n"; + let rendered = render_profile(original, "zsh", Path::new("/bin"), SetupMode::Auto); + assert_eq!(active_init_count(&rendered), 1); + assert!(!rendered.contains(INTEGRATION_START)); + } + + #[test] + fn removes_legacy_and_current_managed_blocks() { + let input = format!( + "{LEGACY_START}\nold\n{LEGACY_END}\nkeep\n{BOOTSTRAP_START}\nnew\n{BOOTSTRAP_END}\n" + ); + assert_eq!(remove_managed_blocks(&input).unwrap(), "keep\n"); + } + + #[test] + fn rejects_malformed_markers() { + assert!(remove_managed_blocks(&format!("{BOOTSTRAP_START}\nmissing\n")).is_err()); + assert!(remove_managed_blocks(&format!("{INTEGRATION_END}\n")).is_err()); + } + + #[test] + fn round_trips_supported_encodings() { + for encoding in [ + TextEncoding::Utf8, + TextEncoding::Utf8Bom, + TextEncoding::Utf16Le, + TextEncoding::Utf16Be, + ] { + let bytes = encode_text("hello 世界\n", encoding, "\r\n"); + let (decoded, actual) = decode_text(&bytes).unwrap(); + assert_eq!(actual, encoding); + assert_eq!(decoded, "hello 世界\r\n"); + } + } + + #[test] + fn profile_update_is_idempotent_and_removable() { + let (path, dir) = temp_profile("idempotent"); + fs::write(&path, "echo user\n").unwrap(); + let shell = Shell::from_name("zsh").unwrap(); + + assert!(update_profile(&path, shell, SetupMode::Auto, false).unwrap()); + let once = fs::read(&path).unwrap(); + assert!(!update_profile(&path, shell, SetupMode::Auto, false).unwrap()); + assert_eq!(fs::read(&path).unwrap(), once); + + assert!(update_profile(&path, shell, SetupMode::Auto, true).unwrap()); + assert_eq!(fs::read_to_string(&path).unwrap(), "echo user\n"); + assert!(!update_profile(&path, shell, SetupMode::Auto, true).unwrap()); + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn preserves_utf16_and_crlf() { + let (path, dir) = temp_profile("utf16"); + let original = encode_text("Write-Host user\n", TextEncoding::Utf16Le, "\r\n"); + fs::write(&path, original).unwrap(); + let shell = Shell::from_name("pwsh").unwrap(); + + update_profile(&path, shell, SetupMode::Hooks, false).unwrap(); + let bytes = fs::read(&path).unwrap(); + assert!(bytes.starts_with(&[0xff, 0xfe])); + let (text, encoding) = decode_text(&bytes).unwrap(); + assert_eq!(encoding, TextEncoding::Utf16Le); + assert!(text.contains("Write-Host user\r\n")); + assert!(!text.replace("\r\n", "").contains('\n')); + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn malformed_profile_is_not_modified() { + let (path, dir) = temp_profile("malformed"); + let original = format!("{BOOTSTRAP_START}\nmissing end\n"); + fs::write(&path, &original).unwrap(); + let shell = Shell::from_name("bash").unwrap(); + + assert!(update_profile(&path, shell, SetupMode::Auto, false).is_err()); + assert_eq!(fs::read_to_string(&path).unwrap(), original); + fs::remove_dir_all(dir).unwrap(); + } + + #[cfg(unix)] + #[test] + fn preserves_unix_permissions() { + use std::os::unix::fs::PermissionsExt; + + let (path, dir) = temp_profile("permissions"); + fs::write(&path, "echo user\n").unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap(); + let shell = Shell::from_name("bash").unwrap(); + + update_profile(&path, shell, SetupMode::Auto, false).unwrap(); + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o640 + ); + fs::remove_dir_all(dir).unwrap(); + } + + #[cfg(unix)] + #[test] + fn updates_symlink_target_without_replacing_the_link() { + use std::os::unix::fs::symlink; + + let (link, dir) = temp_profile("symlink"); + let target = dir.join("managed-profile"); + fs::write(&target, "echo user\n").unwrap(); + symlink(&target, &link).unwrap(); + let shell = Shell::from_name("zsh").unwrap(); + + update_profile(&link, shell, SetupMode::Hooks, false).unwrap(); + assert!( + fs::symlink_metadata(&link) + .unwrap() + .file_type() + .is_symlink() + ); + assert!( + fs::read_to_string(&target) + .unwrap() + .contains(INTEGRATION_START) + ); + fs::remove_dir_all(dir).unwrap(); + } +} From 1a841cc99eb977be8df6b6780146f0962783240f Mon Sep 17 00:00:00 2001 From: wendaining <182899058+wendaining@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:09:59 +0800 Subject: [PATCH 02/15] feat(doctor): diagnose automatic shell setup --- shell/recall.bash | 2 + shell/recall.fish | 2 + shell/recall.ps1 | 1 + shell/recall.zsh | 2 + src/commands/doctor.rs | 88 +++++++++++++++++++++++++++++++++++------- src/commands/setup.rs | 80 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 161 insertions(+), 14 deletions(-) diff --git a/shell/recall.bash b/shell/recall.bash index ffe36ba..b8bda84 100644 --- a/shell/recall.bash +++ b/shell/recall.bash @@ -149,3 +149,5 @@ bind -x '"@RECALL_SEARCH_KEY@": __recall_search' if [[ $OSTYPE == darwin* ]]; then bind -x '"®": __recall_search' fi + +export RECALL_HOOKS_ACTIVE=1 diff --git a/shell/recall.fish b/shell/recall.fish index 8c81d31..8834f2d 100644 --- a/shell/recall.fish +++ b/shell/recall.fish @@ -129,3 +129,5 @@ bind @RECALL_SEARCH_KEY@ __recall_search if test (uname) = Darwin bind '®' __recall_search end + +set -gx RECALL_HOOKS_ACTIVE 1 diff --git a/shell/recall.ps1 b/shell/recall.ps1 index 85aad46..c73009d 100644 --- a/shell/recall.ps1 +++ b/shell/recall.ps1 @@ -231,4 +231,5 @@ if (-not (Get-Module PSReadLine -ErrorAction Ignore)) { } } } + $env:RECALL_HOOKS_ACTIVE = '1' } diff --git a/shell/recall.zsh b/shell/recall.zsh index 6e6ccf1..d80a5c5 100644 --- a/shell/recall.zsh +++ b/shell/recall.zsh @@ -146,3 +146,5 @@ if [[ -o interactive ]]; then bindkey '®' _recall_search fi fi + +export RECALL_HOOKS_ACTIVE=1 diff --git a/src/commands/doctor.rs b/src/commands/doctor.rs index fc075af..db02905 100644 --- a/src/commands/doctor.rs +++ b/src/commands/doctor.rs @@ -1,5 +1,6 @@ use anyhow::Result; +use super::setup::{self, ManagedSetup}; use crate::config::Config; use crate::db::{self, Db, queries, schema}; use crate::shell::Shell; @@ -110,32 +111,91 @@ fn check_shell_integration(cfg: &Config) { ); let shell = util::login_shell(); + let mut setup_state = ManagedSetup::None; + let mut init_count = 0; match Shell::from_command(&shell).and_then(Shell::rc_path) { - Some(path) => { - let configured = std::fs::read_to_string(&path) - .map(|text| text.contains("recall init")) - .unwrap_or(false); - println!( - " rc file: {} ({})", - path.display(), - if configured { - "recall init found" + Some(path) => match setup::inspect_profile(&path) { + Ok(inspection) => { + setup_state = inspection.setup; + init_count = inspection.init_count; + println!(" rc file: {}", path.display()); + } + Err(err) => println!(" rc file: {} (ERROR: {err})", path.display()), + }, + None => println!(" rc file: unknown shell {shell}"), + } + + println!( + " setup: {}", + match setup_state { + ManagedSetup::Auto => "automatic output capture (managed)", + ManagedSetup::Hooks => "hooks only (managed)", + ManagedSetup::LegacyAuto => "automatic output capture (legacy installer setup)", + ManagedSetup::LegacyHooks => "hooks only (legacy installer setup)", + ManagedSetup::UnmanagedHooks => "hooks only (unmanaged recall init)", + ManagedSetup::None => "not configured", + ManagedSetup::Invalid => "ERROR: malformed recall setup markers", + } + ); + println!( + " hooks: {}", + match init_count { + 0 => "not configured".to_string(), + 1 => { + if std::env::var_os("RECALL_HOOKS_ACTIVE").is_some() { + "configured and active".to_string() } else { - "recall init NOT found" + "configured; not active in this shell".to_string() } - ); + } + count => format!("WARNING: {count} active recall init lines found"), } - None => println!(" rc file: unknown shell {shell}"), + ); + if init_count > 1 { + println!(" hint: remove duplicate active `recall init` lines from the startup file"); + } + if matches!( + setup_state, + ManagedSetup::LegacyAuto | ManagedSetup::LegacyHooks + ) { + println!(" hint: run `recall setup` to migrate the legacy installer setup"); } + let proxy_active = std::env::var_os("RECALL_PROXY_ACTIVE").is_some(); println!( " proxy: {}", - if std::env::var_os("RECALL_PROXY_ACTIVE").is_some() { + if proxy_active { "active" + } else if std::env::var("RECALL_PROXY").is_ok_and(|value| value == "0") { + "disabled by RECALL_PROXY=0" } else { - "not active; run `recall shell` to capture output" + "not active" } ); + if !proxy_active { + match setup_state { + ManagedSetup::Auto | ManagedSetup::LegacyAuto => { + if std::env::var("RECALL_PROXY").is_ok_and(|value| value == "0") { + println!( + " hint: unset RECALL_PROXY and open a new terminal to capture output" + ); + } else { + println!(" hint: open a new terminal to activate automatic output capture"); + } + } + ManagedSetup::Hooks | ManagedSetup::LegacyHooks | ManagedSetup::UnmanagedHooks => { + println!( + " hint: hooks record metadata only; run `recall shell` to capture output" + ); + } + ManagedSetup::None => { + println!(" hint: run `recall setup` to enable automatic output capture"); + } + ManagedSetup::Invalid => { + println!(" hint: repair the setup markers before running `recall setup` again"); + } + } + } println!( " login: {}", if cfg!(unix) { diff --git a/src/commands/setup.rs b/src/commands/setup.rs index 2ca1bb7..b7b1c2d 100644 --- a/src/commands/setup.rs +++ b/src/commands/setup.rs @@ -15,6 +15,23 @@ pub(crate) const INTEGRATION_END: &str = "# <<< recall setup integration <<<"; const LEGACY_START: &str = "# >>> recall installer >>>"; const LEGACY_END: &str = "# <<< recall installer <<<"; +#[derive(Debug, Eq, PartialEq)] +pub(crate) enum ManagedSetup { + Auto, + Hooks, + LegacyAuto, + LegacyHooks, + UnmanagedHooks, + None, + Invalid, +} + +#[derive(Debug, Eq, PartialEq)] +pub(crate) struct ProfileInspection { + pub(crate) setup: ManagedSetup, + pub(crate) init_count: usize, +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum TextEncoding { Utf8, @@ -63,6 +80,47 @@ pub fn run(args: SetupArgs) -> Result<()> { Ok(()) } +pub(crate) fn inspect_profile(path: &Path) -> Result { + let path = resolve_profile_target(path)?; + let profile = read_profile(&path)?; + Ok(inspect_text(&profile.text)) +} + +fn inspect_text(text: &str) -> ProfileInspection { + let init_count = active_init_count(text); + if remove_managed_blocks(text).is_err() { + return ProfileInspection { + setup: ManagedSetup::Invalid, + init_count, + }; + } + + let setup = if let Some(block) = managed_block(text, BOOTSTRAP_START, BOOTSTRAP_END) { + if block.contains("recall shell") { + ManagedSetup::Auto + } else { + ManagedSetup::Hooks + } + } else if let Some(block) = managed_block(text, LEGACY_START, LEGACY_END) { + if block.contains("recall shell") { + ManagedSetup::LegacyAuto + } else { + ManagedSetup::LegacyHooks + } + } else if text.contains(INTEGRATION_START) || init_count > 0 { + ManagedSetup::UnmanagedHooks + } else { + ManagedSetup::None + }; + ProfileInspection { setup, init_count } +} + +fn managed_block<'a>(text: &'a str, start: &str, end: &str) -> Option<&'a str> { + let start = text.find(start)? + start.len(); + let end = text[start..].find(end)? + start; + Some(&text[start..end]) +} + fn resolve_shell(name: Option<&str>) -> Result<&'static Shell> { let detected; let name = match name { @@ -469,6 +527,28 @@ mod tests { assert_eq!(remove_managed_blocks(&input).unwrap(), "keep\n"); } + #[test] + fn inspects_managed_and_unmanaged_setup() { + let auto = render_profile("", "zsh", Path::new("/bin"), SetupMode::Auto); + assert_eq!( + inspect_text(&auto), + ProfileInspection { + setup: ManagedSetup::Auto, + init_count: 1, + } + ); + + let hooks = render_profile("", "fish", Path::new("/bin"), SetupMode::Hooks); + assert_eq!(inspect_text(&hooks).setup, ManagedSetup::Hooks); + + let unmanaged = "eval \"$(recall init bash)\"\n"; + assert_eq!(inspect_text(unmanaged).setup, ManagedSetup::UnmanagedHooks); + assert_eq!(inspect_text(unmanaged).init_count, 1); + + let invalid = format!("{BOOTSTRAP_START}\n"); + assert_eq!(inspect_text(&invalid).setup, ManagedSetup::Invalid); + } + #[test] fn rejects_malformed_markers() { assert!(remove_managed_blocks(&format!("{BOOTSTRAP_START}\nmissing\n")).is_err()); From 3c64402a8c6a5d9e7fec1a6d35f954b3d8d9351c Mon Sep 17 00:00:00 2001 From: wendaining <182899058+wendaining@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:12:13 +0800 Subject: [PATCH 03/15] refactor(install): use unified setup on unix --- install.sh | 144 +++++++++++------------------------------------------ 1 file changed, 28 insertions(+), 116 deletions(-) diff --git a/install.sh b/install.sh index 099339b..160ed93 100755 --- a/install.sh +++ b/install.sh @@ -28,7 +28,6 @@ require mktemp require id require mkdir require dirname -require grep require cat show_logo() { @@ -147,18 +146,6 @@ case "$shell_name" in ;; esac -remove_managed_setup() { - file=$1 - [ -f "$file" ] || return 0 - sed '/^# >>> recall installer >>>$/,/^# <<< recall installer <<<$/{d;}' \ - "$file" > "$temp_dir/profile.cleaned" - cat "$temp_dir/profile.cleaned" > "$file" -} - -escape_double_quotes() { - sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/\$/\\$/g' -e 's/`/\\`/g' -} - set_search_key() { file=$1 key=$2 @@ -189,38 +176,10 @@ set_search_key() { choose_proxy_setup() { case "${RECALL_PROXY_SETUP:-}" in - terminal | shell | none) - proxy_setup=$RECALL_PROXY_SETUP - return - ;; - "") ;; - *) die "RECALL_PROXY_SETUP must be terminal, shell, or none" ;; - esac - - if [ ! -r /dev/tty ] || [ ! -w /dev/tty ]; then - proxy_setup=none - return - fi - - say "" - say "Choose how new terminals start recall's PTY proxy:" - say " 1. Terminal emulator startup command (recommended)" - if [ -n "$profile_path" ]; then - say " 2. Shell startup file (use when your terminal has no such setting)" - fi - say " 3. Not now" - printf 'Choice [1]: ' > /dev/tty - IFS= read -r answer < /dev/tty || answer="" - case "$answer" in - "" | 1) proxy_setup=terminal ;; - 2) - if [ -n "$profile_path" ]; then - proxy_setup=shell - else - proxy_setup=none - fi - ;; - *) proxy_setup=none ;; + "" | auto | shell) proxy_setup=auto ;; + hooks | none) proxy_setup=hooks ;; + terminal) proxy_setup=terminal ;; + *) die "RECALL_PROXY_SETUP must be auto, hooks, or terminal" ;; esac } @@ -311,69 +270,16 @@ offer_detected_history() { "$history_data_dir/fish/fish_history" "fish history" } -proxy_setup=none +proxy_setup=auto choose_proxy_setup search_key="" choose_search_key shell_configured=0 if [ -n "$profile_path" ]; then - mkdir -p "$(dirname "$profile_path")" - [ -e "$profile_path" ] || : > "$profile_path" - remove_managed_setup "$profile_path" - - integration_needed=1 - case "$shell_name" in - zsh | bash) - if grep -Fq "recall init $shell_name" "$profile_path"; then - integration_needed=0 - fi - ;; - fish) - if grep -Fq "recall init fish" "$profile_path"; then - integration_needed=0 - fi - ;; - esac - - escaped_install_dir=$(printf '%s' "$install_dir" | escape_double_quotes) - { - printf '\n# >>> recall installer >>>\n' - case "$shell_name" in - zsh | bash) - printf 'case ":$PATH:" in\n' - printf ' *":%s:"*) ;;\n' "$escaped_install_dir" - printf ' *) export PATH="%s:$PATH" ;;\n' "$escaped_install_dir" - printf 'esac\n' - if [ "$integration_needed" -eq 1 ]; then - printf 'eval "$(command recall init %s)"\n' "$shell_name" - fi - if [ "$proxy_setup" = shell ]; then - if [ "$shell_name" = zsh ]; then - printf 'if [[ -o interactive && -z ${RECALL_PROXY_ACTIVE:-} && -z ${RECALL_AUTO_LAUNCH:-} ]]; then\n' - else - printf 'if [[ $- == *i* && -z ${RECALL_PROXY_ACTIVE:-} && -z ${RECALL_AUTO_LAUNCH:-} ]]; then\n' - fi - printf ' export RECALL_AUTO_LAUNCH=1\n' - printf ' exec recall shell\n' - printf 'fi\n' - fi - ;; - fish) - printf 'contains -- "%s" $PATH; or set -gx PATH "%s" $PATH\n' \ - "$escaped_install_dir" "$escaped_install_dir" - if [ "$integration_needed" -eq 1 ]; then - printf 'command recall init fish | source\n' - fi - if [ "$proxy_setup" = shell ]; then - printf 'if status is-interactive; and not set -q RECALL_PROXY_ACTIVE; and not set -q RECALL_AUTO_LAUNCH\n' - printf ' set -gx RECALL_AUTO_LAUNCH 1\n' - printf ' exec recall shell\n' - printf 'end\n' - fi - ;; - esac - printf '# <<< recall installer <<<\n' - } >> "$profile_path" + setup_mode=$proxy_setup + [ "$setup_mode" = terminal ] && setup_mode=hooks + "$destination" setup "$shell_name" --mode "$setup_mode" --profile "$profile_path" \ + || die "failed to configure $profile_path" shell_configured=1 fi @@ -409,20 +315,26 @@ if [ "$config_created" -eq 1 ]; then else say "Config: kept existing $config_path" fi -case "$proxy_setup" in - terminal) - say "PTY proxy: set your terminal's startup command to:" - say " $destination shell" - ;; - shell) - say "PTY proxy: enabled through $profile_path" - ;; - none) - say "PTY proxy: not enabled; run 'recall shell' when needed" - ;; -esac +if [ "$shell_configured" -eq 0 ]; then + say "Output: not configured; run 'recall shell' manually" +else + case "$proxy_setup" in + auto) + say "Output: automatic capture in new interactive shells" + say "Alternative: run 'recall setup $shell_name --mode hooks' for hooks only" + ;; + hooks) + say "Output: hooks only; run 'recall shell' when capture is needed" + ;; + terminal) + say "Output: terminal-managed compatibility mode" + say " set your terminal's startup command to:" + say " $destination shell" + ;; + esac +fi say "" -say "Open a new terminal after finishing the setup above. Run 'recall' or press" +say "Open a new terminal to activate the setup. Run 'recall' or press" say "Alt+R to browse history; press F1 inside recall to see all shortcuts." if [ "$os" = Darwin ] && [ -z "$search_key" ]; then say "" From 7cd4142c04a276132b41f6fc4437ef274bf50b00 Mon Sep 17 00:00:00 2001 From: wendaining <182899058+wendaining@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:13:08 +0800 Subject: [PATCH 04/15] refactor(install): enable unified setup on windows --- install.ps1 | 62 ++++++++++++++++++++++++++++++----------------------- 1 file changed, 35 insertions(+), 27 deletions(-) diff --git a/install.ps1 b/install.ps1 index ca64fe4..5706bce 100644 --- a/install.ps1 +++ b/install.ps1 @@ -7,21 +7,35 @@ # RECALL_INSTALL_DIR install directory (default: $HOME\.local\bin) # RECALL_VERSION release tag to install (default: latest) # RECALL_NO_MODIFY_PROFILE set to skip $PROFILE changes +# RECALL_PROXY_SETUP auto, hooks, or terminal (default: auto) # RECALL_IMPORT_HISTORY yes, no, or ask (default: ask) $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' $repo = 'wendaining/recall' -$markerStart = '# >>> recall installer >>>' -$markerEnd = '# <<< recall installer <<<' - function Say($message) { Write-Host $message } function Die($message) { Write-Error "recall installer: $message" exit 1 } +function Resolve-ProxySetup { + $value = if ($env:RECALL_PROXY_SETUP) { + $env:RECALL_PROXY_SETUP.ToLowerInvariant() + } else { + 'auto' + } + switch ($value) { + 'auto' { return 'auto' } + 'shell' { return 'auto' } + 'hooks' { return 'hooks' } + 'none' { return 'hooks' } + 'terminal' { return 'terminal' } + default { Die 'RECALL_PROXY_SETUP must be auto, hooks, or terminal' } + } +} + function Should-ImportHistory($label, $path) { $preference = if ($env:RECALL_IMPORT_HISTORY) { $env:RECALL_IMPORT_HISTORY.ToLowerInvariant() @@ -157,31 +171,14 @@ try { $env:Path = "$installDir;$env:Path" } + $proxySetup = Resolve-ProxySetup + $shellConfigured = $false if (-not $env:RECALL_NO_MODIFY_PROFILE) { $profilePath = $PROFILE.CurrentUserCurrentHost - $profileDir = Split-Path -Parent $profilePath - if ($profileDir) { New-Item -ItemType Directory -Path $profileDir -Force | Out-Null } - if (-not (Test-Path $profilePath)) { New-Item -ItemType File -Path $profilePath -Force | Out-Null } - - $lines = @(Get-Content -LiteralPath $profilePath -ErrorAction SilentlyContinue) - $kept = New-Object System.Collections.Generic.List[string] - $skip = $false - foreach ($line in $lines) { - if ($line.Trim() -eq $markerStart) { $skip = $true; continue } - if ($line.Trim() -eq $markerEnd) { $skip = $false; continue } - if (-not $skip) { $kept.Add($line) } - } - $escapedDir = $installDir.Replace("'", "''") - $block = @( - '' - $markerStart - "`$env:Path = '$escapedDir;' + `$env:Path" - 'recall init pwsh | Out-String | Invoke-Expression' - $markerEnd - ) - foreach ($line in $block) { $kept.Add($line) } - Set-Content -LiteralPath $profilePath -Value $kept - Say "Shell integration: $profilePath" + $setupMode = if ($proxySetup -eq 'terminal') { 'hooks' } else { $proxySetup } + & $destination setup pwsh --mode $setupMode --profile $profilePath + if ($LASTEXITCODE -ne 0) { Die "failed to configure $profilePath" } + $shellConfigured = $true } else { Say 'Shell integration: skipped (RECALL_NO_MODIFY_PROFILE)' } @@ -216,8 +213,19 @@ try { Say 'recall is ready.' Say '' Say "Installed: $(& $destination --version) at $destination" - Say "Start a proxied shell with: recall shell" + if (-not $shellConfigured) { + Say "Output: not configured; run 'recall shell' manually" + } elseif ($proxySetup -eq 'auto') { + Say 'Output: automatic capture in new interactive shells' + Say "Alternative: run 'recall setup pwsh --mode hooks' for hooks only" + } elseif ($proxySetup -eq 'hooks') { + Say "Output: hooks only; run 'recall shell' when capture is needed" + } else { + Say 'Output: terminal-managed compatibility mode' + Say "Set the Windows Terminal profile's Command line to: $destination shell" + } Say 'Open a new terminal, then run recall or press Alt+R to browse history.' + Say "Before capturing sensitive work, review: $configPath" } finally { Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue } From f2676e131e6c424ce1bf5dd7d80cc4cf92bb6aea Mon Sep 17 00:00:00 2001 From: wendaining <182899058+wendaining@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:14:29 +0800 Subject: [PATCH 05/15] refactor(uninstall): remove managed shell setup --- uninstall.ps1 | 46 ++++++++++++++++++++++++++++++++++++++-------- uninstall.sh | 19 ++++++++++++++----- 2 files changed, 52 insertions(+), 13 deletions(-) diff --git a/uninstall.ps1 b/uninstall.ps1 index f9a7d6f..40e943a 100644 --- a/uninstall.ps1 +++ b/uninstall.ps1 @@ -7,25 +7,55 @@ $ErrorActionPreference = 'Stop' -$markerStart = '# >>> recall installer >>>' -$markerEnd = '# <<< recall installer <<<' +$markerPairs = @{ + '# >>> recall installer >>>' = '# <<< recall installer <<<' + '# >>> recall setup bootstrap >>>' = '# <<< recall setup bootstrap <<<' + '# >>> recall setup integration >>>' = '# <<< recall setup integration <<<' +} function Say($message) { Write-Host $message } function Remove-ManagedBlock($path) { if (-not $path -or -not (Test-Path -LiteralPath $path)) { return } $lines = @(Get-Content -LiteralPath $path -ErrorAction SilentlyContinue) - if ($lines -notcontains $markerStart) { return } + $hasManagedBlock = $false + foreach ($line in $lines) { + if ($markerPairs.ContainsKey($line.Trim())) { + $hasManagedBlock = $true + break + } + } + if (-not $hasManagedBlock) { return } + + $recall = Get-Command recall -ErrorAction SilentlyContinue + if ($recall) { + & $recall.Source setup pwsh --profile $path --remove 2>$null | Out-Null + if ($LASTEXITCODE -eq 0) { + Say "Removed recall-managed setup from $path" + return + } + } $kept = New-Object System.Collections.Generic.List[string] - $skip = $false + $expectedEnd = $null foreach ($line in $lines) { - if ($line.Trim() -eq $markerStart) { $skip = $true; continue } - if ($line.Trim() -eq $markerEnd) { $skip = $false; continue } - if (-not $skip) { $kept.Add($line) } + $trimmed = $line.Trim() + if ($expectedEnd) { + if ($trimmed -eq $expectedEnd) { $expectedEnd = $null } + continue + } + if ($markerPairs.ContainsKey($trimmed)) { + $expectedEnd = $markerPairs[$trimmed] + continue + } + $kept.Add($line) + } + if ($expectedEnd) { + Say "Could not safely remove malformed recall setup from $path" + return } Set-Content -LiteralPath $path -Value $kept - Say "Removed installer-managed setup from $path" + Say "Removed recall-managed setup from $path" } foreach ($profilePath in @($PROFILE.CurrentUserAllHosts, $PROFILE.CurrentUserCurrentHost)) { diff --git a/uninstall.sh b/uninstall.sh index bad9c92..09d453e 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -8,12 +8,21 @@ say() { remove_managed_setup() { file=$1 + shell_name=$2 [ -f "$file" ] || return 0 - grep -Fq '# >>> recall installer >>>' "$file" || return 0 + grep -Eq '^# >>> recall (installer|setup (bootstrap|integration)) >>>$' "$file" \ + || return 0 + if command -v recall >/dev/null 2>&1 \ + && recall setup "$shell_name" --profile "$file" --remove >/dev/null 2>&1; then + say "Removed recall-managed setup from $file" + return 0 + fi temp_file=$(mktemp "${TMPDIR:-/tmp}/recall-uninstall.XXXXXX") \ || return 1 - sed '/^# >>> recall installer >>>$/,/^# <<< recall installer <<<$/{d;}' \ + sed -e '/^# >>> recall installer >>>$/,/^# <<< recall installer <<<$/{d;}' \ + -e '/^# >>> recall setup bootstrap >>>$/,/^# <<< recall setup bootstrap <<<$/{d;}' \ + -e '/^# >>> recall setup integration >>>$/,/^# <<< recall setup integration <<<$/{d;}' \ "$file" > "$temp_file" cat "$temp_file" > "$file" rm -f "$temp_file" @@ -48,9 +57,9 @@ if command -v recall >/dev/null 2>&1; then fi if [ -n "${HOME:-}" ]; then - remove_managed_setup "${ZDOTDIR:-$HOME}/.zshrc" - remove_managed_setup "$HOME/.bashrc" - remove_managed_setup "${XDG_CONFIG_HOME:-$HOME/.config}/fish/config.fish" + remove_managed_setup "${ZDOTDIR:-$HOME}/.zshrc" zsh + remove_managed_setup "$HOME/.bashrc" bash + remove_managed_setup "${XDG_CONFIG_HOME:-$HOME/.config}/fish/config.fish" fish fi binary_found=0 From a4e1079255383f2c9f1040981694695ca71db418 Mon Sep 17 00:00:00 2001 From: wendaining <182899058+wendaining@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:17:18 +0800 Subject: [PATCH 06/15] test(shell): cover automatic proxy startup --- tests/automatic_setup.rs | 162 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 tests/automatic_setup.rs diff --git a/tests/automatic_setup.rs b/tests/automatic_setup.rs new file mode 100644 index 0000000..7452f26 --- /dev/null +++ b/tests/automatic_setup.rs @@ -0,0 +1,162 @@ +#![cfg(target_os = "linux")] + +use std::io::{Read, Write}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use portable_pty::{CommandBuilder, PtySize, native_pty_system}; +use rusqlite::Connection; + +#[test] +fn automatic_setup_loads_the_rc_body_once_and_captures_output() { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let test_dir = + std::env::temp_dir().join(format!("recall-auto-setup-{}-{nonce}", std::process::id())); + std::fs::create_dir_all(&test_dir).unwrap(); + let profile_path = test_dir.join(".bashrc"); + let config_path = test_dir.join("config.toml"); + let db_path = test_dir.join("recall.db"); + std::fs::write( + &profile_path, + "printf '__RECALL_RC_BODY__\\n'\nPS1='__RECALL_PROMPT__ '\n", + ) + .unwrap(); + std::fs::write( + &config_path, + format!( + "[general]\ndb_path = '{}'\n\n[proxy]\nlogin_shell = false\n", + db_path.display() + ), + ) + .unwrap(); + + let setup = std::process::Command::new(env!("CARGO_BIN_EXE_recall")) + .args(["setup", "bash", "--profile"]) + .arg(&profile_path) + .output() + .unwrap(); + assert!( + setup.status.success(), + "{}", + String::from_utf8_lossy(&setup.stderr) + ); + + let pair = native_pty_system() + .openpty(PtySize { + rows: 30, + cols: 120, + pixel_width: 0, + pixel_height: 0, + }) + .unwrap(); + let mut command = CommandBuilder::new("bash"); + command.args(["--noprofile", "--rcfile"]); + command.arg(profile_path.as_os_str()); + command.arg("-i"); + command.env("HOME", &test_dir); + command.env("SHELL", "bash"); + command.env("TERM", "xterm-256color"); + command.env("RECALL_CONFIG", &config_path); + command.env("RECALL_PROXY_ACTIVE", ""); + command.env("RECALL_AUTO_LAUNCH", ""); + command.env("RECALL_PROXY", "1"); + command.env("RECALL_SESSION", ""); + + let mut child = pair.slave.spawn_command(command).unwrap(); + drop(pair.slave); + let mut reader = pair.master.try_clone_reader().unwrap(); + let writer = Arc::new(Mutex::new(pair.master.take_writer().unwrap())); + let master = pair.master; + let output = Arc::new(Mutex::new(Vec::new())); + let reader_output = output.clone(); + let reader_thread = thread::spawn(move || { + let mut chunk = [0u8; 4096]; + loop { + match reader.read(&mut chunk) { + Ok(0) => break, + Ok(count) => reader_output + .lock() + .unwrap() + .extend_from_slice(&chunk[..count]), + Err(_) => break, + } + } + }); + + require_occurrences(&output, "__RECALL_PROMPT__", 1, &mut child); + write_input(&writer, b"printf '__RECALL_CAPTURED_OUTPUT__\\n'\n"); + require_occurrences(&output, "__RECALL_CAPTURED_OUTPUT__", 2, &mut child); + require_occurrences(&output, "__RECALL_PROMPT__", 2, &mut child); + write_input(&writer, b"exit\n"); + + let deadline = Instant::now() + Duration::from_secs(15); + let status = loop { + match child.try_wait().unwrap() { + Some(status) => break status, + None if Instant::now() < deadline => thread::sleep(Duration::from_millis(25)), + None => { + let snapshot = output_text(&output); + let _ = child.kill(); + panic!("automatic setup did not exit after bash exited:\n{snapshot}"); + } + } + }; + assert_eq!(status.exit_code(), 0, "{}", output_text(&output)); + drop(writer); + drop(master); + reader_thread.join().unwrap(); + + let snapshot = output_text(&output); + assert_eq!( + snapshot.matches("__RECALL_RC_BODY__").count(), + 1, + "{snapshot}" + ); + let conn = Connection::open(&db_path).unwrap(); + let (command, output): (String, Vec) = conn + .query_row( + "SELECT command, output FROM blocks WHERE command LIKE '%RECALL_CAPTURED_OUTPUT%'", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert!(command.contains("__RECALL_CAPTURED_OUTPUT__")); + let decoded = zstd::stream::decode_all(output.as_slice()).unwrap(); + assert!(String::from_utf8_lossy(&decoded).contains("__RECALL_CAPTURED_OUTPUT__")); + + drop(conn); + let _ = std::fs::remove_dir_all(test_dir); +} + +fn write_input(writer: &Arc>>, bytes: &[u8]) { + let mut writer = writer.lock().unwrap(); + writer.write_all(bytes).unwrap(); + writer.flush().unwrap(); +} + +fn require_occurrences( + output: &Arc>>, + needle: &str, + expected: usize, + child: &mut Box, +) { + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline { + if output_text(output).matches(needle).count() >= expected { + return; + } + thread::sleep(Duration::from_millis(25)); + } + + let snapshot = output_text(output); + let _ = child.kill(); + panic!("expected {expected} occurrence(s) of {needle}:\n{snapshot}"); +} + +fn output_text(output: &Arc>>) -> String { + String::from_utf8_lossy(&output.lock().unwrap()).into_owned() +} From a45001b626947bc542ba4f566607d33ef71cc4b9 Mon Sep 17 00:00:00 2001 From: wendaining <182899058+wendaining@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:23:01 +0800 Subject: [PATCH 07/15] docs: explain unified automatic output capture --- README.md | 76 ++++++++++++++++++++++++++++++------------------- README.zh-CN.md | 55 ++++++++++++++++++++++------------- 2 files changed, 82 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 1dfa285..18b5971 100644 --- a/README.md +++ b/README.md @@ -34,18 +34,19 @@ the matching binary from the latest GitHub Release, and verifies its SHA-256 checksum. It then: - installs recall into `/usr/local/bin` or `~/.local/bin`; -- adds the shell integration to zsh, bash, or fish automatically; +- configures zsh, bash, or fish for automatic output capture and search; - creates the default `config.toml` without overwriting an existing one; - detects existing bash, zsh, fish, and atuin history and asks whether to import each source; -- asks how new terminals should start recall's PTY proxy for automatic output - capture. Terminal emulator configuration is recommended; shell startup is - available as a fallback. +- starts recall's PTY proxy automatically in new interactive shells without + changing terminal-emulator settings. -For a non-interactive installation, set `RECALL_PROXY_SETUP` to `terminal`, -`shell`, or `none` on the `sh` command. Set `RECALL_IMPORT_HISTORY` to `yes`, -`no`, or `ask` to control history migration (`ask` skips prompts when input is -not interactive). +Automatic output capture is the default. Set `RECALL_PROXY_SETUP=hooks` for the +lighter alternative that installs search and metadata hooks but only captures +output after you run `recall shell`. The legacy values `shell` and `none` remain +aliases for `auto` and `hooks`; explicit `terminal` mode is retained for +existing terminal-managed setups. Set `RECALL_IMPORT_HISTORY` to `yes`, `no`, +or `ask` to control history migration. ### One-line installer (Windows) @@ -55,10 +56,11 @@ irm https://raw.githubusercontent.com/wendaining/recall/master/install.ps1 | iex The installer downloads the latest release, verifies its SHA-256 checksum, and installs `recall.exe` into `%USERPROFILE%\.local\bin` (override with -`RECALL_INSTALL_DIR`). It adds that directory to your user `PATH`, appends the -PowerShell integration to `$PROFILE`, and creates the default `config.toml`. +`RECALL_INSTALL_DIR`). It adds that directory to your user `PATH`, configures +`$PROFILE` for automatic output capture, and creates the default `config.toml`. It also offers to import detected shell and atuin histories. Set -`RECALL_NO_MODIFY_PROFILE` to skip the profile change, or +`RECALL_NO_MODIFY_PROFILE` to skip the profile change, +`RECALL_PROXY_SETUP=hooks` to install hooks without automatic capture, or `RECALL_IMPORT_HISTORY=yes|no|ask` to control migration prompts. ### Build from source @@ -132,9 +134,27 @@ a custom `RECALL_INSTALL_DIR`, pass the same variable to the uninstall command. > configuration automatically. The manual steps below are mainly for source > builds or custom setups. -### 1. Shell integration +### 1. Unified shell setup -If you built from source, add the matching line to your shell's startup file: +If you built from source, run the setup command for your shell: + +```sh +recall setup zsh # or: bash, fish, pwsh, powershell +``` + +This configures automatic output capture by default. recall places a small, +managed bootstrap at the top of the startup file and the integration at the +end. The outer shell hands off to the PTY proxy before loading the rest of the +file; the child shell then loads your configuration once and installs the +hooks after themes, prompts, and PSReadLine are ready. + +For search and metadata hooks without automatic capture, use: + +```sh +recall setup zsh --mode hooks +``` + +`recall init` remains the low-level option for fully manual setups: ```zsh # ~/.zshrc @@ -156,9 +176,9 @@ recall init fish | source recall init pwsh | Out-String | Invoke-Expression ``` -This installs the capture hooks and an **Alt+R** widget that opens the TUI and -inserts the selected command into your prompt. The key is set by -`[ui].search_key` in the config; see below for details. +The integration installs command-boundary hooks and an **Alt+R** widget that +opens the TUI and inserts the selected command into your prompt. The key is set +by `[ui].search_key` in the config; see below for details. Without the proxy, recall still records command metadata in the background. To capture output, run your shell under the proxy. @@ -196,24 +216,22 @@ You can also pick another key, for example `"ctrl-x ctrl-r"` (the installer offers this on macOS). Run `recall doctor` to confirm the shell integration and `PATH`. -### 2. Enable output capture with the PTY proxy +### 2. Output capture and the PTY proxy -The one-line installer offers two setup methods. Configuring your terminal -emulator's startup command is recommended. If your terminal does not provide -that setting, the installer can configure your shell startup file instead. -Either method starts your usual shell inside recall's PTY proxy so recall can -save the output associated with each command. +The installer and `recall setup` start your usual shell inside recall's PTY +proxy automatically. No terminal-emulator configuration is needed. Set +`RECALL_PROXY=0` before starting a new shell to bypass automatic capture while +keeping the search and metadata hooks. -If you skipped the setup or installed from source, start a wrapped shell -manually: +In hooks-only or fully manual setups, start a wrapped shell when output capture +is needed: ```sh recall shell ``` -or configure your terminal emulator to launch it as the shell, so every new -window is captured automatically. The setting name varies between emulators -(`shell`, `command`, …), for example: +Existing terminal-managed setups may continue launching recall directly, but +this is an advanced compatibility path rather than the recommended setup: ``` # in your terminal emulator's config file @@ -238,8 +256,7 @@ On Windows the proxy runs the shell under ConPTY. When neither `proxy.shell` nor the parent process chain), then falls back to `pwsh`, `powershell`, and `%COMSPEC%`. Override with `--shell` or `proxy.shell`, for example `recall shell --shell cmd`. The macOS-only `-l` login flag is not used on -Windows. To capture every Windows Terminal tab automatically, set the profile's -**Command line** to `recall shell` (Settings → your profile → Command line). +Windows. ### 3. Import existing history (optional) @@ -274,6 +291,7 @@ Other commands: ```sh recall search --cmd-only # print the selection (used by the zsh widget) +recall setup [shell] # configure automatic capture and shell hooks recall doctor # diagnose config, databases and clipboard recall prune # drop output older than the retention window recall config path|show|default diff --git a/README.zh-CN.md b/README.zh-CN.md index 0954fcb..00bdfeb 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -31,15 +31,15 @@ curl -fsSL https://raw.githubusercontent.com/wendaining/recall/master/install.sh 下载对应二进制文件并校验 SHA-256。随后它会: - 将 recall 安装到 `/usr/local/bin` 或 `~/.local/bin`; -- 自动把 shell 集成写入 zsh、bash 或 fish 的启动文件; +- 为 zsh、bash 或 fish 配置自动输出捕获与搜索快捷键; - 在正确位置创建默认 `config.toml`,已有配置不会被覆盖; - 检测已有 bash、zsh、fish 与 atuin 历史,并逐项询问是否导入; -- 询问新终端该如何启动 recall 的 PTY 代理以自动捕获输出。推荐配置终端模拟器; - 如果终端不支持,再使用 shell 启动文件方式。 +- 在新的交互式 shell 中自动启动 PTY 代理,无需修改终端模拟器设置。 -非交互安装时,可在 `sh` 命令上将 `RECALL_PROXY_SETUP` 设置为 `terminal`、 -`shell` 或 `none`。`RECALL_IMPORT_HISTORY` 可设为 `yes`、`no` 或 `ask` 来控制 -历史迁移;输入不是交互终端时,`ask` 不会阻塞安装。 +默认会自动捕获输出。若只需要搜索快捷键和元数据,可设置 +`RECALL_PROXY_SETUP=hooks`,需要捕获输出时再运行 `recall shell`。旧值 `shell` +和 `none` 分别兼容映射为 `auto` 与 `hooks`;显式 `terminal` 模式仅为已有的 +终端托管配置保留。`RECALL_IMPORT_HISTORY` 可设为 `yes`、`no` 或 `ask`。 ### Windows 一行安装 @@ -49,9 +49,10 @@ irm https://raw.githubusercontent.com/wendaining/recall/master/install.ps1 | iex 脚本会下载最新 Release、校验 SHA-256,并把 `recall.exe` 安装到 `%USERPROFILE%\.local\bin`(可用 `RECALL_INSTALL_DIR` 覆盖),把该目录加入用户 -`PATH`,将 PowerShell 集成追加到 `$PROFILE`,并创建默认 `config.toml`。设置 -`RECALL_NO_MODIFY_PROFILE` 可跳过修改 profile。安装器也会询问是否导入检测到的 -shell 与 atuin 历史;可用 `RECALL_IMPORT_HISTORY=yes|no|ask` 控制。 +`PATH`,为 `$PROFILE` 配置自动输出捕获,并创建默认 `config.toml`。设置 +`RECALL_NO_MODIFY_PROFILE` 可跳过修改 profile,设置 `RECALL_PROXY_SETUP=hooks` +可只安装 hooks。安装器也会询问是否导入检测到的 shell 与 atuin 历史;可用 +`RECALL_IMPORT_HISTORY=yes|no|ask` 控制。 ### 从源码构建 @@ -114,9 +115,25 @@ irm https://raw.githubusercontent.com/wendaining/recall/master/uninstall.ps1 | i > 一行安装脚本会自动完成 shell 集成并创建默认配置。下面的手动步骤主要用于源码 > 构建或自定义安装。 -### 1. Shell 集成 +### 1. 统一 Shell 设置 -如果是从源码构建,请在对应 shell 的启动文件中加入相应的一行: +如果是从源码构建,请运行对应 shell 的设置命令: + +```sh +recall setup zsh # 也可以是 bash、fish、pwsh、powershell +``` + +默认会启用自动输出捕获。recall 在启动文件顶部写入一小段托管的代理启动逻辑, +并在末尾加载集成。外层 shell 会在读取其余配置前交给 PTY 代理;代理中的子 shell +只加载一次用户配置,并在主题、prompt 和 PSReadLine 就绪后安装 hooks。 + +如果只需要搜索快捷键和元数据,不希望自动启动代理: + +```sh +recall setup zsh --mode hooks +``` + +`recall init` 仍作为完全手动配置的底层接口: ```zsh # ~/.zshrc @@ -138,7 +155,7 @@ recall init fish | source recall init pwsh | Out-String | Invoke-Expression ``` -这会安装捕获钩子,以及一个 **Alt+R** 组件:打开 TUI 并把选中的命令插入到提示符。 +集成会安装命令边界钩子,以及一个 **Alt+R** 组件:打开 TUI 并把选中的命令插入到提示符。 按键由配置里的 `[ui].search_key` 决定,详见下文。 #### 设置快捷键 @@ -176,18 +193,17 @@ Mac 键盘没有 `Alt`,对应的是 `Option`(`⌥`)。recall 同时支持 ### 2. 使用 PTY 代理捕获输出 -一行安装脚本会提供两种设置方式。推荐把 recall 配置为终端模拟器的启动命令;如果 -终端没有这项设置,再让安装脚本写入 shell 启动文件。两种方式都会在 recall 的 PTY -代理中启动你原本使用的 shell,以便保存每条命令对应的终端输出。 +安装脚本和 `recall setup` 会自动在 recall 的 PTY 代理中启动原本使用的 shell, +无需配置终端模拟器。在启动新 shell 前设置 `RECALL_PROXY=0`,可以跳过自动捕获, +同时保留搜索和元数据 hooks。 -如果安装时选择暂不设置,或使用源码安装,可以手动启动被包裹的 shell: +在 hooks-only 或完全手动配置中,需要捕获输出时可启动被包裹的 shell: ```sh recall shell ``` -或配置终端模拟器,把它作为 shell 启动,这样每个新窗口都会自动捕获输出。不同 -模拟器的设置项名称不同(`shell`、`command` 等),例如: +已有的终端托管配置可以继续直接启动 recall,但这只是兼容用的高级方式: ``` # 在你的终端模拟器配置文件中 @@ -207,8 +223,6 @@ Windows 上代理通过 ConPTY 运行 shell。未设置 `proxy.shell` 和 `--she 它会沿父进程链检测你启动 `recall shell` 时所用的 shell,再依次回退到 `pwsh`、 `powershell`、`%COMSPEC%`。可用 `--shell` 或 `proxy.shell` 覆盖,例如 `recall shell --shell cmd`。macOS 专用的 `-l` 登录参数不会在 Windows 上传入。 -要让 Windows Terminal 的每个标签页都自动捕获,可在"设置 → 你的配置文件 → -命令行"中填入 `recall shell`。 ### 3. 导入已有历史(可选) @@ -242,6 +256,7 @@ recall import atuin --path /path/to/history.db ```sh recall search --cmd-only # 打印选择结果(供 shell 组件使用) +recall setup [shell] # 配置自动捕获和 shell hooks recall doctor # 诊断配置、数据库和剪贴板 recall prune # 清理超过保留期的输出 recall config path|show|default From 34312f87df756710c8163b16d27862c01034d786 Mon Sep 17 00:00:00 2001 From: wendaining <182899058+wendaining@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:24:26 +0800 Subject: [PATCH 08/15] fix(setup): detect only active shell integrations --- src/commands/setup.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/commands/setup.rs b/src/commands/setup.rs index b7b1c2d..0f6cb73 100644 --- a/src/commands/setup.rs +++ b/src/commands/setup.rs @@ -77,6 +77,11 @@ pub fn run(args: SetupArgs) -> Result<()> { "already current" } ); + if args.mode == SetupMode::Auto { + println!( + "output capture starts automatically in new interactive shells; use --mode hooks to opt out" + ); + } Ok(()) } @@ -372,7 +377,11 @@ fn active_init_count(text: &str) -> usize { text.lines() .filter(|line| { let line = line.trim_start(); - !line.starts_with('#') && line.contains("recall init") + !line.starts_with('#') + && line.contains("recall init ") + && (line.contains("eval") + || line.contains("source") + || line.contains("Invoke-Expression")) }) .count() } @@ -519,6 +528,14 @@ mod tests { assert!(!rendered.contains(INTEGRATION_START)); } + #[test] + fn does_not_treat_mentioned_init_commands_as_integration() { + let original = "echo 'run recall init zsh later'\n"; + let rendered = render_profile(original, "zsh", Path::new("/bin"), SetupMode::Auto); + assert_eq!(active_init_count(&rendered), 1); + assert!(rendered.contains(INTEGRATION_START)); + } + #[test] fn removes_legacy_and_current_managed_blocks() { let input = format!( From 4dd4e2c38297c623c4b5085c44c3c33eff0d168c Mon Sep 17 00:00:00 2001 From: wendaining <182899058+wendaining@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:25:49 +0800 Subject: [PATCH 09/15] fix(setup): preserve profile end-of-file newlines --- src/commands/setup.rs | 51 ++++++++++++++++++++++++++++++++++--------- uninstall.ps1 | 1 + uninstall.sh | 3 ++- 3 files changed, 44 insertions(+), 11 deletions(-) diff --git a/src/commands/setup.rs b/src/commands/setup.rs index 0f6cb73..1b4c908 100644 --- a/src/commands/setup.rs +++ b/src/commands/setup.rs @@ -12,6 +12,8 @@ pub(crate) const BOOTSTRAP_START: &str = "# >>> recall setup bootstrap >>>"; pub(crate) const BOOTSTRAP_END: &str = "# <<< recall setup bootstrap <<<"; pub(crate) const INTEGRATION_START: &str = "# >>> recall setup integration >>>"; pub(crate) const INTEGRATION_END: &str = "# <<< recall setup integration <<<"; +const INTEGRATION_NO_EOL_START: &str = "# >>> recall setup integration-no-eol >>>"; +const INTEGRATION_NO_EOL_END: &str = "# <<< recall setup integration-no-eol <<<"; const LEGACY_START: &str = "# >>> recall installer >>>"; const LEGACY_END: &str = "# <<< recall installer <<<"; @@ -254,7 +256,7 @@ fn encode_text(text: &str, encoding: TextEncoding, newline: &str) -> Vec { } fn remove_managed_blocks(text: &str) -> Result { - let mut output = Vec::new(); + let mut output = String::new(); let mut expected_end: Option<&str> = None; for line in text.split_inclusive('\n') { @@ -268,41 +270,49 @@ fn remove_managed_blocks(text: &str) -> Result { continue; } if let Some(end) = is_start_marker(marker) { + if marker == INTEGRATION_NO_EOL_START && output.ends_with('\n') { + output.pop(); + } expected_end = Some(end); } else if is_end_marker(marker) { bail!("malformed recall setup markers"); } else { - output.push(line); + output.push_str(line); } } if expected_end.is_some() { bail!("malformed recall setup markers"); } - Ok(output.concat()) + Ok(output) } fn is_start_marker(line: &str) -> Option<&'static str> { match line { BOOTSTRAP_START => Some(BOOTSTRAP_END), INTEGRATION_START => Some(INTEGRATION_END), + INTEGRATION_NO_EOL_START => Some(INTEGRATION_NO_EOL_END), LEGACY_START => Some(LEGACY_END), _ => None, } } fn is_end_marker(line: &str) -> bool { - matches!(line, BOOTSTRAP_END | INTEGRATION_END | LEGACY_END) + matches!( + line, + BOOTSTRAP_END | INTEGRATION_END | INTEGRATION_NO_EOL_END | LEGACY_END + ) } fn render_profile(text: &str, shell: &str, binary_dir: &Path, mode: SetupMode) -> String { let mut output = String::new(); output.push_str(&render_bootstrap(shell, binary_dir, mode)); output.push_str(text); - if !output.is_empty() && !output.ends_with('\n') { - output.push('\n'); - } if active_init_count(text) == 0 { - output.push_str(&render_integration(shell)); + let preserve_no_eol = !text.is_empty() && !text.ends_with('\n'); + if preserve_no_eol { + output.push('\n'); + } + output.push_str(&render_integration(shell, preserve_no_eol)); } output } @@ -361,7 +371,7 @@ fn render_bootstrap(shell: &str, binary_dir: &Path, mode: SetupMode) -> String { } } -fn render_integration(shell: &str) -> String { +fn render_integration(shell: &str, preserve_no_eol: bool) -> String { let command = match shell { "zsh" | "bash" => format!("eval \"$(command recall init {shell})\""), "fish" => "command recall init fish | source".to_string(), @@ -370,7 +380,12 @@ fn render_integration(shell: &str) -> String { } _ => unreachable!("setup only accepts integrated shells"), }; - format!("{INTEGRATION_START}\n{command}\n{INTEGRATION_END}\n") + let (start, end) = if preserve_no_eol { + (INTEGRATION_NO_EOL_START, INTEGRATION_NO_EOL_END) + } else { + (INTEGRATION_START, INTEGRATION_END) + }; + format!("{start}\n{command}\n{end}\n") } fn active_init_count(text: &str) -> usize { @@ -604,6 +619,22 @@ mod tests { fs::remove_dir_all(dir).unwrap(); } + #[test] + fn preserves_a_missing_final_newline() { + let (path, dir) = temp_profile("no-final-newline"); + fs::write(&path, "echo user").unwrap(); + let shell = Shell::from_name("bash").unwrap(); + + update_profile(&path, shell, SetupMode::Auto, false).unwrap(); + let once = fs::read(&path).unwrap(); + assert!(!update_profile(&path, shell, SetupMode::Auto, false).unwrap()); + assert_eq!(fs::read(&path).unwrap(), once); + + update_profile(&path, shell, SetupMode::Auto, true).unwrap(); + assert_eq!(fs::read(&path).unwrap(), b"echo user"); + fs::remove_dir_all(dir).unwrap(); + } + #[test] fn preserves_utf16_and_crlf() { let (path, dir) = temp_profile("utf16"); diff --git a/uninstall.ps1 b/uninstall.ps1 index 40e943a..ae98741 100644 --- a/uninstall.ps1 +++ b/uninstall.ps1 @@ -11,6 +11,7 @@ $markerPairs = @{ '# >>> recall installer >>>' = '# <<< recall installer <<<' '# >>> recall setup bootstrap >>>' = '# <<< recall setup bootstrap <<<' '# >>> recall setup integration >>>' = '# <<< recall setup integration <<<' + '# >>> recall setup integration-no-eol >>>' = '# <<< recall setup integration-no-eol <<<' } function Say($message) { Write-Host $message } diff --git a/uninstall.sh b/uninstall.sh index 09d453e..676cec6 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -10,7 +10,7 @@ remove_managed_setup() { file=$1 shell_name=$2 [ -f "$file" ] || return 0 - grep -Eq '^# >>> recall (installer|setup (bootstrap|integration)) >>>$' "$file" \ + grep -Eq '^# >>> recall (installer|setup (bootstrap|integration|integration-no-eol)) >>>$' "$file" \ || return 0 if command -v recall >/dev/null 2>&1 \ && recall setup "$shell_name" --profile "$file" --remove >/dev/null 2>&1; then @@ -23,6 +23,7 @@ remove_managed_setup() { sed -e '/^# >>> recall installer >>>$/,/^# <<< recall installer <<<$/{d;}' \ -e '/^# >>> recall setup bootstrap >>>$/,/^# <<< recall setup bootstrap <<<$/{d;}' \ -e '/^# >>> recall setup integration >>>$/,/^# <<< recall setup integration <<<$/{d;}' \ + -e '/^# >>> recall setup integration-no-eol >>>$/,/^# <<< recall setup integration-no-eol <<<$/{d;}' \ "$file" > "$temp_file" cat "$temp_file" > "$file" rm -f "$temp_file" From c261c37e61475dda1924adcbb8df86df720626b6 Mon Sep 17 00:00:00 2001 From: wendaining <182899058+wendaining@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:26:37 +0800 Subject: [PATCH 10/15] docs: complete unified setup command reference --- README.md | 3 +++ README.zh-CN.md | 3 +++ src/cli.rs | 2 +- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 18b5971..89a3bbe 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,9 @@ For search and metadata hooks without automatic capture, use: recall setup zsh --mode hooks ``` +Run `recall setup zsh --remove` to remove only the blocks managed by recall. +User-written setup remains untouched. + `recall init` remains the low-level option for fully manual setups: ```zsh diff --git a/README.zh-CN.md b/README.zh-CN.md index 00bdfeb..23f16a1 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -133,6 +133,9 @@ recall setup zsh # 也可以是 bash、fish、pwsh、powershell recall setup zsh --mode hooks ``` +运行 `recall setup zsh --remove` 只会移除 recall 托管的配置块,用户手写的配置 +保持不变。 + `recall init` 仍作为完全手动配置的底层接口: ```zsh diff --git a/src/cli.rs b/src/cli.rs index 5fc950e..e3c6620 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -24,7 +24,7 @@ pub enum Command { Record(RecordArgs), /// Import metadata from another history source. Import(ImportArgs), - /// Diagnose configuration, databases and clipboard backends. + /// Diagnose configuration, shell setup, databases and clipboard backends. Doctor, /// Drop stored output older than the retention window. Prune, From df188267f5d3f2a35b1c8052bbada8e02c76f928 Mon Sep 17 00:00:00 2001 From: wendaining <182899058+wendaining@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:45:34 +0800 Subject: [PATCH 11/15] feat(dev): add isolated local installer --- scripts/dev-install.sh | 191 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100755 scripts/dev-install.sh diff --git a/scripts/dev-install.sh b/scripts/dev-install.sh new file mode 100755 index 0000000..35712bb --- /dev/null +++ b/scripts/dev-install.sh @@ -0,0 +1,191 @@ +#!/bin/sh + +set -eu + +say() { + printf '%s\n' "$*" +} + +die() { + printf 'recall dev installer: %s\n' "$*" >&2 + exit 1 +} + +require() { + command -v "$1" >/dev/null 2>&1 || die "required command not found: $1" +} + +usage() { + cat <<'EOF' +Build and install the current recall checkout for local testing. + +Usage: scripts/dev-install.sh [--user] [--mode auto|hooks] [--shell SHELL] + +Options: + --user Install into the real user environment instead of a sandbox. + --mode MODE Configure automatic capture (auto) or hooks only (hooks). + --shell SHELL Configure zsh, bash, or fish; defaults to $SHELL. + -h, --help Show this help. + +The default sandbox is created under target/dev-install.* and does not modify +the real user profile, configuration, or history database. +EOF +} + +user_install=0 +setup_mode=auto +shell_path=${SHELL:-} +shell_name=${shell_path##*/} + +while [ "$#" -gt 0 ]; do + case "$1" in + --user) + user_install=1 + ;; + --mode) + [ "$#" -ge 2 ] || die "--mode requires auto or hooks" + setup_mode=$2 + shift + ;; + --mode=*) + setup_mode=${1#*=} + ;; + --shell) + [ "$#" -ge 2 ] || die "--shell requires zsh, bash, or fish" + shell_name=${2##*/} + shift + ;; + --shell=*) + shell_name=${1#*=} + shell_name=${shell_name##*/} + ;; + -h | --help) + usage + exit 0 + ;; + *) + die "unknown option: $1" + ;; + esac + shift +done + +case "$setup_mode" in + auto | hooks) ;; + *) die "--mode must be auto or hooks" ;; +esac + +case "$shell_name" in + zsh | bash | fish) ;; + "") die "SHELL is not set; pass --shell zsh, bash, or fish" ;; + *) die "unsupported shell: $shell_name" ;; +esac + +require cargo +require git +require install +require mkdir +require mktemp +require mv +require dirname + +script_dir=$(CDPATH= cd "$(dirname "$0")" && pwd) +repo_root=$(dirname "$script_dir") +[ -f "$repo_root/Cargo.toml" ] || die "run this script from a recall checkout" + +target_dir="$repo_root/target" +say "Building the current checkout in release mode..." +CARGO_TARGET_DIR="$target_dir" cargo build \ + --release --locked --manifest-path "$repo_root/Cargo.toml" + +built_binary="$target_dir/release/recall" +[ -f "$built_binary" ] || die "release build did not produce $built_binary" + +if [ "$user_install" -eq 1 ]; then + [ -n "${HOME:-}" ] || die "HOME is not set" + install_home=$HOME + install_dir=${RECALL_INSTALL_DIR:-$HOME/.local/bin} + config_home=${XDG_CONFIG_HOME:-$HOME/.config} + data_home=${XDG_DATA_HOME:-$HOME/.local/share} + recall_config=${RECALL_CONFIG:-$config_home/recall/config.toml} + zdotdir=${ZDOTDIR:-$HOME} + sandbox_root="" +else + mkdir -p "$target_dir" + sandbox_root=$(mktemp -d "$target_dir/dev-install.XXXXXX") \ + || die "failed to create a sandbox" + install_home="$sandbox_root/home" + install_dir="$sandbox_root/bin" + config_home="$install_home/.config" + data_home="$install_home/.local/share" + recall_config="$config_home/recall/config.toml" + zdotdir=$install_home +fi + +mkdir -p "$install_home" "$install_dir" "$config_home" "$data_home" +destination="$install_dir/recall" +staged_binary="$install_dir/.recall.dev-install.$$" +install -m 755 "$built_binary" "$staged_binary" +mv -f "$staged_binary" "$destination" + +run_recall() { + HOME="$install_home" \ + ZDOTDIR="$zdotdir" \ + XDG_CONFIG_HOME="$config_home" \ + XDG_DATA_HOME="$data_home" \ + RECALL_CONFIG="$recall_config" \ + PATH="$install_dir:${PATH:-}" \ + "$destination" "$@" +} + +run_recall setup "$shell_name" --mode "$setup_mode" + +config_path=$(run_recall config path) +config_created=0 +if [ ! -e "$config_path" ]; then + config_dir=$(dirname "$config_path") + mkdir -p "$config_dir" + config_tmp="$config_dir/.config.toml.dev-install.$$" + run_recall config default > "$config_tmp" + install -m 644 "$config_tmp" "$config_path" + rm -f "$config_tmp" + config_created=1 +fi + +revision=$(git -C "$repo_root" rev-parse --short HEAD) +if [ -n "$(git -C "$repo_root" status --porcelain)" ]; then + revision="$revision-dirty" +fi + +say "" +say "recall development build is ready." +say "" +say "Source: local checkout $revision" +say "Installed: $(run_recall --version) at $destination" +say "Shell setup: $shell_name ($setup_mode)" +if [ "$config_created" -eq 1 ]; then + say "Config: created $config_path" +else + say "Config: kept existing $config_path" +fi +say "History: not imported" + +if [ "$user_install" -eq 1 ]; then + say "" + say "Open a new terminal to try the development build." + say "Review $config_path before capturing sensitive commands." +else + shell_executable=$(command -v "$shell_name" || true) + [ -n "$shell_executable" ] || shell_executable=$shell_name + case "${TERM:-}" in + "" | dumb) test_term=xterm-256color ;; + *) test_term=$TERM ;; + esac + say "Sandbox: $sandbox_root" + say "" + say "Start an isolated shell with:" + say " env HOME=\"$install_home\" ZDOTDIR=\"$zdotdir\" XDG_CONFIG_HOME=\"$config_home\" XDG_DATA_HOME=\"$data_home\" RECALL_CONFIG=\"$recall_config\" PATH=\"$install_dir:\$PATH\" SHELL=\"$shell_executable\" TERM=\"$test_term\" \"$shell_executable\" -i" + say "" + say "Remove the sandbox when finished:" + say " rm -rf \"$sandbox_root\"" +fi From 1ad677245a9a11f7b60369aa7b61df567da80c4f Mon Sep 17 00:00:00 2001 From: wendaining <182899058+wendaining@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:45:57 +0800 Subject: [PATCH 12/15] docs: document local installer workflow --- AGENTS.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 8ec57f4..014b46b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,6 +37,40 @@ cp target/release/recall ~/.local/bin/recall rm -f ~/.local/bin/recall.old ``` +### Testing installation changes locally + +Use the developer-only installer to build the current checkout and exercise the +post-download installation flow: + +```sh +./scripts/dev-install.sh +``` + +The default mode creates an isolated home, binary directory, configuration, and +data directory under `target/dev-install.*`. It prints the command for starting +an interactive shell in that sandbox. The sandbox must not read or modify the +real user profile, Recall configuration, or history database. + +Use `--user` only when a real local installation is intentional: + +```sh +./scripts/dev-install.sh --user +./scripts/dev-install.sh --user --mode hooks +``` + +`--user` installs the current release build into +`${RECALL_INSTALL_DIR:-$HOME/.local/bin}` and configures the current shell. The +developer installer never imports history. It supports zsh, bash, and fish on +Unix; select one explicitly with `--shell` when `$SHELL` is not suitable. + +Keep `scripts/dev-install.sh` independent from the production `install.sh`: the +two scripts must not call or source each other. Shared installation behavior +belongs in the `recall setup` and `recall config` Rust commands rather than +duplicated shell fragments. The developer installer validates a build from the +current checkout, while GitHub API access, release downloads, and checksum +verification remain responsibilities of the production installer and release +CI. + ## Architecture ``` From 85d7d3d45a564d6726349fe1f2e80a8b6fb38cf2 Mon Sep 17 00:00:00 2001 From: wendaining <182899058+wendaining@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:53:10 +0800 Subject: [PATCH 13/15] fix(dev): exercise the production installer locally --- scripts/dev-install.sh | 229 +++++++++++++++++++++++++---------------- 1 file changed, 143 insertions(+), 86 deletions(-) diff --git a/scripts/dev-install.sh b/scripts/dev-install.sh index 35712bb..c56dd43 100755 --- a/scripts/dev-install.sh +++ b/scripts/dev-install.sh @@ -17,25 +17,25 @@ require() { usage() { cat <<'EOF' -Build and install the current recall checkout for local testing. +Run the production installer with a release built from the current checkout. Usage: scripts/dev-install.sh [--user] [--mode auto|hooks] [--shell SHELL] Options: - --user Install into the real user environment instead of a sandbox. - --mode MODE Configure automatic capture (auto) or hooks only (hooks). - --shell SHELL Configure zsh, bash, or fish; defaults to $SHELL. + --user Use the real user environment instead of an isolated sandbox. + --mode MODE Pass auto or hooks to the production installer. + --shell SHELL Test with a specific zsh, bash, or fish executable. -h, --help Show this help. -The default sandbox is created under target/dev-install.* and does not modify -the real user profile, configuration, or history database. +The script builds a local release archive, substitutes it for GitHub downloads, +and then runs install.sh unchanged. By default, all installed files and shell +profiles live under target/dev-install.*. EOF } user_install=0 -setup_mode=auto +setup_mode=${RECALL_PROXY_SETUP:-auto} shell_path=${SHELL:-} -shell_name=${shell_path##*/} while [ "$#" -gt 0 ]; do case "$1" in @@ -51,13 +51,12 @@ while [ "$#" -gt 0 ]; do setup_mode=${1#*=} ;; --shell) - [ "$#" -ge 2 ] || die "--shell requires zsh, bash, or fish" - shell_name=${2##*/} + [ "$#" -ge 2 ] || die "--shell requires an executable" + shell_path=$2 shift ;; --shell=*) - shell_name=${1#*=} - shell_name=${shell_name##*/} + shell_path=${1#*=} ;; -h | --help) usage @@ -71,27 +70,58 @@ while [ "$#" -gt 0 ]; do done case "$setup_mode" in - auto | hooks) ;; - *) die "--mode must be auto or hooks" ;; + auto | hooks | shell | none | terminal) ;; + *) die "--mode must be auto, hooks, or a supported compatibility alias" ;; esac -case "$shell_name" in - zsh | bash | fish) ;; - "") die "SHELL is not set; pass --shell zsh, bash, or fish" ;; - *) die "unsupported shell: $shell_name" ;; -esac +if [ -n "$shell_path" ]; then + case "$shell_path" in + */*) [ -x "$shell_path" ] || die "shell is not executable: $shell_path" ;; + *) + resolved_shell=$(command -v "$shell_path" || true) + [ -n "$resolved_shell" ] || die "shell not found: $shell_path" + shell_path=$resolved_shell + ;; + esac +fi require cargo require git require install -require mkdir +require tar +require uname require mktemp -require mv +require mkdir require dirname +require cp +require chmod script_dir=$(CDPATH= cd "$(dirname "$0")" && pwd) repo_root=$(dirname "$script_dir") [ -f "$repo_root/Cargo.toml" ] || die "run this script from a recall checkout" +[ -f "$repo_root/install.sh" ] || die "production installer not found" + +os=$(uname -s) +arch=$(uname -m) +case "$os" in + Linux) + case "$arch" in + x86_64 | amd64) target=x86_64-unknown-linux-gnu ;; + aarch64 | arm64) target=aarch64-unknown-linux-gnu ;; + *) die "unsupported Linux architecture: $arch" ;; + esac + require sha256sum + ;; + Darwin) + case "$arch" in + x86_64 | amd64) target=x86_64-apple-darwin ;; + aarch64 | arm64) target=aarch64-apple-darwin ;; + *) die "unsupported macOS architecture: $arch" ;; + esac + require shasum + ;; + *) die "unsupported operating system: $os" ;; +esac target_dir="$repo_root/target" say "Building the current checkout in release mode..." @@ -101,90 +131,117 @@ CARGO_TARGET_DIR="$target_dir" cargo build \ built_binary="$target_dir/release/recall" [ -f "$built_binary" ] || die "release build did not produce $built_binary" +revision=$(git -C "$repo_root" rev-parse --short HEAD) +if [ -n "$(git -C "$repo_root" status --porcelain)" ]; then + revision="$revision-dirty" +fi +tag="local-$revision" +archive_name="recall-$tag-$target.tar.gz" + +mkdir -p "$target_dir" +fixture_dir=$(mktemp -d "$target_dir/dev-release.XXXXXX") \ + || die "failed to create a local release fixture" +cleanup() { + rm -rf "$fixture_dir" +} +trap cleanup EXIT HUP INT TERM + +mkdir -p "$fixture_dir/payload" "$fixture_dir/bin" +install -m 755 "$built_binary" "$fixture_dir/payload/recall" +tar -czf "$fixture_dir/$archive_name" -C "$fixture_dir/payload" recall +case "$os" in + Linux) + (cd "$fixture_dir" && sha256sum "$archive_name" > SHA256SUMS) + ;; + Darwin) + (cd "$fixture_dir" && shasum -a 256 "$archive_name" > SHA256SUMS) + ;; +esac + +cat > "$fixture_dir/bin/curl" <<'EOF' +#!/bin/sh + +set -eu + +output="" +url="" +while [ "$#" -gt 0 ]; do + case "$1" in + -o | --output) + [ "$#" -ge 2 ] || exit 2 + output=$2 + shift + ;; + -H | --header | --retry) + [ "$#" -ge 2 ] || exit 2 + shift + ;; + -*) ;; + *) url=$1 ;; + esac + shift +done + +case "$url" in + https://api.github.com/repos/wendaining/recall/releases/latest) + printf '{\n "tag_name": "%s"\n}\n' "$RECALL_DEV_TAG" + ;; + */"$RECALL_DEV_ARCHIVE_NAME") + [ -n "$output" ] || exit 2 + cp "$RECALL_DEV_ARCHIVE" "$output" + ;; + */SHA256SUMS) + [ -n "$output" ] || exit 2 + cp "$RECALL_DEV_SUMS" "$output" + ;; + *) + printf 'recall dev installer: unexpected download URL: %s\n' "$url" >&2 + exit 2 + ;; +esac +EOF +chmod 755 "$fixture_dir/bin/curl" + +export RECALL_DEV_TAG=$tag +export RECALL_DEV_ARCHIVE_NAME=$archive_name +export RECALL_DEV_ARCHIVE="$fixture_dir/$archive_name" +export RECALL_DEV_SUMS="$fixture_dir/SHA256SUMS" +export RECALL_PROXY_SETUP=$setup_mode +export SHELL=$shell_path + if [ "$user_install" -eq 1 ]; then - [ -n "${HOME:-}" ] || die "HOME is not set" - install_home=$HOME - install_dir=${RECALL_INSTALL_DIR:-$HOME/.local/bin} - config_home=${XDG_CONFIG_HOME:-$HOME/.config} - data_home=${XDG_DATA_HOME:-$HOME/.local/share} - recall_config=${RECALL_CONFIG:-$config_home/recall/config.toml} - zdotdir=${ZDOTDIR:-$HOME} - sandbox_root="" + PATH="$fixture_dir/bin:${PATH:-}" sh "$repo_root/install.sh" else - mkdir -p "$target_dir" sandbox_root=$(mktemp -d "$target_dir/dev-install.XXXXXX") \ - || die "failed to create a sandbox" + || die "failed to create an installation sandbox" install_home="$sandbox_root/home" install_dir="$sandbox_root/bin" config_home="$install_home/.config" data_home="$install_home/.local/share" recall_config="$config_home/recall/config.toml" - zdotdir=$install_home -fi + mkdir -p "$install_home" -mkdir -p "$install_home" "$install_dir" "$config_home" "$data_home" -destination="$install_dir/recall" -staged_binary="$install_dir/.recall.dev-install.$$" -install -m 755 "$built_binary" "$staged_binary" -mv -f "$staged_binary" "$destination" - -run_recall() { HOME="$install_home" \ - ZDOTDIR="$zdotdir" \ + ZDOTDIR="$install_home" \ XDG_CONFIG_HOME="$config_home" \ XDG_DATA_HOME="$data_home" \ RECALL_CONFIG="$recall_config" \ - PATH="$install_dir:${PATH:-}" \ - "$destination" "$@" -} - -run_recall setup "$shell_name" --mode "$setup_mode" - -config_path=$(run_recall config path) -config_created=0 -if [ ! -e "$config_path" ]; then - config_dir=$(dirname "$config_path") - mkdir -p "$config_dir" - config_tmp="$config_dir/.config.toml.dev-install.$$" - run_recall config default > "$config_tmp" - install -m 644 "$config_tmp" "$config_path" - rm -f "$config_tmp" - config_created=1 -fi - -revision=$(git -C "$repo_root" rev-parse --short HEAD) -if [ -n "$(git -C "$repo_root" status --porcelain)" ]; then - revision="$revision-dirty" -fi + RECALL_INSTALL_DIR="$install_dir" \ + PATH="$fixture_dir/bin:${PATH:-}" \ + sh "$repo_root/install.sh" -say "" -say "recall development build is ready." -say "" -say "Source: local checkout $revision" -say "Installed: $(run_recall --version) at $destination" -say "Shell setup: $shell_name ($setup_mode)" -if [ "$config_created" -eq 1 ]; then - say "Config: created $config_path" -else - say "Config: kept existing $config_path" -fi -say "History: not imported" - -if [ "$user_install" -eq 1 ]; then - say "" - say "Open a new terminal to try the development build." - say "Review $config_path before capturing sensitive commands." -else - shell_executable=$(command -v "$shell_name" || true) - [ -n "$shell_executable" ] || shell_executable=$shell_name case "${TERM:-}" in "" | dumb) test_term=xterm-256color ;; *) test_term=$TERM ;; esac - say "Sandbox: $sandbox_root" say "" - say "Start an isolated shell with:" - say " env HOME=\"$install_home\" ZDOTDIR=\"$zdotdir\" XDG_CONFIG_HOME=\"$config_home\" XDG_DATA_HOME=\"$data_home\" RECALL_CONFIG=\"$recall_config\" PATH=\"$install_dir:\$PATH\" SHELL=\"$shell_executable\" TERM=\"$test_term\" \"$shell_executable\" -i" + say "Development source: local checkout $revision (no GitHub requests)" + say "Sandbox: $sandbox_root" + if [ -n "$shell_path" ]; then + say "" + say "Start an isolated shell with:" + say " env HOME=\"$install_home\" ZDOTDIR=\"$install_home\" XDG_CONFIG_HOME=\"$config_home\" XDG_DATA_HOME=\"$data_home\" RECALL_CONFIG=\"$recall_config\" PATH=\"$install_dir:\$PATH\" SHELL=\"$shell_path\" TERM=\"$test_term\" \"$shell_path\" -i" + fi say "" say "Remove the sandbox when finished:" say " rm -rf \"$sandbox_root\"" From a90e81b55f6d8c2fb1a4164b92cce2777e08bfdc Mon Sep 17 00:00:00 2001 From: wendaining <182899058+wendaining@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:53:34 +0800 Subject: [PATCH 14/15] docs: clarify production installer testing --- AGENTS.md | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 014b46b..cdf1991 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,8 +39,8 @@ rm -f ~/.local/bin/recall.old ### Testing installation changes locally -Use the developer-only installer to build the current checkout and exercise the -post-download installation flow: +Use the developer-only harness to build the current checkout, package it like a +release, and run the production installer against that local fixture: ```sh ./scripts/dev-install.sh @@ -58,18 +58,22 @@ Use `--user` only when a real local installation is intentional: ./scripts/dev-install.sh --user --mode hooks ``` -`--user` installs the current release build into -`${RECALL_INSTALL_DIR:-$HOME/.local/bin}` and configures the current shell. The -developer installer never imports history. It supports zsh, bash, and fish on -Unix; select one explicitly with `--shell` when `$SHELL` is not suitable. - -Keep `scripts/dev-install.sh` independent from the production `install.sh`: the -two scripts must not call or source each other. Shared installation behavior -belongs in the `recall setup` and `recall config` Rust commands rather than -duplicated shell fragments. The developer installer validates a build from the -current checkout, while GitHub API access, release downloads, and checksum -verification remain responsibilities of the production installer and release -CI. +`--user` gives the production installer the real user environment, including +its normal destination selection, configuration handling, search-key choice, +and history-import prompts. `RECALL_INSTALL_DIR` and the other documented +installer environment variables continue to work. The harness supports the +Unix installer; select a shell explicitly with `--shell` when `$SHELL` is not +suitable. + +Keep the production `install.sh` independent from development concerns: do not +add local-build switches to it or make it source the developer harness. The +harness intentionally invokes the unchanged production installer with a local +GitHub API, archive, and checksum substitute. This exercises the same logo, +archive extraction, checksum verification, setup, configuration, prompts, and +summary that users see without making GitHub requests. Shared post-install +behavior belongs in the `recall setup` and `recall config` Rust commands rather +than duplicated shell fragments. Live GitHub networking and published release +assets remain responsibilities of release CI. ## Architecture From 9cd9c68a0a2b1733c3ca1dde0ef06297245a3d0e Mon Sep 17 00:00:00 2001 From: wendaining <182899058+wendaining@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:59:41 +0800 Subject: [PATCH 15/15] style(install): add terminal-aware ANSI output --- install.sh | 97 ++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 76 insertions(+), 21 deletions(-) diff --git a/install.sh b/install.sh index 160ed93..00f826b 100755 --- a/install.sh +++ b/install.sh @@ -4,12 +4,64 @@ set -eu repo="wendaining/recall" +style_reset="" +style_bold="" +style_dim="" +style_cyan="" +style_green="" +style_yellow="" +style_error_red="" +style_error_reset="" +escape=$(printf '\033') +if [ -t 1 ] \ + && [ "${TERM:-dumb}" != dumb ] \ + && [ -z "${NO_COLOR:-}" ] \ + && [ "${CLICOLOR:-1}" != 0 ]; then + style_reset="${escape}[0m" + style_bold="${escape}[1m" + style_dim="${escape}[2m" + style_cyan="${escape}[36m" + style_green="${escape}[32m" + style_yellow="${escape}[33m" +fi +if [ -t 2 ] \ + && [ "${TERM:-dumb}" != dumb ] \ + && [ -z "${NO_COLOR:-}" ] \ + && [ "${CLICOLOR:-1}" != 0 ]; then + style_error_red="${escape}[31m" + style_error_reset="${escape}[0m" +fi + say() { printf '%s\n' "$*" } +step() { + printf '%s›%s %s%s%s\n' \ + "$style_cyan" "$style_reset" "$style_bold" "$*" "$style_reset" +} + +success() { + printf '%s✓%s %s\n' "$style_green" "$style_reset" "$*" +} + +warning() { + printf '%s!%s %s\n' "$style_yellow" "$style_reset" "$*" +} + +summary() { + summary_label=$1 + shift + printf ' %s%-13s%s %s\n' "$style_cyan" "$summary_label" "$style_reset" "$*" +} + +muted() { + printf '%s%s%s\n' "$style_dim" "$*" "$style_reset" +} + die() { - printf 'recall installer: %s\n' "$*" >&2 + printf '%s✗%s recall installer: %s\n' \ + "$style_error_red" "$style_error_reset" "$*" >&2 exit 1 } @@ -31,6 +83,7 @@ require dirname require cat show_logo() { + printf '%s' "$style_cyan" cat <<'EOF' .-=================-. .-' `-. @@ -44,6 +97,7 @@ show_logo() { `-. _.-' `-=================-' EOF + printf '%s' "$style_reset" } show_logo @@ -91,7 +145,7 @@ cleanup() { } trap cleanup EXIT HUP INT TERM -say "Installing recall $tag for $target..." +step "Installing recall $tag for $target..." curl -fL --retry 3 --progress-bar \ -o "$temp_dir/$archive" "$download_url/$archive" \ || die "failed to download $archive" @@ -108,7 +162,7 @@ case "$os" in Darwin) actual=$(shasum -a 256 "$temp_dir/$archive" | awk '{print $1}') ;; esac [ "$actual" = "$expected" ] || die "checksum mismatch for $archive" -say "Checksum verified." +success "Checksum verified." tar -xzf "$temp_dir/$archive" -C "$temp_dir" [ -f "$temp_dir/recall" ] || die "archive does not contain the recall binary" @@ -243,12 +297,12 @@ offer_history_import() { if "$destination" import atuin --path "$import_path"; then say "Imported $import_label." else - say "Warning: could not import $import_label; installation will continue." + warning "Could not import $import_label; installation will continue." fi elif "$destination" import history "$import_shell" --path "$import_path"; then say "Imported $import_label." else - say "Warning: could not import $import_label; installation will continue." + warning "Could not import $import_label; installation will continue." fi } @@ -302,46 +356,47 @@ fi offer_detected_history say "" -say "recall is ready." +success "recall is ready." say "" -say "Installed: $("$destination" --version) at $destination" +summary "Installed:" "$("$destination" --version) at $destination" if [ "$shell_configured" -eq 1 ]; then - say "Shell setup: $profile_path" + summary "Shell setup:" "$profile_path" else - say "Shell setup: skipped (supported shells: zsh, bash, fish)" + summary "Shell setup:" "skipped (supported shells: zsh, bash, fish)" fi if [ "$config_created" -eq 1 ]; then - say "Config: created $config_path" + summary "Config:" "created $config_path" else - say "Config: kept existing $config_path" + summary "Config:" "kept existing $config_path" fi if [ "$shell_configured" -eq 0 ]; then - say "Output: not configured; run 'recall shell' manually" + summary "Output:" "not configured; run 'recall shell' manually" else case "$proxy_setup" in auto) - say "Output: automatic capture in new interactive shells" - say "Alternative: run 'recall setup $shell_name --mode hooks' for hooks only" + summary "Output:" "automatic capture in new interactive shells" + summary "Alternative:" "run 'recall setup $shell_name --mode hooks' for hooks only" ;; hooks) - say "Output: hooks only; run 'recall shell' when capture is needed" + summary "Output:" "hooks only; run 'recall shell' when capture is needed" ;; terminal) - say "Output: terminal-managed compatibility mode" + summary "Output:" "terminal-managed compatibility mode" say " set your terminal's startup command to:" say " $destination shell" ;; esac fi say "" -say "Open a new terminal to activate the setup. Run 'recall' or press" +step "Open a new terminal to activate the setup." +say "Run 'recall' or press" say "Alt+R to browse history; press F1 inside recall to see all shortcuts." if [ "$os" = Darwin ] && [ -z "$search_key" ]; then say "" - say "macOS note: if Option+R types '®' instead of opening recall, enable" + warning "macOS: if Option+R types '®' instead of opening recall, enable" say "\"Use Option as Meta key\" in your terminal (Terminal.app, iTerm2, Ghostty)." fi -say "Search key: ${search_key:-alt-r} (edit [ui].search_key in the config to change it)" +summary "Search key:" "${search_key:-alt-r} (edit [ui].search_key in the config to change it)" say "" -say "Before capturing sensitive work, review: $config_path" -say "Uninstall: curl -fsSL https://raw.githubusercontent.com/$repo/master/uninstall.sh | sh" +warning "Before capturing sensitive work, review: $config_path" +muted "Uninstall: curl -fsSL https://raw.githubusercontent.com/$repo/master/uninstall.sh | sh"