Skip to content

feat: add tb-session CLI for Claude Code session search#2

Merged
dafilipaj merged 31 commits intomainfrom
feature/tb-session
Mar 30, 2026
Merged

feat: add tb-session CLI for Claude Code session search#2
dafilipaj merged 31 commits intomainfrom
feature/tb-session

Conversation

@dafilipaj
Copy link
Copy Markdown
Contributor

@dafilipaj dafilipaj commented Mar 23, 2026

Summary

New tb-session CLI tool that indexes and full-text searches Claude Code sessions via SQLite FTS5. Joins the cli-toolbox workspace alongside tb-prod, tb-sem, tb-bug, and tb-lf.

Commands

Command Description
search FTS5 full-text search with BM25 ranking, snippets, and filters (--branch, --pr, --project, --after/before)
list Browse sessions by metadata with pagination
show Session detail with conversation preview
resume Resume a past session — opens a new terminal tab (iTerm/Terminal.app) since Claude sessions can't nest
index Explicit index rebuild with stats
doctor Verify setup health (Claude home, projects dir, DB, FTS5, claude binary)
cache-clear Delete index for clean rebuild
prime AI-optimized context dump for Claude skill injection
config Show/init config at ~/.config/tb-session/config.toml
skill install Install SKILL.md to ~/.claude/skills/tb-session/

Key features beyond basic search

  • Worktree-aware scopinglist and search automatically include sessions from all git worktrees of the same repo, not just the exact cwd
  • Smart resume — accepts session IDs, UUID prefixes (any length), or name/search terms that match session summary/first prompt
  • New terminal tab for resume — when invoked from within Claude Code (non-TTY), automatically opens a new iTerm/Terminal.app tab, cd's into the original project directory, and runs claude --resume
  • --pr filtertb-session search --pr 557 finds sessions mentioning a PR by number or URL
  • FTS5 query sanitization — URLs, colons, and other special characters work safely as search terms
  • Versioned sessions-index.json — correctly parses Claude Code's {version, entries} format for fast metadata loading

Architecture

  • SQLite FTS5 database at ~/.cache/tb-session/index.db
  • Lazy indexing with 1h TTL — only re-parses changed/new JSONL files (mtime-based)
  • Synchronous main (no tokio) — all I/O is local files + SQLite
  • Smallest binary in the toolbox (3.6MB vs 8-12MB) — no reqwest/TLS dependency
  • Custom error enum (no define_error! since that requires reqwest)
  • osascript injection prevention — commands passed via env var, not string interpolation
  • macOS-only guard on terminal tab spawning with helpful error on other platforms

Resume: why a new tab?

claude --resume starts a new interactive Claude Code session. This can't run inside an existing session — exec() would kill the current conversation. When tb-session resume detects it's running inside Claude (stdin is not a TTY), it opens a new terminal tab via osascript instead. The tab automatically:

  1. cd's into the session's original project directory (if different from cwd)
  2. Runs claude --resume <full-session-id> with the resolved UUID

When run from a regular terminal, it exec's directly as expected.

Installation

# From source (recommended during development)
cargo install --path crates/tb-session

# Install the Claude Code skill (teaches Claude to use tb-session)
tb-session skill install

# Verify setup
tb-session doctor

After installing, rebuild your index:

tb-session index --all-projects

Usage examples

Terminal usage

# Search sessions by content
tb-session search "authentication middleware"
tb-session search "budget calculation" --all-projects
tb-session search "refactor" --branch feature/auth

# Search by PR number
tb-session search --pr 557
tb-session search --pr 557 --all-projects

# Search by PR URL
tb-session search --pr https://github.com/productiveio/ai-agent/pull/557

# List recent sessions
tb-session list
tb-session list --all-projects --limit 20
tb-session list --branch main --after 2026-03-01

# Show session details
tb-session show bcb7ffed
tb-session show bcb7ff   # prefix match

# Resume by UUID (prefix or full)
tb-session resume bcb7ffed
tb-session resume 27b0337

# Resume by name/summary search
tb-session resume "auth refactor"
tb-session resume "PR review"

