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 Status,
21 Enable(EnableArgs),
23 Disable,
25 Hook(HookArgs),
27 Log(LogArgs),
29 Show(ShowArgs),
31 Report(ReportArgs),
33 Export(ExportArgs),
35 Clear,
37 Dashboard(DashboardArgs),
39}
40
41#[derive(Args)]
42struct EnableArgs {
43 agent: Option<String>,
45
46 #[arg(long)]
48 force: bool,
49}
50
51#[derive(Args)]
52struct HookArgs {
53 event: String,
55
56 #[arg(long)]
58 agent: Option<String>,
59
60 #[arg(long)]
62 model: Option<String>,
63
64 #[arg(long)]
66 tool: Option<String>,
67
68 #[arg(long)]
70 input: Option<String>,
71
72 #[arg(long)]
74 session_id: Option<String>,
75
76 #[arg(long)]
78 tokens: Option<String>,
79
80 #[arg(long)]
82 file: Option<String>,
83}
84
85#[derive(Args)]
86struct LogArgs {
87 #[arg(long, default_value = "30")]
89 days: u64,
90}
91
92#[derive(Args)]
93struct ShowArgs {
94 id: String,
96}
97
98#[derive(Args)]
99struct ReportArgs {
100 #[arg(long, default_value = "30")]
102 days: u64,
103}
104
105#[derive(Args)]
106struct ExportArgs {
107 #[arg(long, default_value = "30")]
109 days: u64,
110}
111
112#[derive(Args)]
113struct DashboardArgs {
114 #[arg(short, long, default_value = "4243")]
116 port: u16,
117
118 #[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
138fn 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 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
216fn 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
296fn run_hook(args: HookArgs, json: bool) {
301 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 let mut state = session_state::SessionState::new(&agent, model_ref);
329 state.session_id = session_id.clone();
331 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 if let Some(mut state) = session_state::load_state(&session_id) {
366 state.apply_event(session_state::SessionEvent::SessionStop);
367
368 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 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 for f in analysis.modified_files {
391 state.touch_file(&f);
392 }
393 }
394
395 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 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 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 if session.model.is_none() {
432 session.model = analysis.model;
433 }
434
435 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 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 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 session.est_cost_usd =
469 cost::estimate_cost(session.model.as_deref(), &session.tokens);
470
471 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 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 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 if let Some(mut state) = session_state::load_state(&active.session_id) {
521 state.apply_event(session_state::SessionEvent::TurnStart);
522 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 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 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 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 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 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 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 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 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 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 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 if is_rebase_in_progress() {
672 return;
673 }
674
675 if let Some(mut active) = sessions::get_active_session() {
676 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 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 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 let remote = args.input.as_deref().unwrap_or("origin");
713 let sessions_pushed = sessions::push_sessions(remote);
714 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
735fn is_rebase_in_progress() -> bool {
737 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
745fn 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
769fn 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
820fn run_show(args: ShowArgs, json: bool) {
825 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 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 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
961fn 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 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
1059fn 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 for s in &session_list {
1073 println!("{}", serde_json::to_string(s).unwrap_or_default());
1074 }
1075 }
1076}
1077
1078fn 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
1098fn 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
1112async 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 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 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 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 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
1255fn find_dashboard_dir() -> Option<std::path::PathBuf> {
1260 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 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 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}