Skip to main content

baton/
cli.rs

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        /// Only list sessions whose recorded cwd equals this path. Defaults to
35        /// the current working directory.
36        #[arg(long, value_name = "PATH", conflicts_with = "all_sessions")]
37        pwd: Option<std::path::PathBuf>,
38        /// List sessions from every cwd (disables the default cwd filter).
39        #[arg(long)]
40        all_sessions: bool,
41    },
42    Inspect {
43        /// Either a session reference like `claude:<id>`, or a bare agent name
44        /// (`claude` / `codex`) to open the picker for that agent. Omit when
45        /// using --last or --interactive.
46        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        /// Only consider sessions whose recorded cwd equals this path.
54        /// Defaults to the current working directory. Ignored when an
55        /// explicit `agent:id` session reference is provided.
56        #[arg(long, value_name = "PATH", conflicts_with = "all_sessions")]
57        pwd: Option<std::path::PathBuf>,
58        /// Consider sessions from every cwd (disables the default cwd filter).
59        #[arg(long)]
60        all_sessions: bool,
61    },
62    Handoff {
63        /// either `<source-ref>` (e.g. `claude:abc`) followed by `<target>`,
64        /// or just `<target>` when using `--last`/`--interactive`.
65        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        /// Only consider source sessions whose recorded cwd equals this
74        /// path. Defaults to the current working directory. Ignored when
75        /// an explicit `agent:id` source reference is provided.
76        #[arg(long, value_name = "PATH", conflicts_with = "all_sessions")]
77        pwd: Option<std::path::PathBuf>,
78        /// Consider source sessions from every cwd (disables the default
79        /// cwd filter).
80        #[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            // When `--last` or `--interactive` is used, the only positional is
172            // the target agent. Otherwise we expect `<source-ref> <target>`.
173            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
350/// Pick a "target" agent for the render header during inspect. The handoff
351/// target is unknown at inspect time, so we render as if going to the
352/// opposite agent — this is a preview only and never written to disk.
353fn 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    // A bare agent name like `claude` looks like a positional session ref
413    // to clap but should mean "open the picker for that agent".
414    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    // Explicit `agent:id` honors the user's choice — scope filter doesn't apply.
426    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}