use clap::Parser;
use heartbeat_rs::pty::{self, IdleConfig};
use std::path::PathBuf;
use std::process;
#[derive(Parser)]
#[command(name = "heartbeat-launch")]
#[command(about = "Launch a command inside a PTY. Designed to give Claude Code interactive mode.")]
#[command(
long_about = "Allocates a PTY via portable-pty (Unix PTY + Windows ConPTY), spawns the \
given command inside it, and forwards stdout to the current process. \
Polls for child exit with a configurable timeout and exits with the \
child's exit code.\n\n\
Everything after `--` is the command and its arguments. The consumer \
is responsible for inbox setup, settings.json, and stop hook wiring."
)]
struct Cli {
#[arg(long, default_value = ".")]
cwd: String,
#[arg(long, default_value = "3600")]
timeout: u64,
#[arg(long)]
exit_signal: Option<PathBuf>,
#[arg(long, default_value = "0")]
idle_timeout: u64,
#[arg(long, default_value = "Continue")]
idle_prompt: String,
#[arg(long, default_value = "3")]
max_idle_retries: u32,
#[arg(trailing_var_arg = true, required = true)]
cmd: Vec<String>,
}
fn main() {
let cli = Cli::parse();
let cwd = PathBuf::from(&cli.cwd);
if !cwd.exists() {
eprintln!(
"heartbeat-launch: working directory does not exist: {}",
cwd.display()
);
process::exit(1);
}
let cwd = match cwd.canonicalize() {
Ok(p) => p,
Err(e) => {
eprintln!(
"heartbeat-launch: cannot resolve working directory {}: {e}",
cli.cwd
);
process::exit(1);
}
};
if cli.cmd.is_empty() {
eprintln!("heartbeat-launch: no command specified");
process::exit(1);
}
if cli.timeout > 0 {
eprintln!(
"heartbeat-launch: spawning {:?} in {} with {}s timeout",
cli.cmd,
cwd.display(),
cli.timeout
);
} else {
eprintln!(
"heartbeat-launch: spawning {:?} in {} (no timeout)",
cli.cmd,
cwd.display()
);
}
let idle_cfg = if cli.idle_timeout > 0 {
Some(IdleConfig {
timeout_secs: cli.idle_timeout,
prompt: cli.idle_prompt.clone(),
max_retries: cli.max_idle_retries,
})
} else {
None
};
let result = pty::run(
&cli.cmd,
&cwd,
cli.timeout,
cli.exit_signal.as_deref(),
idle_cfg.as_ref(),
);
match result {
Ok(result) => {
let code = result.exit_code.min(123) as i32;
process::exit(code);
}
Err(pty::PtyError::Timeout(secs)) => {
eprintln!("heartbeat-launch: timeout after {secs}s — child killed");
process::exit(124); }
Err(pty::PtyError::IdleExhausted(secs)) => {
eprintln!("heartbeat-launch: idle exhausted — no output for {secs}s after maximum keepalive retries, child killed");
process::exit(125);
}
Err(e) => {
eprintln!("heartbeat-launch: error: {e}");
process::exit(1);
}
}
}