use std::str::FromStr;
use clap::builder::ValueHint;
use clap::{CommandFactory, Parser, Subcommand, ValueEnum};
use claudex::plan::Plan;
use claudex_cli::cli::{FilterArgs, SkillCommand};
use claudex_cli::cli_help;
use claudex_cli::commands;
use claudex_cli::skill;
use claudex_cli::ui::{self, ColorChoice};
#[derive(Parser)]
#[command(
name = "claudex",
about = "Query, search, and analyze agent coding sessions",
version,
arg_required_else_help = true
)]
struct Cli {
#[arg(long, value_enum, default_value_t = ColorChoice::Auto, global = true)]
color: ColorChoice,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
#[command(after_long_help = cli_help::SESSIONS_EXAMPLES)]
Sessions {
#[arg(short, long)]
project: Option<String>,
#[arg(long)]
file: Option<String>,
#[arg(short, long, default_value = "20")]
limit: usize,
#[arg(long)]
json: bool,
#[arg(long)]
no_index: bool,
#[command(flatten)]
filter: FilterArgs,
},
#[command(after_long_help = cli_help::COST_EXAMPLES)]
Cost {
#[arg(short, long)]
project: Option<String>,
#[arg(long)]
per_session: bool,
#[arg(short, long, default_value = "20")]
limit: usize,
#[arg(long)]
json: bool,
#[arg(long)]
no_index: bool,
#[command(flatten)]
filter: FilterArgs,
},
#[command(after_long_help = cli_help::SEARCH_EXAMPLES)]
Search {
query: String,
#[arg(short, long)]
project: Option<String>,
#[arg(short, long, default_value = "20")]
limit: usize,
#[arg(long)]
json: bool,
#[arg(long)]
case_sensitive: bool,
#[arg(long, value_parser = ["user", "assistant"])]
role: Option<String>,
#[arg(long)]
tool: Option<String>,
#[arg(long)]
file: Option<String>,
#[arg(long)]
pr: Option<String>,
#[arg(long, default_value = "0")]
context: usize,
#[arg(long)]
no_index: bool,
#[command(flatten)]
filter: FilterArgs,
},
#[command(after_long_help = cli_help::TOOLS_EXAMPLES)]
Tools {
#[arg(short, long)]
project: Option<String>,
#[arg(long)]
per_session: bool,
#[arg(short, long, default_value = "20")]
limit: usize,
#[arg(long)]
json: bool,
#[arg(long)]
no_index: bool,
#[command(flatten)]
filter: FilterArgs,
},
#[command(after_long_help = cli_help::WATCH_HELP)]
Watch {
#[arg(long)]
raw: bool,
#[arg(long, value_hint = ValueHint::FilePath)]
follow: Option<String>,
},
#[command(after_long_help = cli_help::SUMMARY_EXAMPLES)]
Summary {
#[arg(long)]
json: bool,
#[arg(long)]
no_index: bool,
#[arg(long, value_parser = Plan::from_str, default_value = "api")]
plan: Plan,
#[command(flatten)]
filter: FilterArgs,
},
#[command(after_long_help = cli_help::SESSION_EXAMPLES)]
Session {
selector: String,
#[arg(short, long)]
project: Option<String>,
#[arg(long)]
json: bool,
#[arg(long)]
no_index: bool,
},
#[command(after_long_help = cli_help::EXPORT_EXAMPLES)]
Export {
selector: String,
#[arg(long, value_enum, default_value_t = ExportFormat::Markdown)]
format: ExportFormat,
#[arg(short, long, value_hint = ValueHint::FilePath)]
output: Option<String>,
#[arg(short, long)]
project: Option<String>,
},
#[command(after_long_help = cli_help::INDEX_EXAMPLES)]
Index {
#[arg(long)]
force: bool,
#[arg(long)]
status: bool,
#[arg(long)]
prune_retained_days: Option<u64>,
#[arg(long)]
vacuum: bool,
},
#[command(after_long_help = cli_help::PROVIDERS_EXAMPLES)]
Providers {
#[arg(long)]
json: bool,
#[arg(long)]
deep: bool,
#[command(flatten)]
filter: FilterArgs,
},
#[command(after_long_help = cli_help::TIMELINE_EXAMPLES)]
Timeline {
#[arg(long)]
weekly: bool,
#[arg(short, long, default_value = "30")]
limit: usize,
#[arg(long)]
json: bool,
#[command(flatten)]
filter: FilterArgs,
},
#[command(after_long_help = cli_help::BUDGET_EXAMPLES)]
Budget {
#[arg(long)]
monthly: f64,
#[arg(long)]
json: bool,
#[command(flatten)]
filter: FilterArgs,
},
#[command(after_long_help = cli_help::ACTIVITY_EXAMPLES)]
Activity {
#[arg(short, long, default_value = "5")]
limit: usize,
#[arg(long)]
json: bool,
#[command(flatten)]
filter: FilterArgs,
},
#[command(after_long_help = cli_help::TURNS_EXAMPLES)]
Turns {
#[arg(short, long)]
project: Option<String>,
#[arg(short, long, default_value = "20")]
limit: usize,
#[arg(long)]
json: bool,
#[command(flatten)]
filter: FilterArgs,
},
#[command(after_long_help = cli_help::PRS_EXAMPLES)]
Prs {
#[arg(short, long)]
project: Option<String>,
#[arg(short, long, default_value = "20")]
limit: usize,
#[arg(long)]
json: bool,
#[command(flatten)]
filter: FilterArgs,
},
#[command(after_long_help = cli_help::FILES_EXAMPLES)]
Files {
#[arg(short, long)]
project: Option<String>,
#[arg(long)]
path: Option<String>,
#[arg(short, long, default_value = "20")]
limit: usize,
#[arg(long)]
json: bool,
#[command(flatten)]
filter: FilterArgs,
},
#[command(after_long_help = cli_help::MODELS_EXAMPLES)]
Models {
#[arg(short, long)]
project: Option<String>,
#[arg(long)]
json: bool,
#[command(flatten)]
filter: FilterArgs,
},
#[command(after_long_help = cli_help::UPDATE_HELP)]
Update {
#[arg(long)]
check: bool,
#[arg(long)]
force: bool,
#[arg(long)]
version: Option<String>,
},
#[command(after_long_help = cli_help::COMPLETIONS_HELP)]
Completions {
#[arg(value_enum)]
shell: CompletionShell,
},
#[command(after_long_help = cli_help::SKILLS_EXAMPLES)]
Skills {
#[command(subcommand)]
command: SkillCommand,
},
}
#[derive(Clone, Debug, ValueEnum)]
enum ExportFormat {
Markdown,
Json,
}
impl ExportFormat {
fn as_str(&self) -> &'static str {
match self {
ExportFormat::Markdown => "markdown",
ExportFormat::Json => "json",
}
}
}
impl std::fmt::Display for ExportFormat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Clone, Debug, ValueEnum)]
enum CompletionShell {
Bash,
Zsh,
Fish,
Elvish,
Powershell,
}
impl CompletionShell {
fn as_str(&self) -> &'static str {
match self {
CompletionShell::Bash => "bash",
CompletionShell::Zsh => "zsh",
CompletionShell::Fish => "fish",
CompletionShell::Elvish => "elvish",
CompletionShell::Powershell => "powershell",
}
}
}
fn main() {
clap_complete::CompleteEnv::with_factory(Cli::command).complete();
let choice = preparse_color_choice();
ui::apply_color_choice(choice);
let cli = Cli::command()
.color(clap_color_choice(choice))
.try_get_matches()
.and_then(|m| <Cli as clap::FromArgMatches>::from_arg_matches(&m));
let cli = match cli {
Ok(cli) => cli,
Err(e) => render_cli_error(e, choice),
};
let result = match cli.command {
Commands::Sessions {
project,
file,
limit,
json,
no_index,
filter,
} => filter.resolve().and_then(|f| {
commands::sessions::run(
project.as_deref(),
file.as_deref(),
limit,
json,
no_index,
&f,
)
}),
Commands::Cost {
project,
per_session,
limit,
json,
no_index,
filter,
} => filter.resolve().and_then(|f| {
commands::cost::run(project.as_deref(), per_session, limit, json, no_index, &f)
}),
Commands::Search {
query,
project,
limit,
json,
case_sensitive,
role,
tool,
file,
pr,
context,
no_index,
filter,
} => filter.resolve().and_then(|f| {
commands::search::run(commands::search::SearchCommand {
query: &query,
project: project.as_deref(),
limit,
json,
case_sensitive,
role: role.as_deref(),
tool: tool.as_deref(),
file: file.as_deref(),
pr: pr.as_deref(),
context,
no_index,
filter: &f,
})
}),
Commands::Tools {
project,
per_session,
limit,
json,
no_index,
filter,
} => filter.resolve().and_then(|f| {
commands::tools::run(project.as_deref(), per_session, limit, json, no_index, &f)
}),
Commands::Watch { raw, follow } => commands::watch::run(raw, follow.as_deref()),
Commands::Summary {
json,
no_index,
plan,
filter,
} => filter
.resolve()
.and_then(|f| commands::summary::run(json, no_index, plan, &f)),
Commands::Session {
selector,
project,
json,
no_index,
} => commands::session::run(&selector, project.as_deref(), json, no_index),
Commands::Export {
selector,
format,
output,
project,
} => commands::export::run(
&selector,
format.as_str(),
output.as_deref(),
project.as_deref(),
),
Commands::Index {
force,
status,
prune_retained_days,
vacuum,
} => commands::index::run(force, status, prune_retained_days, vacuum),
Commands::Providers { json, deep, filter } => filter
.resolve()
.and_then(|f| commands::providers::run(json, deep, &f)),
Commands::Timeline {
weekly,
limit,
json,
filter,
} => filter
.resolve()
.and_then(|f| commands::timeline::run(weekly, limit, json, &f)),
Commands::Budget {
monthly,
json,
filter,
} => filter
.resolve()
.and_then(|f| commands::budget::run(monthly, json, &f)),
Commands::Activity {
limit,
json,
filter,
} => filter
.resolve()
.and_then(|f| commands::activity::run(limit, json, &f)),
Commands::Turns {
project,
limit,
json,
filter,
} => filter
.resolve()
.and_then(|f| commands::turns::run(project.as_deref(), limit, json, &f)),
Commands::Prs {
project,
limit,
json,
filter,
} => filter
.resolve()
.and_then(|f| commands::prs::run(project.as_deref(), limit, json, &f)),
Commands::Files {
project,
path,
limit,
json,
filter,
} => filter.resolve().and_then(|f| {
commands::files::run(project.as_deref(), path.as_deref(), limit, json, &f)
}),
Commands::Models {
project,
json,
filter,
} => filter
.resolve()
.and_then(|f| commands::models::run(project.as_deref(), json, &f)),
Commands::Update {
check,
force,
version,
} => commands::update::run(check, force, version),
Commands::Completions { shell } => generate_completions(shell.as_str()),
Commands::Skills { command } => skill::execute(command, &Cli::command()),
};
if let Err(e) = result {
eprintln!("error: {e:#}");
std::process::exit(1);
}
}
fn preparse_color_choice() -> ColorChoice {
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
if let Some(val) = arg.strip_prefix("--color=") {
return parse_color(val).unwrap_or(ColorChoice::Auto);
}
if arg == "--color"
&& let Some(val) = args.next()
{
return parse_color(&val).unwrap_or(ColorChoice::Auto);
}
}
ColorChoice::Auto
}
fn parse_color(s: &str) -> Option<ColorChoice> {
match s {
"always" => Some(ColorChoice::Always),
"never" => Some(ColorChoice::Never),
"auto" => Some(ColorChoice::Auto),
_ => None,
}
}
fn clap_color_choice(c: ColorChoice) -> clap::ColorChoice {
match c {
ColorChoice::Always => clap::ColorChoice::Always,
ColorChoice::Never => clap::ColorChoice::Never,
ColorChoice::Auto => clap::ColorChoice::Auto,
}
}
fn render_cli_error(err: clap::Error, choice: ColorChoice) -> ! {
use clap::error::ErrorKind;
if matches!(
err.kind(),
ErrorKind::DisplayHelp
| ErrorKind::DisplayVersion
| ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
) {
err.exit();
}
let code = err.exit_code();
let mut cmd = Cli::command().color(clap_color_choice(choice));
cmd.build();
let mut scoped = resolve_invoked_command(&cmd).clone();
let bin = scoped
.get_bin_name()
.unwrap_or_else(|| scoped.get_name())
.to_string();
let usage = scoped.render_usage().to_string();
let mut out = strip_help_footer(&err.render().to_string());
if !out.contains("Usage:") {
out.push_str("\n\n");
out.push_str(usage.trim_end());
}
out.push_str(&cli_help::error_help_for(&bin));
out.push_str(&format!("\n\nFor more information, try '{bin} --help'."));
eprintln!("{out}");
std::process::exit(code);
}
fn resolve_invoked_command(cmd: &clap::Command) -> &clap::Command {
let mut current = cmd;
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
if arg == "--color" {
let _ = args.next();
continue;
}
if arg.starts_with('-') {
continue;
}
match current.find_subcommand(&arg) {
Some(sub) => current = sub,
None => break,
}
}
current
}
fn strip_help_footer(rendered: &str) -> String {
match rendered.find("For more information") {
Some(pos) => rendered[..pos].trim_end().to_string(),
None => rendered.trim_end().to_string(),
}
}
fn generate_completions(shell: &str) -> anyhow::Result<()> {
if shell == "zsh" {
let bin = std::env::args()
.next()
.unwrap_or_else(|| "claudex".to_string());
print!(
r##"#compdef claudex
function _clap_dynamic_completer_claudex() {{
local _CLAP_COMPLETE_INDEX=$(expr $CURRENT - 1)
local _CLAP_IFS=$'\n'
# File-path flags: fall back to zsh native _files for tilde expansion,
# directory traversal, and proper path completion.
local prev_word="${{words[$(( CURRENT - 1 ))]}}"
case "$prev_word" in
--output|-o|--follow)
_files
return
;;
esac
local completions=("${{(@f)$( \
_CLAP_IFS="$_CLAP_IFS" \
_CLAP_COMPLETE_INDEX="$_CLAP_COMPLETE_INDEX" \
COMPLETE="zsh" \
{bin} -- "${{words[@]}}" 2>/dev/null \
)}}")
if [[ -n $completions ]]; then
local -a flags=()
local -a values=()
local completion
for completion in $completions; do
local value="${{completion%%:*}}"
if [[ "$value" == -* ]]; then
flags+=("$completion")
elif [[ "$value" == */ ]]; then
local dir_no_slash="${{value%/}}"
if [[ "$completion" == *:* ]]; then
local desc="${{completion#*:}}"
values+=("$dir_no_slash:$desc")
else
values+=("$dir_no_slash")
fi
else
values+=("$completion")
fi
done
if [[ "${{words[$CURRENT]}}" == -* ]]; then
[[ -n $flags ]] && _describe 'options' flags
else
[[ -n $values ]] && _describe 'values' values
fi
fi
}}
compdef _clap_dynamic_completer_claudex claudex
"##,
bin = bin,
);
return Ok(());
}
let shells = clap_complete::env::Shells::builtins();
let completer = match shells.completer(shell) {
Some(c) => c,
None => {
let names: Vec<_> = shells.names().collect();
anyhow::bail!(
"unknown shell '{}', expected one of: {}",
shell,
names.join(", ")
);
}
};
let bin = std::env::args()
.next()
.unwrap_or_else(|| "claudex".to_string());
completer.write_registration(
"COMPLETE",
"claudex",
"claudex",
&bin,
&mut std::io::stdout(),
)?;
Ok(())
}