# JSON output for scripting
tb-session search "deploy" --json
tb-session list --json | jq '.[0].session_id'

Prompts that trigger Claude to use tb-session

When the skill is installed, these prompts make Claude reach for tb-session automatically:

> resume the session where we reviewed PR #557
> find the conversation about budgeting calculations
> show me the session where we debugged the auth middleware
> which sessions touched the frontend repo this week?
> resume my last session about deploy scripts
> find sessions mentioning the billing migration

Claude will run tb-session search, pick the right session, and tb-session resume it — opening a new terminal tab with the session.

Test plan

  • 45 unit tests pass (schema, scanner, parser, builder, search, resume, git)
  • 0 clippy warnings
  • Tested with real data: 285+ sessions indexed across 10+ projects
  • Search returns ranked results with snippets
  • JSON output is structured and token-efficient
  • Resume opens new iTerm tab with correct project directory
  • Worktree-aware scoping includes sessions from linked worktrees
  • --pr filter finds sessions by PR number and URL
  • FTS5 sanitization handles URLs without "column not recognized" errors
  • Empty search query returns clear error
  • Short UUID prefixes (< 8 chars) resolve correctly
  • Manual: tb-session doctor
  • Manual: tb-session prime

Workspace changes

  • Cargo.toml: added modern_sqlite to rusqlite features, added tb-session workspace dep
  • scripts/install.sh: added tb-session to ALL_TOOLS
  • scripts/bump.sh: added tb-session to VALID_TOOLS

Post-merge TODO

  • Update SKILL.md description with stronger trigger keywords so Claude automatically reaches for tb-session on "resume session" prompts (currently Claude interprets "resume the session" as "continue the work" instead of using tb-session resume)
  • Add tb-session to the work project CLAUDE.md CLI Toolbox section with explicit instructions to prefer it over manual JSONL searching
  • Run tb-session skill install to deploy updated skill

🤖 Generated with Claude Code

dafilipaj and others added 27 commits March 23, 2026 13:12
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…on support

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…xtraction

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ters

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) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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 <noreply@anthropic.com>
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) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ial 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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/<number>" for GitHub URL matching.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…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) <noreply@anthropic.com>
- 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
@dafilipaj dafilipaj marked this pull request as ready for review March 24, 2026 14:48
@dafilipaj dafilipaj requested review from ilucin and trogulja March 24, 2026 14:48
dafilipaj and others added 2 commits March 24, 2026 15:53
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
trogulja

This comment was marked as duplicate.

Copy link
Copy Markdown
Collaborator

@trogulja trogulja left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM with one inline note on shell_escape.

Make sure to resolve the merge conflicts on Cargo.toml, scripts/bump.sh, and scripts/install.sh before merging — both tb-devctl and tb-session need to be in those lists.

}

fn shell_escape(s: &str) -> String {
if s.contains(|c: char| c.is_whitespace() || c == '\'' || c == '"' || c == '\\') {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security: shell_escape only triggers quoting for whitespace, ', ", and \. Shell metacharacters like $, backtick, ;, &, | pass through unquoted. Since the result is executed as a shell command by Terminal.app/iTerm via do script, a project path containing e.g. $(malicious) would trigger command substitution.

Fix: always single-quote unconditionally — the inner ''\'' replacement already handles embedded quotes:

fn shell_escape(s: &str) -> String {
    if s.is_empty() {
        return s.to_string();
    }
    format!("'{}'", s.replace('\'', "'\\''"))
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 39b33ccshell_escape now always single-quotes unconditionally. Added test cases for $(), backtick, ;, &, and |.

dafilipaj and others added 2 commits March 30, 2026 10:26
…tion

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) <noreply@anthropic.com>
…rity

# Conflicts:
#	Cargo.toml
#	scripts/bump.sh
#	scripts/install.sh
@dafilipaj dafilipaj merged commit d0559c7 into main Mar 30, 2026
1 check passed
@dafilipaj dafilipaj deleted the feature/tb-session branch March 30, 2026 11:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants