From 8a4c6ee06a808551ea5c74e3d7042db39c33ea38 Mon Sep 17 00:00:00 2001 From: Dario Filipaj Date: Mon, 23 Mar 2026 13:12:06 +0100 Subject: [PATCH 01/29] feat(tb-session): scaffold crate with workspace integration Co-Authored-By: Claude Sonnet 4.6 --- Cargo.toml | 3 +- crates/tb-session/Cargo.toml | 32 +++++++++++++++++ crates/tb-session/SKILL.md | 16 +++++++++ crates/tb-session/src/error.rs | 25 ++++++++++++++ crates/tb-session/src/lib.rs | 1 + crates/tb-session/src/main.rs | 63 ++++++++++++++++++++++++++++++++++ 6 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 crates/tb-session/Cargo.toml create mode 100644 crates/tb-session/SKILL.md create mode 100644 crates/tb-session/src/error.rs create mode 100644 crates/tb-session/src/lib.rs create mode 100644 crates/tb-session/src/main.rs diff --git a/Cargo.toml b/Cargo.toml index 0d3738c..648458f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,7 +33,7 @@ indicatif = "0.18" inquire = "0.7" # Database -rusqlite = { version = "0.38", features = ["bundled"] } +rusqlite = { version = "0.38", features = ["bundled", "modern_sqlite"] } # Error handling thiserror = "2.0" @@ -44,6 +44,7 @@ tb-lf = { path = "crates/tb-lf" } tb-sem = { path = "crates/tb-sem" } tb-prod = { path = "crates/tb-prod" } tb-bug = { path = "crates/tb-bug" } +tb-session = { path = "crates/tb-session" } # Dev/test (also in workspace.dependencies so crates can inherit them) assert_cmd = "2" diff --git a/crates/tb-session/Cargo.toml b/crates/tb-session/Cargo.toml new file mode 100644 index 0000000..6f3bb47 --- /dev/null +++ b/crates/tb-session/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "tb-session" +version = "0.1.0" +edition = "2024" +description = "Claude Code session search CLI" +authors.workspace = true +license.workspace = true +repository.workspace = true + +[[bin]] +name = "tb-session" +path = "src/main.rs" + +[lib] +doctest = false + +[dependencies] +toolbox-core = { workspace = true, features = ["version-check"] } +clap.workspace = true +serde.workspace = true +serde_json.workspace = true +chrono.workspace = true +thiserror.workspace = true +colored.workspace = true +rusqlite.workspace = true +toml.workspace = true +dirs = "6" + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true +tempfile.workspace = true diff --git a/crates/tb-session/SKILL.md b/crates/tb-session/SKILL.md new file mode 100644 index 0000000..de7881a --- /dev/null +++ b/crates/tb-session/SKILL.md @@ -0,0 +1,16 @@ +--- +name: tb-session +description: Search and manage Claude Code sessions. Use when the user references past sessions, wants to find prior work, or needs to resume a specific conversation. +--- + +# tb-session + +Claude Code session search CLI. Full-text search across session history with metadata filtering. + +## Getting started + +Run `tb-session prime` for available commands and index status. + +## Live context + +!`tb-session prime` diff --git a/crates/tb-session/src/error.rs b/crates/tb-session/src/error.rs new file mode 100644 index 0000000..69f4db3 --- /dev/null +++ b/crates/tb-session/src/error.rs @@ -0,0 +1,25 @@ +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("config error: {0}")] + Config(String), + + #[error("database error: {0}")] + Db(#[from] rusqlite::Error), + + #[error("io error: {0}")] + Io(#[from] std::io::Error), + + #[error("json error: {0}")] + Json(#[from] serde_json::Error), + + #[error("toml deserialize error: {0}")] + TomlDeserialize(#[from] toml::de::Error), + + #[error("toml serialize error: {0}")] + TomlSerialize(#[from] toml::ser::Error), + + #[error("{0}")] + Other(String), +} + +pub type Result = std::result::Result; diff --git a/crates/tb-session/src/lib.rs b/crates/tb-session/src/lib.rs new file mode 100644 index 0000000..a91e735 --- /dev/null +++ b/crates/tb-session/src/lib.rs @@ -0,0 +1 @@ +pub mod error; diff --git a/crates/tb-session/src/main.rs b/crates/tb-session/src/main.rs new file mode 100644 index 0000000..54dcb7c --- /dev/null +++ b/crates/tb-session/src/main.rs @@ -0,0 +1,63 @@ +use clap::Parser; + +#[derive(Parser)] +#[command( + name = "tb-session", + disable_version_flag = true, + about = "Claude Code session search CLI" +)] +struct Cli { + #[command(subcommand)] + command: Option, + + /// Bypass index cache (force rebuild) + #[arg(long, global = true)] + no_cache: bool, + + /// Output as JSON + #[arg(long, global = true)] + json: bool, + + /// Print version info + #[arg(short = 'V', long = "version")] + version: bool, +} + +#[derive(clap::Subcommand)] +enum Commands { + /// Full-text search across sessions + Search { + /// Search query + query: String, + }, +} + +fn main() { + if let Err(e) = run() { + use colored::Colorize; + eprintln!("{} {e}", "Error:".red().bold()); + std::process::exit(1); + } +} + +fn run() -> tb_session::error::Result<()> { + let cli = Cli::parse(); + + if cli.version { + toolbox_core::version_check::print_version("tb-session", env!("CARGO_PKG_VERSION")); + return Ok(()); + } + + let Some(command) = cli.command else { + Cli::parse_from(["tb-session", "--help"]); + unreachable!() + }; + + match command { + Commands::Search { query } => { + println!("TODO: search for '{}'", query); + } + } + + Ok(()) +} From ace62dbb4592bb81f2c97302f5115e78cbb851de Mon Sep 17 00:00:00 2001 From: Dario Filipaj Date: Mon, 23 Mar 2026 13:17:39 +0100 Subject: [PATCH 02/29] feat(tb-session): add index schema with FTS5, ensure_fresh skeleton Co-Authored-By: Claude Sonnet 4.6 --- crates/tb-session/src/index/builder.rs | 8 ++ crates/tb-session/src/index/mod.rs | 142 +++++++++++++++++++++++++ crates/tb-session/src/index/parser.rs | 34 ++++++ crates/tb-session/src/index/scanner.rs | 30 ++++++ crates/tb-session/src/index/schema.rs | 139 ++++++++++++++++++++++++ 5 files changed, 353 insertions(+) create mode 100644 crates/tb-session/src/index/builder.rs create mode 100644 crates/tb-session/src/index/mod.rs create mode 100644 crates/tb-session/src/index/parser.rs create mode 100644 crates/tb-session/src/index/scanner.rs create mode 100644 crates/tb-session/src/index/schema.rs diff --git a/crates/tb-session/src/index/builder.rs b/crates/tb-session/src/index/builder.rs new file mode 100644 index 0000000..80fad75 --- /dev/null +++ b/crates/tb-session/src/index/builder.rs @@ -0,0 +1,8 @@ +use rusqlite::Connection; +use crate::error::Result; +use super::scanner::FileInfo; +use super::parser::ParsedSession; + +pub fn index_session(_conn: &Connection, _file_info: &FileInfo, _parsed: &ParsedSession) -> Result<()> { + Ok(()) +} diff --git a/crates/tb-session/src/index/mod.rs b/crates/tb-session/src/index/mod.rs new file mode 100644 index 0000000..c330aa1 --- /dev/null +++ b/crates/tb-session/src/index/mod.rs @@ -0,0 +1,142 @@ +pub mod builder; +pub mod parser; +pub mod scanner; +pub mod schema; + +use std::path::{Path, PathBuf}; +use rusqlite::Connection; +use crate::error::{Error, Result}; +use scanner::FileInfo; + +/// Statistics about the current index. +#[derive(Debug)] +pub struct IndexStats { + pub session_count: u64, + pub project_count: u64, + pub db_size_bytes: u64, +} + +/// Open (or create) the SQLite database at `~/.cache/tb-session/index.db`. +/// +/// Enables WAL journal mode for better concurrent read performance, then +/// creates or resets the schema depending on `no_cache`. +pub fn open_db(no_cache: bool) -> Result { + let db_path = db_path()?; + if let Some(parent) = db_path.parent() { + std::fs::create_dir_all(parent)?; + } + + let conn = Connection::open(&db_path)?; + + // Enable WAL mode — best-effort, ignore errors on read-only filesystems. + let _ = conn.execute_batch("PRAGMA journal_mode=WAL;"); + + if no_cache { + schema::reset_tables(&conn)?; + } else { + schema::create_tables(&conn)?; + } + + Ok(conn) +} + +/// Return the path to the index database file. +fn db_path() -> Result { + let cache_dir = dirs::cache_dir() + .ok_or_else(|| Error::Other("cannot determine cache directory".to_string()))?; + Ok(cache_dir.join("tb-session").join("index.db")) +} + +/// Return `true` if the on-disk file is newer than what's recorded in the DB. +/// +/// A file is considered stale when: +/// - it has no entry in `sessions`, or +/// - its recorded `file_mtime` is less than the current mtime on disk. +pub fn is_stale(conn: &Connection, file_info: &FileInfo) -> Result { + let result: rusqlite::Result = conn.query_row( + "SELECT file_mtime FROM sessions WHERE session_id = ?1", + rusqlite::params![file_info.session_id], + |row| row.get(0), + ); + + match result { + Ok(recorded_mtime) => Ok(file_info.file_mtime > recorded_mtime as u64), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(true), + Err(e) => Err(e.into()), + } +} + +/// Scan for JSONL session files, re-index any that are stale. +/// +/// `projects_dir` is the root Claude projects directory +/// (typically `~/.claude/projects`). `scope_to_cwd` narrows the scan to a +/// single project path when provided. +pub fn ensure_fresh( + conn: &Connection, + projects_dir: &Path, + scope_to_cwd: Option<&Path>, +) -> Result<()> { + let files = scanner::scan_projects(projects_dir, scope_to_cwd)?; + + for file_info in &files { + if is_stale(conn, file_info)? { + let parsed = parser::parse_session(&file_info.file_path)?; + builder::index_session(conn, file_info, &parsed)?; + } + } + + Ok(()) +} + +/// Remove index rows for session files that no longer exist on disk. +/// +/// Uses parameterized queries — no string interpolation of user data. +pub fn cleanup_deleted(conn: &Connection) -> Result<()> { + // Collect all file paths currently in the index. + let mut stmt = conn.prepare("SELECT session_id, file_path FROM sessions")?; + let rows: Vec<(String, String)> = stmt + .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))? + .collect::>()?; + + for (session_id, file_path) in rows { + if !std::path::Path::new(&file_path).exists() { + // Delete FTS rows first (no foreign-key cascade in FTS5). + conn.execute( + "DELETE FROM messages_fts WHERE session_id = ?1", + rusqlite::params![session_id], + )?; + conn.execute( + "DELETE FROM sessions WHERE session_id = ?1", + rusqlite::params![session_id], + )?; + } + } + + Ok(()) +} + +/// Return aggregate statistics about the index. +pub fn get_stats(conn: &Connection) -> Result { + let session_count: u64 = conn + .query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get::<_, i64>(0)) + .map(|n| n as u64)?; + + let project_count: u64 = conn + .query_row( + "SELECT COUNT(DISTINCT project_path) FROM sessions", + [], + |row| row.get::<_, i64>(0), + ) + .map(|n| n as u64)?; + + let db_size_bytes: u64 = db_path() + .and_then(|p| std::fs::metadata(&p).map_err(Into::into)) + .map(|m| m.len()) + .unwrap_or(0); + + Ok(IndexStats { + session_count, + project_count, + db_size_bytes, + }) +} diff --git a/crates/tb-session/src/index/parser.rs b/crates/tb-session/src/index/parser.rs new file mode 100644 index 0000000..bc8cd82 --- /dev/null +++ b/crates/tb-session/src/index/parser.rs @@ -0,0 +1,34 @@ +use std::path::Path; +use crate::error::Result; + +#[derive(Debug)] +pub struct ParsedSession { + pub summary: Option, + pub first_prompt: Option, + pub git_branch: Option, + pub message_count: usize, + pub created_at: Option, + pub modified_at: Option, + pub is_sidechain: bool, + pub messages: Vec, +} + +#[derive(Debug)] +pub struct ParsedMessage { + pub role: String, + pub content: String, + pub timestamp: Option, +} + +pub fn parse_session(_file_path: &Path) -> Result { + Ok(ParsedSession { + summary: None, + first_prompt: None, + git_branch: None, + message_count: 0, + created_at: None, + modified_at: None, + is_sidechain: false, + messages: vec![], + }) +} diff --git a/crates/tb-session/src/index/scanner.rs b/crates/tb-session/src/index/scanner.rs new file mode 100644 index 0000000..9deec3d --- /dev/null +++ b/crates/tb-session/src/index/scanner.rs @@ -0,0 +1,30 @@ +use std::path::{Path, PathBuf}; +use crate::error::Result; + +#[derive(Debug)] +pub struct FileInfo { + pub session_id: String, + pub file_path: PathBuf, + pub file_mtime: u64, + pub project_path: String, + pub project_dir: String, + pub index_metadata: Option, +} + +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IndexEntry { + pub session_id: String, + pub summary: Option, + pub first_prompt: Option, + pub message_count: Option, + pub git_branch: Option, + pub created: Option, + pub modified: Option, + pub is_sidechain: Option, + pub project_path: Option, +} + +pub fn scan_projects(_projects_dir: &Path, _scope_to_cwd: Option<&Path>) -> Result> { + Ok(vec![]) +} diff --git a/crates/tb-session/src/index/schema.rs b/crates/tb-session/src/index/schema.rs new file mode 100644 index 0000000..5963b27 --- /dev/null +++ b/crates/tb-session/src/index/schema.rs @@ -0,0 +1,139 @@ +use rusqlite::Connection; +use crate::error::Result; + +/// Create all tables and FTS5 virtual table if they don't exist. +pub fn create_tables(conn: &Connection) -> Result<()> { + conn.execute_batch( + " + CREATE TABLE IF NOT EXISTS sessions ( + session_id TEXT PRIMARY KEY, + project_path TEXT NOT NULL, + project_dir TEXT NOT NULL, + file_path TEXT NOT NULL, + file_mtime INTEGER NOT NULL, + summary TEXT, + first_prompt TEXT, + git_branch TEXT, + message_count INTEGER DEFAULT 0, + created_at TEXT, + modified_at TEXT, + is_sidechain INTEGER DEFAULT 0 + ); + + -- Note: NOT contentless (spec says content='') because contentless FTS5 + -- does not support snippet(), COUNT(*), or simple DELETE. + CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5( + session_id, + role, + content, + timestamp + ); + + CREATE INDEX IF NOT EXISTS idx_sessions_project_path ON sessions(project_path); + CREATE INDEX IF NOT EXISTS idx_sessions_git_branch ON sessions(git_branch); + CREATE INDEX IF NOT EXISTS idx_sessions_modified_at ON sessions(modified_at); + ", + )?; + Ok(()) +} + +/// Drop all tables and recreate (for --no-cache full rebuild). +pub fn reset_tables(conn: &Connection) -> Result<()> { + conn.execute_batch( + " + DROP TABLE IF EXISTS messages_fts; + DROP TABLE IF EXISTS sessions; + ", + )?; + create_tables(conn) +} + +#[cfg(test)] +mod tests { + use super::*; + use rusqlite::Connection; + + fn in_memory() -> Connection { + Connection::open_in_memory().expect("in-memory DB") + } + + #[test] + fn test_create_tables() { + let conn = in_memory(); + create_tables(&conn).expect("create_tables should succeed"); + + // Verify sessions table exists by inserting a row + conn.execute( + "INSERT INTO sessions (session_id, project_path, project_dir, file_path, file_mtime) + VALUES ('sid1', '/proj', 'mydir', '/proj/file.jsonl', 1234567890)", + [], + ) + .expect("sessions table should exist and accept inserts"); + + // Verify messages_fts table exists by inserting a row + conn.execute( + "INSERT INTO messages_fts (session_id, role, content, timestamp) + VALUES ('sid1', 'user', 'hello world', '2024-01-01T00:00:00Z')", + [], + ) + .expect("messages_fts table should exist and accept inserts"); + + // Verify data is retrievable + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM sessions", [], |r| r.get(0)) + .expect("COUNT query should succeed"); + assert_eq!(count, 1); + } + + #[test] + fn test_create_tables_idempotent() { + let conn = in_memory(); + create_tables(&conn).expect("first call should succeed"); + create_tables(&conn).expect("second call should not error"); + + // Tables should still be usable + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM sessions", [], |r| r.get(0)) + .expect("COUNT query should succeed"); + assert_eq!(count, 0); + } + + #[test] + fn test_reset_tables() { + let conn = in_memory(); + create_tables(&conn).expect("create_tables should succeed"); + + // Insert data into both tables + conn.execute( + "INSERT INTO sessions (session_id, project_path, project_dir, file_path, file_mtime) + VALUES ('sid1', '/proj', 'mydir', '/proj/file.jsonl', 1234567890)", + [], + ) + .expect("insert into sessions"); + conn.execute( + "INSERT INTO messages_fts (session_id, role, content, timestamp) + VALUES ('sid1', 'user', 'some content', '2024-01-01T00:00:00Z')", + [], + ) + .expect("insert into messages_fts"); + + // Confirm data exists + let session_count: i64 = conn + .query_row("SELECT COUNT(*) FROM sessions", [], |r| r.get(0)) + .expect("COUNT sessions"); + assert_eq!(session_count, 1); + + // Reset and verify tables are empty + reset_tables(&conn).expect("reset_tables should succeed"); + + let session_count_after: i64 = conn + .query_row("SELECT COUNT(*) FROM sessions", [], |r| r.get(0)) + .expect("COUNT sessions after reset"); + assert_eq!(session_count_after, 0); + + let fts_count_after: i64 = conn + .query_row("SELECT COUNT(*) FROM messages_fts", [], |r| r.get(0)) + .expect("COUNT messages_fts after reset"); + assert_eq!(fts_count_after, 0); + } +} From abbef5f3c4d995fda8e4ec5a4a20c8cad242f4b2 Mon Sep 17 00:00:00 2001 From: Dario Filipaj Date: Mon, 23 Mar 2026 13:27:29 +0100 Subject: [PATCH 03/29] feat(tb-session): add config module with load/save/defaults Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/tb-session/src/commands/config_cmd.rs | 37 ++++++++ crates/tb-session/src/commands/mod.rs | 1 + crates/tb-session/src/config.rs | 96 ++++++++++++++++++++ crates/tb-session/src/lib.rs | 2 + crates/tb-session/src/main.rs | 20 ++++ 5 files changed, 156 insertions(+) create mode 100644 crates/tb-session/src/commands/config_cmd.rs create mode 100644 crates/tb-session/src/commands/mod.rs create mode 100644 crates/tb-session/src/config.rs diff --git a/crates/tb-session/src/commands/config_cmd.rs b/crates/tb-session/src/commands/config_cmd.rs new file mode 100644 index 0000000..5063ce6 --- /dev/null +++ b/crates/tb-session/src/commands/config_cmd.rs @@ -0,0 +1,37 @@ +use crate::config::Config; +use crate::error::Result; + +/// Write default config to disk and print its path. +pub fn init() -> Result<()> { + let config = Config::default(); + config.save()?; + let path = Config::config_path()?; + println!("Config initialized at: {}", path.display()); + Ok(()) +} + +/// Load and print all config values and resolved paths. +pub fn show() -> Result<()> { + let config = Config::load()?; + let path = Config::config_path()?; + + println!("Config file: {}", path.display()); + println!("claude_home: {}", config.claude_home); + println!( + "claude_home resolved: {}", + config.claude_home_path().display() + ); + println!("projects_dir: {}", config.projects_dir().display()); + match config.db_path() { + Ok(p) => println!("db_path: {}", p.display()), + Err(e) => println!("db_path: (error: {})", e), + } + println!("ttl_minutes: {}", config.ttl_minutes); + println!( + "ttl: {}s", + config.ttl().as_secs() + ); + println!("default_limit: {}", config.default_limit); + + Ok(()) +} diff --git a/crates/tb-session/src/commands/mod.rs b/crates/tb-session/src/commands/mod.rs new file mode 100644 index 0000000..18329ee --- /dev/null +++ b/crates/tb-session/src/commands/mod.rs @@ -0,0 +1 @@ +pub mod config_cmd; diff --git a/crates/tb-session/src/config.rs b/crates/tb-session/src/config.rs new file mode 100644 index 0000000..80560b3 --- /dev/null +++ b/crates/tb-session/src/config.rs @@ -0,0 +1,96 @@ +use std::path::PathBuf; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +use crate::error::{Error, Result}; + +fn default_claude_home() -> String { + "~/.claude".to_string() +} + +fn default_ttl_minutes() -> u64 { + 60 +} + +fn default_limit() -> usize { + 10 +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct Config { + #[serde(default = "default_claude_home")] + pub claude_home: String, + + #[serde(default = "default_ttl_minutes")] + pub ttl_minutes: u64, + + #[serde(default = "default_limit")] + pub default_limit: usize, +} + +impl Default for Config { + fn default() -> Self { + Self { + claude_home: default_claude_home(), + ttl_minutes: default_ttl_minutes(), + default_limit: default_limit(), + } + } +} + +impl Config { + pub fn config_path() -> Result { + toolbox_core::config::config_path("tb-session") + .map_err(|e| Error::Config(e.to_string())) + } + + pub fn load() -> Result { + let path = Self::config_path()?; + let config = toolbox_core::config::load_standalone::(&path) + .map_err(|e| Error::Config(e.to_string()))? + .unwrap_or_default(); + Ok(config) + } + + pub fn save(&self) -> Result<()> { + let path = Self::config_path()?; + toolbox_core::config::save_config(&path, self) + .map_err(|e| Error::Config(e.to_string())) + } + + /// Resolves `~` in `claude_home` to an absolute PathBuf. + pub fn claude_home_path(&self) -> PathBuf { + if self.claude_home.starts_with('~') { + if let Some(home) = dirs::home_dir() { + let stripped = self.claude_home.trim_start_matches('~'); + let stripped = stripped.trim_start_matches('/'); + if stripped.is_empty() { + return home; + } + return home.join(stripped); + } + } + PathBuf::from(&self.claude_home) + } + + /// Returns `{claude_home}/projects/`. + pub fn projects_dir(&self) -> PathBuf { + self.claude_home_path().join("projects") + } + + /// Returns `~/.cache/tb-session/index.db`, creating the parent dir. + pub fn db_path(&self) -> Result { + let cache_dir = dirs::cache_dir().ok_or_else(|| { + Error::Config("cannot determine cache directory".to_string()) + })?; + let db_dir = cache_dir.join("tb-session"); + std::fs::create_dir_all(&db_dir).map_err(|e| Error::Config(e.to_string()))?; + Ok(db_dir.join("index.db")) + } + + /// Returns the TTL as a `Duration`. + pub fn ttl(&self) -> Duration { + Duration::from_secs(self.ttl_minutes * 60) + } +} diff --git a/crates/tb-session/src/lib.rs b/crates/tb-session/src/lib.rs index a91e735..84bd2b2 100644 --- a/crates/tb-session/src/lib.rs +++ b/crates/tb-session/src/lib.rs @@ -1 +1,3 @@ +pub mod commands; +pub mod config; pub mod error; diff --git a/crates/tb-session/src/main.rs b/crates/tb-session/src/main.rs index 54dcb7c..b504216 100644 --- a/crates/tb-session/src/main.rs +++ b/crates/tb-session/src/main.rs @@ -1,5 +1,7 @@ use clap::Parser; +use tb_session::config::Config; + #[derive(Parser)] #[command( name = "tb-session", @@ -30,6 +32,20 @@ enum Commands { /// Search query query: String, }, + + /// Manage configuration + Config { + #[command(subcommand)] + action: ConfigAction, + }, +} + +#[derive(clap::Subcommand)] +enum ConfigAction { + /// Write default config to disk + Init, + /// Show current config and resolved paths + Show, } fn main() { @@ -57,6 +73,10 @@ fn run() -> tb_session::error::Result<()> { Commands::Search { query } => { println!("TODO: search for '{}'", query); } + Commands::Config { action } => match action { + ConfigAction::Init => tb_session::commands::config_cmd::init()?, + ConfigAction::Show => tb_session::commands::config_cmd::show()?, + }, } Ok(()) From f1bb81364d695f10b6bee6f2fceddb80de2ed2c1 Mon Sep 17 00:00:00 2001 From: Dario Filipaj Date: Mon, 23 Mar 2026 13:40:59 +0100 Subject: [PATCH 04/29] feat(tb-session): implement JSONL file scanner with sessions-index.json support Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/tb-session/src/index/scanner.rs | 426 ++++++++++++++++++++++++- 1 file changed, 424 insertions(+), 2 deletions(-) diff --git a/crates/tb-session/src/index/scanner.rs b/crates/tb-session/src/index/scanner.rs index 9deec3d..96c4776 100644 --- a/crates/tb-session/src/index/scanner.rs +++ b/crates/tb-session/src/index/scanner.rs @@ -1,4 +1,8 @@ +use std::collections::HashMap; +use std::fs; +use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; + use crate::error::Result; #[derive(Debug)] @@ -25,6 +29,424 @@ pub struct IndexEntry { pub project_path: Option, } -pub fn scan_projects(_projects_dir: &Path, _scope_to_cwd: Option<&Path>) -> Result> { - Ok(vec![]) +/// Encode a filesystem path into the directory-name format used by Claude. +/// +/// Claude encodes project directories by replacing `/` with `-`, e.g. +/// `/Users/test/Projects/myapp` becomes `-Users-test-Projects-myapp`. +/// This encoding is lossy — original hyphens are indistinguishable from +/// path separators — so it can only be used for forward matching, never +/// for decoding back to the original path. +pub fn encode_path(path: &Path) -> String { + path.to_string_lossy().replace('/', "-") +} + +/// Discover JSONL session files under `projects_dir`. +/// +/// When `scope_to_cwd` is `Some`, only the project directory whose encoded +/// name matches the encoded `cwd` is scanned. Otherwise every project +/// directory is visited. +pub fn scan_projects( + projects_dir: &Path, + scope_to_cwd: Option<&Path>, +) -> Result> { + let mut results = Vec::new(); + + let entries = match fs::read_dir(projects_dir) { + Ok(entries) => entries, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(results), + Err(e) => return Err(e.into()), + }; + + let encoded_cwd = scope_to_cwd.map(encode_path); + + for entry in entries { + let entry = entry?; + let dir_path = entry.path(); + + if !dir_path.is_dir() { + continue; + } + + let dir_name = match dir_path.file_name().and_then(|n| n.to_str()) { + Some(name) => name.to_string(), + None => continue, + }; + + // When scoping, skip directories that don't match the encoded cwd. + if let Some(ref encoded) = encoded_cwd { + if dir_name != *encoded { + continue; + } + } + + // Load sessions-index.json for this project directory. + let index = load_sessions_index(&dir_path); + + // Resolve the project path: prefer sessions-index.json projectPath, + // fall back to extracting cwd from the first JSONL file found. + let project_path_from_index = index.values().find_map(|e| e.project_path.clone()); + + // Iterate over JSONL files in this project directory. + let dir_entries = match fs::read_dir(&dir_path) { + Ok(de) => de, + Err(_) => continue, + }; + + let mut fallback_project_path: Option = None; + + // Collect JSONL file info first so we can resolve project_path lazily. + let mut pending: Vec<(String, PathBuf, u64, Option)> = Vec::new(); + + for file_entry in dir_entries { + let file_entry = match file_entry { + Ok(fe) => fe, + Err(_) => continue, + }; + + let file_path = file_entry.path(); + + let file_name = match file_path.file_name().and_then(|n| n.to_str()) { + Some(name) => name.to_string(), + None => continue, + }; + + if !file_name.ends_with(".jsonl") { + continue; + } + + // Session ID is the filename without extension. + let session_id = file_name.trim_end_matches(".jsonl").to_string(); + + let file_mtime = file_path + .metadata() + .and_then(|m| m.modified()) + .map(|t| { + t.duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + }) + .unwrap_or(0); + + let index_entry = index.get(&session_id).cloned(); + + pending.push((session_id, file_path, file_mtime, index_entry)); + } + + // Resolve fallback project_path if the index didn't have one. + let resolved_project_path = if let Some(ref pp) = project_path_from_index { + pp.clone() + } else { + // Try to extract cwd from the first JSONL file. + if fallback_project_path.is_none() { + for (_, path, _, _) in &pending { + if let Some(cwd) = extract_cwd_from_jsonl(path) { + fallback_project_path = Some(cwd); + break; + } + } + } + fallback_project_path.unwrap_or_default() + }; + + for (session_id, file_path, file_mtime, index_entry) in pending { + results.push(FileInfo { + session_id, + file_path, + file_mtime, + project_path: resolved_project_path.clone(), + project_dir: dir_name.clone(), + index_metadata: index_entry, + }); + } + } + + Ok(results) +} + +/// Read the first few lines of a JSONL file looking for a `cwd` field. +/// +/// Returns the first `cwd` value found, or `None` if the file cannot be +/// read or none of the first 5 lines contain a `cwd` key. +pub fn extract_cwd_from_jsonl(path: &Path) -> Option { + let file = fs::File::open(path).ok()?; + let reader = BufReader::new(file); + + for line in reader.lines().take(5) { + let line = line.ok()?; + if let Ok(value) = serde_json::from_str::(&line) { + if let Some(cwd) = value.get("cwd").and_then(|v| v.as_str()) { + return Some(cwd.to_string()); + } + } + } + + None +} + +/// Load and parse `sessions-index.json` from a project directory. +/// +/// Returns a map from `session_id` to `IndexEntry`. Returns an empty map +/// if the file doesn't exist or can't be parsed. +pub fn load_sessions_index(project_dir: &Path) -> HashMap { + let index_path = project_dir.join("sessions-index.json"); + + let content = match fs::read_to_string(&index_path) { + Ok(c) => c, + Err(_) => return HashMap::new(), + }; + + let entries: Vec = match serde_json::from_str(&content) { + Ok(e) => e, + Err(_) => return HashMap::new(), + }; + + entries + .into_iter() + .map(|e| (e.session_id.clone(), e)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn create_test_projects_dir(base: &Path) -> PathBuf { + let projects_dir = base.join(".claude").join("projects"); + fs::create_dir_all(&projects_dir).unwrap(); + projects_dir + } + + fn create_project_with_sessions( + projects_dir: &Path, + dir_name: &str, + sessions: &[&str], + ) -> PathBuf { + let project_dir = projects_dir.join(dir_name); + fs::create_dir_all(&project_dir).unwrap(); + + for session_id in sessions { + let file_path = project_dir.join(format!("{session_id}.jsonl")); + fs::write(&file_path, "{\"type\":\"init\"}\n").unwrap(); + } + + project_dir + } + + #[test] + fn test_scan_finds_jsonl_files() { + let tmp = tempfile::tempdir().unwrap(); + let projects_dir = create_test_projects_dir(tmp.path()); + + create_project_with_sessions( + &projects_dir, + "-Users-test-myapp", + &["abc-123", "def-456"], + ); + + let results = scan_projects(&projects_dir, None).unwrap(); + assert_eq!(results.len(), 2); + + let mut ids: Vec<&str> = results.iter().map(|f| f.session_id.as_str()).collect(); + ids.sort(); + assert_eq!(ids, vec!["abc-123", "def-456"]); + } + + #[test] + fn test_scan_extracts_session_id_from_filename() { + let tmp = tempfile::tempdir().unwrap(); + let projects_dir = create_test_projects_dir(tmp.path()); + + create_project_with_sessions( + &projects_dir, + "-Users-test-myapp", + &["550e8400-e29b-41d4-a716-446655440000"], + ); + + let results = scan_projects(&projects_dir, None).unwrap(); + assert_eq!(results.len(), 1); + assert_eq!( + results[0].session_id, + "550e8400-e29b-41d4-a716-446655440000" + ); + } + + #[test] + fn test_scan_extracts_project_path_from_index() { + let tmp = tempfile::tempdir().unwrap(); + let projects_dir = create_test_projects_dir(tmp.path()); + + let project_dir = create_project_with_sessions( + &projects_dir, + "-Users-test-myapp", + &["session-1"], + ); + + // Write sessions-index.json with a projectPath. + let index_content = serde_json::json!([ + { + "sessionId": "session-1", + "summary": "Test session", + "projectPath": "/Users/test/myapp" + } + ]); + fs::write( + project_dir.join("sessions-index.json"), + index_content.to_string(), + ) + .unwrap(); + + let results = scan_projects(&projects_dir, None).unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].project_path, "/Users/test/myapp"); + assert!(results[0].index_metadata.is_some()); + + let meta = results[0].index_metadata.as_ref().unwrap(); + assert_eq!(meta.summary.as_deref(), Some("Test session")); + } + + #[test] + fn test_scan_scoped_to_cwd() { + let tmp = tempfile::tempdir().unwrap(); + let projects_dir = create_test_projects_dir(tmp.path()); + + // Two project directories — one for /Users/test/myapp, one for + // /Users/test/e2e-tests (note the hyphen in the real path). + create_project_with_sessions( + &projects_dir, + "-Users-test-myapp", + &["session-a"], + ); + create_project_with_sessions( + &projects_dir, + "-Users-test-e2e-tests", + &["session-b"], + ); + + // Scope to /Users/test/myapp — should only find session-a. + let scoped = + scan_projects(&projects_dir, Some(Path::new("/Users/test/myapp"))).unwrap(); + assert_eq!(scoped.len(), 1); + assert_eq!(scoped[0].session_id, "session-a"); + + // Scope to /Users/test/e2e-tests — should only find session-b. + // This works because the encoded form "-Users-test-e2e-tests" matches + // the directory name exactly (even though the encoding is lossy for + // paths that originally contained hyphens). + let scoped = + scan_projects(&projects_dir, Some(Path::new("/Users/test/e2e-tests"))).unwrap(); + assert_eq!(scoped.len(), 1); + assert_eq!(scoped[0].session_id, "session-b"); + + // Unscoped scan should find both. + let all = scan_projects(&projects_dir, None).unwrap(); + assert_eq!(all.len(), 2); + } + + #[test] + fn test_encode_path() { + assert_eq!( + encode_path(Path::new("/Users/test/Projects/myapp")), + "-Users-test-Projects-myapp" + ); + assert_eq!( + encode_path(Path::new("/Users/test/e2e-tests")), + "-Users-test-e2e-tests" + ); + } + + #[test] + fn test_extract_cwd_from_jsonl() { + let tmp = tempfile::tempdir().unwrap(); + let file_path = tmp.path().join("test.jsonl"); + + // cwd on line 3 (within the first 5 lines). + fs::write( + &file_path, + "{\"type\":\"init\"}\n{\"role\":\"user\"}\n{\"cwd\":\"/Users/test/myapp\"}\n", + ) + .unwrap(); + + assert_eq!( + extract_cwd_from_jsonl(&file_path), + Some("/Users/test/myapp".to_string()) + ); + } + + #[test] + fn test_extract_cwd_from_jsonl_missing() { + let tmp = tempfile::tempdir().unwrap(); + let file_path = tmp.path().join("test.jsonl"); + + fs::write(&file_path, "{\"type\":\"init\"}\n{\"role\":\"user\"}\n").unwrap(); + + assert_eq!(extract_cwd_from_jsonl(&file_path), None); + } + + #[test] + fn test_load_sessions_index_missing_file() { + let tmp = tempfile::tempdir().unwrap(); + let index = load_sessions_index(tmp.path()); + assert!(index.is_empty()); + } + + #[test] + fn test_load_sessions_index_valid() { + let tmp = tempfile::tempdir().unwrap(); + let content = serde_json::json!([ + { + "sessionId": "abc-123", + "summary": "Refactored auth module", + "messageCount": 42, + "gitBranch": "feature/auth", + "projectPath": "/Users/test/myapp" + }, + { + "sessionId": "def-456", + "firstPrompt": "Fix the login bug" + } + ]); + fs::write(tmp.path().join("sessions-index.json"), content.to_string()).unwrap(); + + let index = load_sessions_index(tmp.path()); + assert_eq!(index.len(), 2); + + let entry = index.get("abc-123").unwrap(); + assert_eq!(entry.summary.as_deref(), Some("Refactored auth module")); + assert_eq!(entry.message_count, Some(42)); + assert_eq!(entry.git_branch.as_deref(), Some("feature/auth")); + + let entry2 = index.get("def-456").unwrap(); + assert_eq!(entry2.first_prompt.as_deref(), Some("Fix the login bug")); + assert!(entry2.summary.is_none()); + } + + #[test] + fn test_scan_fallback_project_path_from_jsonl() { + let tmp = tempfile::tempdir().unwrap(); + let projects_dir = create_test_projects_dir(tmp.path()); + + let project_dir = create_project_with_sessions( + &projects_dir, + "-Users-test-myapp", + &[], + ); + + // Write a JSONL file with a cwd field (no sessions-index.json). + let file_path = project_dir.join("session-1.jsonl"); + fs::write(&file_path, "{\"cwd\":\"/Users/test/myapp\"}\n").unwrap(); + + let results = scan_projects(&projects_dir, None).unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].project_path, "/Users/test/myapp"); + } + + #[test] + fn test_scan_nonexistent_projects_dir() { + let tmp = tempfile::tempdir().unwrap(); + let nonexistent = tmp.path().join("does-not-exist"); + + let results = scan_projects(&nonexistent, None).unwrap(); + assert!(results.is_empty()); + } } From b28721aa5e4de96b5b835988816fffb9a144758d Mon Sep 17 00:00:00 2001 From: Dario Filipaj Date: Mon, 23 Mar 2026 13:42:54 +0100 Subject: [PATCH 05/29] feat(tb-session): implement JSONL parser for user/assistant message extraction Co-Authored-By: Claude Sonnet 4.6 --- crates/tb-session/src/index/parser.rs | 220 ++++++++++++++++++++++++-- 1 file changed, 211 insertions(+), 9 deletions(-) diff --git a/crates/tb-session/src/index/parser.rs b/crates/tb-session/src/index/parser.rs index bc8cd82..b9337ac 100644 --- a/crates/tb-session/src/index/parser.rs +++ b/crates/tb-session/src/index/parser.rs @@ -1,3 +1,5 @@ +use std::fs::File; +use std::io::{BufRead, BufReader}; use std::path::Path; use crate::error::Result; @@ -20,15 +22,215 @@ pub struct ParsedMessage { pub timestamp: Option, } -pub fn parse_session(_file_path: &Path) -> Result { +fn extract_content(message: &serde_json::Value) -> String { + match &message["content"] { + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Array(arr) => arr + .iter() + .filter(|item| item.get("type").and_then(|t| t.as_str()) == Some("text")) + .filter_map(|item| item.get("text").and_then(|t| t.as_str())) + .collect::>() + .join("\n"), + _ => String::new(), + } +} + +pub fn parse_session(file_path: &Path) -> Result { + let file = File::open(file_path)?; + let reader = BufReader::new(file); + + let mut first_timestamp: Option = None; + let mut last_timestamp: Option = None; + let mut git_branch: Option = None; + let mut is_sidechain = false; + let mut line_count = 0usize; + let mut messages: Vec = Vec::new(); + let mut first_prompt: Option = None; + let mut summary: Option = None; + + for line in reader.lines() { + let line = line?; + if line.trim().is_empty() { + continue; + } + + let entry: serde_json::Value = match serde_json::from_str(&line) { + Ok(v) => v, + Err(_) => continue, + }; + + line_count += 1; + + // Track timestamps + if let Some(ts) = entry.get("timestamp").and_then(|t| t.as_str()) { + if first_timestamp.is_none() { + first_timestamp = Some(ts.to_string()); + } + last_timestamp = Some(ts.to_string()); + } + + // Extract gitBranch from first entry that has it + if git_branch.is_none() { + if let Some(branch) = entry.get("gitBranch").and_then(|b| b.as_str()) { + git_branch = Some(branch.to_string()); + } + } + + // Check isSidechain + if let Some(sc) = entry.get("isSidechain").and_then(|v| v.as_bool()) { + if sc { + is_sidechain = true; + } + } + + // Extract user/assistant messages + if let Some(message) = entry.get("message") { + let role = match message.get("role").and_then(|r| r.as_str()) { + Some(r) if r == "user" || r == "assistant" => r.to_string(), + _ => continue, + }; + + let content = extract_content(message); + + let timestamp = entry + .get("timestamp") + .and_then(|t| t.as_str()) + .map(|s| s.to_string()); + + if role == "user" && first_prompt.is_none() && !content.is_empty() { + first_prompt = Some(content.clone()); + } + + if role == "assistant" && summary.is_none() && !content.is_empty() { + let boundary = content.floor_char_boundary(197); + summary = Some(if boundary < content.len() { + format!("{}...", &content[..boundary]) + } else { + content.clone() + }); + } + + messages.push(ParsedMessage { + role, + content, + timestamp, + }); + } + } + Ok(ParsedSession { - summary: None, - first_prompt: None, - git_branch: None, - message_count: 0, - created_at: None, - modified_at: None, - is_sidechain: false, - messages: vec![], + summary, + first_prompt, + git_branch, + message_count: line_count, + created_at: first_timestamp, + modified_at: last_timestamp, + is_sidechain, + messages, }) } + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + + fn write_jsonl(lines: &[&str]) -> NamedTempFile { + let mut file = NamedTempFile::new().unwrap(); + for line in lines { + writeln!(file, "{line}").unwrap(); + } + file + } + + #[test] + fn test_parse_user_message() { + let file = write_jsonl(&[ + r#"{"timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello, world!"}}"#, + ]); + + let parsed = parse_session(file.path()).unwrap(); + + assert_eq!(parsed.messages.len(), 1); + assert_eq!(parsed.messages[0].role, "user"); + assert_eq!(parsed.messages[0].content, "Hello, world!"); + assert_eq!(parsed.first_prompt.as_deref(), Some("Hello, world!")); + } + + #[test] + fn test_parse_assistant_message_with_content_array() { + let file = write_jsonl(&[ + r#"{"timestamp":"2024-01-01T00:00:00Z","message":{"role":"assistant","content":[{"type":"text","text":"Here is the answer."},{"type":"tool_use","id":"123","name":"bash"}]}}"#, + ]); + + let parsed = parse_session(file.path()).unwrap(); + + assert_eq!(parsed.messages.len(), 1); + assert_eq!(parsed.messages[0].role, "assistant"); + assert_eq!(parsed.messages[0].content, "Here is the answer."); + } + + #[test] + fn test_parse_extracts_metadata() { + let file = write_jsonl(&[ + r#"{"timestamp":"2024-01-01T00:00:00Z","gitBranch":"feature/my-branch","isSidechain":true,"message":{"role":"user","content":"First message"}}"#, + r#"{"timestamp":"2024-01-02T00:00:00Z","message":{"role":"assistant","content":"Response"}}"#, + ]); + + let parsed = parse_session(file.path()).unwrap(); + + assert_eq!(parsed.git_branch.as_deref(), Some("feature/my-branch")); + assert_eq!(parsed.created_at.as_deref(), Some("2024-01-01T00:00:00Z")); + assert_eq!(parsed.modified_at.as_deref(), Some("2024-01-02T00:00:00Z")); + assert_eq!(parsed.message_count, 2); + assert!(parsed.is_sidechain); + } + + #[test] + fn test_parse_skips_non_message_lines() { + let file = write_jsonl(&[ + r#"{"type":"progress","text":"Thinking..."}"#, + r#"{"type":"file-history-snapshot","files":[]}"#, + r#"{"timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Real message"}}"#, + ]); + + let parsed = parse_session(file.path()).unwrap(); + + assert_eq!(parsed.messages.len(), 1); + assert_eq!(parsed.messages[0].content, "Real message"); + // All 3 lines count as valid JSON lines + assert_eq!(parsed.message_count, 3); + } + + #[test] + fn test_parse_handles_malformed_lines() { + let file = write_jsonl(&[ + r#"this is not valid json {"#, + r#"{"timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Valid"}}"#, + ]); + + let parsed = parse_session(file.path()).unwrap(); + + assert_eq!(parsed.messages.len(), 1); + assert_eq!(parsed.messages[0].content, "Valid"); + // Only 1 valid JSON line (the malformed one is skipped) + assert_eq!(parsed.message_count, 1); + } + + #[test] + fn test_parse_summary_from_first_assistant() { + let long_text = "A".repeat(300); + let line = format!( + r#"{{"timestamp":"2024-01-01T00:00:00Z","message":{{"role":"assistant","content":"{long_text}"}}}}"# + ); + let file = write_jsonl(&[&line]); + + let parsed = parse_session(file.path()).unwrap(); + + assert!(parsed.summary.is_some()); + let summary = parsed.summary.unwrap(); + assert!(summary.len() <= 200, "summary length {} > 200", summary.len()); + assert!(summary.ends_with("...")); + } +} From ab727b16ad07eebf1fec0dd92b434cca18d6519c Mon Sep 17 00:00:00 2001 From: Dario Filipaj Date: Mon, 23 Mar 2026 13:46:51 +0100 Subject: [PATCH 06/29] feat(tb-session): implement index builder with FTS5 message insertion Co-Authored-By: Claude Sonnet 4.6 --- crates/tb-session/src/index/builder.rs | 236 ++++++++++++++++++++++++- 1 file changed, 234 insertions(+), 2 deletions(-) diff --git a/crates/tb-session/src/index/builder.rs b/crates/tb-session/src/index/builder.rs index 80fad75..520d473 100644 --- a/crates/tb-session/src/index/builder.rs +++ b/crates/tb-session/src/index/builder.rs @@ -1,8 +1,240 @@ -use rusqlite::Connection; +use rusqlite::{params, Connection}; use crate::error::Result; use super::scanner::FileInfo; use super::parser::ParsedSession; -pub fn index_session(_conn: &Connection, _file_info: &FileInfo, _parsed: &ParsedSession) -> Result<()> { +pub fn index_session(conn: &Connection, file_info: &FileInfo, parsed: &ParsedSession) -> Result<()> { + // Determine metadata: prefer index_metadata fields over parsed fields. + let meta = file_info.index_metadata.as_ref(); + + let summary = meta + .and_then(|m| m.summary.as_deref()) + .or(parsed.summary.as_deref()); + + let first_prompt = meta + .and_then(|m| m.first_prompt.as_deref()) + .or(parsed.first_prompt.as_deref()); + + let git_branch = meta + .and_then(|m| m.git_branch.as_deref()) + .or(parsed.git_branch.as_deref()); + + let message_count: i64 = meta + .and_then(|m| m.message_count) + .unwrap_or(parsed.message_count) as i64; + + let created_at = meta + .and_then(|m| m.created.as_deref()) + .or(parsed.created_at.as_deref()); + + let modified_at = meta + .and_then(|m| m.modified.as_deref()) + .or(parsed.modified_at.as_deref()); + + let is_sidechain: i64 = meta + .and_then(|m| m.is_sidechain) + .unwrap_or(parsed.is_sidechain) as i64; + + // INSERT or REPLACE the session row. + conn.execute( + "INSERT OR REPLACE INTO sessions + (session_id, project_path, project_dir, file_path, file_mtime, + summary, first_prompt, git_branch, message_count, + created_at, modified_at, is_sidechain) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + params![ + file_info.session_id, + file_info.project_path, + file_info.project_dir, + file_info.file_path.to_string_lossy().as_ref(), + file_info.file_mtime as i64, + summary, + first_prompt, + git_branch, + message_count, + created_at, + modified_at, + is_sidechain, + ], + )?; + + // DELETE existing FTS rows for this session (handles re-indexing). + conn.execute( + "DELETE FROM messages_fts WHERE session_id = ?1", + params![file_info.session_id], + )?; + + // INSERT each message into the FTS5 table. + for msg in &parsed.messages { + conn.execute( + "INSERT INTO messages_fts (session_id, role, content, timestamp) + VALUES (?1, ?2, ?3, ?4)", + params![ + file_info.session_id, + msg.role, + msg.content, + msg.timestamp, + ], + )?; + } + Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use rusqlite::Connection; + use std::path::PathBuf; + use crate::index::schema; + use super::super::scanner::IndexEntry; + use super::super::parser::ParsedMessage; + + fn in_memory() -> Connection { + let conn = Connection::open_in_memory().expect("in-memory DB"); + schema::create_tables(&conn).expect("create_tables"); + conn + } + + fn make_file_info(session_id: &str) -> FileInfo { + FileInfo { + session_id: session_id.to_string(), + file_path: PathBuf::from(format!("/tmp/{session_id}.jsonl")), + file_mtime: 1700000000, + project_path: "/Users/test/myapp".to_string(), + project_dir: "-Users-test-myapp".to_string(), + index_metadata: None, + } + } + + fn make_parsed(summary: Option<&str>, messages: Vec) -> ParsedSession { + ParsedSession { + summary: summary.map(|s| s.to_string()), + first_prompt: messages + .iter() + .find(|m| m.role == "user") + .map(|m| m.content.clone()), + git_branch: Some("main".to_string()), + message_count: messages.len(), + created_at: Some("2024-01-01T00:00:00Z".to_string()), + modified_at: Some("2024-01-02T00:00:00Z".to_string()), + is_sidechain: false, + messages, + } + } + + fn msg(role: &str, content: &str) -> ParsedMessage { + ParsedMessage { + role: role.to_string(), + content: content.to_string(), + timestamp: Some("2024-01-01T00:00:00Z".to_string()), + } + } + + #[test] + fn test_index_session_inserts_metadata() { + let conn = in_memory(); + let file_info = make_file_info("session-1"); + let parsed = make_parsed(Some("My summary"), vec![msg("user", "Hello")]); + + index_session(&conn, &file_info, &parsed).unwrap(); + + let summary: String = conn + .query_row( + "SELECT summary FROM sessions WHERE session_id = 'session-1'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(summary, "My summary"); + } + + #[test] + fn test_index_session_inserts_fts_messages() { + let conn = in_memory(); + let file_info = make_file_info("session-2"); + let parsed = make_parsed( + Some("summary"), + vec![ + msg("user", "First message"), + msg("assistant", "Second message"), + msg("user", "Third message"), + ], + ); + + index_session(&conn, &file_info, &parsed).unwrap(); + + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM messages_fts WHERE session_id = 'session-2'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count, 3); + } + + #[test] + fn test_index_session_prefers_index_metadata() { + let conn = in_memory(); + let mut file_info = make_file_info("session-3"); + file_info.index_metadata = Some(IndexEntry { + session_id: "session-3".to_string(), + summary: Some("Index summary".to_string()), + first_prompt: Some("Index first prompt".to_string()), + message_count: Some(99), + git_branch: Some("feature/index-branch".to_string()), + created: Some("2023-06-01T00:00:00Z".to_string()), + modified: Some("2023-06-02T00:00:00Z".to_string()), + is_sidechain: Some(true), + project_path: Some("/Users/test/myapp".to_string()), + }); + + let parsed = make_parsed(Some("Parsed summary"), vec![msg("user", "Parsed prompt")]); + + index_session(&conn, &file_info, &parsed).unwrap(); + + let (summary, first_prompt, message_count, git_branch, created_at, modified_at, is_sidechain): ( + String, String, i64, String, String, String, i64, + ) = conn + .query_row( + "SELECT summary, first_prompt, message_count, git_branch, created_at, modified_at, is_sidechain + FROM sessions WHERE session_id = 'session-3'", + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?)), + ) + .unwrap(); + + assert_eq!(summary, "Index summary"); + assert_eq!(first_prompt, "Index first prompt"); + assert_eq!(message_count, 99); + assert_eq!(git_branch, "feature/index-branch"); + assert_eq!(created_at, "2023-06-01T00:00:00Z"); + assert_eq!(modified_at, "2023-06-02T00:00:00Z"); + assert_eq!(is_sidechain, 1); + } + + #[test] + fn test_fts_search_finds_content() { + let conn = in_memory(); + let file_info = make_file_info("session-4"); + let parsed = make_parsed( + Some("summary"), + vec![ + msg("user", "implement the authentication middleware"), + msg("assistant", "Sure, I will implement the authentication middleware"), + ], + ); + + index_session(&conn, &file_info, &parsed).unwrap(); + + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM messages_fts WHERE messages_fts MATCH 'authentication'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count, 2); + } +} From fe13a541e4b1b3acea048bb50b66c014a9eb7e54 Mon Sep 17 00:00:00 2001 From: Dario Filipaj Date: Mon, 23 Mar 2026 13:49:01 +0100 Subject: [PATCH 07/29] feat(tb-session): add list command with metadata-only pagination Co-Authored-By: Claude Sonnet 4.6 --- crates/tb-session/src/commands/list.rs | 152 +++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 crates/tb-session/src/commands/list.rs diff --git a/crates/tb-session/src/commands/list.rs b/crates/tb-session/src/commands/list.rs new file mode 100644 index 0000000..0733cf9 --- /dev/null +++ b/crates/tb-session/src/commands/list.rs @@ -0,0 +1,152 @@ +use rusqlite::Connection; + +use crate::error::Result; +use crate::models::{SessionList, SessionSummary}; + +#[allow(clippy::too_many_arguments)] +pub fn run( + conn: &Connection, + branch: Option<&str>, + from: Option<&str>, + to: Option<&str>, + all_projects: bool, + limit: u32, + page: u32, + json: bool, +) -> Result<()> { + let mut sql = String::from( + "SELECT session_id, summary, git_branch, project_path, message_count, \ + created_at, modified_at FROM sessions WHERE is_sidechain = 0", + ); + let mut params: Vec> = Vec::new(); + + // Current-project filter (default) + let cwd = if !all_projects { + let dir = std::env::current_dir()?; + Some(dir.to_string_lossy().into_owned()) + } else { + None + }; + if let Some(ref path) = cwd { + sql.push_str(" AND project_path = ?"); + params.push(Box::new(path.clone())); + } + + if let Some(b) = branch { + sql.push_str(" AND git_branch = ?"); + params.push(Box::new(b.to_string())); + } + + if let Some(f) = from { + sql.push_str(" AND modified_at >= ?"); + params.push(Box::new(f.to_string())); + } + + if let Some(t) = to { + sql.push_str(" AND modified_at <= ?"); + params.push(Box::new(t.to_string())); + } + + // COUNT query for pagination + let count_sql = format!( + "SELECT COUNT(*) FROM ({}) AS t", + sql.replace( + "SELECT session_id, summary, git_branch, project_path, message_count, \ + created_at, modified_at FROM sessions", + "SELECT 1 FROM sessions", + ) + ); + + let param_refs: Vec<&dyn rusqlite::types::ToSql> = + params.iter().map(|p| p.as_ref()).collect(); + + let total: u32 = conn.query_row( + &count_sql, + rusqlite::params_from_iter(param_refs.iter().copied()), + |row| row.get(0), + )?; + + // Paginated data query + let offset = (page.saturating_sub(1)) * limit; + sql.push_str(&format!( + " ORDER BY modified_at DESC LIMIT {} OFFSET {}", + limit, offset + )); + + let mut stmt = conn.prepare(&sql)?; + let results: Vec = stmt + .query_map( + rusqlite::params_from_iter(param_refs.iter().copied()), + |row| { + Ok(SessionSummary { + session_id: row.get(0)?, + summary: row.get(1)?, + git_branch: row.get(2)?, + project_path: row.get(3)?, + message_count: row.get(4)?, + created_at: row.get(5)?, + modified_at: row.get(6)?, + }) + }, + )? + .collect::>()?; + + let list = SessionList { + total_results: total, + page, + results, + }; + + if json { + println!("{}", toolbox_core::output::render_json(&list)); + return Ok(()); + } + + if list.results.is_empty() { + println!( + "{}", + toolbox_core::output::empty_hint( + "sessions", + "Try --all-projects or wider date range." + ) + ); + return Ok(()); + } + + // Human-readable table + println!( + "{:<36} {:<40} {:<20} {:<6} {:<12}", + "SESSION ID", "SUMMARY", "BRANCH", "MSGS", "MODIFIED" + ); + for s in &list.results { + let summary = s + .summary + .as_deref() + .unwrap_or("(no summary)"); + let branch = s + .git_branch + .as_deref() + .unwrap_or("-"); + let modified = s + .modified_at + .as_deref() + .map(toolbox_core::output::relative_time) + .unwrap_or_else(|| "-".to_string()); + println!( + "{:<36} {:<40} {:<20} {:<6} {:<12}", + toolbox_core::output::truncate(&s.session_id, 36), + toolbox_core::output::truncate(summary, 38), + toolbox_core::output::truncate(branch, 18), + s.message_count.unwrap_or(0), + modified, + ); + } + + if let Some(hint) = + toolbox_core::output::pagination_hint(list.page, limit, list.total_results) + { + eprintln!("{}", hint); + } + + Ok(()) +} From 92a244b43a26b4eaf77d80f0e070083367a7510f Mon Sep 17 00:00:00 2001 From: Dario Filipaj Date: Mon, 23 Mar 2026 13:51:24 +0100 Subject: [PATCH 08/29] feat(tb-session): implement search command with FTS5 and metadata filters Add FTS5 full-text search with BM25 scoring, dynamic SQL parameter building for branch/project/date filters, min-max relevance normalization, snippet extraction, and dual human-table/JSON output modes. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/tb-session/src/commands/mod.rs | 1 + crates/tb-session/src/commands/search.rs | 283 +++++++++++++++++++++++ crates/tb-session/src/lib.rs | 2 + crates/tb-session/src/main.rs | 61 ++++- crates/tb-session/src/models.rs | 108 +++++++++ 5 files changed, 453 insertions(+), 2 deletions(-) create mode 100644 crates/tb-session/src/commands/search.rs create mode 100644 crates/tb-session/src/models.rs diff --git a/crates/tb-session/src/commands/mod.rs b/crates/tb-session/src/commands/mod.rs index 18329ee..7820c9f 100644 --- a/crates/tb-session/src/commands/mod.rs +++ b/crates/tb-session/src/commands/mod.rs @@ -1 +1,2 @@ pub mod config_cmd; +pub mod search; diff --git a/crates/tb-session/src/commands/search.rs b/crates/tb-session/src/commands/search.rs new file mode 100644 index 0000000..3aa1daf --- /dev/null +++ b/crates/tb-session/src/commands/search.rs @@ -0,0 +1,283 @@ +use rusqlite::types::ToSql; +use rusqlite::Connection; + +use crate::error::Result; +use crate::models::{SearchFilters, SearchResult, SessionMatch}; + +#[allow(clippy::too_many_arguments)] +pub fn run( + conn: &Connection, + query: &str, + branch: Option<&str>, + from: Option<&str>, + to: Option<&str>, + project: Option<&str>, + all_projects: bool, + limit: usize, + json: bool, +) -> Result<()> { + // -- Build dynamic SQL ------------------------------------------------ + let mut where_clauses = vec![ + "messages_fts MATCH ?1".to_string(), + "s.is_sidechain = 0".to_string(), + ]; + let mut params: Vec> = vec![Box::new(query.to_string())]; + let mut param_idx: usize = 2; + + // Project scope + if !all_projects && project.is_none() { + // Default: scope to current working directory + let cwd = std::env::current_dir() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + if !cwd.is_empty() { + where_clauses.push(format!("s.project_path = ?{param_idx}")); + params.push(Box::new(cwd)); + param_idx += 1; + } + } else if all_projects && project.is_some() { + // --all-projects + --project: LIKE filter across all projects + let pattern = format!("%{}%", project.unwrap()); + where_clauses.push(format!("s.project_path LIKE ?{param_idx}")); + params.push(Box::new(pattern)); + param_idx += 1; + } else if let Some(proj) = project { + // --project without --all-projects: LIKE filter + let pattern = format!("%{proj}%"); + where_clauses.push(format!("s.project_path LIKE ?{param_idx}")); + params.push(Box::new(pattern)); + param_idx += 1; + } + + // Branch filter + if let Some(br) = branch { + where_clauses.push(format!("s.git_branch = ?{param_idx}")); + params.push(Box::new(br.to_string())); + param_idx += 1; + } + + // Date filters + if let Some(after) = from { + where_clauses.push(format!("s.modified_at >= ?{param_idx}")); + params.push(Box::new(after.to_string())); + param_idx += 1; + } + if let Some(before) = to { + where_clauses.push(format!("s.modified_at <= ?{param_idx}")); + params.push(Box::new(before.to_string())); + param_idx += 1; + } + + let where_sql = where_clauses.join(" AND "); + + let sql = format!( + "SELECT + s.session_id, + s.summary, + s.first_prompt, + s.git_branch, + s.project_path, + s.message_count, + s.created_at, + s.modified_at, + rank, + snippet(messages_fts, 2, '...', '...', '...', 20), + messages_fts.role + FROM messages_fts + JOIN sessions s ON s.session_id = messages_fts.session_id + WHERE {where_sql} + GROUP BY s.session_id + ORDER BY rank + LIMIT ?{param_idx}" + ); + + params.push(Box::new(limit as i64)); + + // Convert to &dyn ToSql slice + let param_refs: Vec<&dyn ToSql> = params.iter().map(|p| p.as_ref()).collect(); + + // -- Execute ---------------------------------------------------------- + let mut stmt = conn.prepare(&sql)?; + + let raw_rows: Vec = stmt + .query_map(param_refs.as_slice(), |row| { + Ok(RawRow { + session_id: row.get(0)?, + summary: row.get(1)?, + first_prompt: row.get(2)?, + git_branch: row.get(3)?, + project_path: row.get(4)?, + message_count: row.get(5)?, + created_at: row.get(6)?, + modified_at: row.get(7)?, + rank: row.get(8)?, + snippet: row.get(9)?, + matched_role: row.get(10)?, + }) + })? + .collect::>>()?; + + // -- BM25 normalization: min-max scaling ------------------------------ + let results: Vec = if raw_rows.is_empty() { + Vec::new() + } else if raw_rows.len() == 1 { + vec![raw_rows[0].into_match(1.0)] + } else { + // rank is negative in FTS5 (more negative = better match) + // worst = least negative (highest value), best = most negative (lowest value) + let worst = raw_rows + .iter() + .map(|r| r.rank) + .fold(f64::NEG_INFINITY, f64::max); + let best = raw_rows + .iter() + .map(|r| r.rank) + .fold(f64::INFINITY, f64::min); + let range = worst - best; + + raw_rows + .iter() + .map(|r| { + let score = if range.abs() < f64::EPSILON { + 1.0 + } else { + ((worst - r.rank) / range).clamp(0.0, 1.0) + }; + r.into_match(score) + }) + .collect() + }; + + let total_results = results.len(); + + // -- Build active filters for output ---------------------------------- + let has_filters = branch.is_some() + || from.is_some() + || to.is_some() + || project.is_some() + || all_projects; + + let filters = if has_filters { + Some(SearchFilters { + project: project.map(|s| s.to_string()), + branch: branch.map(|s| s.to_string()), + from: from.map(|s| s.to_string()), + to: to.map(|s| s.to_string()), + all_projects, + }) + } else { + None + }; + + // -- Output ----------------------------------------------------------- + if json { + let output = SearchResult { + query: query.to_string(), + filters, + total_results, + results, + }; + println!("{}", toolbox_core::output::render_json(&output)); + return Ok(()); + } + + // Human-readable output + if results.is_empty() { + println!( + "{}", + toolbox_core::output::empty_hint( + "sessions", + "Try broader terms or --all-projects." + ) + ); + return Ok(()); + } + + use colored::Colorize; + use toolbox_core::output::{relative_time, truncate}; + + eprintln!( + "{} results for '{}'", + total_results.to_string().bold(), + query.bold() + ); + + // Table header + println!( + "\n{:<12} {:<40} {:<25} {:<6} {:<10} {:<5}", + "SESSION", "SUMMARY", "BRANCH", "MSGS", "MODIFIED", "SCORE" + ); + + for m in &results { + let session_short = truncate(&m.session_id, 10); + let summary = m.summary.as_deref().unwrap_or("-"); + let branch_str = m.git_branch.as_deref().unwrap_or("-"); + let modified = m + .modified_at + .as_deref() + .map(relative_time) + .unwrap_or_else(|| "-".to_string()); + let score_pct = format!("{:.0}%", m.relevance_score * 100.0); + + println!( + "{:<12} {:<40} {:<25} {:<6} {:<10} {:<5}", + session_short, + truncate(summary, 38), + truncate(branch_str, 23), + m.message_count, + modified, + score_pct, + ); + } + + // Matched snippets below the table + println!(); + for m in &results { + if let Some(ref snippet) = m.matched_snippet { + let role = m.matched_role.as_deref().unwrap_or("?"); + let session_short = truncate(&m.session_id, 10); + println!( + " {} [{}] {}", + session_short.dimmed(), + role.cyan(), + truncate(snippet, 120), + ); + } + } + + Ok(()) +} + +/// Intermediate row from the SQL query, before BM25 normalization. +struct RawRow { + session_id: String, + summary: Option, + first_prompt: Option, + git_branch: Option, + project_path: Option, + message_count: i64, + created_at: Option, + modified_at: Option, + rank: f64, + snippet: Option, + matched_role: Option, +} + +impl RawRow { + /// Convert into a `SessionMatch` with a pre-computed relevance score. + fn into_match(&self, score: f64) -> SessionMatch { + SessionMatch { + session_id: self.session_id.clone(), + summary: self.summary.clone(), + first_prompt: self.first_prompt.clone(), + git_branch: self.git_branch.clone(), + project_path: self.project_path.clone(), + message_count: self.message_count, + created_at: self.created_at.clone(), + modified_at: self.modified_at.clone(), + relevance_score: score, + matched_snippet: self.snippet.clone(), + matched_role: self.matched_role.clone(), + } + } +} diff --git a/crates/tb-session/src/lib.rs b/crates/tb-session/src/lib.rs index 84bd2b2..ce56c4e 100644 --- a/crates/tb-session/src/lib.rs +++ b/crates/tb-session/src/lib.rs @@ -1,3 +1,5 @@ pub mod commands; pub mod config; pub mod error; +pub mod index; +pub mod models; diff --git a/crates/tb-session/src/main.rs b/crates/tb-session/src/main.rs index b504216..b0be8de 100644 --- a/crates/tb-session/src/main.rs +++ b/crates/tb-session/src/main.rs @@ -31,6 +31,30 @@ enum Commands { Search { /// Search query query: String, + + /// Filter by git branch name + #[arg(long)] + branch: Option, + + /// Filter by project path (substring match) + #[arg(long)] + project: Option, + + /// Search across all projects (default: current directory only) + #[arg(long)] + all_projects: bool, + + /// Maximum number of results + #[arg(long)] + limit: Option, + + /// Only sessions modified on or after this date (ISO 8601) + #[arg(long)] + after: Option, + + /// Only sessions modified on or before this date (ISO 8601) + #[arg(long)] + before: Option, }, /// Manage configuration @@ -70,8 +94,41 @@ fn run() -> tb_session::error::Result<()> { }; match command { - Commands::Search { query } => { - println!("TODO: search for '{}'", query); + Commands::Search { + query, + branch, + project, + all_projects, + limit, + after, + before, + } => { + let config = Config::load()?; + let conn = tb_session::index::open_db(cli.no_cache)?; + + // Ensure index is fresh (scoped to cwd unless --all-projects) + let projects_dir = config.projects_dir(); + let cwd = std::env::current_dir().ok(); + let scope = if all_projects { + None + } else { + cwd.as_deref() + }; + tb_session::index::ensure_fresh(&conn, &projects_dir, scope)?; + + let effective_limit = limit.unwrap_or(config.default_limit); + + tb_session::commands::search::run( + &conn, + &query, + branch.as_deref(), + after.as_deref(), + before.as_deref(), + project.as_deref(), + all_projects, + effective_limit, + cli.json, + )?; } Commands::Config { action } => match action { ConfigAction::Init => tb_session::commands::config_cmd::init()?, diff --git a/crates/tb-session/src/models.rs b/crates/tb-session/src/models.rs new file mode 100644 index 0000000..9c7d422 --- /dev/null +++ b/crates/tb-session/src/models.rs @@ -0,0 +1,108 @@ +use serde::Serialize; + +/// Top-level response for the `search` command. +#[derive(Debug, Serialize)] +pub struct SearchResult { + pub query: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub filters: Option, + pub total_results: usize, + pub results: Vec, +} + +/// Active filters applied to a search. +#[derive(Debug, Serialize)] +pub struct SearchFilters { + #[serde(skip_serializing_if = "Option::is_none")] + pub project: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub branch: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub from: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub to: Option, + pub all_projects: bool, +} + +/// A single session that matched a search query. +#[derive(Debug, Serialize)] +pub struct SessionMatch { + pub session_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub first_prompt: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub git_branch: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub project_path: Option, + pub message_count: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub created_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub modified_at: Option, + pub relevance_score: f64, + #[serde(skip_serializing_if = "Option::is_none")] + pub matched_snippet: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub matched_role: Option, +} + +/// Detailed view of a single session (for `show` command). +#[derive(Debug, Serialize)] +pub struct SessionDetail { + pub session_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub first_prompt: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub git_branch: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub project_path: Option, + pub message_count: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub created_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub modified_at: Option, + pub is_sidechain: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub messages: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub total_messages: Option, +} + +/// A single message preview within a session. +#[derive(Debug, Serialize)] +pub struct MessagePreview { + pub role: String, + pub content: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub timestamp: Option, +} + +/// Top-level response for the `list` command. +#[derive(Debug, Serialize)] +pub struct SessionList { + pub total_results: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub page: Option, + pub results: Vec, +} + +/// Summary of a session in list output. +#[derive(Debug, Serialize)] +pub struct SessionSummary { + pub session_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub git_branch: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub project_path: Option, + pub message_count: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub created_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub modified_at: Option, +} From 679b8c13df73049a5aceae07a829a9036eb0aa3a Mon Sep 17 00:00:00 2001 From: Dario Filipaj Date: Mon, 23 Mar 2026 13:55:05 +0100 Subject: [PATCH 09/29] feat(tb-session): add show and resume commands Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/tb-session/src/commands/resume.rs | 57 +++++++ crates/tb-session/src/commands/show.rs | 197 +++++++++++++++++++++++ 2 files changed, 254 insertions(+) create mode 100644 crates/tb-session/src/commands/resume.rs create mode 100644 crates/tb-session/src/commands/show.rs diff --git a/crates/tb-session/src/commands/resume.rs b/crates/tb-session/src/commands/resume.rs new file mode 100644 index 0000000..827b5b4 --- /dev/null +++ b/crates/tb-session/src/commands/resume.rs @@ -0,0 +1,57 @@ +use crate::error::{Error, Result}; + +pub fn run(session_id: &str) -> Result<()> { + let claude_path = which_claude()?; + + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + let err = std::process::Command::new(&claude_path) + .arg("--resume") + .arg(session_id) + .exec(); + // exec() only returns on error + return Err(Error::Other(format!( + "Failed to exec claude: {}", + err + ))); + } + + #[cfg(not(unix))] + { + let status = std::process::Command::new(&claude_path) + .arg("--resume") + .arg(session_id) + .status() + .map_err(|e| Error::Other(format!("Failed to run claude: {}", e)))?; + + if !status.success() { + return Err(Error::Other(format!( + "claude exited with status: {}", + status + ))); + } + Ok(()) + } +} + +fn which_claude() -> Result { + let output = std::process::Command::new("which") + .arg("claude") + .output() + .map_err(|e| Error::Other(format!("Failed to run 'which claude': {}", e)))?; + + if !output.status.success() { + return Err(Error::Other( + "claude CLI not found in PATH. Install it from https://docs.anthropic.com/s/claude-code" + .to_string(), + )); + } + + let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if path.is_empty() { + return Err(Error::Other("claude CLI not found in PATH".to_string())); + } + + Ok(path) +} diff --git a/crates/tb-session/src/commands/show.rs b/crates/tb-session/src/commands/show.rs new file mode 100644 index 0000000..2d3cd40 --- /dev/null +++ b/crates/tb-session/src/commands/show.rs @@ -0,0 +1,197 @@ +use rusqlite::Connection; +use std::path::Path; + +use colored::Colorize; + +use crate::error::{Error, Result}; +use crate::index::parser; +use crate::models::{MessagePreview, SessionDetail}; + +pub fn run(conn: &Connection, session_id: &str, json: bool) -> Result<()> { + // Look up session by full ID or prefix match + let prefix_pattern = format!("{}%", session_id); + let mut stmt = conn.prepare( + "SELECT session_id, summary, first_prompt, git_branch, project_path, + message_count, created_at, modified_at, is_sidechain, file_path + FROM sessions + WHERE session_id = ?1 OR session_id LIKE ?2 + LIMIT 1", + )?; + + let row = stmt + .query_row(rusqlite::params![session_id, prefix_pattern], |row| { + Ok(RawSession { + session_id: row.get(0)?, + summary: row.get(1)?, + first_prompt: row.get(2)?, + git_branch: row.get(3)?, + project_path: row.get(4)?, + message_count: row.get(5)?, + created_at: row.get(6)?, + modified_at: row.get(7)?, + is_sidechain: row.get::<_, i64>(8)? != 0, + file_path: row.get(9)?, + }) + }) + .map_err(|e| match e { + rusqlite::Error::QueryReturnedNoRows => { + Error::Other(format!("Session '{}' not found", session_id)) + } + other => Error::Db(other), + })?; + + // Parse JSONL file for message preview if it exists + let messages = if Path::new(&row.file_path).exists() { + match parser::parse_session(Path::new(&row.file_path)) { + Ok(parsed) => Some(parsed.messages), + Err(_) => None, + } + } else { + None + }; + + if json { + output_json(&row, messages.as_deref()); + } else { + output_human(&row, messages.as_deref()); + } + + Ok(()) +} + +fn output_json(row: &RawSession, messages: Option<&[parser::ParsedMessage]>) { + let total_messages = messages.as_ref().map(|m| m.len()); + + let previews = messages.map(|msgs| { + msgs.iter() + .take(20) + .map(|m| MessagePreview { + role: m.role.clone(), + content: truncate_string(&m.content, 2000), + timestamp: m.timestamp.clone(), + }) + .collect() + }); + + let detail = SessionDetail { + session_id: row.session_id.clone(), + summary: row.summary.clone(), + first_prompt: row.first_prompt.clone(), + git_branch: row.git_branch.clone(), + project_path: row.project_path.clone(), + message_count: row.message_count, + created_at: row.created_at.clone(), + modified_at: row.modified_at.clone(), + is_sidechain: row.is_sidechain, + messages: previews, + total_messages, + }; + + println!("{}", toolbox_core::output::render_json(&detail)); +} + +fn output_human(row: &RawSession, messages: Option<&[parser::ParsedMessage]>) { + // Metadata header + println!("{} {}", "Session:".bold(), row.session_id); + if let Some(ref summary) = row.summary { + println!("{} {}", "Summary:".bold(), summary); + } + if let Some(ref prompt) = row.first_prompt { + println!( + "{} {}", + "First prompt:".bold(), + toolbox_core::output::truncate(prompt, 120) + ); + } + if let Some(ref branch) = row.git_branch { + println!("{} {}", "Branch:".bold(), branch); + } + if let Some(ref path) = row.project_path { + println!("{} {}", "Project:".bold(), path); + } + println!("{} {}", "Messages:".bold(), row.message_count); + if let Some(ref created) = row.created_at { + println!( + "{} {} ({})", + "Created:".bold(), + created, + toolbox_core::output::relative_time(created) + ); + } + if let Some(ref modified) = row.modified_at { + println!( + "{} {} ({})", + "Modified:".bold(), + modified, + toolbox_core::output::relative_time(modified) + ); + } + if row.is_sidechain { + println!("{} yes", "Sidechain:".bold()); + } + + // Conversation preview + if let Some(msgs) = messages { + if !msgs.is_empty() { + println!("\n{}", "--- Conversation Preview ---".dimmed()); + + let total = msgs.len(); + if total <= 10 { + for m in msgs { + print_message(m); + } + } else { + // First 5 + for m in &msgs[..5] { + print_message(m); + } + println!( + " {}", + format!("... ({} omitted) ...", total - 10).dimmed() + ); + // Last 5 + for m in &msgs[total - 5..] { + print_message(m); + } + } + } + } + + println!( + "\n{} tb-session resume {}", + "Resume:".bold(), + row.session_id + ); +} + +fn print_message(m: &parser::ParsedMessage) { + let content = toolbox_core::output::truncate(&m.content, 500); + let role_label = match m.role.as_str() { + "user" => "user".blue().bold(), + "assistant" => "assistant".green().bold(), + other => other.normal(), + }; + println!("\n [{}] {}", role_label, content); +} + +/// Truncate a string to at most `max` characters, appending "..." if truncated. +fn truncate_string(s: &str, max: usize) -> String { + if s.len() <= max { + return s.to_string(); + } + let boundary = s.floor_char_boundary(max.saturating_sub(3)); + format!("{}...", &s[..boundary]) +} + +struct RawSession { + session_id: String, + summary: Option, + first_prompt: Option, + git_branch: Option, + project_path: Option, + message_count: i64, + created_at: Option, + modified_at: Option, + is_sidechain: bool, + file_path: String, +} From 8ef1f4b5909c6624abc5f6423730e6d808cf54b5 Mon Sep 17 00:00:00 2001 From: Dario Filipaj Date: Mon, 23 Mar 2026 14:02:02 +0100 Subject: [PATCH 10/29] feat(tb-session): add index, doctor, and cache-clear utility commands Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/tb-session/src/commands/cache_clear.rs | 27 ++++ crates/tb-session/src/commands/doctor.rs | 146 ++++++++++++++++++ crates/tb-session/src/commands/index_cmd.rs | 38 +++++ 3 files changed, 211 insertions(+) create mode 100644 crates/tb-session/src/commands/cache_clear.rs create mode 100644 crates/tb-session/src/commands/doctor.rs create mode 100644 crates/tb-session/src/commands/index_cmd.rs diff --git a/crates/tb-session/src/commands/cache_clear.rs b/crates/tb-session/src/commands/cache_clear.rs new file mode 100644 index 0000000..cfc799f --- /dev/null +++ b/crates/tb-session/src/commands/cache_clear.rs @@ -0,0 +1,27 @@ +use crate::config::Config; +use crate::error::Result; + +pub fn run() -> Result<()> { + let config = Config::load()?; + let db_path = config.db_path()?; + + if db_path.exists() { + std::fs::remove_file(&db_path)?; + + // Also remove SQLite WAL and shared-memory sidecar files if present. + let wal = db_path.with_extension("db-wal"); + if wal.exists() { + std::fs::remove_file(&wal)?; + } + let shm = db_path.with_extension("db-shm"); + if shm.exists() { + std::fs::remove_file(&shm)?; + } + + println!("Index cleared: {}", db_path.display()); + } else { + println!("No index to clear."); + } + + Ok(()) +} diff --git a/crates/tb-session/src/commands/doctor.rs b/crates/tb-session/src/commands/doctor.rs new file mode 100644 index 0000000..4e1747b --- /dev/null +++ b/crates/tb-session/src/commands/doctor.rs @@ -0,0 +1,146 @@ +use colored::Colorize; + +use crate::config::Config; +use crate::error::Result; + +fn humanize_bytes(bytes: u64) -> String { + if bytes < 1024 { + format!("{} B", bytes) + } else if bytes < 1024 * 1024 { + format!("{:.1} KB", bytes as f64 / 1024.0) + } else { + format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0)) + } +} + +fn check(ok: bool, label: &str) -> bool { + if ok { + println!(" {} {}", "✓".green().bold(), label); + } else { + println!(" {} {}", "✗".red().bold(), label); + } + ok +} + +fn warn(label: &str) { + println!(" {} {}", "⚠".yellow().bold(), label); +} + +pub fn run() -> Result<()> { + let mut all_ok = true; + + println!("{}", "tb-session doctor".bold()); + println!(); + + let config = Config::load()?; + + // 1. Claude home directory + let claude_home = config.claude_home_path(); + let claude_home_exists = claude_home.exists(); + let label = format!("Claude home exists ({})", claude_home.display()); + if !check(claude_home_exists, &label) { + all_ok = false; + } + + // 2. Projects directory + let projects_dir = config.projects_dir(); + let subdir_count = if projects_dir.exists() { + std::fs::read_dir(&projects_dir) + .map(|entries| entries.filter_map(|e| e.ok()).filter(|e| e.path().is_dir()).count()) + .unwrap_or(0) + } else { + 0 + }; + let projects_exists = projects_dir.exists(); + let label = format!( + "Projects directory exists ({} — {} subdirs)", + projects_dir.display(), + subdir_count + ); + if !check(projects_exists, &label) { + all_ok = false; + } + + // 3. Database + FTS5 test + let db_path = config.db_path()?; + let db_exists = db_path.exists(); + if db_exists { + let db_size = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0); + let label = format!( + "Database exists ({} — {})", + db_path.display(), + humanize_bytes(db_size) + ); + check(true, &label); + + // Test FTS5 availability + let fts5_ok = test_fts5(&db_path); + if !check(fts5_ok, "SQLite FTS5 extension available") { + all_ok = false; + } + } else { + warn(&format!("Database not yet created ({})", db_path.display())); + } + + // 4. Config file + let config_path = Config::config_path()?; + if config_path.exists() { + let label = format!("Config file exists ({})", config_path.display()); + check(true, &label); + } else { + warn(&format!( + "Config file not found ({} — using defaults)", + config_path.display() + )); + } + + // 5. claude binary in PATH + let claude_found = which_claude(); + let label = if let Some(ref path) = claude_found { + format!("claude binary found ({})", path) + } else { + "claude binary found in PATH".to_string() + }; + if !check(claude_found.is_some(), &label) { + all_ok = false; + } + + println!(); + if all_ok { + println!("{}", "All checks passed.".green().bold()); + } else { + println!("{}", "Some checks failed.".red().bold()); + } + + Ok(()) +} + +fn test_fts5(db_path: &std::path::Path) -> bool { + let conn = match rusqlite::Connection::open(db_path) { + Ok(c) => c, + Err(_) => return false, + }; + let create = conn.execute_batch( + "CREATE VIRTUAL TABLE IF NOT EXISTS _doctor_fts5_test \ + USING fts5(content); \ + DROP TABLE IF EXISTS _doctor_fts5_test;", + ); + create.is_ok() +} + +fn which_claude() -> Option { + let output = std::process::Command::new("which") + .arg("claude") + .output() + .ok()?; + if output.status.success() { + let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if path.is_empty() { + None + } else { + Some(path) + } + } else { + None + } +} diff --git a/crates/tb-session/src/commands/index_cmd.rs b/crates/tb-session/src/commands/index_cmd.rs new file mode 100644 index 0000000..514825f --- /dev/null +++ b/crates/tb-session/src/commands/index_cmd.rs @@ -0,0 +1,38 @@ +use crate::config::Config; +use crate::error::Result; + +fn humanize_bytes(bytes: u64) -> String { + if bytes < 1024 { + format!("{} B", bytes) + } else if bytes < 1024 * 1024 { + format!("{:.1} KB", bytes as f64 / 1024.0) + } else { + format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0)) + } +} + +pub fn run(all_projects: bool) -> Result<()> { + let config = Config::load()?; + let conn = crate::index::open_db(true)?; + + let projects_dir = config.projects_dir(); + let cwd = std::env::current_dir().ok(); + let scope: Option<&std::path::Path> = if all_projects { + None + } else { + cwd.as_deref() + }; + + println!("Indexing sessions..."); + crate::index::ensure_fresh(&conn, &projects_dir, scope)?; + + let stats = crate::index::get_stats(&conn)?; + println!("Indexed {} sessions", stats.session_count); + println!( + "Total: {} sessions across {} projects", + stats.session_count, stats.project_count + ); + println!("Database: {}", humanize_bytes(stats.db_size_bytes)); + + Ok(()) +} From da93f0b736582e631ec432a951f295d83cf4886c Mon Sep 17 00:00:00 2001 From: Dario Filipaj Date: Mon, 23 Mar 2026 14:15:32 +0100 Subject: [PATCH 11/29] feat(tb-session): add prime command and SKILL.md for Claude integration Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/tb-session/SKILL.md | 22 +++++- crates/tb-session/src/commands/mod.rs | 7 ++ crates/tb-session/src/commands/prime.rs | 95 +++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 crates/tb-session/src/commands/prime.rs diff --git a/crates/tb-session/SKILL.md b/crates/tb-session/SKILL.md index de7881a..74565ad 100644 --- a/crates/tb-session/SKILL.md +++ b/crates/tb-session/SKILL.md @@ -5,7 +5,27 @@ description: Search and manage Claude Code sessions. Use when the user reference # tb-session -Claude Code session search CLI. Full-text search across session history with metadata filtering. +Claude Code session search CLI. Full-text search across session history with metadata filtering. Built for AI agent consumption but works for humans too. + +## Capabilities + +- **Search** — full-text search across user and assistant messages with BM25 ranking +- **List** — browse sessions by metadata (branch, date, project) +- **Show** — session detail with conversation preview +- **Resume** — resume a past session (execs into claude --resume) + +## When to use + +- User references prior work: "remember when we...", "that session where..." +- Before starting work: check if a prior session already started the same task +- User asks to find or resume a past session +- Always use `--json` for programmatic access + +## Important + +- `resume` is a session-ending action — it execs into a new Claude process. Always confirm with the user before resuming. +- Default scope is the current project directory. Use `--all-projects` to widen. +- Session ID can be a prefix — `show abc123` matches `abc123-def-456-...` ## Getting started diff --git a/crates/tb-session/src/commands/mod.rs b/crates/tb-session/src/commands/mod.rs index 7820c9f..a66ae6e 100644 --- a/crates/tb-session/src/commands/mod.rs +++ b/crates/tb-session/src/commands/mod.rs @@ -1,2 +1,9 @@ +pub mod cache_clear; pub mod config_cmd; +pub mod doctor; +pub mod index_cmd; +pub mod list; +pub mod prime; +pub mod resume; pub mod search; +pub mod show; diff --git a/crates/tb-session/src/commands/prime.rs b/crates/tb-session/src/commands/prime.rs new file mode 100644 index 0000000..33efaf1 --- /dev/null +++ b/crates/tb-session/src/commands/prime.rs @@ -0,0 +1,95 @@ +use chrono::TimeZone; + +use crate::config::Config; +use crate::error::Result; +use crate::index; + +pub fn run() -> Result<()> { + let config = Config::load()?; + + // Render the commands/options section with the configured default_limit + let default_limit = config.default_limit; + + print!( + r#"# tb-session — Claude Code session search + +## Commands + +- `tb-session search ` — full-text search across sessions + - `--branch ` — filter by git branch + - `--after ` — created after (YYYY-MM-DD or relative: 7d, 2w, today) + - `--before ` — created before + - `--project ` — filter by project path (substring match) + - `--all-projects` — search across all projects (default: current dir) + - `--limit ` — max results (default: {default_limit}) + - `--json` — structured output + - `--no-cache` — force index rebuild first +- `tb-session list` — browse sessions by metadata (no full-text) + - Same filters as search + `--page ` +- `tb-session show ` — session detail and conversation preview +- `tb-session resume ` — resume session (execs claude --resume) +- `tb-session index [--all-projects]` — rebuild search index +- `tb-session doctor` — verify setup health +- `tb-session cache-clear` — delete index for clean rebuild + +## Index Status + +"# + ); + + // Try to open the DB and get stats; if absent, show a friendly message + match index::open_db(false) { + Ok(conn) => match index::get_stats(&conn) { + Ok(stats) => { + println!("- {} sessions | {} projects", stats.session_count, stats.project_count); + + // Last updated: check mtime of the DB file + let last_updated = config + .db_path() + .ok() + .and_then(|p| std::fs::metadata(&p).ok()) + .and_then(|m| m.modified().ok()) + .map(|t| { + // Convert SystemTime to ISO 8601 string for relative_time + let duration = t + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + let secs = duration.as_secs() as i64; + // Build an ISO 8601 timestamp + chrono::Utc + .timestamp_opt(secs, 0) + .single() + .map(|dt| dt.to_rfc3339()) + .unwrap_or_else(|| "unknown".to_string()) + }) + .unwrap_or_else(|| "unknown".to_string()); + + let relative = if last_updated == "unknown" { + "unknown".to_string() + } else { + toolbox_core::output::relative_time(&last_updated) + }; + + println!("- Last updated: {relative}"); + } + Err(_) => { + println!("- Not yet built — run `tb-session index` to build"); + } + }, + Err(_) => { + println!("- Not yet built — run `tb-session index` to build"); + } + } + + // Current project + let cwd = std::env::current_dir() + .map(|p| p.display().to_string()) + .unwrap_or_else(|_| "(unknown)".to_string()); + + println!(); + println!("## Current Project"); + println!(); + println!("- {cwd}"); + + Ok(()) +} From 492f06504a24e5a89fe2495b43455f9a41c0905d Mon Sep 17 00:00:00 2001 From: Dario Filipaj Date: Mon, 23 Mar 2026 14:23:40 +0100 Subject: [PATCH 12/29] fix(tb-session): polish and fix issues from code review Fix type mismatches in list command (u32 vs usize), remove invalid unwrap_or on non-Option field, wrap page in Some() for SessionList construction, fix pagination_hint call with correct types, remove unnecessary as_ref() in show command, rename into_match to to_match to follow Rust naming conventions for non-consuming methods, and wire all commands in main.rs dispatch. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/tb-session/src/commands/list.rs | 22 ++-- crates/tb-session/src/commands/search.rs | 8 +- crates/tb-session/src/commands/show.rs | 2 +- crates/tb-session/src/main.rs | 122 +++++++++++++++++++++++ 4 files changed, 139 insertions(+), 15 deletions(-) diff --git a/crates/tb-session/src/commands/list.rs b/crates/tb-session/src/commands/list.rs index 0733cf9..39c89a2 100644 --- a/crates/tb-session/src/commands/list.rs +++ b/crates/tb-session/src/commands/list.rs @@ -10,8 +10,8 @@ pub fn run( from: Option<&str>, to: Option<&str>, all_projects: bool, - limit: u32, - page: u32, + limit: usize, + page: usize, json: bool, ) -> Result<()> { let mut sql = String::from( @@ -60,11 +60,13 @@ pub fn run( let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect(); - let total: u32 = conn.query_row( - &count_sql, - rusqlite::params_from_iter(param_refs.iter().copied()), - |row| row.get(0), - )?; + let total: usize = conn + .query_row( + &count_sql, + rusqlite::params_from_iter(param_refs.iter().copied()), + |row| row.get::<_, i64>(0), + ) + .map(|n| n as usize)?; // Paginated data query let offset = (page.saturating_sub(1)) * limit; @@ -93,7 +95,7 @@ pub fn run( let list = SessionList { total_results: total, - page, + page: Some(page), results, }; @@ -137,13 +139,13 @@ pub fn run( toolbox_core::output::truncate(&s.session_id, 36), toolbox_core::output::truncate(summary, 38), toolbox_core::output::truncate(branch, 18), - s.message_count.unwrap_or(0), + s.message_count, modified, ); } if let Some(hint) = - toolbox_core::output::pagination_hint(list.page, limit, list.total_results) + toolbox_core::output::pagination_hint(page as u32, limit as u32, total as u32) { eprintln!("{}", hint); } diff --git a/crates/tb-session/src/commands/search.rs b/crates/tb-session/src/commands/search.rs index 3aa1daf..07905b7 100644 --- a/crates/tb-session/src/commands/search.rs +++ b/crates/tb-session/src/commands/search.rs @@ -121,7 +121,7 @@ pub fn run( let results: Vec = if raw_rows.is_empty() { Vec::new() } else if raw_rows.len() == 1 { - vec![raw_rows[0].into_match(1.0)] + vec![raw_rows[0].to_match(1.0)] } else { // rank is negative in FTS5 (more negative = better match) // worst = least negative (highest value), best = most negative (lowest value) @@ -143,7 +143,7 @@ pub fn run( } else { ((worst - r.rank) / range).clamp(0.0, 1.0) }; - r.into_match(score) + r.to_match(score) }) .collect() }; @@ -264,8 +264,8 @@ struct RawRow { } impl RawRow { - /// Convert into a `SessionMatch` with a pre-computed relevance score. - fn into_match(&self, score: f64) -> SessionMatch { + /// Convert to a `SessionMatch` with a pre-computed relevance score. + fn to_match(&self, score: f64) -> SessionMatch { SessionMatch { session_id: self.session_id.clone(), summary: self.summary.clone(), diff --git a/crates/tb-session/src/commands/show.rs b/crates/tb-session/src/commands/show.rs index 2d3cd40..5e0a6ff 100644 --- a/crates/tb-session/src/commands/show.rs +++ b/crates/tb-session/src/commands/show.rs @@ -60,7 +60,7 @@ pub fn run(conn: &Connection, session_id: &str, json: bool) -> Result<()> { } fn output_json(row: &RawSession, messages: Option<&[parser::ParsedMessage]>) { - let total_messages = messages.as_ref().map(|m| m.len()); + let total_messages = messages.map(|m| m.len()); let previews = messages.map(|msgs| { msgs.iter() diff --git a/crates/tb-session/src/main.rs b/crates/tb-session/src/main.rs index b0be8de..e4d2d6d 100644 --- a/crates/tb-session/src/main.rs +++ b/crates/tb-session/src/main.rs @@ -57,6 +57,67 @@ enum Commands { before: Option, }, + /// List sessions by metadata (no full-text search) + List { + /// Filter by git branch name + #[arg(long)] + branch: Option, + + /// List sessions across all projects + #[arg(long)] + all_projects: bool, + + /// Maximum number of results per page + #[arg(long)] + limit: Option, + + /// Page number (starts at 1) + #[arg(long, default_value = "1")] + page: usize, + + /// Only sessions modified on or after this date + #[arg(long)] + after: Option, + + /// Only sessions modified on or before this date + #[arg(long)] + before: Option, + }, + + /// Show session detail and conversation preview + Show { + /// Session ID (full or prefix) + session_id: String, + }, + + /// Resume a session (execs into claude --resume) + Resume { + /// Session ID + session_id: String, + }, + + /// Rebuild the search index + Index { + /// Index all projects (default: current dir only) + #[arg(long)] + all_projects: bool, + }, + + /// Verify setup and diagnose issues + Doctor, + + /// Delete the index database for a clean rebuild + CacheClear, + + /// AI-optimized context dump + Prime, + + /// Manage Claude Code skill file + Skill { + #[command(subcommand)] + action: toolbox_core::skill::SkillAction, + }, + /// Manage configuration Config { #[command(subcommand)] @@ -130,6 +191,67 @@ fn run() -> tb_session::error::Result<()> { cli.json, )?; } + Commands::List { + branch, + all_projects, + limit, + page, + after, + before, + } => { + let config = Config::load()?; + let conn = tb_session::index::open_db(cli.no_cache)?; + + let projects_dir = config.projects_dir(); + let cwd = std::env::current_dir().ok(); + let scope = if all_projects { None } else { cwd.as_deref() }; + tb_session::index::ensure_fresh(&conn, &projects_dir, scope)?; + + let effective_limit = limit.unwrap_or(config.default_limit); + + tb_session::commands::list::run( + &conn, + branch.as_deref(), + after.as_deref(), + before.as_deref(), + all_projects, + effective_limit, + page, + cli.json, + )?; + } + Commands::Show { session_id } => { + let config = Config::load()?; + let conn = tb_session::index::open_db(cli.no_cache)?; + let projects_dir = config.projects_dir(); + tb_session::index::ensure_fresh(&conn, &projects_dir, None)?; + tb_session::commands::show::run(&conn, &session_id, cli.json)?; + } + Commands::Resume { session_id } => { + tb_session::commands::resume::run(&session_id)?; + } + Commands::Index { all_projects } => { + tb_session::commands::index_cmd::run(all_projects)?; + } + Commands::Doctor => { + tb_session::commands::doctor::run()?; + toolbox_core::version_check::print_update_hint("tb-session", env!("CARGO_PKG_VERSION")); + } + Commands::CacheClear => { + tb_session::commands::cache_clear::run()?; + } + Commands::Prime => { + tb_session::commands::prime::run()?; + toolbox_core::version_check::print_update_hint("tb-session", env!("CARGO_PKG_VERSION")); + } + Commands::Skill { action } => { + let skill = toolbox_core::skill::SkillConfig { + tool_name: "tb-session", + content: include_str!("../SKILL.md"), + }; + toolbox_core::skill::run(&skill, &action) + .map_err(tb_session::error::Error::Other)?; + } Commands::Config { action } => match action { ConfigAction::Init => tb_session::commands::config_cmd::init()?, ConfigAction::Show => tb_session::commands::config_cmd::show()?, From 26e9979e43915d4913c21ed7e976fa756326c723 Mon Sep 17 00:00:00 2001 From: Dario Filipaj Date: Mon, 23 Mar 2026 14:24:48 +0100 Subject: [PATCH 13/29] chore: add tb-session to distribution scripts --- scripts/bump.sh | 2 +- scripts/install.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/bump.sh b/scripts/bump.sh index 89fcbdf..a3c70a9 100755 --- a/scripts/bump.sh +++ b/scripts/bump.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -VALID_TOOLS="tb-prod tb-sem tb-bug tb-lf" +VALID_TOOLS="tb-prod tb-sem tb-bug tb-lf tb-session" usage() { echo "Usage: $0 " diff --git a/scripts/install.sh b/scripts/install.sh index ee0e0c4..4596785 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2,7 +2,7 @@ set -euo pipefail REPO="productiveio/cli-toolbox" -ALL_TOOLS="tb-prod tb-sem tb-bug tb-lf" +ALL_TOOLS="tb-prod tb-sem tb-bug tb-lf tb-session" INSTALL_DIR="$HOME/.local/bin" # --- Flags --- From b87a561fd03d4f29643c2dae5c6768ba4a563406 Mon Sep 17 00:00:00 2001 From: Dario Filipaj Date: Mon, 23 Mar 2026 14:31:22 +0100 Subject: [PATCH 14/29] fix(tb-session): apply clippy auto-fixes --- Cargo.lock | 111 ++++++++++++++++++++++- crates/tb-session/src/commands/resume.rs | 4 +- crates/tb-session/src/commands/show.rs | 5 +- crates/tb-session/src/config.rs | 5 +- crates/tb-session/src/index/parser.rs | 10 +- crates/tb-session/src/index/scanner.rs | 10 +- 6 files changed, 124 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cd54139..48b6d08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -465,6 +465,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.3.0" @@ -498,6 +510,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -645,7 +663,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -653,6 +671,18 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hashlink" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" +dependencies = [ + "hashbrown 0.16.1", +] [[package]] name = "heck" @@ -1057,6 +1087,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1322,6 +1363,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + [[package]] name = "portable-atomic" version = "1.13.1" @@ -1639,6 +1686,31 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rsqlite-vfs" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.18", +] + +[[package]] +name = "rusqlite" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3" +dependencies = [ + "bitflags 2.11.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + [[package]] name = "rustc-hash" version = "2.1.1" @@ -1939,6 +2011,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4206ed3a67690b9c29b77d728f6acc3ce78f16bf846d83c94f76400320181b" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -2127,6 +2211,25 @@ dependencies = [ "toolbox-core", ] +[[package]] +name = "tb-session" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "chrono", + "clap", + "colored", + "dirs", + "predicates", + "rusqlite", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "toml", + "toolbox-core", +] + [[package]] name = "tempfile" version = "3.26.0" @@ -2556,6 +2659,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "vcr-cassette" version = "2.0.1" diff --git a/crates/tb-session/src/commands/resume.rs b/crates/tb-session/src/commands/resume.rs index 827b5b4..af142eb 100644 --- a/crates/tb-session/src/commands/resume.rs +++ b/crates/tb-session/src/commands/resume.rs @@ -11,10 +11,10 @@ pub fn run(session_id: &str) -> Result<()> { .arg(session_id) .exec(); // exec() only returns on error - return Err(Error::Other(format!( + Err(Error::Other(format!( "Failed to exec claude: {}", err - ))); + ))) } #[cfg(not(unix))] diff --git a/crates/tb-session/src/commands/show.rs b/crates/tb-session/src/commands/show.rs index 5e0a6ff..e6817df 100644 --- a/crates/tb-session/src/commands/show.rs +++ b/crates/tb-session/src/commands/show.rs @@ -131,8 +131,8 @@ fn output_human(row: &RawSession, messages: Option<&[parser::ParsedMessage]>) { } // Conversation preview - if let Some(msgs) = messages { - if !msgs.is_empty() { + if let Some(msgs) = messages + && !msgs.is_empty() { println!("\n{}", "--- Conversation Preview ---".dimmed()); let total = msgs.len(); @@ -155,7 +155,6 @@ fn output_human(row: &RawSession, messages: Option<&[parser::ParsedMessage]>) { } } } - } println!( "\n{} tb-session resume {}", diff --git a/crates/tb-session/src/config.rs b/crates/tb-session/src/config.rs index 80560b3..508f6a9 100644 --- a/crates/tb-session/src/config.rs +++ b/crates/tb-session/src/config.rs @@ -61,8 +61,8 @@ impl Config { /// Resolves `~` in `claude_home` to an absolute PathBuf. pub fn claude_home_path(&self) -> PathBuf { - if self.claude_home.starts_with('~') { - if let Some(home) = dirs::home_dir() { + if self.claude_home.starts_with('~') + && let Some(home) = dirs::home_dir() { let stripped = self.claude_home.trim_start_matches('~'); let stripped = stripped.trim_start_matches('/'); if stripped.is_empty() { @@ -70,7 +70,6 @@ impl Config { } return home.join(stripped); } - } PathBuf::from(&self.claude_home) } diff --git a/crates/tb-session/src/index/parser.rs b/crates/tb-session/src/index/parser.rs index b9337ac..286381c 100644 --- a/crates/tb-session/src/index/parser.rs +++ b/crates/tb-session/src/index/parser.rs @@ -70,18 +70,16 @@ pub fn parse_session(file_path: &Path) -> Result { } // Extract gitBranch from first entry that has it - if git_branch.is_none() { - if let Some(branch) = entry.get("gitBranch").and_then(|b| b.as_str()) { + if git_branch.is_none() + && let Some(branch) = entry.get("gitBranch").and_then(|b| b.as_str()) { git_branch = Some(branch.to_string()); } - } // Check isSidechain - if let Some(sc) = entry.get("isSidechain").and_then(|v| v.as_bool()) { - if sc { + if let Some(sc) = entry.get("isSidechain").and_then(|v| v.as_bool()) + && sc { is_sidechain = true; } - } // Extract user/assistant messages if let Some(message) = entry.get("message") { diff --git a/crates/tb-session/src/index/scanner.rs b/crates/tb-session/src/index/scanner.rs index 96c4776..2eb7e98 100644 --- a/crates/tb-session/src/index/scanner.rs +++ b/crates/tb-session/src/index/scanner.rs @@ -73,11 +73,10 @@ pub fn scan_projects( }; // When scoping, skip directories that don't match the encoded cwd. - if let Some(ref encoded) = encoded_cwd { - if dir_name != *encoded { + if let Some(ref encoded) = encoded_cwd + && dir_name != *encoded { continue; } - } // Load sessions-index.json for this project directory. let index = load_sessions_index(&dir_path); @@ -173,11 +172,10 @@ pub fn extract_cwd_from_jsonl(path: &Path) -> Option { for line in reader.lines().take(5) { let line = line.ok()?; - if let Ok(value) = serde_json::from_str::(&line) { - if let Some(cwd) = value.get("cwd").and_then(|v| v.as_str()) { + if let Ok(value) = serde_json::from_str::(&line) + && let Some(cwd) = value.get("cwd").and_then(|v| v.as_str()) { return Some(cwd.to_string()); } - } } None From 6bfd20be7ab2b6b4dfd60858e9b9eac515862905 Mon Sep 17 00:00:00 2001 From: Dario Filipaj Date: Mon, 23 Mar 2026 14:33:32 +0100 Subject: [PATCH 15/29] fix(tb-session): fix FTS5 snippet with GROUP BY, deduplicate search results --- crates/tb-session/src/commands/search.rs | 29 +++++++++++++++++------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/crates/tb-session/src/commands/search.rs b/crates/tb-session/src/commands/search.rs index 07905b7..f8ba839 100644 --- a/crates/tb-session/src/commands/search.rs +++ b/crates/tb-session/src/commands/search.rs @@ -70,6 +70,8 @@ pub fn run( let where_sql = where_clauses.join(" AND "); + // Use a subquery to find best-matching sessions first, then fetch snippets. + // snippet() cannot be used with GROUP BY directly in FTS5. let sql = format!( "SELECT s.session_id, @@ -80,15 +82,26 @@ pub fn run( s.message_count, s.created_at, s.modified_at, - rank, - snippet(messages_fts, 2, '...', '...', '...', 20), + best.best_rank, + snippet(messages_fts, 2, '«', '»', '…', 20), messages_fts.role - FROM messages_fts - JOIN sessions s ON s.session_id = messages_fts.session_id - WHERE {where_sql} - GROUP BY s.session_id - ORDER BY rank - LIMIT ?{param_idx}" + FROM ( + SELECT messages_fts.session_id, MIN(rank) AS best_rank + FROM messages_fts + JOIN sessions s ON s.session_id = messages_fts.session_id + WHERE {where_sql} + GROUP BY messages_fts.session_id + ORDER BY best_rank + LIMIT ?{param_idx} + ) best + JOIN sessions s ON s.session_id = best.session_id + JOIN messages_fts ON messages_fts.rowid = ( + SELECT rowid FROM messages_fts + WHERE messages_fts.session_id = best.session_id + AND messages_fts MATCH ?1 + LIMIT 1 + ) + ORDER BY best.best_rank" ); params.push(Box::new(limit as i64)); From 34523baa20dde181c78ecba3d5121e95b061a86c Mon Sep 17 00:00:00 2001 From: Dario Filipaj Date: Mon, 23 Mar 2026 15:10:01 +0100 Subject: [PATCH 16/29] fix(tb-session): use neutral label for claude binary check in doctor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The else branch said "claude binary found in PATH" even when the binary was not found — the check() function renders ✓/✗, so labels should describe what's being checked, not assert the result. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/tb-session/src/commands/doctor.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tb-session/src/commands/doctor.rs b/crates/tb-session/src/commands/doctor.rs index 4e1747b..5e0f8b2 100644 --- a/crates/tb-session/src/commands/doctor.rs +++ b/crates/tb-session/src/commands/doctor.rs @@ -97,9 +97,9 @@ pub fn run() -> Result<()> { // 5. claude binary in PATH let claude_found = which_claude(); let label = if let Some(ref path) = claude_found { - format!("claude binary found ({})", path) + format!("claude binary in PATH ({})", path) } else { - "claude binary found in PATH".to_string() + "claude binary in PATH".to_string() }; if !check(claude_found.is_some(), &label) { all_ok = false; From 066b096a0ffda8652e834bed6cbfbb2e938f14a2 Mon Sep 17 00:00:00 2001 From: Dario Filipaj Date: Mon, 23 Mar 2026 17:12:39 +0100 Subject: [PATCH 17/29] fix(tb-session): handle versioned sessions-index.json format The real format is {"version": 1, "entries": [...]} but the parser only handled flat arrays, causing all index metadata to be silently discarded. Now tries versioned format first, falls back to flat array. Co-Authored-By: Claude Sonnet 4.6 --- crates/tb-session/src/index/scanner.rs | 44 ++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/crates/tb-session/src/index/scanner.rs b/crates/tb-session/src/index/scanner.rs index 2eb7e98..acf3694 100644 --- a/crates/tb-session/src/index/scanner.rs +++ b/crates/tb-session/src/index/scanner.rs @@ -181,10 +181,21 @@ pub fn extract_cwd_from_jsonl(path: &Path) -> Option { None } +/// Versioned wrapper format used by Claude: `{"version": N, "entries": [...]}`. +#[derive(serde::Deserialize)] +struct VersionedIndex { + #[allow(dead_code)] + version: u32, + entries: Vec, +} + /// Load and parse `sessions-index.json` from a project directory. /// /// Returns a map from `session_id` to `IndexEntry`. Returns an empty map /// if the file doesn't exist or can't be parsed. +/// +/// Supports both the versioned format `{"version": N, "entries": [...]}` and +/// the legacy flat-array format `[...]`. pub fn load_sessions_index(project_dir: &Path) -> HashMap { let index_path = project_dir.join("sessions-index.json"); @@ -193,6 +204,16 @@ pub fn load_sessions_index(project_dir: &Path) -> HashMap { Err(_) => return HashMap::new(), }; + // Try versioned format first: {"version": N, "entries": [...]} + if let Ok(versioned) = serde_json::from_str::(&content) { + return versioned + .entries + .into_iter() + .map(|e| (e.session_id.clone(), e)) + .collect(); + } + + // Fallback: flat array format [...] let entries: Vec = match serde_json::from_str(&content) { Ok(e) => e, Err(_) => return HashMap::new(), @@ -447,4 +468,27 @@ mod tests { let results = scan_projects(&nonexistent, None).unwrap(); assert!(results.is_empty()); } + + #[test] + fn test_load_sessions_index_versioned_format() { + let tmp = tempfile::tempdir().unwrap(); + let content = serde_json::json!({ + "version": 1, + "entries": [ + { + "sessionId": "abc-123", + "summary": "Refactored auth", + "messageCount": 42, + "gitBranch": "feature/auth", + "projectPath": "/Users/test/myapp" + } + ], + "originalPath": "/Users/test/myapp" + }); + fs::write(tmp.path().join("sessions-index.json"), content.to_string()).unwrap(); + + let index = load_sessions_index(tmp.path()); + assert_eq!(index.len(), 1); + assert_eq!(index.get("abc-123").unwrap().summary.as_deref(), Some("Refactored auth")); + } } From 6954639a59770a3075082d80376ead8a1d97b337 Mon Sep 17 00:00:00 2001 From: Dario Filipaj Date: Mon, 23 Mar 2026 17:17:55 +0100 Subject: [PATCH 18/29] feat(tb-session): worktree-aware session scoping for list and search Detect git worktrees and include sessions from all worktrees of the same repo when running `list` and `search`. Previously these commands filtered by exact `project_path = cwd`, making sessions from other worktrees invisible. - Add git::repo_paths() to discover all worktrees via `git worktree list` - Change list/search project filter from `= ?` to `IN (?, ?, ...)` - Ensure index freshness for each worktree path - Replace fragile str::replace COUNT query with shared WHERE clause builder Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/tb-session/src/commands/list.rs | 56 +++++++++++------------- crates/tb-session/src/commands/search.rs | 34 +++++++------- crates/tb-session/src/git.rs | 48 ++++++++++++++++++++ crates/tb-session/src/lib.rs | 1 + crates/tb-session/src/main.rs | 47 ++++++++++++-------- 5 files changed, 119 insertions(+), 67 deletions(-) create mode 100644 crates/tb-session/src/git.rs diff --git a/crates/tb-session/src/commands/list.rs b/crates/tb-session/src/commands/list.rs index 39c89a2..a5404ac 100644 --- a/crates/tb-session/src/commands/list.rs +++ b/crates/tb-session/src/commands/list.rs @@ -9,53 +9,47 @@ pub fn run( branch: Option<&str>, from: Option<&str>, to: Option<&str>, - all_projects: bool, + repo_paths: &[String], limit: usize, page: usize, json: bool, ) -> Result<()> { - let mut sql = String::from( - "SELECT session_id, summary, git_branch, project_path, message_count, \ - created_at, modified_at FROM sessions WHERE is_sidechain = 0", - ); + // Build WHERE clause + let mut where_parts = vec!["is_sidechain = 0".to_string()]; let mut params: Vec> = Vec::new(); - // Current-project filter (default) - let cwd = if !all_projects { - let dir = std::env::current_dir()?; - Some(dir.to_string_lossy().into_owned()) - } else { - None - }; - if let Some(ref path) = cwd { - sql.push_str(" AND project_path = ?"); - params.push(Box::new(path.clone())); + if !repo_paths.is_empty() { + let placeholders: Vec = (0..repo_paths.len()) + .map(|i| format!("?{}", i + 1)) + .collect(); + where_parts.push(format!("project_path IN ({})", placeholders.join(", "))); + for path in repo_paths { + params.push(Box::new(path.clone())); + } } if let Some(b) = branch { - sql.push_str(" AND git_branch = ?"); + let idx = params.len() + 1; + where_parts.push(format!("git_branch = ?{idx}")); params.push(Box::new(b.to_string())); } if let Some(f) = from { - sql.push_str(" AND modified_at >= ?"); + let idx = params.len() + 1; + where_parts.push(format!("modified_at >= ?{idx}")); params.push(Box::new(f.to_string())); } if let Some(t) = to { - sql.push_str(" AND modified_at <= ?"); + let idx = params.len() + 1; + where_parts.push(format!("modified_at <= ?{idx}")); params.push(Box::new(t.to_string())); } + let where_clause = where_parts.join(" AND "); + // COUNT query for pagination - let count_sql = format!( - "SELECT COUNT(*) FROM ({}) AS t", - sql.replace( - "SELECT session_id, summary, git_branch, project_path, message_count, \ - created_at, modified_at FROM sessions", - "SELECT 1 FROM sessions", - ) - ); + let count_sql = format!("SELECT COUNT(*) FROM sessions WHERE {where_clause}"); let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect(); @@ -70,12 +64,14 @@ pub fn run( // Paginated data query let offset = (page.saturating_sub(1)) * limit; - sql.push_str(&format!( - " ORDER BY modified_at DESC LIMIT {} OFFSET {}", + let data_sql = format!( + "SELECT session_id, summary, git_branch, project_path, message_count, \ + created_at, modified_at FROM sessions WHERE {where_clause} \ + ORDER BY modified_at DESC LIMIT {} OFFSET {}", limit, offset - )); + ); - let mut stmt = conn.prepare(&sql)?; + let mut stmt = conn.prepare(&data_sql)?; let results: Vec = stmt .query_map( rusqlite::params_from_iter(param_refs.iter().copied()), diff --git a/crates/tb-session/src/commands/search.rs b/crates/tb-session/src/commands/search.rs index f8ba839..1b9591c 100644 --- a/crates/tb-session/src/commands/search.rs +++ b/crates/tb-session/src/commands/search.rs @@ -12,7 +12,7 @@ pub fn run( from: Option<&str>, to: Option<&str>, project: Option<&str>, - all_projects: bool, + repo_paths: &[String], limit: usize, json: bool, ) -> Result<()> { @@ -25,29 +25,24 @@ pub fn run( let mut param_idx: usize = 2; // Project scope - if !all_projects && project.is_none() { - // Default: scope to current working directory - let cwd = std::env::current_dir() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_default(); - if !cwd.is_empty() { - where_clauses.push(format!("s.project_path = ?{param_idx}")); - params.push(Box::new(cwd)); - param_idx += 1; - } - } else if all_projects && project.is_some() { - // --all-projects + --project: LIKE filter across all projects - let pattern = format!("%{}%", project.unwrap()); - where_clauses.push(format!("s.project_path LIKE ?{param_idx}")); - params.push(Box::new(pattern)); - param_idx += 1; - } else if let Some(proj) = project { - // --project without --all-projects: LIKE filter + if let Some(proj) = project { + // --project flag: LIKE filter (works with or without --all-projects) let pattern = format!("%{proj}%"); where_clauses.push(format!("s.project_path LIKE ?{param_idx}")); params.push(Box::new(pattern)); param_idx += 1; + } else if !repo_paths.is_empty() { + // Default: scope to current repo worktrees + let placeholders: Vec = (0..repo_paths.len()) + .map(|i| format!("?{}", param_idx + i)) + .collect(); + where_clauses.push(format!("s.project_path IN ({})", placeholders.join(", "))); + for path in repo_paths { + params.push(Box::new(path.clone())); + param_idx += 1; + } } + // else: no project filter (all projects) // Branch filter if let Some(br) = branch { @@ -164,6 +159,7 @@ pub fn run( let total_results = results.len(); // -- Build active filters for output ---------------------------------- + let all_projects = repo_paths.is_empty() && project.is_none(); let has_filters = branch.is_some() || from.is_some() || to.is_some() diff --git a/crates/tb-session/src/git.rs b/crates/tb-session/src/git.rs new file mode 100644 index 0000000..f6caabc --- /dev/null +++ b/crates/tb-session/src/git.rs @@ -0,0 +1,48 @@ +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// Get all worktree paths for the git repo containing `cwd`. +/// Returns the main worktree + all linked worktrees. +/// Returns just `[cwd]` if not in a git repo or git is unavailable. +pub fn repo_paths(cwd: &Path) -> Vec { + let output = match Command::new("git") + .args(["worktree", "list", "--porcelain"]) + .current_dir(cwd) + .output() + { + Ok(o) if o.status.success() => o, + _ => return vec![cwd.to_path_buf()], + }; + + let stdout = String::from_utf8_lossy(&output.stdout); + let paths: Vec = stdout + .lines() + .filter_map(|line| line.strip_prefix("worktree ")) + .map(PathBuf::from) + .collect(); + + if paths.is_empty() { + vec![cwd.to_path_buf()] + } else { + paths + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_repo_paths_returns_cwd_outside_git() { + let tmp = tempfile::tempdir().unwrap(); + let paths = repo_paths(tmp.path()); + assert_eq!(paths, vec![tmp.path().to_path_buf()]); + } + + #[test] + fn test_repo_paths_returns_at_least_one_in_git() { + let cwd = std::env::current_dir().unwrap(); + let paths = repo_paths(&cwd); + assert!(!paths.is_empty()); + } +} diff --git a/crates/tb-session/src/lib.rs b/crates/tb-session/src/lib.rs index ce56c4e..99eb2ef 100644 --- a/crates/tb-session/src/lib.rs +++ b/crates/tb-session/src/lib.rs @@ -1,5 +1,6 @@ pub mod commands; pub mod config; pub mod error; +pub mod git; pub mod index; pub mod models; diff --git a/crates/tb-session/src/main.rs b/crates/tb-session/src/main.rs index e4d2d6d..2755e5b 100644 --- a/crates/tb-session/src/main.rs +++ b/crates/tb-session/src/main.rs @@ -141,6 +141,26 @@ fn main() { } } +fn resolve_and_freshen( + conn: &rusqlite::Connection, + projects_dir: &std::path::Path, + all_projects: bool, +) -> tb_session::error::Result> { + if all_projects { + tb_session::index::ensure_fresh(conn, projects_dir, None)?; + return Ok(vec![]); + } + let cwd = std::env::current_dir()?; + let repo_paths: Vec = tb_session::git::repo_paths(&cwd) + .into_iter() + .map(|p| p.to_string_lossy().into_owned()) + .collect(); + for path in &repo_paths { + tb_session::index::ensure_fresh(conn, projects_dir, Some(std::path::Path::new(path)))?; + } + Ok(repo_paths) +} + fn run() -> tb_session::error::Result<()> { let cli = Cli::parse(); @@ -166,17 +186,8 @@ fn run() -> tb_session::error::Result<()> { } => { let config = Config::load()?; let conn = tb_session::index::open_db(cli.no_cache)?; - - // Ensure index is fresh (scoped to cwd unless --all-projects) let projects_dir = config.projects_dir(); - let cwd = std::env::current_dir().ok(); - let scope = if all_projects { - None - } else { - cwd.as_deref() - }; - tb_session::index::ensure_fresh(&conn, &projects_dir, scope)?; - + let repo_paths = resolve_and_freshen(&conn, &projects_dir, all_projects)?; let effective_limit = limit.unwrap_or(config.default_limit); tb_session::commands::search::run( @@ -186,7 +197,7 @@ fn run() -> tb_session::error::Result<()> { after.as_deref(), before.as_deref(), project.as_deref(), - all_projects, + &repo_paths, effective_limit, cli.json, )?; @@ -201,12 +212,8 @@ fn run() -> tb_session::error::Result<()> { } => { let config = Config::load()?; let conn = tb_session::index::open_db(cli.no_cache)?; - let projects_dir = config.projects_dir(); - let cwd = std::env::current_dir().ok(); - let scope = if all_projects { None } else { cwd.as_deref() }; - tb_session::index::ensure_fresh(&conn, &projects_dir, scope)?; - + let repo_paths = resolve_and_freshen(&conn, &projects_dir, all_projects)?; let effective_limit = limit.unwrap_or(config.default_limit); tb_session::commands::list::run( @@ -214,7 +221,7 @@ fn run() -> tb_session::error::Result<()> { branch.as_deref(), after.as_deref(), before.as_deref(), - all_projects, + &repo_paths, effective_limit, page, cli.json, @@ -228,7 +235,11 @@ fn run() -> tb_session::error::Result<()> { tb_session::commands::show::run(&conn, &session_id, cli.json)?; } Commands::Resume { session_id } => { - tb_session::commands::resume::run(&session_id)?; + let config = Config::load()?; + let conn = tb_session::index::open_db(cli.no_cache)?; + let projects_dir = config.projects_dir(); + tb_session::index::ensure_fresh(&conn, &projects_dir, None)?; + tb_session::commands::resume::run(&conn, &session_id)?; } Commands::Index { all_projects } => { tb_session::commands::index_cmd::run(all_projects)?; From feb800d0753c99c5b52f9cc644e50fe803bb197d Mon Sep 17 00:00:00 2001 From: Dario Filipaj Date: Mon, 23 Mar 2026 17:22:23 +0100 Subject: [PATCH 19/29] feat(tb-session): resume accepts names and search terms, not just UUIDs Co-Authored-By: Claude Sonnet 4.6 --- crates/tb-session/src/commands/resume.rs | 177 ++++++++++++++++++++++- crates/tb-session/src/main.rs | 2 +- 2 files changed, 170 insertions(+), 9 deletions(-) diff --git a/crates/tb-session/src/commands/resume.rs b/crates/tb-session/src/commands/resume.rs index af142eb..dfa06d4 100644 --- a/crates/tb-session/src/commands/resume.rs +++ b/crates/tb-session/src/commands/resume.rs @@ -1,27 +1,107 @@ +use std::io::IsTerminal; + +use rusqlite::Connection; + use crate::error::{Error, Result}; -pub fn run(session_id: &str) -> Result<()> { +/// Returns true if the input looks like a UUID or UUID prefix (8+ hex chars with optional dashes). +fn looks_like_uuid(s: &str) -> bool { + let s = s.trim(); + s.len() >= 8 && s.chars().all(|c| c.is_ascii_hexdigit() || c == '-') +} + +/// Search sessions by summary or first_prompt, returning the most recently modified match. +fn resolve_by_name(conn: &Connection, query: &str) -> Option<(String, Option)> { + let pattern = format!("%{}%", query); + conn.query_row( + "SELECT session_id, project_path FROM sessions \ + WHERE (summary LIKE ?1 OR first_prompt LIKE ?1) \ + AND is_sidechain = 0 \ + ORDER BY modified_at DESC \ + LIMIT 1", + rusqlite::params![pattern], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .ok() +} + +pub fn run(conn: &Connection, session_id: &str) -> Result<()> { let claude_path = which_claude()?; + // Resolve full session ID and project_path from the index + let resolved: Option<(String, Option)> = if looks_like_uuid(session_id) { + // UUID prefix match (existing behavior) + let prefix_pattern = format!("{}%", session_id); + conn.query_row( + "SELECT session_id, project_path FROM sessions WHERE session_id = ?1 OR session_id LIKE ?2 ORDER BY modified_at DESC LIMIT 1", + rusqlite::params![session_id, prefix_pattern], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .ok() + } else { + // Name/search term match + resolve_by_name(conn, session_id) + }; + + if resolved.is_none() && !looks_like_uuid(session_id) { + return Err(Error::Other(format!( + "No session found matching '{}'. Try: tb-session search \"{}\" --all-projects", + session_id, session_id + ))); + } + + let full_session_id = resolved + .as_ref() + .map(|(id, _)| id.as_str()) + .unwrap_or(session_id); + + // Resolve the directory to resume in + let resume_dir = resolved.as_ref().and_then(|(_, path)| { + let path = path.as_deref()?; + let target = std::path::Path::new(path); + let cwd = std::env::current_dir().ok()?; + if cwd == target { + return None; + } + if target.is_dir() { + Some(path) + } else { + eprintln!( + "Warning: original project directory no longer exists: {}", + path + ); + None + } + }); + + // If stdin is not a TTY, we're likely running inside Claude Code or a script. + // Spawn a new terminal window instead of exec'ing (which would kill the parent). + if !std::io::stdin().is_terminal() { + return open_in_terminal(&claude_path, full_session_id, resume_dir); + } + + // Interactive: cd into the original project and exec claude directly + if let Some(path) = resume_dir { + eprintln!("Resuming in {}", path); + std::env::set_current_dir(path) + .map_err(|e| Error::Other(format!("Failed to cd into {}: {}", path, e)))?; + } + #[cfg(unix)] { use std::os::unix::process::CommandExt; let err = std::process::Command::new(&claude_path) .arg("--resume") - .arg(session_id) + .arg(full_session_id) .exec(); - // exec() only returns on error - Err(Error::Other(format!( - "Failed to exec claude: {}", - err - ))) + Err(Error::Other(format!("Failed to exec claude: {}", err))) } #[cfg(not(unix))] { let status = std::process::Command::new(&claude_path) .arg("--resume") - .arg(session_id) + .arg(full_session_id) .status() .map_err(|e| Error::Other(format!("Failed to run claude: {}", e)))?; @@ -35,6 +115,87 @@ pub fn run(session_id: &str) -> Result<()> { } } +/// Open a new terminal tab and run `claude --resume` there. +fn open_in_terminal(claude_path: &str, session_id: &str, resume_dir: Option<&str>) -> Result<()> { + let resume_cmd = format!("{} --resume {}", shell_escape(claude_path), session_id); + let full_cmd = match resume_dir { + Some(dir) => format!("cd {} && {}", shell_escape(dir), resume_cmd), + None => resume_cmd, + }; + + let terminal = std::env::var("TERM_PROGRAM").unwrap_or_default(); + let script = build_osascript(&terminal, &full_cmd); + + let output = std::process::Command::new("osascript") + .arg("-e") + .arg(&script) + .output() + .map_err(|e| Error::Other(format!("Failed to run osascript: {}", e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(Error::Other(format!( + "Failed to open terminal window: {}", + stderr.trim() + ))); + } + + if let Some(dir) = resume_dir { + eprintln!("Opened new terminal tab in {} to resume session", dir); + } else { + eprintln!("Opened new terminal tab to resume session"); + } + Ok(()) +} + +fn build_osascript(terminal: &str, cmd: &str) -> String { + match terminal { + "iTerm.app" => format!( + r#"tell application "iTerm2" + tell current window + create tab with default profile + tell current session + write text "{}" + end tell + end tell +end tell"#, + cmd.replace('\\', "\\\\").replace('"', "\\\"") + ), + // Terminal.app and anything else + _ => format!( + r#"tell application "Terminal" + activate + do script "{}" +end tell"#, + cmd.replace('\\', "\\\\").replace('"', "\\\"") + ), + } +} + +fn shell_escape(s: &str) -> String { + if s.contains(|c: char| c.is_whitespace() || c == '\'' || c == '"' || c == '\\') { + format!("'{}'", s.replace('\'', "'\\''")) + } else { + s.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_looks_like_uuid() { + assert!(looks_like_uuid("9a06add5-028b-484c-a0bf-f4fc08921042")); + assert!(looks_like_uuid("9a06add5")); + assert!(looks_like_uuid("abcdef12")); + assert!(!looks_like_uuid("auth-refactor")); + assert!(!looks_like_uuid("fix bug")); + assert!(!looks_like_uuid("PR #6")); + assert!(!looks_like_uuid("abc")); // too short + } +} + fn which_claude() -> Result { let output = std::process::Command::new("which") .arg("claude") diff --git a/crates/tb-session/src/main.rs b/crates/tb-session/src/main.rs index 2755e5b..589ec2c 100644 --- a/crates/tb-session/src/main.rs +++ b/crates/tb-session/src/main.rs @@ -92,7 +92,7 @@ enum Commands { /// Resume a session (execs into claude --resume) Resume { - /// Session ID + /// Session ID, UUID prefix, or search term (matches summary/first prompt) session_id: String, }, From 45f80d74fbc0555ef5acaa2ef7170ca3c97a3635 Mon Sep 17 00:00:00 2001 From: Dario Filipaj Date: Tue, 24 Mar 2026 14:20:50 +0100 Subject: [PATCH 20/29] fix(tb-session): sanitize FTS5 search queries to handle URLs and special chars FTS5 interprets `:`, `*`, `AND`, `OR` etc. as operators. Wrapping each term in double quotes forces literal matching, so URLs like https://github.com/... no longer cause "column not recognized" errors. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/tb-session/src/commands/search.rs | 34 +++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/crates/tb-session/src/commands/search.rs b/crates/tb-session/src/commands/search.rs index 1b9591c..938075e 100644 --- a/crates/tb-session/src/commands/search.rs +++ b/crates/tb-session/src/commands/search.rs @@ -21,7 +21,7 @@ pub fn run( "messages_fts MATCH ?1".to_string(), "s.is_sidechain = 0".to_string(), ]; - let mut params: Vec> = vec![Box::new(query.to_string())]; + let mut params: Vec> = vec![Box::new(sanitize_fts5_query(query))]; let mut param_idx: usize = 2; // Project scope @@ -290,3 +290,35 @@ impl RawRow { } } } + +/// Sanitize user input for FTS5 MATCH by quoting each whitespace-separated term. +/// +/// FTS5 has its own query language where `:`, `*`, `(`, `)`, `AND`, `OR`, `NOT`, etc. +/// are operators. Wrapping terms in double quotes forces literal matching. +/// Any embedded double quotes in the input are removed. +fn sanitize_fts5_query(query: &str) -> String { + query + .split_whitespace() + .map(|term| format!("\"{}\"", term.replace('"', ""))) + .collect::>() + .join(" ") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sanitize_fts5_query() { + assert_eq!( + sanitize_fts5_query("https://github.com/org/repo/pull/1"), + "\"https://github.com/org/repo/pull/1\"" + ); + assert_eq!(sanitize_fts5_query("fix bug"), "\"fix\" \"bug\""); + assert_eq!(sanitize_fts5_query("hello"), "\"hello\""); + assert_eq!( + sanitize_fts5_query("column:value OR other"), + "\"column:value\" \"OR\" \"other\"" + ); + } +} From 93eb5dcfd4cafc630675269385bdc43281d07a4e Mon Sep 17 00:00:00 2001 From: Dario Filipaj Date: Tue, 24 Mar 2026 14:25:04 +0100 Subject: [PATCH 21/29] fix(tb-session): try UUID prefix match before name search in resume Always try UUID prefix match first, then fall back to name/summary search. Removes the looks_like_uuid heuristic for branching, which rejected short prefixes like 7-char IDs (e.g. 27b0337). Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/tb-session/src/commands/resume.rs | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/crates/tb-session/src/commands/resume.rs b/crates/tb-session/src/commands/resume.rs index dfa06d4..47ba195 100644 --- a/crates/tb-session/src/commands/resume.rs +++ b/crates/tb-session/src/commands/resume.rs @@ -28,20 +28,17 @@ fn resolve_by_name(conn: &Connection, query: &str) -> Option<(String, Option Result<()> { let claude_path = which_claude()?; - // Resolve full session ID and project_path from the index - let resolved: Option<(String, Option)> = if looks_like_uuid(session_id) { - // UUID prefix match (existing behavior) - let prefix_pattern = format!("{}%", session_id); - conn.query_row( + // Resolve full session ID and project_path from the index. + // Try UUID prefix match first, then fall back to name/summary search. + let prefix_pattern = format!("{}%", session_id); + let resolved: Option<(String, Option)> = conn + .query_row( "SELECT session_id, project_path FROM sessions WHERE session_id = ?1 OR session_id LIKE ?2 ORDER BY modified_at DESC LIMIT 1", rusqlite::params![session_id, prefix_pattern], |row| Ok((row.get(0)?, row.get(1)?)), ) .ok() - } else { - // Name/search term match - resolve_by_name(conn, session_id) - }; + .or_else(|| resolve_by_name(conn, session_id)); if resolved.is_none() && !looks_like_uuid(session_id) { return Err(Error::Other(format!( From c00e48e58d93743fc458990d904646fc7165613b Mon Sep 17 00:00:00 2001 From: Dario Filipaj Date: Tue, 24 Mar 2026 14:40:33 +0100 Subject: [PATCH 22/29] =?UTF-8?q?fix(tb-session):=20address=20PR=20review?= =?UTF-8?q?=20feedback=20=E2=80=94=204=20issues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. message_count now uses messages.len() (actual user/assistant count) instead of line_count (all JSON lines including progress events) 2. Empty search query returns a clear error instead of crashing FTS5 3. session_id is now shell-escaped in osascript commands 4. open_in_terminal guards against non-macOS platforms Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/tb-session/src/commands/resume.rs | 14 +++++++++++++- crates/tb-session/src/commands/search.rs | 8 +++++++- crates/tb-session/src/index/parser.rs | 9 +++------ 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/crates/tb-session/src/commands/resume.rs b/crates/tb-session/src/commands/resume.rs index 47ba195..0562e2b 100644 --- a/crates/tb-session/src/commands/resume.rs +++ b/crates/tb-session/src/commands/resume.rs @@ -114,7 +114,19 @@ pub fn run(conn: &Connection, session_id: &str) -> Result<()> { /// Open a new terminal tab and run `claude --resume` there. fn open_in_terminal(claude_path: &str, session_id: &str, resume_dir: Option<&str>) -> Result<()> { - let resume_cmd = format!("{} --resume {}", shell_escape(claude_path), session_id); + if !cfg!(target_os = "macos") { + return Err(Error::Other( + "resume in new terminal tab is only supported on macOS. \ + Run manually: claude --resume " + .into(), + )); + } + + let resume_cmd = format!( + "{} --resume {}", + shell_escape(claude_path), + shell_escape(session_id) + ); let full_cmd = match resume_dir { Some(dir) => format!("cd {} && {}", shell_escape(dir), resume_cmd), None => resume_cmd, diff --git a/crates/tb-session/src/commands/search.rs b/crates/tb-session/src/commands/search.rs index 938075e..a5a4a2f 100644 --- a/crates/tb-session/src/commands/search.rs +++ b/crates/tb-session/src/commands/search.rs @@ -21,7 +21,13 @@ pub fn run( "messages_fts MATCH ?1".to_string(), "s.is_sidechain = 0".to_string(), ]; - let mut params: Vec> = vec![Box::new(sanitize_fts5_query(query))]; + let sanitized = sanitize_fts5_query(query); + if sanitized.is_empty() { + return Err(crate::error::Error::Other( + "search query cannot be empty".into(), + )); + } + let mut params: Vec> = vec![Box::new(sanitized)]; let mut param_idx: usize = 2; // Project scope diff --git a/crates/tb-session/src/index/parser.rs b/crates/tb-session/src/index/parser.rs index 286381c..89050f6 100644 --- a/crates/tb-session/src/index/parser.rs +++ b/crates/tb-session/src/index/parser.rs @@ -43,7 +43,6 @@ pub fn parse_session(file_path: &Path) -> Result { let mut last_timestamp: Option = None; let mut git_branch: Option = None; let mut is_sidechain = false; - let mut line_count = 0usize; let mut messages: Vec = Vec::new(); let mut first_prompt: Option = None; let mut summary: Option = None; @@ -59,8 +58,6 @@ pub fn parse_session(file_path: &Path) -> Result { Err(_) => continue, }; - line_count += 1; - // Track timestamps if let Some(ts) = entry.get("timestamp").and_then(|t| t.as_str()) { if first_timestamp.is_none() { @@ -120,7 +117,7 @@ pub fn parse_session(file_path: &Path) -> Result { summary, first_prompt, git_branch, - message_count: line_count, + message_count: messages.len(), created_at: first_timestamp, modified_at: last_timestamp, is_sidechain, @@ -197,8 +194,8 @@ mod tests { assert_eq!(parsed.messages.len(), 1); assert_eq!(parsed.messages[0].content, "Real message"); - // All 3 lines count as valid JSON lines - assert_eq!(parsed.message_count, 3); + // message_count reflects actual user/assistant messages, not all JSON lines + assert_eq!(parsed.message_count, 1); } #[test] From b38f9ed2a699d92f2fba1b240506748098edf780 Mon Sep 17 00:00:00 2001 From: Dario Filipaj Date: Tue, 24 Mar 2026 14:44:29 +0100 Subject: [PATCH 23/29] feat(tb-session): add --pr filter to search command Searches for sessions mentioning a PR by number or URL: tb-session search --pr 557 tb-session search --pr https://github.com/org/repo/pull/557 The positional query argument is now optional when --pr is provided. PR numbers are expanded to "pull/" for GitHub URL matching. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/tb-session/src/main.rs | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/crates/tb-session/src/main.rs b/crates/tb-session/src/main.rs index 589ec2c..accc39d 100644 --- a/crates/tb-session/src/main.rs +++ b/crates/tb-session/src/main.rs @@ -29,8 +29,8 @@ struct Cli { enum Commands { /// Full-text search across sessions Search { - /// Search query - query: String, + /// Search query (optional when --pr is used) + query: Option, /// Filter by git branch name #[arg(long)] @@ -55,6 +55,10 @@ enum Commands { /// Only sessions modified on or before this date (ISO 8601) #[arg(long)] before: Option, + + /// Search for sessions mentioning a PR (number or URL) + #[arg(long)] + pr: Option, }, /// List sessions by metadata (no full-text search) @@ -141,6 +145,17 @@ fn main() { } } +/// Build an FTS5 search query from a PR reference (number or URL). +fn build_pr_query(pr_ref: &str) -> String { + if pr_ref.starts_with("http") { + // Full URL — search for it directly + pr_ref.to_string() + } else { + // PR number — search for "pull/" which matches GitHub PR URLs + format!("pull/{}", pr_ref) + } +} + fn resolve_and_freshen( conn: &rusqlite::Connection, projects_dir: &std::path::Path, @@ -183,7 +198,18 @@ fn run() -> tb_session::error::Result<()> { limit, after, before, + pr, } => { + let effective_query = match (&query, &pr) { + (Some(q), _) => q.clone(), + (None, Some(pr_ref)) => build_pr_query(pr_ref), + (None, None) => { + return Err(tb_session::error::Error::Other( + "provide a search query or use --pr ".into(), + )); + } + }; + let config = Config::load()?; let conn = tb_session::index::open_db(cli.no_cache)?; let projects_dir = config.projects_dir(); @@ -192,7 +218,7 @@ fn run() -> tb_session::error::Result<()> { tb_session::commands::search::run( &conn, - &query, + &effective_query, branch.as_deref(), after.as_deref(), before.as_deref(), From 94014c9b1fb847a1f29e3bedfa94124d3d331cda Mon Sep 17 00:00:00 2001 From: Dario Filipaj Date: Tue, 24 Mar 2026 15:22:10 +0100 Subject: [PATCH 24/29] =?UTF-8?q?fix(tb-session):=20address=20PR=20review?= =?UTF-8?q?=20batch=202=20=E2=80=94=203=20issues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Security: osascript now reads command via TB_SESSION_CMD env var instead of string interpolation, preventing AppleScript injection 2. Performance: show/resume use resolve_and_freshen (scoped to repo worktrees) instead of scanning all projects 3. UX: error when both positional query and --pr are provided Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/tb-session/src/commands/resume.rs | 29 ++++++++++++------------ crates/tb-session/src/main.rs | 11 ++++++--- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/crates/tb-session/src/commands/resume.rs b/crates/tb-session/src/commands/resume.rs index 0562e2b..8a1fca2 100644 --- a/crates/tb-session/src/commands/resume.rs +++ b/crates/tb-session/src/commands/resume.rs @@ -132,12 +132,16 @@ fn open_in_terminal(claude_path: &str, session_id: &str, resume_dir: Option<&str None => resume_cmd, }; + // Pass command via TB_SESSION_CMD env var to avoid AppleScript injection. + // The osascript reads it with `do shell script "echo $TB_SESSION_CMD"` or + // writes it directly via `write text` using `(system attribute "TB_SESSION_CMD")`. let terminal = std::env::var("TERM_PROGRAM").unwrap_or_default(); - let script = build_osascript(&terminal, &full_cmd); + let script = build_osascript(&terminal); let output = std::process::Command::new("osascript") .arg("-e") .arg(&script) + .env("TB_SESSION_CMD", &full_cmd) .output() .map_err(|e| Error::Other(format!("Failed to run osascript: {}", e)))?; @@ -157,27 +161,24 @@ fn open_in_terminal(claude_path: &str, session_id: &str, resume_dir: Option<&str Ok(()) } -fn build_osascript(terminal: &str, cmd: &str) -> String { +fn build_osascript(terminal: &str) -> String { + // Command is passed via TB_SESSION_CMD env var to avoid AppleScript string injection. match terminal { - "iTerm.app" => format!( - r#"tell application "iTerm2" + "iTerm.app" => r#"tell application "iTerm2" tell current window create tab with default profile tell current session - write text "{}" + write text (system attribute "TB_SESSION_CMD") end tell end tell -end tell"#, - cmd.replace('\\', "\\\\").replace('"', "\\\"") - ), +end tell"# + .to_string(), // Terminal.app and anything else - _ => format!( - r#"tell application "Terminal" + _ => r#"tell application "Terminal" activate - do script "{}" -end tell"#, - cmd.replace('\\', "\\\\").replace('"', "\\\"") - ), + do script (system attribute "TB_SESSION_CMD") +end tell"# + .to_string(), } } diff --git a/crates/tb-session/src/main.rs b/crates/tb-session/src/main.rs index accc39d..ba876aa 100644 --- a/crates/tb-session/src/main.rs +++ b/crates/tb-session/src/main.rs @@ -201,7 +201,12 @@ fn run() -> tb_session::error::Result<()> { pr, } => { let effective_query = match (&query, &pr) { - (Some(q), _) => q.clone(), + (Some(_), Some(_)) => { + return Err(tb_session::error::Error::Other( + "cannot use --pr with a positional query — use one or the other".into(), + )); + } + (Some(q), None) => q.clone(), (None, Some(pr_ref)) => build_pr_query(pr_ref), (None, None) => { return Err(tb_session::error::Error::Other( @@ -257,14 +262,14 @@ fn run() -> tb_session::error::Result<()> { let config = Config::load()?; let conn = tb_session::index::open_db(cli.no_cache)?; let projects_dir = config.projects_dir(); - tb_session::index::ensure_fresh(&conn, &projects_dir, None)?; + resolve_and_freshen(&conn, &projects_dir, false)?; tb_session::commands::show::run(&conn, &session_id, cli.json)?; } Commands::Resume { session_id } => { let config = Config::load()?; let conn = tb_session::index::open_db(cli.no_cache)?; let projects_dir = config.projects_dir(); - tb_session::index::ensure_fresh(&conn, &projects_dir, None)?; + resolve_and_freshen(&conn, &projects_dir, false)?; tb_session::commands::resume::run(&conn, &session_id)?; } Commands::Index { all_projects } => { From fbc179b4f5cbf276d4a5398d0e93ece0a17f0457 Mon Sep 17 00:00:00 2001 From: Dario Filipaj Date: Tue, 24 Mar 2026 15:27:14 +0100 Subject: [PATCH 25/29] test(tb-session): add 16 tests for uncovered pure functions and edge cases High priority (previously zero tests): - build_pr_query: PR number, URL passthrough, non-numeric input - shell_escape: plain, spaces, single quotes, double quotes, backslash, empty - build_osascript: iTerm.app, Terminal.app, unknown terminal fallback Medium priority (edge cases for changed logic): - sanitize_fts5_query: empty, whitespace-only, embedded quotes, FTS5 operators - parse_worktree_output: extracted from git.rs, tested with fixture strings - parser: zero-message session, non-user/assistant roles skipped, empty content handling for first_prompt/summary, summary not overwritten - looks_like_uuid: 7-char boundary, all-dashes, whitespace-padded Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/tb-session/src/commands/resume.rs | 61 +++++++++++++++++++ crates/tb-session/src/commands/search.rs | 18 ++++++ crates/tb-session/src/git.rs | 36 ++++++++++-- crates/tb-session/src/index/parser.rs | 75 ++++++++++++++++++++++++ crates/tb-session/src/main.rs | 25 ++++++++ 5 files changed, 210 insertions(+), 5 deletions(-) diff --git a/crates/tb-session/src/commands/resume.rs b/crates/tb-session/src/commands/resume.rs index 8a1fca2..c3165be 100644 --- a/crates/tb-session/src/commands/resume.rs +++ b/crates/tb-session/src/commands/resume.rs @@ -204,6 +204,67 @@ mod tests { assert!(!looks_like_uuid("PR #6")); assert!(!looks_like_uuid("abc")); // too short } + + #[test] + fn test_shell_escape() { + // Plain string — no escaping needed + assert_eq!(shell_escape("claude"), "claude"); + assert_eq!(shell_escape("/usr/bin/claude"), "/usr/bin/claude"); + + // String with spaces — single-quoted + assert_eq!(shell_escape("/path/to my dir"), "'/path/to my dir'"); + + // String with single quote — escaped within single quotes + assert_eq!(shell_escape("it's"), "'it'\\''s'"); + + // String with double quote — single-quoted wrapper + assert_eq!(shell_escape("say \"hi\""), "'say \"hi\"'"); + + // String with backslash — single-quoted + assert_eq!(shell_escape("back\\slash"), "'back\\slash'"); + + // Empty string — returned as-is + assert_eq!(shell_escape(""), ""); + } + + #[test] + fn test_build_osascript_iterm() { + let script = build_osascript("iTerm.app"); + assert!(script.contains("iTerm2")); + assert!(script.contains("create tab with default profile")); + assert!(script.contains("system attribute \"TB_SESSION_CMD\"")); + } + + #[test] + fn test_build_osascript_terminal() { + let script = build_osascript("Apple_Terminal"); + assert!(script.contains("Terminal")); + assert!(script.contains("do script")); + assert!(script.contains("system attribute \"TB_SESSION_CMD\"")); + } + + #[test] + fn test_build_osascript_unknown_falls_to_terminal() { + let script = build_osascript(""); + assert!(script.contains("Terminal")); + assert!(script.contains("do script")); + } + + #[test] + fn test_looks_like_uuid_edge_cases() { + // Exactly 7 hex chars — too short + assert!(!looks_like_uuid("abcdef1")); + + // Exactly 8 hex chars — minimum valid + assert!(looks_like_uuid("abcdef12")); + + // All dashes — currently true (8+ chars, all are hex or dash) + // This documents current behavior: dashes-only passes the check + assert!(looks_like_uuid("--------")); + + // Whitespace-padded — trim should handle + assert!(looks_like_uuid(" 9a06add5 ")); + } } fn which_claude() -> Result { diff --git a/crates/tb-session/src/commands/search.rs b/crates/tb-session/src/commands/search.rs index a5a4a2f..a207adc 100644 --- a/crates/tb-session/src/commands/search.rs +++ b/crates/tb-session/src/commands/search.rs @@ -327,4 +327,22 @@ mod tests { "\"column:value\" \"OR\" \"other\"" ); } + + #[test] + fn test_sanitize_fts5_query_edge_cases() { + // Empty string → empty string (caller guards this) + assert_eq!(sanitize_fts5_query(""), ""); + + // Whitespace-only → empty string (split_whitespace yields nothing) + assert_eq!(sanitize_fts5_query(" "), ""); + + // Embedded double quotes are stripped + assert_eq!(sanitize_fts5_query("say \"hi\" world"), "\"say\" \"hi\" \"world\""); + + // FTS5 operators get quoted as literal terms + assert_eq!(sanitize_fts5_query("AND OR NOT"), "\"AND\" \"OR\" \"NOT\""); + + // Single special character + assert_eq!(sanitize_fts5_query("*"), "\"*\""); + } } diff --git a/crates/tb-session/src/git.rs b/crates/tb-session/src/git.rs index f6caabc..8a7762a 100644 --- a/crates/tb-session/src/git.rs +++ b/crates/tb-session/src/git.rs @@ -1,6 +1,15 @@ use std::path::{Path, PathBuf}; use std::process::Command; +/// Parse the porcelain output of `git worktree list --porcelain` into paths. +fn parse_worktree_output(stdout: &str) -> Vec { + stdout + .lines() + .filter_map(|line| line.strip_prefix("worktree ")) + .map(PathBuf::from) + .collect() +} + /// Get all worktree paths for the git repo containing `cwd`. /// Returns the main worktree + all linked worktrees. /// Returns just `[cwd]` if not in a git repo or git is unavailable. @@ -15,11 +24,7 @@ pub fn repo_paths(cwd: &Path) -> Vec { }; let stdout = String::from_utf8_lossy(&output.stdout); - let paths: Vec = stdout - .lines() - .filter_map(|line| line.strip_prefix("worktree ")) - .map(PathBuf::from) - .collect(); + let paths = parse_worktree_output(&stdout); if paths.is_empty() { vec![cwd.to_path_buf()] @@ -45,4 +50,25 @@ mod tests { let paths = repo_paths(&cwd); assert!(!paths.is_empty()); } + + #[test] + fn test_parse_worktree_output_multiple() { + let output = "worktree /Users/test/repo\nHEAD abc123\nbranch refs/heads/main\n\nworktree /Users/test/worktrees/feature\nHEAD def456\nbranch refs/heads/feature\n\n"; + let paths = parse_worktree_output(output); + assert_eq!(paths, vec![ + PathBuf::from("/Users/test/repo"), + PathBuf::from("/Users/test/worktrees/feature"), + ]); + } + + #[test] + fn test_parse_worktree_output_empty() { + assert!(parse_worktree_output("").is_empty()); + } + + #[test] + fn test_parse_worktree_output_no_worktree_lines() { + let output = "HEAD abc123\nbranch refs/heads/main\n"; + assert!(parse_worktree_output(output).is_empty()); + } } diff --git a/crates/tb-session/src/index/parser.rs b/crates/tb-session/src/index/parser.rs index 89050f6..5144409 100644 --- a/crates/tb-session/src/index/parser.rs +++ b/crates/tb-session/src/index/parser.rs @@ -213,6 +213,81 @@ mod tests { assert_eq!(parsed.message_count, 1); } + #[test] + fn test_parse_zero_messages() { + // All lines are non-message entries — message_count should be 0 + let file = write_jsonl(&[ + r#"{"type":"progress","text":"Thinking..."}"#, + r#"{"type":"file-history-snapshot","files":[]}"#, + r#"{"cwd":"/Users/test/myapp"}"#, + ]); + + let parsed = parse_session(file.path()).unwrap(); + assert_eq!(parsed.messages.len(), 0); + assert_eq!(parsed.message_count, 0); + assert!(parsed.first_prompt.is_none()); + assert!(parsed.summary.is_none()); + } + + #[test] + fn test_parse_skips_non_user_assistant_roles() { + let file = write_jsonl(&[ + r#"{"timestamp":"2024-01-01T00:00:00Z","message":{"role":"system","content":"System prompt"}}"#, + r#"{"timestamp":"2024-01-01T00:00:01Z","message":{"role":"tool","content":"Tool output"}}"#, + r#"{"timestamp":"2024-01-01T00:00:02Z","message":{"role":"user","content":"Hello"}}"#, + ]); + + let parsed = parse_session(file.path()).unwrap(); + assert_eq!(parsed.messages.len(), 1); + assert_eq!(parsed.message_count, 1); + assert_eq!(parsed.messages[0].role, "user"); + } + + #[test] + fn test_parse_first_prompt_skips_empty_content() { + let file = write_jsonl(&[ + r#"{"timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":""}}"#, + r#"{"timestamp":"2024-01-01T00:00:01Z","message":{"role":"user","content":"Real prompt"}}"#, + ]); + + let parsed = parse_session(file.path()).unwrap(); + assert_eq!(parsed.first_prompt.as_deref(), Some("Real prompt")); + } + + #[test] + fn test_parse_summary_skips_empty_assistant() { + let file = write_jsonl(&[ + r#"{"timestamp":"2024-01-01T00:00:00Z","message":{"role":"assistant","content":""}}"#, + r#"{"timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Real summary"}}"#, + ]); + + let parsed = parse_session(file.path()).unwrap(); + assert_eq!(parsed.summary.as_deref(), Some("Real summary")); + } + + #[test] + fn test_parse_summary_not_overwritten_by_second_assistant() { + let file = write_jsonl(&[ + r#"{"timestamp":"2024-01-01T00:00:00Z","message":{"role":"assistant","content":"First response"}}"#, + r#"{"timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Second response"}}"#, + ]); + + let parsed = parse_session(file.path()).unwrap(); + assert_eq!(parsed.summary.as_deref(), Some("First response")); + } + + #[test] + fn test_parse_extract_content_non_standard_types() { + // Content that is null/number/object should produce empty string + let file = write_jsonl(&[ + r#"{"timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":null}}"#, + ]); + + let parsed = parse_session(file.path()).unwrap(); + assert_eq!(parsed.messages.len(), 1); + assert_eq!(parsed.messages[0].content, ""); + } + #[test] fn test_parse_summary_from_first_assistant() { let long_text = "A".repeat(300); diff --git a/crates/tb-session/src/main.rs b/crates/tb-session/src/main.rs index ba876aa..3e852c9 100644 --- a/crates/tb-session/src/main.rs +++ b/crates/tb-session/src/main.rs @@ -302,3 +302,28 @@ fn run() -> tb_session::error::Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_pr_query() { + // PR number → pull/N + assert_eq!(build_pr_query("557"), "pull/557"); + assert_eq!(build_pr_query("1"), "pull/1"); + + // Full URL → returned verbatim + assert_eq!( + build_pr_query("https://github.com/org/repo/pull/557"), + "https://github.com/org/repo/pull/557" + ); + assert_eq!( + build_pr_query("http://github.com/org/repo/pull/1"), + "http://github.com/org/repo/pull/1" + ); + + // Non-numeric non-URL string (documents current behavior) + assert_eq!(build_pr_query("feature-branch"), "pull/feature-branch"); + } +} From c9cb0a040d62c96dd1ee6a33040beaa7a09ffddc Mon Sep 17 00:00:00 2001 From: Dario Filipaj Date: Tue, 24 Mar 2026 15:32:52 +0100 Subject: [PATCH 26/29] docs(tb-session): update SKILL.md with new features - Resume opens new terminal tab (not session-ending exec) - --pr filter for searching by PR number/URL - Worktree-aware scoping - Name/search term resume (not just UUIDs) - URLs work as search queries - Quick reference with common commands Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/tb-session/SKILL.md | 41 ++++++++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/crates/tb-session/SKILL.md b/crates/tb-session/SKILL.md index 74565ad..8241e6a 100644 --- a/crates/tb-session/SKILL.md +++ b/crates/tb-session/SKILL.md @@ -9,23 +9,48 @@ Claude Code session search CLI. Full-text search across session history with met ## Capabilities -- **Search** — full-text search across user and assistant messages with BM25 ranking -- **List** — browse sessions by metadata (branch, date, project) +- **Search** — full-text search across messages with BM25 ranking, `--pr` filter, metadata filters +- **List** — browse sessions by metadata (branch, date, project), worktree-aware - **Show** — session detail with conversation preview -- **Resume** — resume a past session (execs into claude --resume) +- **Resume** — resume a past session in a **new terminal tab** (accepts UUIDs, prefixes, or name search) ## When to use - User references prior work: "remember when we...", "that session where..." -- Before starting work: check if a prior session already started the same task +- User asks about a specific PR: use `--pr` to find sessions mentioning it - User asks to find or resume a past session -- Always use `--json` for programmatic access +- Before starting work: check if a prior session already started the same task +- Use `--json` for programmatic access when processing results + +## Quick reference + +```bash +# Search by content +tb-session search "authentication middleware" +tb-session search "budget calculation" --all-projects + +# Search by PR (number or URL) +tb-session search --pr 557 +tb-session search --pr https://github.com/org/repo/pull/123 + +# List recent sessions +tb-session list +tb-session list --all-projects --limit 20 + +# Show session details (prefix match works) +tb-session show bcb7ff + +# Resume by UUID prefix or name +tb-session resume bcb7ffed +tb-session resume "auth refactor" +``` ## Important -- `resume` is a session-ending action — it execs into a new Claude process. Always confirm with the user before resuming. -- Default scope is the current project directory. Use `--all-projects` to widen. -- Session ID can be a prefix — `show abc123` matches `abc123-def-456-...` +- **Resume opens a new terminal tab** — Claude sessions can't nest inside each other. When called from within Claude Code (non-TTY), `resume` opens a new iTerm/Terminal.app tab, cd's into the original project, and runs `claude --resume`. This is expected — not an error. +- **Worktree-aware by default** — `list` and `search` include sessions from all git worktrees of the same repo. Use `--all-projects` for everything. +- **Resume accepts names** — `resume "auth refactor"` searches summary/first prompt and resumes the most recent match. UUID prefixes of any length also work. +- **URLs work as search queries** — special characters are sanitized automatically. ## Getting started From e0686ce68a211a7e055d7ed063c1acb04b28732a Mon Sep 17 00:00:00 2001 From: Dario Filipaj Date: Tue, 24 Mar 2026 15:41:10 +0100 Subject: [PATCH 27/29] fix(tb-session): always cd in new terminal tab when resuming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New terminal tabs don't inherit the parent process's cwd — they start in iTerm/Terminal's default directory. The previous logic skipped the cd when cwd matched the project_path, but that only applies to the direct exec path (TTY mode), not the osascript/new-tab path. Now: project_dir is always passed to open_in_terminal. The cwd == target skip only applies to the interactive (TTY) exec path. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/tb-session/src/commands/resume.rs | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/crates/tb-session/src/commands/resume.rs b/crates/tb-session/src/commands/resume.rs index c3165be..87c7308 100644 --- a/crates/tb-session/src/commands/resume.rs +++ b/crates/tb-session/src/commands/resume.rs @@ -52,14 +52,10 @@ pub fn run(conn: &Connection, session_id: &str) -> Result<()> { .map(|(id, _)| id.as_str()) .unwrap_or(session_id); - // Resolve the directory to resume in - let resume_dir = resolved.as_ref().and_then(|(_, path)| { + // Resolve the project directory from the session metadata. + let project_dir = resolved.as_ref().and_then(|(_, path)| { let path = path.as_deref()?; let target = std::path::Path::new(path); - let cwd = std::env::current_dir().ok()?; - if cwd == target { - return None; - } if target.is_dir() { Some(path) } else { @@ -73,15 +69,20 @@ pub fn run(conn: &Connection, session_id: &str) -> Result<()> { // If stdin is not a TTY, we're likely running inside Claude Code or a script. // Spawn a new terminal window instead of exec'ing (which would kill the parent). + // Always pass project_dir — the new tab doesn't inherit our cwd. if !std::io::stdin().is_terminal() { - return open_in_terminal(&claude_path, full_session_id, resume_dir); + return open_in_terminal(&claude_path, full_session_id, project_dir); } // Interactive: cd into the original project and exec claude directly - if let Some(path) = resume_dir { - eprintln!("Resuming in {}", path); - std::env::set_current_dir(path) - .map_err(|e| Error::Other(format!("Failed to cd into {}: {}", path, e)))?; + // Only cd if we're not already there. + if let Some(path) = project_dir { + let cwd = std::env::current_dir().unwrap_or_default(); + if cwd != std::path::Path::new(path) { + eprintln!("Resuming in {}", path); + std::env::set_current_dir(path) + .map_err(|e| Error::Other(format!("Failed to cd into {}: {}", path, e)))?; + } } #[cfg(unix)] From 4fecc9931a4891540e097ceb861d1378b58aed6c Mon Sep 17 00:00:00 2001 From: Dario Filipaj Date: Tue, 24 Mar 2026 15:56:08 +0100 Subject: [PATCH 28/29] style(tb-session): apply cargo fmt Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/tb-session/src/commands/config_cmd.rs | 5 +- crates/tb-session/src/commands/doctor.rs | 13 +++-- crates/tb-session/src/commands/index_cmd.rs | 6 +- crates/tb-session/src/commands/list.rs | 18 ++---- crates/tb-session/src/commands/prime.rs | 9 +-- crates/tb-session/src/commands/search.rs | 19 +++---- crates/tb-session/src/commands/show.rs | 40 +++++++------- crates/tb-session/src/config.rs | 26 ++++----- crates/tb-session/src/git.rs | 11 ++-- crates/tb-session/src/index/builder.rs | 30 +++++----- crates/tb-session/src/index/mod.rs | 8 ++- crates/tb-session/src/index/parser.rs | 22 +++++--- crates/tb-session/src/index/scanner.rs | 58 +++++++------------- crates/tb-session/src/index/schema.rs | 2 +- crates/tb-session/src/main.rs | 3 +- 15 files changed, 121 insertions(+), 149 deletions(-) diff --git a/crates/tb-session/src/commands/config_cmd.rs b/crates/tb-session/src/commands/config_cmd.rs index 5063ce6..93232f0 100644 --- a/crates/tb-session/src/commands/config_cmd.rs +++ b/crates/tb-session/src/commands/config_cmd.rs @@ -27,10 +27,7 @@ pub fn show() -> Result<()> { Err(e) => println!("db_path: (error: {})", e), } println!("ttl_minutes: {}", config.ttl_minutes); - println!( - "ttl: {}s", - config.ttl().as_secs() - ); + println!("ttl: {}s", config.ttl().as_secs()); println!("default_limit: {}", config.default_limit); Ok(()) diff --git a/crates/tb-session/src/commands/doctor.rs b/crates/tb-session/src/commands/doctor.rs index 5e0f8b2..0335856 100644 --- a/crates/tb-session/src/commands/doctor.rs +++ b/crates/tb-session/src/commands/doctor.rs @@ -46,7 +46,12 @@ pub fn run() -> Result<()> { let projects_dir = config.projects_dir(); let subdir_count = if projects_dir.exists() { std::fs::read_dir(&projects_dir) - .map(|entries| entries.filter_map(|e| e.ok()).filter(|e| e.path().is_dir()).count()) + .map(|entries| { + entries + .filter_map(|e| e.ok()) + .filter(|e| e.path().is_dir()) + .count() + }) .unwrap_or(0) } else { 0 @@ -135,11 +140,7 @@ fn which_claude() -> Option { .ok()?; if output.status.success() { let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if path.is_empty() { - None - } else { - Some(path) - } + if path.is_empty() { None } else { Some(path) } } else { None } diff --git a/crates/tb-session/src/commands/index_cmd.rs b/crates/tb-session/src/commands/index_cmd.rs index 514825f..89c8a1d 100644 --- a/crates/tb-session/src/commands/index_cmd.rs +++ b/crates/tb-session/src/commands/index_cmd.rs @@ -17,11 +17,7 @@ pub fn run(all_projects: bool) -> Result<()> { let projects_dir = config.projects_dir(); let cwd = std::env::current_dir().ok(); - let scope: Option<&std::path::Path> = if all_projects { - None - } else { - cwd.as_deref() - }; + let scope: Option<&std::path::Path> = if all_projects { None } else { cwd.as_deref() }; println!("Indexing sessions..."); crate::index::ensure_fresh(&conn, &projects_dir, scope)?; diff --git a/crates/tb-session/src/commands/list.rs b/crates/tb-session/src/commands/list.rs index a5404ac..b9d374d 100644 --- a/crates/tb-session/src/commands/list.rs +++ b/crates/tb-session/src/commands/list.rs @@ -51,8 +51,7 @@ pub fn run( // COUNT query for pagination let count_sql = format!("SELECT COUNT(*) FROM sessions WHERE {where_clause}"); - let param_refs: Vec<&dyn rusqlite::types::ToSql> = - params.iter().map(|p| p.as_ref()).collect(); + let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect(); let total: usize = conn .query_row( @@ -103,10 +102,7 @@ pub fn run( if list.results.is_empty() { println!( "{}", - toolbox_core::output::empty_hint( - "sessions", - "Try --all-projects or wider date range." - ) + toolbox_core::output::empty_hint("sessions", "Try --all-projects or wider date range.") ); return Ok(()); } @@ -117,14 +113,8 @@ pub fn run( "SESSION ID", "SUMMARY", "BRANCH", "MSGS", "MODIFIED" ); for s in &list.results { - let summary = s - .summary - .as_deref() - .unwrap_or("(no summary)"); - let branch = s - .git_branch - .as_deref() - .unwrap_or("-"); + let summary = s.summary.as_deref().unwrap_or("(no summary)"); + let branch = s.git_branch.as_deref().unwrap_or("-"); let modified = s .modified_at .as_deref() diff --git a/crates/tb-session/src/commands/prime.rs b/crates/tb-session/src/commands/prime.rs index 33efaf1..c39a90b 100644 --- a/crates/tb-session/src/commands/prime.rs +++ b/crates/tb-session/src/commands/prime.rs @@ -41,7 +41,10 @@ pub fn run() -> Result<()> { match index::open_db(false) { Ok(conn) => match index::get_stats(&conn) { Ok(stats) => { - println!("- {} sessions | {} projects", stats.session_count, stats.project_count); + println!( + "- {} sessions | {} projects", + stats.session_count, stats.project_count + ); // Last updated: check mtime of the DB file let last_updated = config @@ -51,9 +54,7 @@ pub fn run() -> Result<()> { .and_then(|m| m.modified().ok()) .map(|t| { // Convert SystemTime to ISO 8601 string for relative_time - let duration = t - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default(); + let duration = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); let secs = duration.as_secs() as i64; // Build an ISO 8601 timestamp chrono::Utc diff --git a/crates/tb-session/src/commands/search.rs b/crates/tb-session/src/commands/search.rs index a207adc..ab8b1b7 100644 --- a/crates/tb-session/src/commands/search.rs +++ b/crates/tb-session/src/commands/search.rs @@ -1,5 +1,5 @@ -use rusqlite::types::ToSql; use rusqlite::Connection; +use rusqlite::types::ToSql; use crate::error::Result; use crate::models::{SearchFilters, SearchResult, SessionMatch}; @@ -166,11 +166,8 @@ pub fn run( // -- Build active filters for output ---------------------------------- let all_projects = repo_paths.is_empty() && project.is_none(); - let has_filters = branch.is_some() - || from.is_some() - || to.is_some() - || project.is_some() - || all_projects; + let has_filters = + branch.is_some() || from.is_some() || to.is_some() || project.is_some() || all_projects; let filters = if has_filters { Some(SearchFilters { @@ -200,10 +197,7 @@ pub fn run( if results.is_empty() { println!( "{}", - toolbox_core::output::empty_hint( - "sessions", - "Try broader terms or --all-projects." - ) + toolbox_core::output::empty_hint("sessions", "Try broader terms or --all-projects.") ); return Ok(()); } @@ -337,7 +331,10 @@ mod tests { assert_eq!(sanitize_fts5_query(" "), ""); // Embedded double quotes are stripped - assert_eq!(sanitize_fts5_query("say \"hi\" world"), "\"say\" \"hi\" \"world\""); + assert_eq!( + sanitize_fts5_query("say \"hi\" world"), + "\"say\" \"hi\" \"world\"" + ); // FTS5 operators get quoted as literal terms assert_eq!(sanitize_fts5_query("AND OR NOT"), "\"AND\" \"OR\" \"NOT\""); diff --git a/crates/tb-session/src/commands/show.rs b/crates/tb-session/src/commands/show.rs index e6817df..07807bb 100644 --- a/crates/tb-session/src/commands/show.rs +++ b/crates/tb-session/src/commands/show.rs @@ -132,29 +132,27 @@ fn output_human(row: &RawSession, messages: Option<&[parser::ParsedMessage]>) { // Conversation preview if let Some(msgs) = messages - && !msgs.is_empty() { - println!("\n{}", "--- Conversation Preview ---".dimmed()); - - let total = msgs.len(); - if total <= 10 { - for m in msgs { - print_message(m); - } - } else { - // First 5 - for m in &msgs[..5] { - print_message(m); - } - println!( - " {}", - format!("... ({} omitted) ...", total - 10).dimmed() - ); - // Last 5 - for m in &msgs[total - 5..] { - print_message(m); - } + && !msgs.is_empty() + { + println!("\n{}", "--- Conversation Preview ---".dimmed()); + + let total = msgs.len(); + if total <= 10 { + for m in msgs { + print_message(m); + } + } else { + // First 5 + for m in &msgs[..5] { + print_message(m); + } + println!(" {}", format!("... ({} omitted) ...", total - 10).dimmed()); + // Last 5 + for m in &msgs[total - 5..] { + print_message(m); } } + } println!( "\n{} tb-session resume {}", diff --git a/crates/tb-session/src/config.rs b/crates/tb-session/src/config.rs index 508f6a9..95eb0a6 100644 --- a/crates/tb-session/src/config.rs +++ b/crates/tb-session/src/config.rs @@ -41,8 +41,7 @@ impl Default for Config { impl Config { pub fn config_path() -> Result { - toolbox_core::config::config_path("tb-session") - .map_err(|e| Error::Config(e.to_string())) + toolbox_core::config::config_path("tb-session").map_err(|e| Error::Config(e.to_string())) } pub fn load() -> Result { @@ -55,21 +54,21 @@ impl Config { pub fn save(&self) -> Result<()> { let path = Self::config_path()?; - toolbox_core::config::save_config(&path, self) - .map_err(|e| Error::Config(e.to_string())) + toolbox_core::config::save_config(&path, self).map_err(|e| Error::Config(e.to_string())) } /// Resolves `~` in `claude_home` to an absolute PathBuf. pub fn claude_home_path(&self) -> PathBuf { if self.claude_home.starts_with('~') - && let Some(home) = dirs::home_dir() { - let stripped = self.claude_home.trim_start_matches('~'); - let stripped = stripped.trim_start_matches('/'); - if stripped.is_empty() { - return home; - } - return home.join(stripped); + && let Some(home) = dirs::home_dir() + { + let stripped = self.claude_home.trim_start_matches('~'); + let stripped = stripped.trim_start_matches('/'); + if stripped.is_empty() { + return home; } + return home.join(stripped); + } PathBuf::from(&self.claude_home) } @@ -80,9 +79,8 @@ impl Config { /// Returns `~/.cache/tb-session/index.db`, creating the parent dir. pub fn db_path(&self) -> Result { - let cache_dir = dirs::cache_dir().ok_or_else(|| { - Error::Config("cannot determine cache directory".to_string()) - })?; + let cache_dir = dirs::cache_dir() + .ok_or_else(|| Error::Config("cannot determine cache directory".to_string()))?; let db_dir = cache_dir.join("tb-session"); std::fs::create_dir_all(&db_dir).map_err(|e| Error::Config(e.to_string()))?; Ok(db_dir.join("index.db")) diff --git a/crates/tb-session/src/git.rs b/crates/tb-session/src/git.rs index 8a7762a..2dce950 100644 --- a/crates/tb-session/src/git.rs +++ b/crates/tb-session/src/git.rs @@ -55,10 +55,13 @@ mod tests { fn test_parse_worktree_output_multiple() { let output = "worktree /Users/test/repo\nHEAD abc123\nbranch refs/heads/main\n\nworktree /Users/test/worktrees/feature\nHEAD def456\nbranch refs/heads/feature\n\n"; let paths = parse_worktree_output(output); - assert_eq!(paths, vec![ - PathBuf::from("/Users/test/repo"), - PathBuf::from("/Users/test/worktrees/feature"), - ]); + assert_eq!( + paths, + vec![ + PathBuf::from("/Users/test/repo"), + PathBuf::from("/Users/test/worktrees/feature"), + ] + ); } #[test] diff --git a/crates/tb-session/src/index/builder.rs b/crates/tb-session/src/index/builder.rs index 520d473..cdc596c 100644 --- a/crates/tb-session/src/index/builder.rs +++ b/crates/tb-session/src/index/builder.rs @@ -1,9 +1,13 @@ -use rusqlite::{params, Connection}; -use crate::error::Result; -use super::scanner::FileInfo; use super::parser::ParsedSession; +use super::scanner::FileInfo; +use crate::error::Result; +use rusqlite::{Connection, params}; -pub fn index_session(conn: &Connection, file_info: &FileInfo, parsed: &ParsedSession) -> Result<()> { +pub fn index_session( + conn: &Connection, + file_info: &FileInfo, + parsed: &ParsedSession, +) -> Result<()> { // Determine metadata: prefer index_metadata fields over parsed fields. let meta = file_info.index_metadata.as_ref(); @@ -69,12 +73,7 @@ pub fn index_session(conn: &Connection, file_info: &FileInfo, parsed: &ParsedSes conn.execute( "INSERT INTO messages_fts (session_id, role, content, timestamp) VALUES (?1, ?2, ?3, ?4)", - params![ - file_info.session_id, - msg.role, - msg.content, - msg.timestamp, - ], + params![file_info.session_id, msg.role, msg.content, msg.timestamp,], )?; } @@ -83,12 +82,12 @@ pub fn index_session(conn: &Connection, file_info: &FileInfo, parsed: &ParsedSes #[cfg(test)] mod tests { + use super::super::parser::ParsedMessage; + use super::super::scanner::IndexEntry; use super::*; + use crate::index::schema; use rusqlite::Connection; use std::path::PathBuf; - use crate::index::schema; - use super::super::scanner::IndexEntry; - use super::super::parser::ParsedMessage; fn in_memory() -> Connection { let conn = Connection::open_in_memory().expect("in-memory DB"); @@ -222,7 +221,10 @@ mod tests { Some("summary"), vec![ msg("user", "implement the authentication middleware"), - msg("assistant", "Sure, I will implement the authentication middleware"), + msg( + "assistant", + "Sure, I will implement the authentication middleware", + ), ], ); diff --git a/crates/tb-session/src/index/mod.rs b/crates/tb-session/src/index/mod.rs index c330aa1..95dbe19 100644 --- a/crates/tb-session/src/index/mod.rs +++ b/crates/tb-session/src/index/mod.rs @@ -3,10 +3,10 @@ pub mod parser; pub mod scanner; pub mod schema; -use std::path::{Path, PathBuf}; -use rusqlite::Connection; use crate::error::{Error, Result}; +use rusqlite::Connection; use scanner::FileInfo; +use std::path::{Path, PathBuf}; /// Statistics about the current index. #[derive(Debug)] @@ -118,7 +118,9 @@ pub fn cleanup_deleted(conn: &Connection) -> Result<()> { /// Return aggregate statistics about the index. pub fn get_stats(conn: &Connection) -> Result { let session_count: u64 = conn - .query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get::<_, i64>(0)) + .query_row("SELECT COUNT(*) FROM sessions", [], |row| { + row.get::<_, i64>(0) + }) .map(|n| n as u64)?; let project_count: u64 = conn diff --git a/crates/tb-session/src/index/parser.rs b/crates/tb-session/src/index/parser.rs index 5144409..136dfa3 100644 --- a/crates/tb-session/src/index/parser.rs +++ b/crates/tb-session/src/index/parser.rs @@ -1,7 +1,7 @@ +use crate::error::Result; use std::fs::File; use std::io::{BufRead, BufReader}; use std::path::Path; -use crate::error::Result; #[derive(Debug)] pub struct ParsedSession { @@ -68,15 +68,17 @@ pub fn parse_session(file_path: &Path) -> Result { // Extract gitBranch from first entry that has it if git_branch.is_none() - && let Some(branch) = entry.get("gitBranch").and_then(|b| b.as_str()) { - git_branch = Some(branch.to_string()); - } + && let Some(branch) = entry.get("gitBranch").and_then(|b| b.as_str()) + { + git_branch = Some(branch.to_string()); + } // Check isSidechain if let Some(sc) = entry.get("isSidechain").and_then(|v| v.as_bool()) - && sc { - is_sidechain = true; - } + && sc + { + is_sidechain = true; + } // Extract user/assistant messages if let Some(message) = entry.get("message") { @@ -300,7 +302,11 @@ mod tests { assert!(parsed.summary.is_some()); let summary = parsed.summary.unwrap(); - assert!(summary.len() <= 200, "summary length {} > 200", summary.len()); + assert!( + summary.len() <= 200, + "summary length {} > 200", + summary.len() + ); assert!(summary.ends_with("...")); } } diff --git a/crates/tb-session/src/index/scanner.rs b/crates/tb-session/src/index/scanner.rs index acf3694..e9e7e0b 100644 --- a/crates/tb-session/src/index/scanner.rs +++ b/crates/tb-session/src/index/scanner.rs @@ -45,10 +45,7 @@ pub fn encode_path(path: &Path) -> String { /// When `scope_to_cwd` is `Some`, only the project directory whose encoded /// name matches the encoded `cwd` is scanned. Otherwise every project /// directory is visited. -pub fn scan_projects( - projects_dir: &Path, - scope_to_cwd: Option<&Path>, -) -> Result> { +pub fn scan_projects(projects_dir: &Path, scope_to_cwd: Option<&Path>) -> Result> { let mut results = Vec::new(); let entries = match fs::read_dir(projects_dir) { @@ -74,9 +71,10 @@ pub fn scan_projects( // When scoping, skip directories that don't match the encoded cwd. if let Some(ref encoded) = encoded_cwd - && dir_name != *encoded { - continue; - } + && dir_name != *encoded + { + continue; + } // Load sessions-index.json for this project directory. let index = load_sessions_index(&dir_path); @@ -173,9 +171,10 @@ pub fn extract_cwd_from_jsonl(path: &Path) -> Option { for line in reader.lines().take(5) { let line = line.ok()?; if let Ok(value) = serde_json::from_str::(&line) - && let Some(cwd) = value.get("cwd").and_then(|v| v.as_str()) { - return Some(cwd.to_string()); - } + && let Some(cwd) = value.get("cwd").and_then(|v| v.as_str()) + { + return Some(cwd.to_string()); + } } None @@ -257,11 +256,7 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let projects_dir = create_test_projects_dir(tmp.path()); - create_project_with_sessions( - &projects_dir, - "-Users-test-myapp", - &["abc-123", "def-456"], - ); + create_project_with_sessions(&projects_dir, "-Users-test-myapp", &["abc-123", "def-456"]); let results = scan_projects(&projects_dir, None).unwrap(); assert_eq!(results.len(), 2); @@ -295,11 +290,8 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let projects_dir = create_test_projects_dir(tmp.path()); - let project_dir = create_project_with_sessions( - &projects_dir, - "-Users-test-myapp", - &["session-1"], - ); + let project_dir = + create_project_with_sessions(&projects_dir, "-Users-test-myapp", &["session-1"]); // Write sessions-index.json with a projectPath. let index_content = serde_json::json!([ @@ -331,20 +323,11 @@ mod tests { // Two project directories — one for /Users/test/myapp, one for // /Users/test/e2e-tests (note the hyphen in the real path). - create_project_with_sessions( - &projects_dir, - "-Users-test-myapp", - &["session-a"], - ); - create_project_with_sessions( - &projects_dir, - "-Users-test-e2e-tests", - &["session-b"], - ); + create_project_with_sessions(&projects_dir, "-Users-test-myapp", &["session-a"]); + create_project_with_sessions(&projects_dir, "-Users-test-e2e-tests", &["session-b"]); // Scope to /Users/test/myapp — should only find session-a. - let scoped = - scan_projects(&projects_dir, Some(Path::new("/Users/test/myapp"))).unwrap(); + let scoped = scan_projects(&projects_dir, Some(Path::new("/Users/test/myapp"))).unwrap(); assert_eq!(scoped.len(), 1); assert_eq!(scoped[0].session_id, "session-a"); @@ -445,11 +428,7 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let projects_dir = create_test_projects_dir(tmp.path()); - let project_dir = create_project_with_sessions( - &projects_dir, - "-Users-test-myapp", - &[], - ); + let project_dir = create_project_with_sessions(&projects_dir, "-Users-test-myapp", &[]); // Write a JSONL file with a cwd field (no sessions-index.json). let file_path = project_dir.join("session-1.jsonl"); @@ -489,6 +468,9 @@ mod tests { let index = load_sessions_index(tmp.path()); assert_eq!(index.len(), 1); - assert_eq!(index.get("abc-123").unwrap().summary.as_deref(), Some("Refactored auth")); + assert_eq!( + index.get("abc-123").unwrap().summary.as_deref(), + Some("Refactored auth") + ); } } diff --git a/crates/tb-session/src/index/schema.rs b/crates/tb-session/src/index/schema.rs index 5963b27..746b502 100644 --- a/crates/tb-session/src/index/schema.rs +++ b/crates/tb-session/src/index/schema.rs @@ -1,5 +1,5 @@ -use rusqlite::Connection; use crate::error::Result; +use rusqlite::Connection; /// Create all tables and FTS5 virtual table if they don't exist. pub fn create_tables(conn: &Connection) -> Result<()> { diff --git a/crates/tb-session/src/main.rs b/crates/tb-session/src/main.rs index 3e852c9..b0c8875 100644 --- a/crates/tb-session/src/main.rs +++ b/crates/tb-session/src/main.rs @@ -291,8 +291,7 @@ fn run() -> tb_session::error::Result<()> { tool_name: "tb-session", content: include_str!("../SKILL.md"), }; - toolbox_core::skill::run(&skill, &action) - .map_err(tb_session::error::Error::Other)?; + toolbox_core::skill::run(&skill, &action).map_err(tb_session::error::Error::Other)?; } Commands::Config { action } => match action { ConfigAction::Init => tb_session::commands::config_cmd::init()?, From 39b33cca84368cd90d433a473f77f6a0168ddb7b Mon Sep 17 00:00:00 2001 From: Dario Filipaj Date: Mon, 30 Mar 2026 10:26:47 +0200 Subject: [PATCH 29/29] fix(tb-session): always single-quote in shell_escape to prevent injection Shell metacharacters ($, `, ;, &, |) were passing through unquoted when the string didn't contain whitespace or quotes. Since the escaped value is executed as a shell command via Terminal.app/iTerm `do script`, a project path like `$(malicious)` could trigger command substitution. Always single-quote unconditionally with standard POSIX escaping. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/tb-session/src/commands/resume.rs | 26 +++++++++++++++--------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/crates/tb-session/src/commands/resume.rs b/crates/tb-session/src/commands/resume.rs index 87c7308..d7fb8c2 100644 --- a/crates/tb-session/src/commands/resume.rs +++ b/crates/tb-session/src/commands/resume.rs @@ -184,11 +184,10 @@ end tell"# } fn shell_escape(s: &str) -> String { - if s.contains(|c: char| c.is_whitespace() || c == '\'' || c == '"' || c == '\\') { - format!("'{}'", s.replace('\'', "'\\''")) - } else { - s.to_string() + if s.is_empty() { + return s.to_string(); } + format!("'{}'", s.replace('\'', "'\\''")) } #[cfg(test)] @@ -208,22 +207,29 @@ mod tests { #[test] fn test_shell_escape() { - // Plain string — no escaping needed - assert_eq!(shell_escape("claude"), "claude"); - assert_eq!(shell_escape("/usr/bin/claude"), "/usr/bin/claude"); + // Plain strings — always single-quoted + assert_eq!(shell_escape("claude"), "'claude'"); + assert_eq!(shell_escape("/usr/bin/claude"), "'/usr/bin/claude'"); - // String with spaces — single-quoted + // String with spaces assert_eq!(shell_escape("/path/to my dir"), "'/path/to my dir'"); // String with single quote — escaped within single quotes assert_eq!(shell_escape("it's"), "'it'\\''s'"); - // String with double quote — single-quoted wrapper + // String with double quote assert_eq!(shell_escape("say \"hi\""), "'say \"hi\"'"); - // String with backslash — single-quoted + // String with backslash assert_eq!(shell_escape("back\\slash"), "'back\\slash'"); + // Shell metacharacters — safely quoted + assert_eq!(shell_escape("$(whoami)"), "'$(whoami)'"); + assert_eq!(shell_escape("foo;rm -rf /"), "'foo;rm -rf /'"); + assert_eq!(shell_escape("a|b"), "'a|b'"); + assert_eq!(shell_escape("a&b"), "'a&b'"); + assert_eq!(shell_escape("`cmd`"), "'`cmd`'"); + // Empty string — returned as-is assert_eq!(shell_escape(""), ""); }