#![allow(unknown_lints)]
#![allow(
clippy::collapsible_if,
clippy::manual_is_multiple_of,
clippy::io_other_error
)]
mod app;
mod config;
mod discovery;
mod history;
mod hooks;
mod logger;
mod monitor;
mod orchestrator;
mod process;
mod session;
mod terminals;
mod theme;
mod ui;
use std::io;
use std::time::{Duration, Instant};
use clap::Parser;
use crossterm::{
event::{self, Event},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{Terminal, backend::CrosstermBackend};
use app::App;
#[derive(Parser)]
#[command(
name = "claudectl",
version,
about = "Monitor and manage Claude Code CLI agents"
)]
struct Cli {
#[arg(short, long, default_value_t = 2000)]
interval: u64,
#[arg(short, long)]
list: bool,
#[arg(long)]
notify: bool,
#[arg(long)]
json: bool,
#[arg(short, long)]
watch: bool,
#[arg(
long,
default_value = "{pid} {project}: {status} (${cost}, ctx {context}%)"
)]
format: String,
#[arg(long)]
debug: bool,
#[arg(long)]
summary: bool,
#[arg(long, default_value = "24h")]
since: String,
#[arg(long)]
webhook: Option<String>,
#[arg(long)]
webhook_on: Option<String>,
#[arg(long = "new")]
new_session: bool,
#[arg(long, default_value = ".")]
cwd: String,
#[arg(long)]
prompt: Option<String>,
#[arg(long)]
resume: Option<String>,
#[arg(long)]
budget: Option<f64>,
#[arg(long)]
kill_on_budget: bool,
#[arg(long)]
config: bool,
#[arg(long)]
theme: Option<String>,
#[arg(long)]
log: Option<String>,
#[arg(long)]
hooks: bool,
#[arg(long)]
history: bool,
#[arg(long)]
stats: bool,
#[arg(long)]
run: Option<String>,
#[arg(long)]
parallel: bool,
#[arg(long)]
clean: bool,
#[arg(long)]
older_than: Option<String>,
#[arg(long)]
finished: bool,
#[arg(long)]
dry_run: bool,
}
fn main() -> io::Result<()> {
let cli = Cli::parse();
if let Some(ref log_path) = cli.log {
if let Err(e) = logger::init(log_path) {
eprintln!("Warning: could not open log file {log_path}: {e}");
}
}
let mut cfg = config::Config::load();
if cli.interval != 2000 {
cfg.interval = cli.interval;
}
if cli.notify {
cfg.notify = true;
}
if cli.debug {
cfg.debug = true;
}
if cli.budget.is_some() {
cfg.budget = cli.budget;
}
if cli.kill_on_budget {
cfg.kill_on_budget = true;
}
if cli.webhook.is_some() {
cfg.webhook = cli.webhook.clone();
}
if cli.webhook_on.is_some() {
cfg.webhook_on = cli.webhook_on.as_deref().map(|s| {
s.split(',')
.map(|t| t.trim().to_string())
.collect::<Vec<_>>()
});
}
let hook_registry = config::load_hooks();
if cli.config {
cfg.print_resolved();
return Ok(());
}
if cli.hooks {
hook_registry.print_list();
return Ok(());
}
if let Some(ref run_file) = cli.run {
let task_file = orchestrator::load_tasks(run_file)?;
return orchestrator::run_tasks(task_file, cli.parallel);
}
if cli.clean {
return run_clean(cli.older_than.as_deref(), cli.finished, cli.dry_run);
}
if cli.history {
history::print_history(&cli.since);
return Ok(());
}
if cli.stats {
history::print_stats(&cli.since);
return Ok(());
}
if cli.new_session {
return launch_session(&cli.cwd, cli.prompt.as_deref(), cli.resume.as_deref());
}
if cli.summary {
return print_summary(&cli.since);
}
if cli.json && !cli.watch {
return print_json();
}
if cli.list {
return print_list();
}
if cli.watch {
return run_watch(Duration::from_millis(cfg.interval), cli.json, &cli.format);
}
let tick_rate = Duration::from_millis(cfg.interval);
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let theme_mode = theme::ThemeMode::detect(cli.theme.as_deref());
let app_theme = theme::Theme::from_mode(theme_mode);
let result = run(&mut terminal, tick_rate, &cfg, app_theme, hook_registry);
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
result
}
fn launch_session(cwd: &str, prompt: Option<&str>, resume: Option<&str>) -> io::Result<()> {
let cwd_path = std::path::Path::new(cwd)
.canonicalize()
.unwrap_or_else(|_| std::path::PathBuf::from(cwd));
let mut cmd = std::process::Command::new("claude");
if let Some(resume_id) = resume {
cmd.arg("--resume").arg(resume_id);
}
if let Some(prompt_text) = prompt {
cmd.arg("-p").arg(prompt_text);
}
cmd.current_dir(&cwd_path);
match cmd.spawn() {
Ok(child) => {
println!(
"Launched Claude session (PID {}) in {}",
child.id(),
cwd_path.display()
);
Ok(())
}
Err(e) => {
eprintln!("Failed to launch claude: {e}");
Err(e)
}
}
}
fn parse_duration_str(s: &str) -> Duration {
let s = s.trim();
if let Some(hours) = s.strip_suffix('h') {
if let Ok(h) = hours.parse::<u64>() {
return Duration::from_secs(h * 3600);
}
}
if let Some(mins) = s.strip_suffix('m') {
if let Ok(m) = mins.parse::<u64>() {
return Duration::from_secs(m * 60);
}
}
if let Some(days) = s.strip_suffix('d') {
if let Ok(d) = days.parse::<u64>() {
return Duration::from_secs(d * 86400);
}
}
Duration::from_secs(24 * 3600) }
fn run_clean(older_than: Option<&str>, finished_only: bool, dry_run: bool) -> io::Result<()> {
let min_age = older_than.map(parse_duration_str);
let now = std::time::SystemTime::now();
let home = std::env::var_os("HOME")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::PathBuf::from("/tmp"));
let active_pids: std::collections::HashSet<u32> = {
let app = App::new();
app.sessions.iter().map(|s| s.pid).collect()
};
let mut removed_sessions = 0u64;
let mut removed_jsonl = 0u64;
let mut freed_bytes = 0u64;
let sessions_dir = home.join(".claude/sessions");
if let Ok(entries) = std::fs::read_dir(&sessions_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
let pid: u32 = match stem.parse() {
Ok(p) => p,
Err(_) => continue,
};
if active_pids.contains(&pid) {
continue;
}
if let Some(min_age) = min_age {
let modified = entry.metadata().ok().and_then(|m| m.modified().ok());
if let Some(modified) = modified {
let age = now.duration_since(modified).unwrap_or_default();
if age < min_age {
continue;
}
}
}
let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
if dry_run {
println!(" would remove: {} ({} bytes)", path.display(), size);
} else {
let _ = std::fs::remove_file(&path);
}
removed_sessions += 1;
freed_bytes += size;
}
}
let projects_dir = home.join(".claude/projects");
if let Ok(project_entries) = std::fs::read_dir(&projects_dir) {
for project_entry in project_entries.flatten() {
let project_path = project_entry.path();
if !project_path.is_dir() {
continue;
}
let Ok(files) = std::fs::read_dir(&project_path) else {
continue;
};
for file_entry in files.flatten() {
let file_path = file_entry.path();
if file_path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
continue;
}
let metadata = match file_entry.metadata() {
Ok(m) => m,
Err(_) => continue,
};
if let Some(min_age) = min_age {
let modified = metadata.modified().ok();
if let Some(modified) = modified {
let age = now.duration_since(modified).unwrap_or_default();
if age < min_age {
continue;
}
}
}
if finished_only {
let app = App::new();
let is_active = app.sessions.iter().any(|s| {
s.jsonl_path
.as_ref()
.map(|p| p == &file_path)
.unwrap_or(false)
});
if is_active {
continue;
}
}
let size = metadata.len();
if dry_run {
println!(" would remove: {} ({} bytes)", file_path.display(), size);
} else {
let _ = std::fs::remove_file(&file_path);
}
removed_jsonl += 1;
freed_bytes += size;
}
}
}
let freed_str = if freed_bytes >= 1_073_741_824 {
format!("{:.1} GB", freed_bytes as f64 / 1_073_741_824.0)
} else if freed_bytes >= 1_048_576 {
format!("{:.1} MB", freed_bytes as f64 / 1_048_576.0)
} else if freed_bytes >= 1024 {
format!("{:.1} KB", freed_bytes as f64 / 1024.0)
} else {
format!("{freed_bytes} bytes")
};
if dry_run {
println!();
println!(
"Dry run: would remove {} sessions + {} transcripts, freeing {}",
removed_sessions, removed_jsonl, freed_str
);
} else if removed_sessions + removed_jsonl == 0 {
println!("Nothing to clean up.");
} else {
println!(
"Removed {} sessions + {} transcripts, freed {}",
removed_sessions, removed_jsonl, freed_str
);
}
Ok(())
}
fn print_summary(since: &str) -> io::Result<()> {
let since_duration = parse_duration_str(since);
let app = App::new();
if app.sessions.is_empty() {
println!("No active Claude sessions.");
return Ok(());
}
for s in &app.sessions {
let status_color = match s.status {
session::SessionStatus::Processing => "\x1b[32m",
session::SessionStatus::NeedsInput => "\x1b[35m",
session::SessionStatus::WaitingInput => "\x1b[33m",
session::SessionStatus::Idle => "\x1b[90m",
session::SessionStatus::Finished => "\x1b[31m",
};
let reset = "\x1b[0m";
println!(
"=== {} ({}, {}, {status_color}{}{reset}) ===",
s.display_name(),
s.format_elapsed(),
s.format_cost(),
s.status,
);
let since_secs = since_duration.as_secs();
let git_since = format!("{since_secs} seconds ago");
let git_log = std::process::Command::new("git")
.args(["log", "--oneline", &format!("--since={git_since}")])
.current_dir(&s.cwd)
.output();
if let Ok(output) = git_log {
let stdout = String::from_utf8_lossy(&output.stdout);
let commits: Vec<&str> = stdout.lines().collect();
if !commits.is_empty() {
println!(" Commits: {}", commits.len());
for c in commits.iter().take(5) {
println!(" {c}");
}
if commits.len() > 5 {
println!(" ... and {} more", commits.len() - 5);
}
}
}
let git_diff = std::process::Command::new("git")
.args(["diff", "--stat", "HEAD"])
.current_dir(&s.cwd)
.output();
if let Ok(output) = git_diff {
let stdout = String::from_utf8_lossy(&output.stdout);
let lines: Vec<&str> = stdout.lines().collect();
if !lines.is_empty() {
let file_count = lines.len().saturating_sub(1); if file_count > 0 {
println!(" Files changed: {file_count}");
}
}
}
let total_tokens = s.total_input_tokens + s.total_output_tokens;
if total_tokens > 0 {
println!(
" Tokens: {} in / {} out",
format_count(s.total_input_tokens),
format_count(s.total_output_tokens)
);
}
if !s.model.is_empty() {
println!(
" Model: {} (context: {}%)",
s.model,
s.context_percent() as u32
);
}
if s.subagent_count > 0 {
println!(" Subagents: {}", s.subagent_count);
}
println!();
}
let total_cost: f64 = app.sessions.iter().map(|s| s.cost_usd).sum();
println!("Total cost: ${total_cost:.2}");
Ok(())
}
fn format_count(n: u64) -> String {
if n >= 1_000_000 {
format!("{:.1}M", n as f64 / 1_000_000.0)
} else if n >= 1_000 {
format!("{:.1}k", n as f64 / 1_000.0)
} else {
n.to_string()
}
}
fn print_json() -> io::Result<()> {
let app = App::new();
let values: Vec<serde_json::Value> = app.sessions.iter().map(|s| s.to_json_value()).collect();
let json = serde_json::to_string_pretty(&values).unwrap_or_else(|_| "[]".to_string());
println!("{json}");
Ok(())
}
fn print_list() -> io::Result<()> {
let app = App::new();
if app.sessions.is_empty() {
println!("No active Claude sessions.");
return Ok(());
}
println!(
"{:<7} {:<16} {:<12} {:<8} {:<8} {:<9} {:<10} {:<6} {:<6} TOKENS",
"PID", "PROJECT", "STATUS", "CTX%", "COST", "$/HR", "ELAPSED", "CPU%", "MEM"
);
println!("{}", "-".repeat(105));
for s in &app.sessions {
println!(
"{:<7} {:<16} {:<12} {:<8} {:<8} {:<9} {:<10} {:<6.1} {:<6} {}",
s.pid,
s.display_name(),
s.status.to_string(),
s.format_context(),
s.format_cost(),
s.format_burn_rate(),
s.format_elapsed(),
s.cpu_percent,
s.format_mem(),
s.format_tokens(),
);
}
let total_cost: f64 = app.sessions.iter().map(|s| s.cost_usd).sum();
println!("{}", "-".repeat(105));
println!("Total cost: ${total_cost:.2}");
Ok(())
}
fn run_watch(tick_rate: Duration, json_mode: bool, format_str: &str) -> io::Result<()> {
use crate::session::SessionStatus;
use std::collections::HashMap;
let mut app = App::new();
let mut prev_statuses: HashMap<u32, SessionStatus> =
app.sessions.iter().map(|s| (s.pid, s.status)).collect();
for s in &app.sessions {
if json_mode {
let obj = serde_json::json!({
"event": "initial",
"pid": s.pid,
"project": s.display_name(),
"status": s.status.to_string(),
"cost_usd": (s.cost_usd * 100.0).round() / 100.0,
"context_pct": (s.context_percent() * 100.0).round() / 100.0,
"elapsed_secs": s.elapsed.as_secs(),
});
println!("{}", serde_json::to_string(&obj).unwrap_or_default());
} else {
println!("{}", format_session(format_str, s));
}
}
loop {
std::thread::sleep(tick_rate);
app.tick();
for s in &app.sessions {
let prev = prev_statuses.get(&s.pid).copied();
let changed = prev.is_none_or(|p| p != s.status);
if !changed {
continue;
}
if json_mode {
let obj = serde_json::json!({
"event": "status_change",
"pid": s.pid,
"project": s.display_name(),
"old_status": prev.map(|p| p.to_string()).unwrap_or_default(),
"new_status": s.status.to_string(),
"cost_usd": (s.cost_usd * 100.0).round() / 100.0,
"context_pct": (s.context_percent() * 100.0).round() / 100.0,
"elapsed_secs": s.elapsed.as_secs(),
});
println!("{}", serde_json::to_string(&obj).unwrap_or_default());
} else {
println!("{}", format_session(format_str, s));
}
}
prev_statuses = app.sessions.iter().map(|s| (s.pid, s.status)).collect();
}
}
fn format_session(fmt: &str, s: &session::ClaudeSession) -> String {
fmt.replace("{pid}", &s.pid.to_string())
.replace("{project}", s.display_name())
.replace("{status}", &s.status.to_string())
.replace("{cost}", &format!("{:.2}", s.cost_usd))
.replace("{context}", &format!("{}", s.context_percent() as u32))
}
fn run(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
tick_rate: Duration,
cfg: &config::Config,
app_theme: theme::Theme,
hook_registry: hooks::HookRegistry,
) -> io::Result<()> {
let mut app = App::new();
app.notify = cfg.notify;
app.debug = cfg.debug;
app.webhook_url = cfg.webhook.clone();
app.webhook_filter = cfg.webhook_on.clone();
app.budget_usd = cfg.budget;
app.kill_on_budget = cfg.kill_on_budget;
app.grouped_view = cfg.grouped;
app.theme = app_theme;
app.hooks = hook_registry;
app.daily_limit = cfg.daily_limit;
app.weekly_limit = cfg.weekly_limit;
app.context_warn_threshold = cfg.context_warn_threshold;
let mut last_tick = Instant::now();
loop {
terminal.draw(|frame| {
ui::table::render(frame, frame.area(), &app);
})?;
let timeout = tick_rate
.checked_sub(last_tick.elapsed())
.unwrap_or_else(|| Duration::from_secs(0));
if event::poll(timeout)? {
if let Event::Key(key) = event::read()? {
if !app.handle_key(key) {
return Ok(());
}
}
}
if last_tick.elapsed() >= tick_rate {
app.tick();
last_tick = Instant::now();
}
}
}