1use std::io::Write as _;
2
3use anyhow::Context as _;
4use clap::{Parser, Subcommand};
5use time::OffsetDateTime;
6
7use crate::config::{self, Config};
8use crate::handoff_store::HandoffStore;
9use crate::launch::{self, Launcher, ProcessLauncher};
10use crate::model::{Agent, Block, Conversation, SessionSummary};
11use crate::providers;
12use crate::render;
13use crate::select::{self, FzfSelector};
14use crate::session_ref::SessionRef;
15use crate::settings;
16
17#[derive(Parser)]
18#[command(name = "baton", version)]
19pub struct Cli {
20 #[command(subcommand)]
21 pub command: Command,
22}
23
24#[derive(Subcommand)]
25pub enum Command {
26 List {
27 agent: String,
28 #[arg(long)]
29 last: bool,
30 #[arg(long)]
31 interactive: bool,
32 #[arg(short, long)]
33 verbose: bool,
34 #[arg(long, value_name = "PATH", conflicts_with = "all_sessions")]
37 pwd: Option<std::path::PathBuf>,
38 #[arg(long)]
40 all_sessions: bool,
41 },
42 Inspect {
43 session: Option<String>,
47 #[arg(long, value_name = "AGENT")]
48 last: Option<String>,
49 #[arg(long, value_name = "AGENT")]
50 interactive: Option<String>,
51 #[arg(long)]
52 full: bool,
53 #[arg(long, value_name = "PATH", conflicts_with = "all_sessions")]
57 pwd: Option<std::path::PathBuf>,
58 #[arg(long)]
60 all_sessions: bool,
61 },
62 Handoff {
63 first: String,
66 second: Option<String>,
67 #[arg(long, value_name = "AGENT")]
68 last: Option<String>,
69 #[arg(long, value_name = "AGENT")]
70 interactive: Option<String>,
71 #[arg(long)]
72 no_launch: bool,
73 #[arg(long, value_name = "PATH", conflicts_with = "all_sessions")]
77 pwd: Option<std::path::PathBuf>,
78 #[arg(long)]
81 all_sessions: bool,
82 },
83 Settings {
84 #[command(subcommand)]
85 action: SettingsAction,
86 },
87}
88
89#[derive(Subcommand)]
90pub enum SettingsAction {
91 Path,
92 Show,
93 Edit,
94 Get {
95 key: String,
96 },
97 Set {
98 key: String,
99 value: String,
100 },
101 AddRoot {
102 agent: String,
103 path: std::path::PathBuf,
104 },
105 RemoveRoot {
106 agent: String,
107 path: std::path::PathBuf,
108 },
109 ResetRoot {
110 agent: String,
111 },
112}
113
114const INSPECT_PREVIEW_LINES: usize = 80;
115
116#[derive(Debug, thiserror::Error)]
117#[error("{message}")]
118pub struct ExitError {
119 code: i32,
120 message: String,
121}
122
123impl ExitError {
124 fn new(code: i32, message: impl Into<String>) -> Self {
125 Self {
126 code,
127 message: message.into(),
128 }
129 }
130
131 pub fn code(&self) -> i32 {
132 self.code
133 }
134}
135
136pub fn exit_code(error: &anyhow::Error) -> i32 {
137 error
138 .downcast_ref::<ExitError>()
139 .map(ExitError::code)
140 .unwrap_or(1)
141}
142
143pub fn run_to_exit_code() -> anyhow::Result<i32> {
144 let cli = Cli::parse();
145 match cli.command {
146 Command::List {
147 agent,
148 last,
149 interactive,
150 verbose,
151 pwd,
152 all_sessions,
153 } => cmd_list(&agent, last, interactive, verbose, pwd, all_sessions).map(|_| 0),
154 Command::Inspect {
155 session,
156 last,
157 interactive,
158 full,
159 pwd,
160 all_sessions,
161 } => cmd_inspect(session, last, interactive, full, pwd, all_sessions).map(|_| 0),
162 Command::Handoff {
163 first,
164 second,
165 last,
166 interactive,
167 no_launch,
168 pwd,
169 all_sessions,
170 } => {
171 let (source, target) = match second {
174 Some(t) => (Some(first), t),
175 None => (None, first),
176 };
177 cmd_handoff(
178 source,
179 &target,
180 last,
181 interactive,
182 no_launch,
183 pwd,
184 all_sessions,
185 )
186 }
187 Command::Settings { action } => cmd_settings(action).map(|_| 0),
188 }
189}
190
191fn parse_agent(s: &str) -> anyhow::Result<Agent> {
192 Agent::parse(s)
193 .ok_or_else(|| anyhow::anyhow!("unknown agent `{s}` (expected `claude` or `codex`)"))
194}
195
196fn resolve_scope(
197 pwd: Option<std::path::PathBuf>,
198 all_sessions: bool,
199) -> anyhow::Result<Option<select::Scope>> {
200 if all_sessions {
201 return Ok(None);
202 }
203 let raw = match pwd {
204 Some(p) => p,
205 None => std::env::current_dir().context("could not determine current directory")?,
206 };
207 Ok(Some(select::Scope::new(raw)))
208}
209
210fn load_scoped_sessions(
211 agent: Agent,
212 scope: Option<&select::Scope>,
213 cfg: &Config,
214) -> anyhow::Result<Vec<SessionSummary>> {
215 let provider = providers::for_agent(agent, cfg);
216 let mut sessions = provider
217 .list_sessions()
218 .with_context(|| format!("could not list {} sessions", agent.as_str()))?;
219 if let Some(s) = scope {
220 s.retain(&mut sessions);
221 }
222 Ok(sessions)
223}
224
225fn cmd_list(
226 agent_str: &str,
227 last: bool,
228 interactive: bool,
229 verbose: bool,
230 pwd: Option<std::path::PathBuf>,
231 all_sessions: bool,
232) -> anyhow::Result<()> {
233 if last && interactive {
234 return Err(anyhow::anyhow!(
235 "`--last` and `--interactive` are mutually exclusive"
236 ));
237 }
238 let agent = parse_agent(agent_str)?;
239 let cfg = settings::load_default().context("could not load settings")?;
240 let scope = resolve_scope(pwd, all_sessions)?;
241 let sessions = load_scoped_sessions(agent, scope.as_ref(), &cfg)?;
242
243 if sessions.is_empty() {
244 if let Some(s) = scope.as_ref() {
245 s.hint();
246 }
247 if interactive {
248 return Err(anyhow::anyhow!("no sessions available"));
249 }
250 return Ok(());
251 }
252
253 let now = OffsetDateTime::now_utc();
254
255 if interactive {
256 let chosen = select::pick_interactive(sessions, &FzfSelector, now)?;
257 println!("{}:{}", agent.as_str(), chosen.id);
258 return Ok(());
259 }
260
261 let stdout = std::io::stdout();
262 let mut out = stdout.lock();
263 if last {
264 if let Some(s) = sessions.first() {
265 writeln!(out, "{}", format_list_row(s, all_sessions, verbose, now))?;
266 }
267 return Ok(());
268 }
269 for s in &sessions {
270 writeln!(out, "{}", format_list_row(s, all_sessions, verbose, now))?;
271 }
272 Ok(())
273}
274
275fn format_list_row(
276 s: &SessionSummary,
277 show_dir: bool,
278 verbose: bool,
279 now: OffsetDateTime,
280) -> String {
281 let base = select::format_list_row(s, show_dir, now);
282 if verbose {
283 format!("{}\t{}", base, s.path.display())
284 } else {
285 base
286 }
287}
288
289fn cmd_inspect(
290 session: Option<String>,
291 last: Option<String>,
292 interactive: Option<String>,
293 full: bool,
294 pwd: Option<std::path::PathBuf>,
295 all_sessions: bool,
296) -> anyhow::Result<()> {
297 let cfg = settings::load_default().context("could not load settings")?;
298 let scope = resolve_scope(pwd, all_sessions)?;
299 let (agent, summary) = resolve_selection(
300 session.as_deref(),
301 last.as_deref(),
302 interactive.as_deref(),
303 scope.as_ref(),
304 &cfg,
305 )?;
306 let provider = providers::for_agent(agent, &cfg);
307 let resolved = provider.resolve_session(&summary.id).with_context(|| {
308 format!(
309 "could not resolve {} session `{}`",
310 agent.as_str(),
311 summary.id
312 )
313 })?;
314 let conv = provider.parse_transcript(&resolved).with_context(|| {
315 format!(
316 "could not parse {} transcript `{}`",
317 agent.as_str(),
318 resolved.path.display()
319 )
320 })?;
321
322 print_inspect_header(&conv);
323
324 let now = OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc());
325 let rendered = render::render(&conv, agent_swap(agent), now);
326
327 if full {
328 print!("{rendered}");
329 if !rendered.ends_with('\n') {
330 println!();
331 }
332 return Ok(());
333 }
334
335 let lines: Vec<&str> = rendered.lines().collect();
336 let preview_len = lines.len().min(INSPECT_PREVIEW_LINES);
337 println!("--- preview ---");
338 for line in &lines[..preview_len] {
339 println!("{line}");
340 }
341 if lines.len() > preview_len {
342 let hidden = lines.len() - preview_len;
343 println!("--- end preview ({hidden} more lines hidden, use --full)");
344 } else {
345 println!("--- end preview ---");
346 }
347 Ok(())
348}
349
350fn agent_swap(a: Agent) -> Agent {
354 match a {
355 Agent::Claude => Agent::Codex,
356 Agent::Codex => Agent::Claude,
357 }
358}
359
360fn print_inspect_header(conv: &Conversation) {
361 let mut human = 0usize;
362 let mut agent_msg = 0usize;
363 let mut tool_calls = 0usize;
364 let mut tool_results = 0usize;
365 let mut system = 0usize;
366 let mut unknown = 0usize;
367 for b in &conv.blocks {
368 match b {
369 Block::HumanMessage(_) => human += 1,
370 Block::AgentMessage(_) => agent_msg += 1,
371 Block::ToolCall(_) => tool_calls += 1,
372 Block::ToolResult(_) => tool_results += 1,
373 Block::SystemEvent(_) => system += 1,
374 Block::UnknownEvent(_) => unknown += 1,
375 }
376 }
377 println!("source: {}", conv.source.as_str());
378 println!("session_id: {}", conv.session_id);
379 println!("transcript: {}", conv.transcript_path.display());
380 let cwd = conv
381 .cwd
382 .as_ref()
383 .map(|p| p.display().to_string())
384 .unwrap_or_else(|| "-".to_string());
385 println!("cwd: {cwd}");
386 println!(
387 "blocks: human={human}, agent={agent_msg}, tool_calls={tool_calls}, tool_results={tool_results}, system={system}, unknown={unknown}"
388 );
389}
390
391fn resolve_selection(
392 explicit: Option<&str>,
393 last_agent: Option<&str>,
394 interactive_agent: Option<&str>,
395 scope: Option<&select::Scope>,
396 cfg: &Config,
397) -> anyhow::Result<(Agent, SessionSummary)> {
398 let modes = [
399 explicit.is_some(),
400 last_agent.is_some(),
401 interactive_agent.is_some(),
402 ]
403 .iter()
404 .filter(|b| **b)
405 .count();
406 if modes > 1 {
407 return Err(anyhow::anyhow!(
408 "specify at most one of <session-ref>, --last <agent>, or --interactive <agent>"
409 ));
410 }
411
412 let bare_agent = explicit.filter(|s| !s.contains(':')).and_then(Agent::parse);
415 let (explicit, interactive_agent) = match bare_agent {
416 Some(_) => (None, explicit),
417 None => (explicit, interactive_agent),
418 };
419 let interactive_agent = if modes == 0 {
420 Some("claude")
421 } else {
422 interactive_agent
423 };
424
425 if let Some(s) = explicit {
427 let r = SessionRef::parse(s)?;
428 let provider = providers::for_agent(r.agent, cfg);
429 let sessions = provider
430 .list_sessions()
431 .with_context(|| format!("could not list {} sessions", r.agent.as_str()))?;
432 let summary = sessions
433 .into_iter()
434 .find(|x| x.id == r.id)
435 .ok_or_else(|| anyhow::anyhow!("session id `{}` not found", r.id))?;
436 return Ok((r.agent, summary));
437 }
438
439 let pick_first = last_agent.is_some();
440 let agent = parse_agent(last_agent.or(interactive_agent).unwrap())?;
441 let sessions = load_scoped_sessions(agent, scope, cfg)?;
442 if sessions.is_empty() {
443 if let Some(s) = scope {
444 s.hint();
445 }
446 return Err(anyhow::anyhow!("no sessions available"));
447 }
448 let summary = if pick_first {
449 sessions.into_iter().next().unwrap()
450 } else {
451 select::pick_interactive(sessions, &FzfSelector, OffsetDateTime::now_utc())?
452 };
453 Ok((agent, summary))
454}
455
456fn cmd_handoff(
457 source: Option<String>,
458 target_str: &str,
459 last: Option<String>,
460 interactive: Option<String>,
461 no_launch: bool,
462 pwd: Option<std::path::PathBuf>,
463 all_sessions: bool,
464) -> anyhow::Result<i32> {
465 let target = parse_agent(target_str)?;
466 let cfg = settings::load_default().context("could not load settings")?;
467 let scope = resolve_scope(pwd, all_sessions)?;
468 let (source_agent, summary) = resolve_selection(
469 source.as_deref(),
470 last.as_deref(),
471 interactive.as_deref(),
472 scope.as_ref(),
473 &cfg,
474 )?;
475
476 if source_agent == target {
477 return Err(anyhow::anyhow!(
478 "source and target cannot both be `{}`",
479 target.as_str()
480 ));
481 }
482
483 let provider = providers::for_agent(source_agent, &cfg);
484 let resolved = provider.resolve_session(&summary.id).with_context(|| {
485 format!(
486 "could not resolve {} session `{}`",
487 source_agent.as_str(),
488 summary.id
489 )
490 })?;
491 let conv = provider.parse_transcript(&resolved).with_context(|| {
492 format!(
493 "could not parse {} transcript `{}`",
494 source_agent.as_str(),
495 resolved.path.display()
496 )
497 })?;
498
499 let now = OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc());
500 let rendered = render::render(&conv, target, now);
501
502 let store = HandoffStore::new(config::effective_handoff_dir(&cfg));
503 let path = store.write(source_agent, target, &conv.session_id, now, &rendered)?;
504 println!("wrote: {}", path.display());
505
506 if no_launch {
507 return Ok(0);
508 }
509
510 let prompt = launch::catch_up_prompt(&path);
511 let launcher = ProcessLauncher;
512 match launcher.launch(target, &prompt) {
513 Ok(_) => Ok(0),
514 Err(e) => Err(ExitError::new(2, format!("launch failed: {e}")).into()),
515 }
516}
517
518fn cmd_settings(action: SettingsAction) -> anyhow::Result<()> {
519 let path = settings::config_path();
520 match action {
521 SettingsAction::Path => {
522 println!("{}", path.display());
523 Ok(())
524 }
525 SettingsAction::Show => {
526 let cfg = settings::load(&path)?;
527 println!("# config: {}", path.display());
528 print!("{}", toml::to_string_pretty(&cfg)?);
529 println!();
530 println!("# effective");
531 println!(
532 "handoff_dir = {}",
533 config::effective_handoff_dir(&cfg).display()
534 );
535 let claude_roots = config::effective_claude_roots(&cfg.roots.claude);
536 println!("roots.claude = [");
537 for r in &claude_roots {
538 println!(" {},", r.display());
539 }
540 println!("]");
541 let codex_roots = config::effective_codex_roots(&cfg.roots.codex);
542 println!("roots.codex = [");
543 for r in &codex_roots {
544 println!(" {},", r.display());
545 }
546 println!("]");
547 Ok(())
548 }
549 SettingsAction::Edit => {
550 settings::ensure_exists(&path)?;
551 let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string());
552 let status = std::process::Command::new(&editor)
553 .arg(&path)
554 .status()
555 .with_context(|| format!("could not launch editor `{editor}`"))?;
556 if !status.success() {
557 return Err(anyhow::anyhow!("editor `{editor}` exited with {status}"));
558 }
559 Ok(())
560 }
561 SettingsAction::Get { key } => {
562 let cfg = settings::load(&path)?;
563 println!("{}", settings::get_value(&cfg, &key)?);
564 Ok(())
565 }
566 SettingsAction::Set { key, value } => {
567 let mut cfg = settings::load(&path)?;
568 settings::set_value(&mut cfg, &key, &value)?;
569 settings::write(&path, &cfg)?;
570 println!("ok");
571 Ok(())
572 }
573 SettingsAction::AddRoot { agent, path: root } => {
574 let agent = parse_agent(&agent)?;
575 let mut cfg = settings::load(&path)?;
576 settings::add_root(&mut cfg, agent, root);
577 settings::write(&path, &cfg)?;
578 println!("ok");
579 Ok(())
580 }
581 SettingsAction::RemoveRoot { agent, path: root } => {
582 let agent = parse_agent(&agent)?;
583 let mut cfg = settings::load(&path)?;
584 settings::remove_root(&mut cfg, agent, &root);
585 settings::write(&path, &cfg)?;
586 println!("ok");
587 Ok(())
588 }
589 SettingsAction::ResetRoot { agent } => {
590 let agent = parse_agent(&agent)?;
591 let mut cfg = settings::load(&path)?;
592 settings::reset_root(&mut cfg, agent);
593 settings::write(&path, &cfg)?;
594 println!("ok");
595 Ok(())
596 }
597 }
598}