Skip to main content

chub_cli/commands/
track.rs

1use clap::{Args, Subcommand};
2use owo_colors::OwoColorize;
3
4use chub_core::config;
5use chub_core::identity::{detect_agent, detect_agent_version, detect_model};
6use chub_core::team::tracking::{session_state, transcript};
7use chub_core::team::{cost, hooks, session_journal, sessions};
8
9use crate::output;
10
11#[derive(Args)]
12pub struct TrackArgs {
13    #[command(subcommand)]
14    command: TrackCommand,
15}
16
17#[derive(Subcommand)]
18enum TrackCommand {
19    /// Show active session and tracking status
20    Status,
21    /// Install agent hooks for automatic session tracking
22    Enable(EnableArgs),
23    /// Remove agent hooks
24    Disable,
25    /// Handle agent lifecycle hooks (called by agent hooks, not by user)
26    Hook(HookArgs),
27    /// Show session history
28    Log(LogArgs),
29    /// Show details for a specific session
30    Show(ShowArgs),
31    /// Aggregate usage report (costs, models, tools)
32    Report(ReportArgs),
33    /// Export session data as JSON
34    Export(ExportArgs),
35    /// Clear local session transcripts
36    Clear,
37    /// Launch local web dashboard for session tracking
38    Dashboard(DashboardArgs),
39}
40
41#[derive(Args)]
42struct EnableArgs {
43    /// Agent to install hooks for (auto-detect if omitted)
44    agent: Option<String>,
45
46    /// Overwrite existing hooks
47    #[arg(long)]
48    force: bool,
49}
50
51#[derive(Args)]
52struct HookArgs {
53    /// Hook event name: session-start, stop, prompt, pre-tool, post-tool, commit-msg, post-commit
54    event: String,
55
56    /// Agent name (auto-detected if omitted)
57    #[arg(long)]
58    agent: Option<String>,
59
60    /// Model name
61    #[arg(long)]
62    model: Option<String>,
63
64    /// Tool name (for pre-tool/post-tool events)
65    #[arg(long)]
66    tool: Option<String>,
67
68    /// Prompt text or input summary (read from stdin if not provided)
69    #[arg(long)]
70    input: Option<String>,
71
72    /// Session ID (for stop event; auto-detected if omitted)
73    #[arg(long)]
74    session_id: Option<String>,
75
76    /// Token counts: input,output,cache_read,cache_write[,reasoning]
77    #[arg(long)]
78    tokens: Option<String>,
79
80    /// File path (for file-change events)
81    #[arg(long)]
82    file: Option<String>,
83}
84
85#[derive(Args)]
86struct LogArgs {
87    /// Number of days to show (default 30)
88    #[arg(long, default_value = "30")]
89    days: u64,
90}
91
92#[derive(Args)]
93struct ShowArgs {
94    /// Session ID
95    id: String,
96}
97
98#[derive(Args)]
99struct ReportArgs {
100    /// Number of days (default 30)
101    #[arg(long, default_value = "30")]
102    days: u64,
103}
104
105#[derive(Args)]
106struct ExportArgs {
107    /// Number of days (default 30)
108    #[arg(long, default_value = "30")]
109    days: u64,
110}
111
112#[derive(Args)]
113struct DashboardArgs {
114    /// Port to listen on
115    #[arg(short, long, default_value = "4243")]
116    port: u16,
117
118    /// Host to bind to
119    #[arg(long, default_value = "127.0.0.1")]
120    host: String,
121}
122
123pub async fn run(args: TrackArgs, json: bool) {
124    match args.command {
125        TrackCommand::Status => run_status(json),
126        TrackCommand::Enable(enable_args) => run_enable(enable_args, json),
127        TrackCommand::Disable => run_disable(json),
128        TrackCommand::Hook(hook_args) => run_hook(hook_args, json),
129        TrackCommand::Log(log_args) => run_log(log_args, json),
130        TrackCommand::Show(show_args) => run_show(show_args, json),
131        TrackCommand::Report(report_args) => run_report(report_args, json),
132        TrackCommand::Export(export_args) => run_export(export_args, json),
133        TrackCommand::Clear => run_clear(json),
134        TrackCommand::Dashboard(dash_args) => run_dashboard(dash_args, json).await,
135    }
136}
137
138// ---------------------------------------------------------------------------
139// Status
140// ---------------------------------------------------------------------------
141
142fn run_status(json: bool) {
143    let active = sessions::get_active_session();
144    let journal_files = session_journal::list_journal_files();
145    let entire_states = session_state::list_states();
146
147    if json {
148        println!(
149            "{}",
150            serde_json::json!({
151                "active_session": active.as_ref().map(|s| serde_json::json!({
152                    "session_id": s.session_id,
153                    "agent": s.agent,
154                    "model": s.model,
155                    "started_at": s.started_at,
156                    "turns": s.turns,
157                    "tool_calls": s.tool_calls,
158                })),
159                "agent_detected": detect_agent(),
160                "agent_version": detect_agent_version(),
161                "model_detected": detect_model(),
162                "local_journals": journal_files.len(),
163                "entire_sessions": entire_states.len(),
164            })
165        );
166    } else if let Some(ref session) = active {
167        eprintln!("{}", "Active session:".bold());
168        eprintln!("  ID:      {}", session.session_id);
169        eprintln!("  Agent:   {}", session.agent);
170        if let Some(ref model) = session.model {
171            eprintln!("  Model:   {}", model);
172        }
173        eprintln!("  Started: {}", session.started_at);
174        eprintln!("  Turns:   {}", session.turns);
175        eprintln!("  Tools:   {} calls", session.tool_calls);
176        if session.tokens.reasoning > 0 {
177            eprintln!(
178                "  Tokens:  {} in / {} out / {} reasoning",
179                session.tokens.input, session.tokens.output, session.tokens.reasoning
180            );
181        } else {
182            eprintln!(
183                "  Tokens:  {} in / {} out",
184                session.tokens.input, session.tokens.output
185            );
186        }
187
188        // Show entire.io state info if available
189        if let Some(state) = session_state::load_state(&session.session_id) {
190            eprintln!("  Phase:   {:?}", state.phase);
191            if !state.files_touched.is_empty() {
192                eprintln!("  Files:   {} touched", state.files_touched.len());
193            }
194            if state.transcript_path.is_some() {
195                eprintln!("  Transcript: linked");
196            }
197        }
198    } else {
199        eprintln!("{}", "No active session.".dimmed());
200        eprintln!("  Agent detected: {}", detect_agent());
201        if let Some(model) = detect_model() {
202            eprintln!("  Model detected: {}", model);
203        }
204        if !journal_files.is_empty() {
205            eprintln!("  Local journals: {} sessions", journal_files.len());
206        }
207        if !entire_states.is_empty() {
208            eprintln!(
209                "  Entire.io sessions: {} (in .git/entire-sessions/)",
210                entire_states.len()
211            );
212        }
213    }
214}
215
216// ---------------------------------------------------------------------------
217// Enable / Disable hooks
218// ---------------------------------------------------------------------------
219
220fn run_enable(args: EnableArgs, json: bool) {
221    match hooks::install_hooks(args.agent.as_deref(), args.force) {
222        Ok(results) => {
223            if json {
224                let items: Vec<_> = results
225                    .iter()
226                    .map(|r| {
227                        serde_json::json!({
228                            "agent": r.agent,
229                            "config_file": r.config_file,
230                            "action": format!("{:?}", r.action),
231                        })
232                    })
233                    .collect();
234                println!(
235                    "{}",
236                    serde_json::to_string_pretty(&items).unwrap_or_default()
237                );
238            } else {
239                eprintln!("{}\n", "Hook installation results:".bold());
240                for r in &results {
241                    let status = match &r.action {
242                        hooks::HookAction::Installed => "installed".green().to_string(),
243                        hooks::HookAction::Updated => "updated".yellow().to_string(),
244                        hooks::HookAction::AlreadyInstalled => {
245                            "already installed".dimmed().to_string()
246                        }
247                        hooks::HookAction::Removed => "removed".dimmed().to_string(),
248                        hooks::HookAction::Error(e) => format!("error: {}", e).red().to_string(),
249                    };
250                    eprintln!(
251                        "  {} {} → {}",
252                        r.agent.cyan(),
253                        r.config_file.dimmed(),
254                        status
255                    );
256                }
257                eprintln!(
258                    "\n{}",
259                    "Hooks installed. Sessions will be tracked automatically.".green()
260                );
261            }
262        }
263        Err(e) => output::error(&e.to_string(), json),
264    }
265}
266
267fn run_disable(json: bool) {
268    match hooks::uninstall_hooks() {
269        Ok(results) => {
270            if json {
271                let items: Vec<_> = results
272                    .iter()
273                    .map(|r| {
274                        serde_json::json!({
275                            "agent": r.agent,
276                            "config_file": r.config_file,
277                            "action": format!("{:?}", r.action),
278                        })
279                    })
280                    .collect();
281                println!(
282                    "{}",
283                    serde_json::to_string_pretty(&items).unwrap_or_default()
284                );
285            } else {
286                for r in &results {
287                    eprintln!("  {} {} → removed", r.agent.cyan(), r.config_file.dimmed());
288                }
289                eprintln!("{}", "Hooks removed.".green());
290            }
291        }
292        Err(e) => output::error(&e.to_string(), json),
293    }
294}
295
296// ---------------------------------------------------------------------------
297// Hook handler
298// ---------------------------------------------------------------------------
299
300fn run_hook(args: HookArgs, json: bool) {
301    // Try to read stdin JSON from agent hooks (non-blocking)
302    let stdin_data = hooks::parse_hook_stdin();
303
304    match args.event.as_str() {
305        "session-start" => {
306            let agent = args
307                .agent
308                .or_else(|| {
309                    stdin_data
310                        .as_ref()
311                        .and_then(|v| v.get("agent").and_then(|a| a.as_str()))
312                        .map(|s| s.to_string())
313                })
314                .unwrap_or_else(|| detect_agent().to_string());
315
316            let model_from_stdin = stdin_data
317                .as_ref()
318                .and_then(|v| v.get("model").and_then(|m| m.as_str()))
319                .map(|s| s.to_string());
320            let model = args.model.or(model_from_stdin).or_else(detect_model);
321            let model_ref = model.as_deref();
322
323            match sessions::start_session(&agent, model_ref) {
324                Some(session_id) => {
325                    session_journal::record_session_start(&session_id, &agent, model_ref);
326
327                    // Also create entire.io-compatible session state
328                    let mut state = session_state::SessionState::new(&agent, model_ref);
329                    // Override session_id to match chub's ID
330                    state.session_id = session_id.clone();
331                    // Link transcript if Claude Code
332                    if agent.contains("claude") {
333                        if let Some(repo_path) = chub_core::team::project::find_project_root(None) {
334                            let repo_str = repo_path.to_string_lossy();
335                            if let Some(t_path) =
336                                transcript::find_transcript(&repo_str, &session_id)
337                            {
338                                state.transcript_path = Some(t_path.to_string_lossy().to_string());
339                            }
340                        }
341                    }
342                    session_state::save_state(&state);
343
344                    if json {
345                        println!(
346                            "{}",
347                            serde_json::json!({ "status": "started", "session_id": session_id })
348                        );
349                    } else {
350                        eprintln!("Session started: {}", session_id);
351                    }
352                }
353                None => {
354                    output::error("Failed to start session (no .git directory?)", json);
355                }
356            }
357        }
358
359        "stop" | "session-end" => {
360            if let Some(active) = sessions::get_active_session() {
361                let session_id = active.session_id.clone();
362                session_journal::record_session_end(&session_id, None, active.turns);
363
364                // Finalize entire.io-compatible session state
365                if let Some(mut state) = session_state::load_state(&session_id) {
366                    state.apply_event(session_state::SessionEvent::SessionStop);
367
368                    // Link transcript at stop time if not already linked
369                    // (at session-start the transcript file may not exist yet)
370                    if state.transcript_path.is_none() && active.agent.contains("claude") {
371                        if let Some(repo_path) = chub_core::team::project::find_project_root(None) {
372                            let repo_str = repo_path.to_string_lossy();
373                            if let Some(t_path) =
374                                transcript::find_transcript(&repo_str, &session_id)
375                            {
376                                state.transcript_path = Some(t_path.to_string_lossy().to_string());
377                            }
378                        }
379                    }
380
381                    // Parse transcript for final token counts and model
382                    let mut transcript_model: Option<String> = None;
383                    if let Some(ref t_path) = state.transcript_path {
384                        let analysis = transcript::parse_transcript(std::path::Path::new(t_path));
385                        state.token_usage = Some(analysis.token_usage);
386                        state.step_count = analysis.turn_count;
387                        transcript_model = analysis.model;
388
389                        // Add any files from transcript we haven't seen
390                        for f in analysis.modified_files {
391                            state.touch_file(&f);
392                        }
393                    }
394
395                    // Calculate cost on state — prefer active model, fall back to transcript
396                    let model_for_cost = active.model.as_deref().or(transcript_model.as_deref());
397                    if let Some(ref usage) = state.token_usage {
398                        let chub_tokens = sessions::TokenUsage {
399                            input: usage.input_tokens as u64,
400                            output: usage.output_tokens as u64,
401                            cache_read: usage.cache_read_tokens as u64,
402                            cache_write: usage.cache_creation_tokens as u64,
403                            reasoning: usage.reasoning_tokens as u64,
404                        };
405                        state.est_cost_usd = cost::estimate_cost(model_for_cost, &chub_tokens);
406                    }
407
408                    // Archive transcript to .git/chub/transcripts/ for LLM review
409                    if let Some(ref t_path) = state.transcript_path {
410                        transcript::archive_transcript_to_git(
411                            std::path::Path::new(t_path),
412                            &session_id,
413                        );
414                    }
415
416                    session_state::save_state(&state);
417                }
418
419                if let Some(mut session) = sessions::end_session() {
420                    // Link transcript for token/model enrichment on session data
421                    if session.agent.contains("claude") {
422                        if let Some(repo_path) = chub_core::team::project::find_project_root(None) {
423                            let repo_str = repo_path.to_string_lossy();
424                            if let Some(t_path) =
425                                transcript::find_transcript(&repo_str, &session.session_id)
426                            {
427                                let analysis =
428                                    transcript::parse_transcript(std::path::Path::new(&t_path));
429
430                                // Set model from transcript if not already set
431                                if session.model.is_none() {
432                                    session.model = analysis.model;
433                                }
434
435                                // Enrich tokens from transcript
436                                if session.tokens.total() == 0 {
437                                    session.tokens = sessions::TokenUsage {
438                                        input: analysis.token_usage.input_tokens as u64,
439                                        output: analysis.token_usage.output_tokens as u64,
440                                        cache_read: analysis.token_usage.cache_read_tokens as u64,
441                                        cache_write: analysis.token_usage.cache_creation_tokens
442                                            as u64,
443                                        reasoning: analysis.token_usage.reasoning_tokens as u64,
444                                    };
445                                }
446
447                                // Use transcript turn count (filters system messages)
448                                if analysis.turn_count > 0 {
449                                    session.turns = analysis.turn_count as u32;
450                                }
451
452                                for f in analysis.modified_files {
453                                    session.files_changed.push(f);
454                                }
455                                session.files_changed.sort();
456                                session.files_changed.dedup();
457
458                                // Wire extended_thinking into session env
459                                if analysis.has_extended_thinking {
460                                    let env = session.env.get_or_insert_with(Default::default);
461                                    env.extended_thinking = Some(true);
462                                }
463                            }
464                        }
465                    }
466
467                    // Calculate cost
468                    session.est_cost_usd =
469                        cost::estimate_cost(session.model.as_deref(), &session.tokens);
470
471                    // Re-write with cost
472                    sessions::write_session_summary(&session);
473
474                    if json {
475                        println!(
476                            "{}",
477                            serde_json::to_string_pretty(&session).unwrap_or_default()
478                        );
479                    } else {
480                        eprintln!("Session ended: {}", session.session_id);
481                        if let Some(cost) = session.est_cost_usd {
482                            eprintln!(
483                                "  {} turns, {} tokens, ~${:.3}",
484                                session.turns,
485                                session.tokens.total(),
486                                cost
487                            );
488                        }
489                    }
490                }
491            } else if json {
492                println!("{}", serde_json::json!({ "status": "no_active_session" }));
493            } else {
494                eprintln!("{}", "No active session to end.".dimmed());
495            }
496        }
497
498        "prompt" => {
499            if let Some(mut active) = sessions::get_active_session() {
500                active.turns += 1;
501
502                // Extract prompt text from stdin or CLI flag
503                let prompt_text = args.input.or_else(|| {
504                    stdin_data
505                        .as_ref()
506                        .and_then(|v| v.get("prompt").and_then(|p| p.as_str()))
507                        .map(|s| s.to_string())
508                });
509
510                // Check for model update from stdin
511                if let Some(ref data) = stdin_data {
512                    if let Some(model) = data.get("model").and_then(|m| m.as_str()) {
513                        if active.model.as_deref() != Some(model) {
514                            active.model = Some(model.to_string());
515                        }
516                    }
517                }
518
519                // Update entire.io-compatible session state
520                if let Some(mut state) = session_state::load_state(&active.session_id) {
521                    state.apply_event(session_state::SessionEvent::TurnStart);
522                    // Set first_prompt only on the first prompt
523                    if state.first_prompt.is_none() {
524                        state.first_prompt = prompt_text.clone();
525                    }
526                    session_state::save_state(&state);
527                }
528
529                sessions::save_active_session(&active);
530                session_journal::record_prompt(&active.session_id, prompt_text.as_deref());
531            }
532        }
533
534        "pre-tool" => {
535            if let Some(mut active) = sessions::get_active_session() {
536                // Extract tool name from stdin or CLI flag
537                let tool = args
538                    .tool
539                    .or_else(|| stdin_data.as_ref().and_then(hooks::extract_tool_name));
540                let tool_name = tool.as_deref().unwrap_or("unknown");
541
542                // Extract input summary
543                let input_summary = args.input.or_else(|| {
544                    stdin_data
545                        .as_ref()
546                        .and_then(|v| v.get("tool_input").map(|i| summarize_json(i, 120)))
547                });
548
549                active.tool_calls += 1;
550                active.tools_used.insert(tool_name.to_string());
551
552                // Update entire.io-compatible session state
553                if let Some(mut state) = session_state::load_state(&active.session_id) {
554                    state.step_count += 1;
555                    state.tool_calls += 1;
556                    state.tools_used.insert(tool_name.to_string());
557                    session_state::save_state(&state);
558                }
559
560                sessions::save_active_session(&active);
561                session_journal::record_tool_call(
562                    &active.session_id,
563                    tool_name,
564                    input_summary.as_deref(),
565                );
566            }
567        }
568
569        "post-tool" => {
570            if let Some(mut active) = sessions::get_active_session() {
571                let tool = args
572                    .tool
573                    .or_else(|| stdin_data.as_ref().and_then(hooks::extract_tool_name));
574                let tool_name = tool.as_deref().unwrap_or("unknown");
575
576                // Track file changes from stdin tool_input (Write/Edit)
577                let file_path = args.file.or_else(|| {
578                    stdin_data
579                        .as_ref()
580                        .and_then(|v| v.get("tool_input"))
581                        .and_then(hooks::extract_file_path)
582                });
583                if let Some(ref file) = file_path {
584                    active.files_changed.insert(file.clone());
585                    session_journal::record_file_change(&active.session_id, file, Some("edit"));
586                }
587
588                // Parse tokens if provided via CLI flag
589                if let Some(ref token_str) = args.tokens {
590                    if let Some(tokens) = parse_token_string(token_str) {
591                        active.tokens.add(&tokens);
592                        session_journal::record_response(&active.session_id, Some(tokens));
593                    }
594                }
595
596                // Update entire.io-compatible session state
597                if let Some(mut state) = session_state::load_state(&active.session_id) {
598                    if let Some(ref file) = file_path {
599                        state.touch_file(file);
600                    }
601                    session_state::save_state(&state);
602                }
603
604                // Estimate output size from tool_response in stdin
605                let output_size = stdin_data
606                    .as_ref()
607                    .and_then(|v| v.get("tool_response"))
608                    .map(|r| r.to_string().len() as u64);
609
610                sessions::save_active_session(&active);
611                session_journal::record_tool_result(&active.session_id, tool_name, output_size);
612            }
613        }
614
615        "model-update" => {
616            if let Some(mut active) = sessions::get_active_session() {
617                let model = args.model.or_else(|| {
618                    stdin_data
619                        .as_ref()
620                        .and_then(|v| v.get("model").and_then(|m| m.as_str()))
621                        .map(|s| s.to_string())
622                });
623                if let Some(ref model) = model {
624                    active.model = Some(model.clone());
625                    sessions::save_active_session(&active);
626                    session_journal::append_event(
627                        &active.session_id,
628                        &session_journal::SessionEvent::ModelUpdate {
629                            ts: chub_core::util::now_iso8601(),
630                            model: model.clone(),
631                        },
632                    );
633                }
634            }
635        }
636
637        "commit-msg" => {
638            // Called by prepare-commit-msg git hook
639            // Adds Chub-Session and Chub-Checkpoint trailers to the commit message
640            // (user can remove the trailer before committing to skip linking)
641            if let Some(active) = sessions::get_active_session() {
642                let msg_file = args.input.as_deref();
643                if let Some(path) = msg_file {
644                    if let Ok(content) = std::fs::read_to_string(path) {
645                        // Skip during rebase
646                        if is_rebase_in_progress() {
647                            return;
648                        }
649                        let mut trailers = String::new();
650                        if !content.contains("Chub-Session:") {
651                            trailers.push_str(&format!("\nChub-Session: {}", active.session_id));
652                        }
653                        if !content.contains("Chub-Checkpoint:") {
654                            // Generate checkpoint ID and stash it for post-commit
655                            let checkpoint_id =
656                                chub_core::team::tracking::types::CheckpointID::generate();
657                            trailers.push_str(&format!("\nChub-Checkpoint: {}", checkpoint_id.0));
658                        }
659                        if !trailers.is_empty() {
660                            let new_content = format!("{}{}\n", content.trim_end(), trailers);
661                            let _ = std::fs::write(path, new_content);
662                        }
663                    }
664                }
665            }
666        }
667
668        "post-commit" => {
669            // Called by post-commit git hook
670            // Records the commit hash, creates a checkpoint on the orphan branch
671            if is_rebase_in_progress() {
672                return;
673            }
674
675            if let Some(mut active) = sessions::get_active_session() {
676                // Get the latest commit hash
677                if let Ok(output) = std::process::Command::new("git")
678                    .args(["rev-parse", "--short", "HEAD"])
679                    .output()
680                {
681                    let hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
682                    if !hash.is_empty() {
683                        active.commits.push(hash.clone());
684                        sessions::save_active_session(&active);
685
686                        // Update entire.io-compatible session state
687                        if let Some(mut state) = session_state::load_state(&active.session_id) {
688                            state.apply_event(session_state::SessionEvent::GitCommit);
689                            state.commits.push(hash);
690
691                            // Create checkpoint on orphan branch (condense)
692                            use chub_core::team::tracking::checkpoint;
693                            let t_path =
694                                state.transcript_path.as_ref().map(std::path::PathBuf::from);
695                            let attribution = state.base_commit.as_str();
696                            let attr = if !attribution.is_empty() {
697                                transcript::calculate_attribution(attribution)
698                            } else {
699                                None
700                            };
701                            checkpoint::create_checkpoint(&state, t_path.as_deref(), attr);
702
703                            session_state::save_state(&state);
704                        }
705                    }
706                }
707            }
708        }
709
710        "pre-push" => {
711            // Push chub/sessions/v1 and entire/checkpoints/v1 alongside user's push
712            let remote = args.input.as_deref().unwrap_or("origin");
713            let sessions_pushed = sessions::push_sessions(remote);
714            // Also push checkpoint branch if it exists
715            use chub_core::team::tracking::branch_store;
716            let checkpoints_pushed = branch_store::push_branch("entire/checkpoints/v1", remote);
717            if json {
718                println!(
719                    "{}",
720                    serde_json::json!({
721                        "remote": remote,
722                        "sessions": if sessions_pushed { "pushed" } else { "skipped" },
723                        "checkpoints": if checkpoints_pushed { "pushed" } else { "skipped" },
724                    })
725                );
726            }
727        }
728
729        other => {
730            output::error(&format!("Unknown hook event: \"{}\"", other), json);
731        }
732    }
733}
734
735/// Check if a rebase is in progress (skip checkpoint operations during rebase).
736fn is_rebase_in_progress() -> bool {
737    // Check for .git/rebase-merge/ or .git/rebase-apply/
738    if let Some(root) = chub_core::team::project::find_project_root(None) {
739        let git_dir = root.join(".git");
740        return git_dir.join("rebase-merge").is_dir() || git_dir.join("rebase-apply").is_dir();
741    }
742    false
743}
744
745/// Summarize a JSON value to at most `max_len` characters.
746fn summarize_json(value: &serde_json::Value, max_len: usize) -> String {
747    let s = value.to_string();
748    if s.len() <= max_len {
749        s
750    } else {
751        format!("{}...", &s[..max_len.saturating_sub(3)])
752    }
753}
754
755fn parse_token_string(s: &str) -> Option<sessions::TokenUsage> {
756    let parts: Vec<u64> = s.split(',').filter_map(|p| p.trim().parse().ok()).collect();
757    if parts.is_empty() {
758        return None;
759    }
760    Some(sessions::TokenUsage {
761        input: *parts.first().unwrap_or(&0),
762        output: *parts.get(1).unwrap_or(&0),
763        cache_read: *parts.get(2).unwrap_or(&0),
764        cache_write: *parts.get(3).unwrap_or(&0),
765        reasoning: *parts.get(4).unwrap_or(&0),
766    })
767}
768
769// ---------------------------------------------------------------------------
770// Log
771// ---------------------------------------------------------------------------
772
773fn run_log(args: LogArgs, json: bool) {
774    let session_list = sessions::list_sessions(args.days);
775
776    if json {
777        println!(
778            "{}",
779            serde_json::to_string_pretty(&session_list).unwrap_or_default()
780        );
781        return;
782    }
783
784    if session_list.is_empty() {
785        eprintln!(
786            "{}",
787            format!("No sessions in the last {} days.", args.days).dimmed()
788        );
789        return;
790    }
791
792    eprintln!(
793        "{}\n",
794        format!("{} sessions (last {} days):", session_list.len(), args.days).bold()
795    );
796
797    for s in &session_list {
798        let cost_str = s
799            .est_cost_usd
800            .map(|c| format!("${:.3}", c))
801            .unwrap_or_else(|| "-".to_string());
802        let model_str = s.model.as_deref().unwrap_or("-");
803        let duration_str = s
804            .duration_s
805            .map(format_duration)
806            .unwrap_or_else(|| "active".yellow().to_string());
807
808        eprintln!(
809            "  {} {} {} {} {} {}",
810            s.session_id.bold(),
811            s.agent.cyan(),
812            model_str.dimmed(),
813            duration_str,
814            format!("{} turns", s.turns).dimmed(),
815            cost_str.green(),
816        );
817    }
818}
819
820// ---------------------------------------------------------------------------
821// Show
822// ---------------------------------------------------------------------------
823
824fn run_show(args: ShowArgs, json: bool) {
825    // Try summary first
826    if let Some(session) = sessions::get_session(&args.id) {
827        if json {
828            println!(
829                "{}",
830                serde_json::to_string_pretty(&session).unwrap_or_default()
831            );
832        } else {
833            print_session_detail(&session);
834        }
835        return;
836    }
837
838    // Check active session
839    if let Some(active) = sessions::get_active_session() {
840        if active.session_id == args.id {
841            if json {
842                println!(
843                    "{}",
844                    serde_json::to_string_pretty(&active).unwrap_or_default()
845                );
846            } else {
847                eprintln!("{} (active)\n", active.session_id.bold());
848                eprintln!("  Agent:   {}", active.agent);
849                if let Some(ref model) = active.model {
850                    eprintln!("  Model:   {}", model);
851                }
852                eprintln!("  Started: {}", active.started_at);
853                eprintln!("  Turns:   {}", active.turns);
854                eprintln!("  Tools:   {} calls", active.tool_calls);
855                if active.tokens.reasoning > 0 {
856                    eprintln!(
857                        "  Tokens:  {} total ({} in / {} out / {} reasoning)",
858                        active.tokens.total(),
859                        active.tokens.input,
860                        active.tokens.output,
861                        active.tokens.reasoning
862                    );
863                } else {
864                    eprintln!(
865                        "  Tokens:  {} total ({} in / {} out)",
866                        active.tokens.total(),
867                        active.tokens.input,
868                        active.tokens.output
869                    );
870                }
871            }
872            return;
873        }
874    }
875
876    output::error(&format!("Session \"{}\" not found.", args.id), json);
877}
878
879fn print_session_detail(s: &sessions::Session) {
880    eprintln!("{}\n", s.session_id.bold());
881    eprintln!("  Agent:    {}", s.agent);
882    if let Some(ref model) = s.model {
883        eprintln!("  Model:    {}", model);
884    }
885    eprintln!("  Started:  {}", s.started_at);
886    if let Some(ref ended) = s.ended_at {
887        eprintln!("  Ended:    {}", ended);
888    }
889    if let Some(d) = s.duration_s {
890        eprintln!("  Duration: {}", format_duration(d));
891    }
892    eprintln!("  Turns:    {}", s.turns);
893    if s.tokens.reasoning > 0 {
894        eprintln!(
895            "  Tokens:   {} total ({} in / {} out / {} reasoning / {} cache-r / {} cache-w)",
896            s.tokens.total(),
897            s.tokens.input,
898            s.tokens.output,
899            s.tokens.reasoning,
900            s.tokens.cache_read,
901            s.tokens.cache_write
902        );
903    } else {
904        eprintln!(
905            "  Tokens:   {} total ({} in / {} out / {} cache-r / {} cache-w)",
906            s.tokens.total(),
907            s.tokens.input,
908            s.tokens.output,
909            s.tokens.cache_read,
910            s.tokens.cache_write
911        );
912    }
913    eprintln!("  Tools:    {} calls", s.tool_calls);
914    if !s.tools_used.is_empty() {
915        eprintln!("            {}", s.tools_used.join(", "));
916    }
917    if let Some(cost) = s.est_cost_usd {
918        eprintln!("  Cost:     ${:.3}", cost);
919    }
920    if !s.files_changed.is_empty() {
921        eprintln!("  Files:    {}", s.files_changed.len());
922        for f in &s.files_changed {
923            eprintln!("            {}", f.dimmed());
924        }
925    }
926    if !s.commits.is_empty() {
927        eprintln!("  Commits:  {}", s.commits.join(", "));
928    }
929    if let Some(ref env) = s.env {
930        let mut parts = Vec::new();
931        if let Some(ref os) = env.os {
932            parts.push(format!("os={}", os));
933        }
934        if let Some(ref arch) = env.arch {
935            parts.push(format!("arch={}", arch));
936        }
937        if let Some(ref branch) = env.branch {
938            parts.push(format!("branch={}", branch));
939        }
940        if let Some(ref repo) = env.repo {
941            parts.push(format!("repo={}", repo));
942        }
943        if let Some(ref user) = env.git_user {
944            parts.push(format!("user={}", user));
945        }
946        if env.extended_thinking == Some(true) {
947            parts.push("thinking=on".to_string());
948        }
949        if !parts.is_empty() {
950            eprintln!("  Env:      {}", parts.join(", ").dimmed());
951        }
952    }
953
954    // Show journal size if available
955    let jsize = session_journal::journal_size(&s.session_id);
956    if jsize > 0 {
957        eprintln!("  Journal:  {:.1} KB", jsize as f64 / 1024.0);
958    }
959}
960
961// ---------------------------------------------------------------------------
962// Report
963// ---------------------------------------------------------------------------
964
965fn run_report(args: ReportArgs, json: bool) {
966    let report = sessions::generate_report(args.days);
967
968    if json {
969        println!(
970            "{}",
971            serde_json::to_string_pretty(&report).unwrap_or_default()
972        );
973        return;
974    }
975
976    eprintln!(
977        "{}\n",
978        format!("AI Usage Report (last {} days)", report.period_days).bold()
979    );
980
981    eprintln!("  Sessions:  {}", report.session_count);
982    eprintln!("  Duration:  {}", format_duration(report.total_duration_s));
983    if report.total_tokens.reasoning > 0 {
984        eprintln!(
985            "  Tokens:    {} total ({} in / {} out / {} reasoning)",
986            report.total_tokens.total(),
987            report.total_tokens.input,
988            report.total_tokens.output,
989            report.total_tokens.reasoning
990        );
991    } else {
992        eprintln!(
993            "  Tokens:    {} total ({} in / {} out)",
994            report.total_tokens.total(),
995            report.total_tokens.input,
996            report.total_tokens.output
997        );
998    }
999    eprintln!("  Tool calls: {}", report.total_tool_calls);
1000    eprintln!(
1001        "  Est. cost: {}",
1002        format!("${:.2}", report.total_est_cost_usd).green()
1003    );
1004
1005    if !report.by_agent.is_empty() {
1006        eprintln!("\n{}", "By agent:".bold());
1007        for (agent, count, cost_val) in &report.by_agent {
1008            eprintln!("  {}  {} sessions, ${:.2}", agent.cyan(), count, cost_val);
1009        }
1010    }
1011
1012    if !report.by_model.is_empty() {
1013        eprintln!("\n{}", "By model:".bold());
1014        for (model, count, tokens) in &report.by_model {
1015            eprintln!("  {}  {} sessions, {} tokens", model, count, tokens);
1016        }
1017    }
1018
1019    if !report.top_tools.is_empty() {
1020        eprintln!("\n{}", "Top tools:".bold());
1021        for (tool, count) in report.top_tools.iter().take(10) {
1022            eprintln!("  {}  {} calls", tool, count);
1023        }
1024    }
1025
1026    // Budget alert
1027    let cfg = config::load_config();
1028    let budget = cfg.tracking.budget_alert_usd;
1029    if budget > 0.0 {
1030        let pct = (report.total_est_cost_usd / budget) * 100.0;
1031        if pct >= 100.0 {
1032            eprintln!(
1033                "\n{}",
1034                format!(
1035                    "Budget exceeded: ${:.2} / ${:.2} ({:.0}%)",
1036                    report.total_est_cost_usd, budget, pct
1037                )
1038                .red()
1039                .bold()
1040            );
1041        } else if pct >= 80.0 {
1042            eprintln!(
1043                "\n{}",
1044                format!(
1045                    "Budget warning: ${:.2} / ${:.2} ({:.0}%)",
1046                    report.total_est_cost_usd, budget, pct
1047                )
1048                .yellow()
1049            );
1050        } else {
1051            eprintln!(
1052                "\n  Budget: ${:.2} / ${:.2} ({:.0}%)",
1053                report.total_est_cost_usd, budget, pct
1054            );
1055        }
1056    }
1057}
1058
1059// ---------------------------------------------------------------------------
1060// Export
1061// ---------------------------------------------------------------------------
1062
1063fn run_export(args: ExportArgs, json: bool) {
1064    let session_list = sessions::list_sessions(args.days);
1065    if json {
1066        println!(
1067            "{}",
1068            serde_json::to_string_pretty(&session_list).unwrap_or_default()
1069        );
1070    } else {
1071        // Export as JSONL for piping
1072        for s in &session_list {
1073            println!("{}", serde_json::to_string(s).unwrap_or_default());
1074        }
1075    }
1076}
1077
1078// ---------------------------------------------------------------------------
1079// Clear
1080// ---------------------------------------------------------------------------
1081
1082fn run_clear(json: bool) {
1083    let count = session_journal::clear_journals();
1084    if json {
1085        println!(
1086            "{}",
1087            serde_json::json!({ "status": "cleared", "journals_removed": count })
1088        );
1089    } else {
1090        eprintln!(
1091            "{} ({} journal files removed)",
1092            "Local transcripts cleared.".green(),
1093            count
1094        );
1095    }
1096}
1097
1098// ---------------------------------------------------------------------------
1099// Helpers
1100// ---------------------------------------------------------------------------
1101
1102fn format_duration(secs: u64) -> String {
1103    if secs < 60 {
1104        format!("{}s", secs)
1105    } else if secs < 3600 {
1106        format!("{}m {}s", secs / 60, secs % 60)
1107    } else {
1108        format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
1109    }
1110}
1111
1112// ---------------------------------------------------------------------------
1113// Dashboard
1114// ---------------------------------------------------------------------------
1115
1116async fn run_dashboard(args: DashboardArgs, _json: bool) {
1117    use axum::{extract::Query, routing::get, Json, Router};
1118    use tower_http::cors::CorsLayer;
1119    use tower_http::services::{ServeDir, ServeFile};
1120
1121    #[derive(serde::Deserialize)]
1122    struct DaysQuery {
1123        #[serde(default = "default_days")]
1124        days: u64,
1125    }
1126    fn default_days() -> u64 {
1127        30
1128    }
1129
1130    #[derive(serde::Deserialize)]
1131    struct SessionQuery {
1132        id: String,
1133    }
1134
1135    // API routes
1136    let mut app = Router::new()
1137        .route("/api/status", get(|| async {
1138            let active = sessions::get_active_session();
1139            let entire_states = session_state::list_states();
1140            Json(serde_json::json!({
1141                "active_session": active.as_ref().map(|s| serde_json::json!({
1142                    "session_id": s.session_id,
1143                    "agent": s.agent,
1144                    "model": s.model,
1145                    "started_at": s.started_at,
1146                    "turns": s.turns,
1147                    "tool_calls": s.tool_calls,
1148                    "tokens": { "input": s.tokens.input, "output": s.tokens.output, "reasoning": s.tokens.reasoning, "total": s.tokens.total() },
1149                    "env": s.env,
1150                })),
1151                "agent_detected": detect_agent(),
1152                "model_detected": detect_model(),
1153                "entire_sessions": entire_states.len(),
1154            }))
1155        }))
1156        .route("/api/sessions", get(|Query(q): Query<DaysQuery>| async move {
1157            let session_list = sessions::list_sessions(q.days);
1158            Json(session_list)
1159        }))
1160        .route("/api/report", get(|Query(q): Query<DaysQuery>| async move {
1161            let report = sessions::generate_report(q.days);
1162            Json(report)
1163        }))
1164        .route("/api/session", get(|Query(q): Query<SessionQuery>| async move {
1165            let session = sessions::get_session(&q.id);
1166            Json(session)
1167        }))
1168        .route("/api/transcript", get(|Query(q): Query<SessionQuery>| async move {
1169            // Find transcript path from session state
1170            if let Some(state) = session_state::load_state(&q.id) {
1171                if let Some(ref t_path) = state.transcript_path {
1172                    let messages = transcript::parse_conversation(std::path::Path::new(t_path));
1173                    return Json(serde_json::json!({
1174                        "session_id": q.id,
1175                        "messages": messages,
1176                    }));
1177                }
1178            }
1179            // Try to find transcript by scanning
1180            if let Some(repo_path) = chub_core::team::project::find_project_root(None) {
1181                let repo_str = repo_path.to_string_lossy();
1182                if let Some(t_path) = transcript::find_transcript(&repo_str, &q.id) {
1183                    let messages = transcript::parse_conversation(&t_path);
1184                    return Json(serde_json::json!({
1185                        "session_id": q.id,
1186                        "messages": messages,
1187                    }));
1188                }
1189            }
1190            Json(serde_json::json!({
1191                "session_id": q.id,
1192                "messages": [],
1193                "error": "No transcript found",
1194            }))
1195        }))
1196        .route("/api/entire-states", get(|| async {
1197            let states = session_state::list_states();
1198            let summaries: Vec<_> = states.iter().map(|s| serde_json::json!({
1199                "sessionID": s.session_id,
1200                "phase": format!("{:?}", s.phase),
1201                "agentType": s.agent_type,
1202                "startedAt": s.started_at,
1203                "endedAt": s.ended_at,
1204                "stepCount": s.step_count,
1205                "filesTouched": &s.files_touched,
1206                "tool_calls": s.tool_calls,
1207                "commits": s.commits,
1208                "est_cost_usd": s.est_cost_usd,
1209                "transcriptPath": s.transcript_path,
1210            })).collect();
1211            Json(summaries)
1212        }))
1213        .layer(CorsLayer::permissive());
1214
1215    // Serve React SPA from website/dashboard/dist if it exists, otherwise fallback HTML
1216    let dashboard_dir = find_dashboard_dir();
1217    if let Some(ref dir) = dashboard_dir {
1218        let index = dir.join("index.html");
1219        app = app.fallback_service(ServeDir::new(dir).not_found_service(ServeFile::new(index)));
1220        eprintln!("  Dashboard: React SPA from {}", dir.display());
1221    } else {
1222        app = app.route("/", get(dashboard_fallback_html));
1223        eprintln!("  Dashboard: built-in fallback (run `npm run build` in website/dashboard/ for full UI)");
1224    }
1225
1226    let host: std::net::IpAddr = args.host.parse().unwrap_or_else(|_| {
1227        eprintln!("Invalid host, using 127.0.0.1");
1228        "127.0.0.1".parse().unwrap()
1229    });
1230    let addr = std::net::SocketAddr::from((host, args.port));
1231
1232    eprintln!("{}\n", "Chub Tracking Dashboard".bold());
1233    eprintln!(
1234        "  {}",
1235        format!("http://localhost:{}", args.port).bold().underline()
1236    );
1237    eprintln!("\nPress Ctrl+C to stop.\n");
1238
1239    let listener = match tokio::net::TcpListener::bind(addr).await {
1240        Ok(l) => l,
1241        Err(e) => {
1242            output::error(
1243                &format!("Failed to bind to port {}: {}", args.port, e),
1244                false,
1245            );
1246            return;
1247        }
1248    };
1249
1250    if let Err(e) = axum::serve(listener, app).await {
1251        output::error(&format!("Server error: {}", e), false);
1252    }
1253}
1254
1255/// Find the dashboard SPA dist directory. Checks several locations:
1256/// 1. Next to the binary: <exe_dir>/dashboard/
1257/// 2. Project workspace: website/dashboard/dist/
1258/// 3. Relative to CWD: website/dashboard/dist/
1259fn find_dashboard_dir() -> Option<std::path::PathBuf> {
1260    // Next to binary
1261    if let Ok(exe) = std::env::current_exe() {
1262        let beside_exe = exe.parent().unwrap_or(exe.as_path()).join("dashboard");
1263        if beside_exe.join("index.html").exists() {
1264            return Some(beside_exe);
1265        }
1266    }
1267
1268    // Project root (find .chub/ directory)
1269    if let Some(root) = chub_core::team::project::find_project_root(None) {
1270        let dist = root.join("website/dashboard/dist");
1271        if dist.join("index.html").exists() {
1272            return Some(dist);
1273        }
1274    }
1275
1276    // CWD
1277    let cwd_dist = std::path::PathBuf::from("website/dashboard/dist");
1278    if cwd_dist.join("index.html").exists() {
1279        return Some(cwd_dist);
1280    }
1281
1282    None
1283}
1284
1285async fn dashboard_fallback_html() -> axum::response::Html<&'static str> {
1286    axum::response::Html(
1287        r#"<!DOCTYPE html>
1288<html><head><meta charset="utf-8"><title>Chub Dashboard</title>
1289<style>body{font-family:system-ui;background:#0d1117;color:#c9d1d9;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;}
1290.c{text-align:center;max-width:500px;padding:32px;}h1{color:#58a6ff;margin-bottom:12px;}
1291code{background:#161b22;padding:4px 8px;border-radius:4px;font-size:14px;}
1292a{color:#58a6ff;}</style></head>
1293<body><div class="c">
1294<h1>Chub Dashboard</h1>
1295<p>The React dashboard is not built yet. Build it with:</p>
1296<p style="margin:16px 0"><code>cd website/dashboard && npm install && npm run build</code></p>
1297<p>Then restart <code>chub track dashboard</code>.</p>
1298<p style="margin-top:24px;color:#8b949e;">API is available at <a href="/api/status">/api/status</a>, <a href="/api/sessions">/api/sessions</a>, <a href="/api/report">/api/report</a></p>
1299</div></body></html>"#,
1300    )
1301}