feat(claudbg-horp): add --filter flag to sessions/agents list commands

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
Elijah Voigt 2 months ago
parent 277e3a8667
commit 403762400e

@ -0,0 +1,32 @@
---
# claudbg-horp
title: 'CLI: --filter flag on sessions list and agents list'
status: completed
type: task
priority: normal
created_at: 2026-03-31T00:33:14Z
updated_at: 2026-03-31T05:14:11Z
parent: claudbg-2vwx
blocked_by:
- claudbg-4bms
---
Add `--filter <query>` flag to `sessions list` and `agents list`. The flag may be passed multiple times; multiple values are ANDed together (equivalent to joining with ` AND `). Depends on the filter query parser.
## Summary of Changes
Added `--filter <query>` flag to `sessions list` and `agents list`.
### Changes
- **`src/cli.rs`**: Added `filter: Vec<String>` field with `#[arg(long, action = clap::ArgAction::Append)]` to both `SessionsCmd::List` and `AgentsCmd::List`.
- **`src/main.rs`**: Destructure and pass `filter` from both list variants through to the command functions. Default for the bare `sessions` command (no subcommand) is `vec![]`.
- **`src/commands/sessions.rs`**: Added private `RawSessionRow` struct implementing `crate::filter::SessionRow`. The `list` function now accepts `filters: Vec<String>`, parses them all up front with `Filter::parse` (returning errors immediately), collects raw DB rows into `RawSessionRow` values, applies all filters (AND semantics), and then converts passing rows to display strings before rendering.
- **`src/commands/agents.rs`**: Added private `AgentRowRef<'a>` wrapper around `&AgentRef` implementing `crate::filter::SessionRow` (id → agent_id, model → agent_type, date → modified_at, others zero/empty). The `list` function now accepts `filters: Vec<String>`, parses them, and filters the `agents` vec before building display rows.
- Updated all existing test call sites in `sessions.rs` and `agents.rs` to pass `vec![]` for the new `filters` parameter.
All 234 tests pass.

