You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
368 lines
11 KiB
Rust
368 lines
11 KiB
Rust
//! CLI types: output format, include list, limit, global options, and command structure.
|
|
|
|
/// Output format for all commands.
|
|
#[derive(Debug, Clone, Default, clap::ValueEnum)]
|
|
pub enum OutputFormat {
|
|
/// Human-readable table (default).
|
|
#[default]
|
|
Table,
|
|
/// JSON output.
|
|
Json,
|
|
/// XML output.
|
|
Xml,
|
|
}
|
|
|
|
/// Comma-separated list of optional content to include in transcripts.
|
|
///
|
|
/// Accepted tokens: `thinking`, `output`.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct IncludeList {
|
|
/// Include `thinking` blocks in transcripts.
|
|
pub thinking: bool,
|
|
/// Include tool `output` blocks in transcripts.
|
|
pub output: bool,
|
|
}
|
|
|
|
impl std::str::FromStr for IncludeList {
|
|
type Err = crate::error::AppError;
|
|
|
|
/// Parse a comma-separated string of include tokens.
|
|
///
|
|
/// Accepted tokens: `thinking`, `output`.
|
|
/// Returns [`AppError::InvalidArg`] for any unknown token.
|
|
fn from_str(s: &str) -> crate::error::Result<Self> {
|
|
let mut list = IncludeList::default();
|
|
for token in s.split(',') {
|
|
let token = token.trim();
|
|
if token.is_empty() {
|
|
continue;
|
|
}
|
|
match token {
|
|
"thinking" => list.thinking = true,
|
|
"output" => list.output = true,
|
|
other => {
|
|
return Err(crate::error::AppError::InvalidArg(format!(
|
|
"unknown include token: {other}"
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
Ok(list)
|
|
}
|
|
}
|
|
|
|
/// How many entries to show in list commands.
|
|
///
|
|
/// Accepts an integer (e.g. `10`) or the keyword `all` to show every entry.
|
|
#[derive(Debug, Clone)]
|
|
pub enum Limit {
|
|
/// Show at most N entries.
|
|
Count(usize),
|
|
/// Show all entries without truncation.
|
|
All,
|
|
}
|
|
|
|
impl Default for Limit {
|
|
fn default() -> Self {
|
|
Limit::Count(10)
|
|
}
|
|
}
|
|
|
|
impl std::str::FromStr for Limit {
|
|
type Err = crate::error::AppError;
|
|
|
|
fn from_str(s: &str) -> crate::error::Result<Self> {
|
|
if s.eq_ignore_ascii_case("all") {
|
|
return Ok(Limit::All);
|
|
}
|
|
s.parse::<usize>()
|
|
.map(Limit::Count)
|
|
.map_err(|_| crate::error::AppError::InvalidArg(format!("invalid limit: {s}")))
|
|
}
|
|
}
|
|
|
|
impl Limit {
|
|
/// Apply this limit to a vector, returning a truncated or full slice.
|
|
pub fn apply<T>(&self, items: Vec<T>) -> Vec<T> {
|
|
match self {
|
|
Limit::All => items,
|
|
Limit::Count(n) => items.into_iter().take(*n).collect(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Top-level CLI entry point for claudbg.
|
|
#[derive(Debug, clap::Parser)]
|
|
#[command(name = "claudbg", about = "Claude Code session inspector")]
|
|
pub struct Cli {
|
|
/// Global options shared across all subcommands.
|
|
#[command(flatten)]
|
|
pub global: GlobalOpts,
|
|
/// The subcommand to execute.
|
|
#[command(subcommand)]
|
|
pub command: Commands,
|
|
}
|
|
|
|
/// Global options available on every subcommand.
|
|
#[derive(Debug, Clone, clap::Args)]
|
|
pub struct GlobalOpts {
|
|
/// Output format: table (default), json, xml.
|
|
#[arg(long, global = true, default_value = "table")]
|
|
pub output: OutputFormat,
|
|
/// Show full UUIDs and extra detail.
|
|
#[arg(long, global = true)]
|
|
pub verbose: bool,
|
|
/// Comma-separated list of extra content to include in transcriptions.
|
|
/// Valid values: thinking, output. Example: --include thinking,output
|
|
#[arg(long, global = true, default_value = "")]
|
|
pub include: IncludeList,
|
|
/// Force color output even when not writing to a terminal.
|
|
#[arg(long, global = true, overrides_with = "no_color")]
|
|
pub color: bool,
|
|
/// Disable color output (also honored via the NO_COLOR env var).
|
|
#[arg(long = "no-color", global = true, overrides_with = "color")]
|
|
pub no_color: bool,
|
|
}
|
|
|
|
impl GlobalOpts {
|
|
/// Determine whether color output should be enabled.
|
|
///
|
|
/// Priority (highest → lowest):
|
|
/// 1. `NO_COLOR` env var set to a non-empty value → disabled.
|
|
/// 2. `--no-color` flag passed → disabled.
|
|
/// 3. `--color` flag passed → enabled.
|
|
/// 4. Auto-detect: enabled iff stdout is a TTY.
|
|
pub fn color_enabled(&self) -> bool {
|
|
use std::io::IsTerminal as _;
|
|
|
|
// NO_COLOR spec: any non-empty value disables color.
|
|
if std::env::var("NO_COLOR").is_ok_and(|v| !v.is_empty()) {
|
|
return false;
|
|
}
|
|
if self.no_color {
|
|
return false;
|
|
}
|
|
if self.color {
|
|
return true;
|
|
}
|
|
std::io::stdout().is_terminal()
|
|
}
|
|
}
|
|
|
|
/// Top-level subcommands.
|
|
#[derive(Debug, clap::Subcommand)]
|
|
pub enum Commands {
|
|
/// List, dump, and transcribe sessions.
|
|
#[command(subcommand_required = false)]
|
|
Sessions {
|
|
/// Sessions subcommand (defaults to `list` when omitted).
|
|
#[command(subcommand)]
|
|
cmd: Option<SessionsCmd>,
|
|
},
|
|
/// List, dump, and transcribe sub-agent runs.
|
|
Agents {
|
|
/// Agents subcommand.
|
|
#[command(subcommand)]
|
|
cmd: AgentsCmd,
|
|
},
|
|
/// Sync all session files into the local cache DB.
|
|
Index {
|
|
/// Force full rebuild even if files appear up to date.
|
|
#[arg(long)]
|
|
force: bool,
|
|
},
|
|
/// Launch terminal UI (coming soon).
|
|
Tui,
|
|
/// Run ad-hoc queries against the cache DB (coming soon).
|
|
Query,
|
|
}
|
|
|
|
/// Subcommands for `sessions`.
|
|
#[derive(Debug, clap::Subcommand)]
|
|
pub enum SessionsCmd {
|
|
/// List all sessions, most recent first.
|
|
List {
|
|
/// Maximum number of sessions to show, or "all" for no limit.
|
|
#[arg(long, default_value = "10")]
|
|
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 {
|
|
/// Session ID or 8-char prefix.
|
|
id: String,
|
|
/// Stream new entries as the session file is written.
|
|
#[arg(long)]
|
|
follow: bool,
|
|
},
|
|
/// Show a human-readable transcript of a session.
|
|
Transcribe {
|
|
/// Session ID or 8-char prefix.
|
|
id: String,
|
|
/// Stream new entries as the session file is written.
|
|
#[arg(long)]
|
|
follow: bool,
|
|
},
|
|
}
|
|
|
|
/// Subcommands for `agents`.
|
|
#[derive(Debug, clap::Subcommand)]
|
|
pub enum AgentsCmd {
|
|
/// List all agent runs within a session.
|
|
List {
|
|
/// Parent session ID or 8-char prefix.
|
|
session_id: String,
|
|
/// Maximum number of agents to show, or "all" for no limit.
|
|
#[arg(long, default_value = "10")]
|
|
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 {
|
|
/// Parent session ID or 8-char prefix.
|
|
session_id: String,
|
|
/// Agent ID or 8-char prefix.
|
|
agent_id: String,
|
|
/// Stream new entries as the agent file is written.
|
|
#[arg(long)]
|
|
follow: bool,
|
|
},
|
|
/// Show a human-readable transcript of an agent run.
|
|
Transcribe {
|
|
/// Parent session ID or 8-char prefix.
|
|
session_id: String,
|
|
/// Agent ID or 8-char prefix.
|
|
agent_id: String,
|
|
/// Stream new entries as the agent file is written.
|
|
#[arg(long)]
|
|
follow: bool,
|
|
},
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::str::FromStr;
|
|
|
|
/// Empty string produces a default `IncludeList` with both flags false.
|
|
#[test]
|
|
fn include_list_empty_string() {
|
|
let list = IncludeList::from_str("").unwrap();
|
|
assert!(!list.thinking);
|
|
assert!(!list.output);
|
|
}
|
|
|
|
/// Parsing `"thinking"` sets the thinking flag only.
|
|
#[test]
|
|
fn include_list_thinking_only() {
|
|
let list = IncludeList::from_str("thinking").unwrap();
|
|
assert!(list.thinking);
|
|
assert!(!list.output);
|
|
}
|
|
|
|
/// Parsing `"output"` sets the output flag only.
|
|
#[test]
|
|
fn include_list_output_only() {
|
|
let list = IncludeList::from_str("output").unwrap();
|
|
assert!(!list.thinking);
|
|
assert!(list.output);
|
|
}
|
|
|
|
/// Parsing `"thinking,output"` sets both flags.
|
|
#[test]
|
|
fn include_list_both() {
|
|
let list = IncludeList::from_str("thinking,output").unwrap();
|
|
assert!(list.thinking);
|
|
assert!(list.output);
|
|
}
|
|
|
|
/// An unknown token returns an `AppError::InvalidArg`.
|
|
#[test]
|
|
fn include_list_unknown_token() {
|
|
let result = IncludeList::from_str("images");
|
|
assert!(result.is_err());
|
|
let msg = result.unwrap_err().to_string();
|
|
assert!(msg.contains("unknown include token: images"), "got: {msg}");
|
|
}
|
|
|
|
/// Whitespace around tokens is trimmed.
|
|
#[test]
|
|
fn include_list_whitespace_trim() {
|
|
let list = IncludeList::from_str(" thinking , output ").unwrap();
|
|
assert!(list.thinking);
|
|
assert!(list.output);
|
|
}
|
|
|
|
/// `OutputFormat` defaults to `Table`.
|
|
#[test]
|
|
fn output_format_default() {
|
|
let fmt = OutputFormat::default();
|
|
assert!(matches!(fmt, OutputFormat::Table));
|
|
}
|
|
|
|
/// Parsing a number produces `Limit::Count`.
|
|
#[test]
|
|
fn limit_parse_count() {
|
|
let limit = Limit::from_str("5").unwrap();
|
|
assert!(matches!(limit, Limit::Count(5)));
|
|
}
|
|
|
|
/// Parsing `"all"` produces `Limit::All`.
|
|
#[test]
|
|
fn limit_parse_all() {
|
|
let limit = Limit::from_str("all").unwrap();
|
|
assert!(matches!(limit, Limit::All));
|
|
}
|
|
|
|
/// Parsing `"ALL"` (case-insensitive) also produces `Limit::All`.
|
|
#[test]
|
|
fn limit_parse_all_uppercase() {
|
|
let limit = Limit::from_str("ALL").unwrap();
|
|
assert!(matches!(limit, Limit::All));
|
|
}
|
|
|
|
/// Parsing an invalid string returns an error.
|
|
#[test]
|
|
fn limit_parse_invalid() {
|
|
let result = Limit::from_str("banana");
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
/// Default limit is `Count(10)`.
|
|
#[test]
|
|
fn limit_default_is_ten() {
|
|
let limit = Limit::default();
|
|
assert!(matches!(limit, Limit::Count(10)));
|
|
}
|
|
|
|
/// `Limit::Count(3).apply` truncates a longer vec.
|
|
#[test]
|
|
fn limit_apply_count_truncates() {
|
|
let v = vec![1, 2, 3, 4, 5];
|
|
let result = Limit::Count(3).apply(v);
|
|
assert_eq!(result, vec![1, 2, 3]);
|
|
}
|
|
|
|
/// `Limit::All.apply` returns the full vec unchanged.
|
|
#[test]
|
|
fn limit_apply_all_returns_all() {
|
|
let v = vec![1, 2, 3, 4, 5];
|
|
let result = Limit::All.apply(v);
|
|
assert_eq!(result, vec![1, 2, 3, 4, 5]);
|
|
}
|
|
|
|
/// `Limit::Count(N).apply` on a shorter vec returns the full vec.
|
|
#[test]
|
|
fn limit_apply_count_shorter_vec_unchanged() {
|
|
let v = vec![1, 2];
|
|
let result = Limit::Count(10).apply(v);
|
|
assert_eq!(result, vec![1, 2]);
|
|
}
|
|
}
|