use clap::{Parser, Subcommand, ValueEnum};
use serde::{Deserialize, Serialize};
#[derive(Parser, Debug)]
#[command(
name = "babysit",
version,
about = "Wrap a shell command in a PTY and expose it to external agents via subcommands",
long_about = LONG_ABOUT,
arg_required_else_help = true,
)]
pub struct Cli {
#[command(subcommand)]
pub command: Command,
}
const LONG_ABOUT: &str = "\
Wrap a command in a PTY and drive it from outside — built for an AI agent to run
an interactive/long-running command, watch it, type into it, and finish the task,
with a human able to step in.
MODEL
The command runs under a headless background worker that owns the PTY, records
all output to a log, and serves a per-session control socket. Your terminal (or
an agent's `babysit` calls) are just clients of that worker, so you can detach,
re-attach, and query state from anywhere. State lives in ~/.babysit/sessions/<id>/
(override the ~/.babysit root with $BABYSIT_DIR, e.g. for tests or demos).
SELECTING A SESSION
`run --json` prints the session id as JSON. Other commands take `-s <id>`;
there is no most-recent fallback, so name the session you mean. Inside the
wrapped command the id is in $BABYSIT_SESSION_ID, so nested calls can omit -s.
AGENT LOOP (typical)
1. babysit run -d --json -- <cmd> start detached; capture .id from the JSON
2. babysit expect -s ID 'prompt>' block until the program is ready
babysit wait-idle -s ID …or block until output settles
3. babysit screenshot -s ID --trim read the CURRENT screen (TUIs redraw in place)
babysit log -s ID --tail 50 …or the raw output stream
4. babysit send -s ID --json 'text' type a line; returns {sent, offset}
babysit key -s ID Down Down Enter press named keys (arrows, Esc, C-c, F1…)
5. babysit expect -s ID --since OFF 're' wait for the reply race-free, using
the `offset` returned by step 4
6. repeat 2–5 until done, then:
babysit wait -s ID block for the exit code
Poll cheaply: `status --json` reports `output_bytes` and `screen_seq`; if they
haven't changed, nothing moved — no need to re-fetch a screenshot. Blocking
commands (expect, wait-idle) time out after 30s by default so a stuck program
can't hang you; pass --timeout 0 to wait indefinitely.
MATCHING TUIs vs STREAMS (gotchas)
• `expect` scans the raw OUTPUT STREAM. Full-screen TUIs (menus, pickers)
redraw in place, so the text you SEE isn't a contiguous run in the stream —
use `babysit expect --screen 're'`, which matches the rendered screen grid.
• Don't `expect` the text you just `send`: the PTY usually echoes your input
back, so you'd match your own keystrokes. Wait for the program's reply
marker instead.
• `wait-idle` measures output VOLUME, so a spinner/progress bar that keeps
redrawing never settles. For those, poll `screenshot`'s `screen_hash`
(stable until the on-screen text changes) instead.
HUMAN HANDOFF
Stuck or need approval? `babysit flag -s ID 'why'` marks the session (shown with
a ⚑ in `babysit ls`); a human runs `babysit attach -s ID` to take over, then
detaches (Ctrl-\\ Ctrl-\\). Clear it with `babysit unflag -s ID`.
MORE
Every command has more flags than the loop above shows — run
`babysit help <command>` (e.g. `babysit help expect`) before guessing.
";
#[derive(clap::Args, Debug, Clone)]
pub struct SessionSel {
#[arg(short = 's', long, value_name = "ID")]
pub session: Option<String>,
}
#[derive(Subcommand, Debug)]
pub enum Command {
Run {
#[arg(long, value_name = "ID")]
id: Option<String>,
#[arg(short = 'd', long)]
detach: bool,
#[arg(long = "detached-id", value_name = "ID", hide = true)]
detached_id: Option<String>,
#[arg(long = "no-tty")]
no_tty: bool,
#[arg(long, value_name = "DUR")]
timeout: Option<String>,
#[arg(long = "idle-timeout", value_name = "DUR")]
idle_timeout: Option<String>,
#[arg(long, value_name = "COLSxROWS")]
size: Option<String>,
#[arg(long)]
json: bool,
#[arg(trailing_var_arg = true, allow_hyphen_values = true, num_args = 1..)]
cmd: Vec<String>,
},
#[command(alias = "ls")]
List {
#[arg(long)]
json: bool,
},
#[command(aliases = ["st", "info"])]
Status {
#[command(flatten)]
sel: SessionSel,
#[arg(long)]
json: bool,
},
#[command(alias = "logs")]
Log {
#[command(flatten)]
sel: SessionSel,
#[arg(long)]
tail: Option<usize>,
#[arg(long, value_name = "REGEX")]
grep: Option<String>,
#[arg(long)]
raw: bool,
#[arg(long, value_name = "BYTES")]
since: Option<u64>,
#[arg(short = 'f', long)]
follow: bool,
#[arg(long)]
json: bool,
},
#[command(alias = "shot")]
Screenshot {
#[command(flatten)]
sel: SessionSel,
#[arg(long, value_enum, default_value = "plain")]
format: ShotFormat,
#[arg(long)]
trim: bool,
},
Wait {
#[command(flatten)]
sel: SessionSel,
#[arg(long, value_name = "DUR")]
timeout: Option<String>,
},
#[command(alias = "r")]
Restart {
#[command(flatten)]
sel: SessionSel,
#[arg(long)]
json: bool,
},
#[command(alias = "stop")]
Kill {
#[command(flatten)]
sel: SessionSel,
#[arg(long)]
json: bool,
},
#[command(alias = "type")]
Send {
#[command(flatten)]
sel: SessionSel,
text: String,
#[arg(short = 'n', long = "no-newline")]
no_newline: bool,
#[arg(long)]
json: bool,
},
Key {
#[command(flatten)]
sel: SessionSel,
#[arg(required = true, num_args = 1.., value_name = "KEY")]
keys: Vec<String>,
#[arg(long)]
json: bool,
},
Expect {
#[command(flatten)]
sel: SessionSel,
#[arg(value_name = "REGEX")]
pattern: String,
#[arg(long, value_name = "DUR", default_value = "30s")]
timeout: String,
#[arg(long, value_name = "BYTES")]
since: Option<u64>,
#[arg(long = "from-now")]
from_now: bool,
#[arg(long)]
raw: bool,
#[arg(long)]
screen: bool,
#[arg(long)]
json: bool,
},
#[command(name = "wait-idle")]
WaitIdle {
#[command(flatten)]
sel: SessionSel,
#[arg(long, default_value = "500ms", value_name = "DUR")]
settle: String,
#[arg(long, value_name = "DUR", default_value = "30s")]
timeout: String,
},
Resize {
#[command(flatten)]
sel: SessionSel,
#[arg(value_name = "COLSxROWS")]
size: String,
#[arg(long)]
json: bool,
},
Flag {
#[command(flatten)]
sel: SessionSel,
#[arg(value_name = "MESSAGE")]
message: Option<String>,
#[arg(long)]
json: bool,
},
Unflag {
#[command(flatten)]
sel: SessionSel,
#[arg(long)]
json: bool,
},
#[command(alias = "a")]
Attach {
#[command(flatten)]
sel: SessionSel,
},
Detach {
#[command(flatten)]
sel: SessionSel,
#[arg(long)]
json: bool,
},
Prune {
#[arg(long)]
dry_run: bool,
#[arg(long)]
json: bool,
},
Upgrade,
Config {
#[arg(value_enum)]
shell: Shell,
},
}
#[derive(ValueEnum, Debug, Clone, Copy)]
pub enum Shell {
Zsh,
Bash,
}
#[derive(ValueEnum, Serialize, Deserialize, Debug, Clone, Copy)]
#[serde(rename_all = "snake_case")]
pub enum ShotFormat {
Plain,
Ansi,
Json,
}