@ -185,6 +185,10 @@ pub enum SessionsCmd {
/// Maximum number of sessions to show, or "all" for no limit. /// Maximum number of sessions to show, or "all" for no limit.
#[arg(long, default_value = "10")] #[arg(long, default_value = "10")]
limit: Limit, limit: Limit,
/// Filter expression (may be passed multiple times; all filters are ANDed).
/// Example: --filter 'model:haiku' --filter 'agents>0'
#[arg(long, action = clap::ArgAction::Append)]
filter: Vec<String>,
}, },
/// Dump raw messages from a session. /// Dump raw messages from a session.
Dump { Dump {
@ -214,6 +218,10 @@ pub enum AgentsCmd {
/// Maximum number of agents to show, or "all" for no limit. /// Maximum number of agents to show, or "all" for no limit.
#[arg(long, default_value = "10")] #[arg(long, default_value = "10")]
limit: Limit, limit: Limit,
/// Filter expression (may be passed multiple times; all filters are ANDed).
/// Example: --filter 'id:abc12345'
#[arg(long, action = clap::ArgAction::Append)]
filter: Vec<String>,
}, },
/// Dump raw messages from an agent run. /// Dump raw messages from an agent run.
Dump { Dump {

@ -151,6 +151,40 @@ fn find_agent(session_id: &str, agent_id: &str) -> Result<crate::parser::discove
Ok(agent_ref) Ok(agent_ref)
} }
// ---------------------------------------------------------------------------
// Helper: SessionRow impl for AgentRef
// ---------------------------------------------------------------------------
/// Wrapper around [`crate::parser::discovery::AgentRef`] that implements
/// [`crate::filter::SessionRow`] so filters can be applied to agent list rows.
struct AgentRowRef<'a>(&'a crate::parser::discovery::AgentRef);
impl crate::filter::SessionRow for AgentRowRef<'_> {
fn model(&self) -> &str {
// Expose agent_type via the `model` key so filters like `model:subagent` work.
self.0.agent_type.as_deref().unwrap_or("")
}
fn project(&self) -> &str {
// No meaningful project for an agent row.
""
}
fn id(&self) -> &str {
&self.0.agent_id
}
fn agents(&self) -> u64 {
0
}
fn messages(&self) -> u64 {
0
}
fn tokens(&self) -> Option<u64> {
None
}
fn date(&self) -> Option<chrono::NaiveDate> {
Some(self.0.modified_at.date_naive())
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Public commands // Public commands
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -164,7 +198,20 @@ fn find_agent(session_id: &str, agent_id: &str) -> Result<crate::parser::discove
/// Respects `opts.output` and `opts.verbose` (verbose shows full UUIDs and /// Respects `opts.output` and `opts.verbose` (verbose shows full UUIDs and
/// full file paths instead of 8-char prefixes and basenames). /// full file paths instead of 8-char prefixes and basenames).
/// The `limit` controls how many rows are returned (default 10, or all). /// The `limit` controls how many rows are returned (default 10, or all).
pub async fn list(session_id: &str, limit: crate::cli::Limit, opts: &crate::cli::GlobalOpts) -> Result<()> { /// The `filters` are ANDed together; each is a filter query string as
/// accepted by [`crate::filter::Filter::parse`].
pub async fn list(
session_id: &str,
limit: crate::cli::Limit,
filters: Vec<String>,
opts: &crate::cli::GlobalOpts,
) -> Result<()> {
// Parse all filter expressions up front so we can report errors immediately.
let parsed_filters: Vec<crate::filter::Filter> = filters
.iter()
.map(|s| crate::filter::Filter::parse(s))
.collect::<Result<Vec<_>>>()?;
let sessions = crate::parser::discovery::discover_sessions()?; let sessions = crate::parser::discovery::discover_sessions()?;
let session_ref = sessions let session_ref = sessions
.iter() .iter()
@ -175,6 +222,12 @@ pub async fn list(session_id: &str, limit: crate::cli::Limit, opts: &crate::cli:
let agents = crate::parser::discovery::discover_agents_for_session(&session_ref.file_path)?; let agents = crate::parser::discovery::discover_agents_for_session(&session_ref.file_path)?;
// Apply filters (AND semantics: all filters must match).
let agents: Vec<_> = agents
.into_iter()
.filter(|a| parsed_filters.iter().all(|f| f.matches(&AgentRowRef(a))))
.collect();
let rows: Vec<Vec<String>> = agents let rows: Vec<Vec<String>> = agents
.iter() .iter()
.map(|a| { .map(|a| {
@ -512,7 +565,7 @@ mod tests {
// SAFETY: single-threaded test context; no other threads read HOME concurrently. // SAFETY: single-threaded test context; no other threads read HOME concurrently.
unsafe { std::env::set_var("HOME", dir.path()) }; unsafe { std::env::set_var("HOME", dir.path()) };
let opts = default_opts(); let opts = default_opts();
let result = list("nonexistent-session-id-xyz", crate::cli::Limit::default(), &opts).await; let result = list("nonexistent-session-id-xyz", crate::cli::Limit::default(), vec![], &opts).await;
assert!( assert!(
matches!(result, Err(crate::error::AppError::NotFound(_))), matches!(result, Err(crate::error::AppError::NotFound(_))),
"expected NotFound, got: {result:?}" "expected NotFound, got: {result:?}"
@ -568,7 +621,7 @@ mod tests {
// SAFETY: single-threaded test context; no other threads read HOME concurrently. // SAFETY: single-threaded test context; no other threads read HOME concurrently.
unsafe { std::env::set_var("HOME", dir.path()) }; unsafe { std::env::set_var("HOME", dir.path()) };
let opts = default_opts(); let opts = default_opts();
let result = list("aaaabbbb", crate::cli::Limit::default(), &opts).await; let result = list("aaaabbbb", crate::cli::Limit::default(), vec![], &opts).await;
assert!(result.is_ok(), "expected Ok, got: {result:?}"); assert!(result.is_ok(), "expected Ok, got: {result:?}");
} }

@ -162,13 +162,70 @@ fn render_entry_text(entry: &crate::models::session::RawEntry, opts: &crate::cli
} }
} }
// ---------------------------------------------------------------------------
// Helper struct for filter evaluation
// ---------------------------------------------------------------------------
/// Raw session data used to evaluate filter predicates before building display strings.
struct RawSessionRow {
session_id: String,
project_path: String,
model: String,
last_msg_at: String,
message_count: i64,
agent_count: usize,
}
impl crate::filter::SessionRow for RawSessionRow {
fn model(&self) -> &str {
&self.model
}
fn project(&self) -> &str {
&self.project_path
}
fn id(&self) -> &str {
&self.session_id
}
fn agents(&self) -> u64 {
self.agent_count as u64
}
fn messages(&self) -> u64 {
self.message_count as u64
}
fn tokens(&self) -> Option<u64> {
None
}
fn date(&self) -> Option<chrono::NaiveDate> {
// last_msg_at is e.g. "2026-03-30 14:22:01"; parse the date portion.
self.last_msg_at
.get(..10)
.and_then(|s| chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").ok())
}
}
// ---------------------------------------------------------------------------
// sessions list
// ---------------------------------------------------------------------------
/// Run `sessions list`. /// Run `sessions list`.
/// ///
/// Discovers all session files, syncs them to the DB, then queries the DB /// Discovers all session files, syncs them to the DB, then queries the DB
/// for a summary sorted most-recent-first. Respects `opts.output` and /// for a summary sorted most-recent-first. Respects `opts.output` and
/// `opts.verbose` (verbose shows full UUIDs instead of 8-char prefixes). /// `opts.verbose` (verbose shows full UUIDs instead of 8-char prefixes).
/// The `limit` controls how many rows are returned (default 10, or all). /// The `limit` controls how many rows are returned (default 10, or all).
pub async fn list(limit: crate::cli::Limit, opts: &crate::cli::GlobalOpts) -> Result<()> { /// The `filters` are ANDed together; each is a filter query string as
/// accepted by [`crate::filter::Filter::parse`].
pub async fn list(
limit: crate::cli::Limit,
filters: Vec<String>,
opts: &crate::cli::GlobalOpts,
) -> Result<()> {
// Parse all filter expressions up front so we can report errors immediately.
let parsed_filters: Vec<crate::filter::Filter> = filters
.iter()
.map(|s| crate::filter::Filter::parse(s))
.collect::<Result<Vec<_>>>()?;
let db_path = crate::db::connection::default_db_path(); let db_path = crate::db::connection::default_db_path();
let db = crate::db::connection::open_db(&db_path, false).await?; let db = crate::db::connection::open_db(&db_path, false).await?;
@ -197,8 +254,8 @@ pub async fn list(limit: crate::cli::Limit, opts: &crate::cli::GlobalOpts) -> Re
.await .await
.map_err(|e| crate::error::AppError::Db(e.to_string()))?; .map_err(|e| crate::error::AppError::Db(e.to_string()))?;
// Collect rows. // Collect raw rows.
let mut rows: Vec<Vec<String>> = Vec::new(); let mut raw_rows: Vec<RawSessionRow> = Vec::new();
while let Some(row) = rows_cursor while let Some(row) = rows_cursor
.next() .next()
.await .await
@ -210,41 +267,61 @@ pub async fn list(limit: crate::cli::Limit, opts: &crate::cli::GlobalOpts) -> Re
let last_msg_at: String = row.get(3).unwrap_or_default(); let last_msg_at: String = row.get(3).unwrap_or_default();
let message_count: i64 = row.get(4).unwrap_or_default(); let message_count: i64 = row.get(4).unwrap_or_default();
// Count sub-agents for this session.
let agent_count = if let Some(file_path) = session_file_map.get(&session_id) {
crate::parser::discovery::discover_agents_for_session(file_path)
.unwrap_or_default()
.len()
} else {
0
};
raw_rows.push(RawSessionRow {
session_id,
project_path,
model,
last_msg_at,
message_count,
agent_count,
});
}
// Apply filters (AND semantics: all filters must match).
let raw_rows: Vec<RawSessionRow> = raw_rows
.into_iter()
.filter(|r| parsed_filters.iter().all(|f| f.matches(r)))
.collect();
// Convert to display rows.
let mut rows: Vec<Vec<String>> = Vec::new();
for raw in raw_rows {
let display_id = if opts.verbose { let display_id = if opts.verbose {
session_id.clone() raw.session_id.clone()
} else { } else {
crate::util::short_id(&session_id).to_string() crate::util::short_id(&raw.session_id).to_string()
}; };
const MAX_PATH_LEN: usize = 40; const MAX_PATH_LEN: usize = 40;
let display_path = if project_path.len() > MAX_PATH_LEN { let display_path = if raw.project_path.len() > MAX_PATH_LEN {
let boundary = project_path let boundary = raw
.project_path
.char_indices() .char_indices()
.rev() .rev()
.map(|(i, _)| i) .map(|(i, _)| i)
.nth(MAX_PATH_LEN - 1) .nth(MAX_PATH_LEN - 1)
.unwrap_or(0); .unwrap_or(0);
format!("…{}", &project_path[boundary..]) format!("…{}", &raw.project_path[boundary..])
} else { } else {
project_path raw.project_path
};
// Count sub-agents for this session.
let agent_count = if let Some(file_path) = session_file_map.get(&session_id) {
crate::parser::discovery::discover_agents_for_session(file_path)
.unwrap_or_default()
.len()
} else {
0
}; };
rows.push(vec![ rows.push(vec![
display_id, display_id,
last_msg_at, raw.last_msg_at,
display_path, display_path,
model, raw.model,
message_count.to_string(), raw.message_count.to_string(),
agent_count.to_string(), raw.agent_count.to_string(),
]); ]);
} }
@ -697,7 +774,7 @@ mod tests {
// SAFETY: single-threaded test context; no other threads read HOME concurrently. // SAFETY: single-threaded test context; no other threads read HOME concurrently.
unsafe { std::env::set_var("HOME", dir.path()) }; unsafe { std::env::set_var("HOME", dir.path()) };
let opts = default_opts(); let opts = default_opts();
let result = list(crate::cli::Limit::default(), &opts).await; let result = list(crate::cli::Limit::default(), vec![], &opts).await;
assert!(result.is_ok(), "list failed: {:?}", result.err()); assert!(result.is_ok(), "list failed: {:?}", result.err());
} }
@ -713,7 +790,7 @@ mod tests {
output: OutputFormat::Json, output: OutputFormat::Json,
..default_opts() ..default_opts()
}; };
let result = list(crate::cli::Limit::default(), &opts).await; let result = list(crate::cli::Limit::default(), vec![], &opts).await;
assert!(result.is_ok(), "list json failed: {:?}", result.err()); assert!(result.is_ok(), "list json failed: {:?}", result.err());
} }
@ -729,7 +806,7 @@ mod tests {
output: OutputFormat::Xml, output: OutputFormat::Xml,
..default_opts() ..default_opts()
}; };
let result = list(crate::cli::Limit::default(), &opts).await; let result = list(crate::cli::Limit::default(), vec![], &opts).await;
assert!(result.is_ok(), "list xml failed: {:?}", result.err()); assert!(result.is_ok(), "list xml failed: {:?}", result.err());
} }

@ -8,8 +8,8 @@ use claudbg::error::Result;
async fn main() -> Result<()> { async fn main() -> Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
match cli.command { match cli.command {
Commands::Sessions { cmd } => match cmd.unwrap_or(SessionsCmd::List { limit: Default::default() }) { Commands::Sessions { cmd } => match cmd.unwrap_or(SessionsCmd::List { limit: Default::default(), filter: vec![] }) {
SessionsCmd::List { limit } => claudbg::commands::sessions::list(limit, &cli.global).await?, SessionsCmd::List { limit, filter } => claudbg::commands::sessions::list(limit, filter, &cli.global).await?,
SessionsCmd::Dump { id, follow } => { SessionsCmd::Dump { id, follow } => {
claudbg::commands::sessions::dump(&id, follow, &cli.global).await? claudbg::commands::sessions::dump(&id, follow, &cli.global).await?
} }
@ -18,8 +18,8 @@ async fn main() -> Result<()> {
} }
}, },
Commands::Agents { cmd } => match cmd { Commands::Agents { cmd } => match cmd {
AgentsCmd::List { session_id, limit } => { AgentsCmd::List { session_id, limit, filter } => {
claudbg::commands::agents::list(&session_id, limit, &cli.global).await? claudbg::commands::agents::list(&session_id, limit, filter, &cli.global).await?
} }
AgentsCmd::Dump { AgentsCmd::Dump {
session_id, session_id,

Loading…
Cancel
Save