use std::{net::SocketAddr, path::PathBuf};
use basis::{AllowAll, Approver, DenyAll};
use crate::approver::TerminalApprover;
use crate::duration_arg::DurationArg;
use clap::{Parser, Subcommand};
#[derive(Debug, Parser)]
#[command(
name = "basis",
version,
about = "basis — an embeddable agent harness",
long_about = None,
after_help = "\
Shorthand:
basis \"fix the failing test\" the same as: basis spawn \"fix the failing test\"
basis -- run a prompt that collides with a subcommand name
basis spawn - read the prompt from stdin
basis spawn \"task\" --await wait for the task's terminal result
basis wait <ID> wait again using the durable task handle
basis wait <ID> --message <MID> retry a specific message reply
basis send <ID> \"message\" enqueue a follow-up turn
basis ask <ID> \"question\" enqueue and await that message's reply
basis serve --acp serve ACP on stdio
basis serve --bridge serve ACP over a websocket
Exit codes:
0 the run finished
1 the run failed, or basis could not start it
2 the invocation was wrong
3 a bound tripped (--deadline, --tool-budget, --token-budget); committed work was kept",
)]
pub(crate) struct Cli {
#[command(subcommand)]
pub(crate) command: Option<Command>,
}
#[derive(Debug, Subcommand)]
pub(crate) enum Command {
#[command(name = "spawn", alias = "run")]
Spawn(RunArgs),
Fingerprint(FingerprintArgs),
Send(SendArgs),
Ask(AskArgs),
Wait(WaitArgs),
Cancel(CancelArgs),
Watch(WatchArgs),
Inbox(InboxArgs),
Serve(ServeArgs),
}
#[derive(Debug, clap::Args)]
pub(crate) struct SendArgs {
pub(crate) task: String,
pub(crate) message: String,
#[arg(long = "await")]
pub(crate) await_result: bool,
#[arg(long, value_name = "DURATION", requires = "await_result")]
pub(crate) timeout: Option<DurationArg>,
#[arg(long)]
pub(crate) json: bool,
}
#[derive(Debug, clap::Args)]
pub(crate) struct AskArgs {
pub(crate) task: String,
pub(crate) message: String,
#[arg(long, value_name = "DURATION")]
pub(crate) timeout: Option<DurationArg>,
#[arg(long)]
pub(crate) json: bool,
}
#[derive(Debug, clap::Args)]
pub(crate) struct WaitArgs {
pub(crate) task: String,
#[arg(long, value_name = "MESSAGE_ID")]
pub(crate) message: Option<String>,
#[arg(long, value_name = "DURATION")]
pub(crate) timeout: Option<DurationArg>,
#[arg(long)]
pub(crate) json: bool,
}
#[derive(Debug, clap::Args)]
pub(crate) struct CancelArgs {
pub(crate) task: String,
#[arg(long)]
pub(crate) json: bool,
}
#[derive(Debug, clap::Args)]
pub(crate) struct WatchArgs {
pub(crate) task: String,
#[arg(long, value_name = "DURATION")]
pub(crate) timeout: Option<DurationArg>,
#[arg(long)]
pub(crate) json: bool,
}
#[derive(Debug, clap::Args)]
pub(crate) struct InboxArgs {
pub(crate) task: Option<String>,
#[arg(long)]
pub(crate) json: bool,
}
#[derive(Debug, clap::Args)]
pub(crate) struct ServeArgs {
#[arg(long, conflicts_with = "bridge", required_unless_present = "bridge")]
pub(crate) acp: bool,
#[arg(long, conflicts_with = "acp", required_unless_present = "acp")]
pub(crate) bridge: bool,
#[command(flatten)]
pub(crate) acp_args: AcpArgs,
#[command(flatten)]
pub(crate) bridge_args: BridgeArgs,
}
#[derive(Debug, clap::Args)]
pub(crate) struct BridgeArgs {
#[arg(long, value_name = "ADDR", requires = "bridge")]
pub(crate) bind: Option<SocketAddr>,
#[arg(long = "allow-origin", value_name = "ORIGIN", requires = "bridge")]
pub(crate) allow_origin: Vec<String>,
#[arg(long, requires = "bridge")]
pub(crate) allow_non_loopback: bool,
}
impl ServeArgs {
pub(crate) fn has_bridge_options(&self) -> bool {
self.bridge_args.bind.is_some()
|| !self.bridge_args.allow_origin.is_empty()
|| self.bridge_args.allow_non_loopback
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub(crate) enum EffortArg {
Low,
Medium,
High,
#[value(name = "xhigh")]
XHigh,
Max,
}
impl From<EffortArg> for basis::Effort {
fn from(effort: EffortArg) -> Self {
match effort {
EffortArg::Low => Self::Low,
EffortArg::Medium => Self::Medium,
EffortArg::High => Self::High,
EffortArg::XHigh => Self::XHigh,
EffortArg::Max => Self::Max,
}
}
}
#[derive(Debug, Default, clap::Args)]
pub(crate) struct AcpArgs {
#[arg(long, value_name = "NAME")]
pub(crate) provider: Option<String>,
#[arg(long, value_name = "URL")]
pub(crate) base_url: Option<String>,
#[arg(long, value_name = "ID")]
pub(crate) model: Option<String>,
#[arg(long)]
pub(crate) no_shell: bool,
#[arg(long, value_name = "LEVEL")]
pub(crate) effort: Option<EffortArg>,
#[arg(long, value_name = "MODE", default_value = "prompt")]
pub(crate) approve: ApproveMode,
}
#[derive(Debug, clap::Args)]
pub(crate) struct RunArgs {
pub(crate) prompt: String,
#[arg(long)]
pub(crate) detached: bool,
#[arg(long = "await")]
pub(crate) await_result: bool,
#[arg(long, conflicts_with = "await_result")]
pub(crate) resumable: bool,
#[arg(long, value_name = "DURATION", requires = "await_result")]
pub(crate) timeout: Option<DurationArg>,
#[arg(short = 'C', long, value_name = "DIR")]
pub(crate) workspace: Option<PathBuf>,
#[arg(long)]
pub(crate) json: bool,
#[arg(long, value_name = "NAME")]
pub(crate) provider: Option<String>,
#[arg(long, value_name = "URL")]
pub(crate) base_url: Option<String>,
#[arg(long, value_name = "ID")]
pub(crate) model: Option<String>,
#[arg(long)]
pub(crate) no_shell: bool,
#[arg(long, value_name = "LEVEL")]
pub(crate) effort: Option<EffortArg>,
#[arg(long, value_name = "MODE", default_value = "always")]
pub(crate) approve: ApproveMode,
#[arg(long, value_name = "DURATION")]
pub(crate) deadline: Option<DurationArg>,
#[arg(long, value_name = "N")]
pub(crate) tool_budget: Option<usize>,
#[arg(long, value_name = "N")]
pub(crate) token_budget: Option<u64>,
}
#[derive(Debug, clap::Args)]
pub(crate) struct FingerprintArgs {
#[arg(short = 'C', long, value_name = "DIR")]
pub(crate) workspace: Option<PathBuf>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum)]
pub(crate) enum ApproveMode {
Always,
#[default]
Prompt,
Never,
}
impl ApproveMode {
pub(crate) fn approver(self) -> Box<dyn Approver> {
match self {
Self::Always => Box::new(AllowAll),
Self::Prompt => Box::new(TerminalApprover::new()),
Self::Never => Box::new(DenyAll),
}
}
}
impl From<ApproveMode> for basis_acp::ApprovalMode {
fn from(mode: ApproveMode) -> Self {
match mode {
ApproveMode::Always => Self::Always,
ApproveMode::Prompt => Self::Prompt,
ApproveMode::Never => Self::Never,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::exit::EXIT_USAGE;
const EFFORTS: [(&str, EffortArg); 5] = [
("low", EffortArg::Low),
("medium", EffortArg::Medium),
("high", EffortArg::High),
("xhigh", EffortArg::XHigh),
("max", EffortArg::Max),
];
#[test]
fn run_accepts_exactly_the_five_effort_spellings() {
for (value, expected) in EFFORTS {
let cli = Cli::try_parse_from(["basis", "run", "prompt", "--effort", value])
.expect("effort should parse");
let Some(Command::Spawn(args)) = cli.command else {
panic!("run command should parse");
};
assert_eq!(args.effort, Some(expected));
}
for invalid in ["x-high", "x_high", "none", "minimal", "ultra"] {
assert!(
Cli::try_parse_from(["basis", "run", "prompt", "--effort", invalid]).is_err(),
"unexpectedly accepted {invalid}"
);
}
}
#[test]
fn acp_accepts_the_same_five_effort_spellings() {
for (value, expected) in EFFORTS {
let cli = Cli::try_parse_from(["basis", "serve", "--acp", "--effort", value])
.expect("effort should parse");
let Some(Command::Serve(args)) = cli.command else {
panic!("ACP command should parse");
};
assert!(args.acp);
assert_eq!(args.acp_args.effort, Some(expected));
}
}
fn run_args(args: &[&str]) -> RunArgs {
let mut argv = vec!["basis", "spawn", "prompt"];
argv.extend_from_slice(args);
let Some(Command::Spawn(parsed)) = Cli::try_parse_from(argv).expect("parses").command
else {
panic!("run command should parse");
};
parsed
}
#[test]
fn a_run_states_its_own_bounds_or_has_none() {
let plain = run_args(&[]);
assert_eq!(plain.deadline, None);
assert_eq!(plain.tool_budget, None);
assert_eq!(plain.token_budget, None);
assert!(!plain.detached);
assert!(!plain.await_result);
assert_eq!(plain.timeout, None);
}
#[test]
fn lifecycle_flags_are_explicit_and_waits_are_bounded() {
let spawned = run_args(&["--detached", "--await", "--timeout", "45s"]);
assert!(spawned.detached);
assert!(spawned.await_result);
assert_eq!(
spawned.timeout.map(DurationArg::duration),
Some(std::time::Duration::from_secs(45))
);
let error = Cli::try_parse_from(["basis", "spawn", "prompt", "--timeout", "45s"])
.expect_err("a wait timeout without --await has no meaning");
assert_eq!(error.exit_code(), i32::from(EXIT_USAGE));
}
#[test]
fn communication_verbs_share_one_task_handle() {
let Some(Command::Send(send)) = Cli::try_parse_from([
"basis",
"send",
"workspace/task",
"refine the answer",
"--await",
"--timeout",
"2m",
])
.expect("send parses")
.command
else {
panic!("send command should parse");
};
assert_eq!(send.task, "workspace/task");
assert_eq!(send.message, "refine the answer");
assert!(send.await_result);
let Some(Command::Ask(ask)) = Cli::try_parse_from([
"basis",
"ask",
"workspace/task",
"what changed?",
"--timeout",
"2m",
])
.expect("ask parses")
.command
else {
panic!("ask command should parse");
};
assert_eq!(ask.task, "workspace/task");
assert_eq!(ask.message, "what changed?");
let Some(Command::Wait(wait)) = Cli::try_parse_from([
"basis",
"wait",
"workspace/task",
"--message",
"message-id",
"--timeout",
"2m",
])
.expect("wait parses")
.command
else {
panic!("wait command should parse");
};
assert_eq!(wait.task, "workspace/task");
assert_eq!(wait.message.as_deref(), Some("message-id"));
assert!(matches!(
Cli::try_parse_from(["basis", "cancel", "workspace/task"])
.expect("cancel parses")
.command,
Some(Command::Cancel(_))
));
assert!(matches!(
Cli::try_parse_from(["basis", "watch", "workspace/task"])
.expect("watch parses")
.command,
Some(Command::Watch(_))
));
assert!(matches!(
Cli::try_parse_from(["basis", "inbox", "workspace/task"])
.expect("inbox parses")
.command,
Some(Command::Inbox(_))
));
}
#[test]
fn the_three_bounds_parse_off_the_command_line() {
let bounded = run_args(&[
"--deadline",
"10m",
"--tool-budget",
"40",
"--token-budget",
"200000",
]);
assert_eq!(
bounded.deadline.map(DurationArg::duration),
Some(std::time::Duration::from_secs(600))
);
assert_eq!(bounded.tool_budget, Some(40));
assert_eq!(bounded.token_budget, Some(200_000));
}
#[test]
fn a_deadline_without_a_unit_is_refused_at_the_command_line() {
let error = Cli::try_parse_from(["basis", "run", "prompt", "--deadline", "30"])
.expect_err("refused");
assert!(
error.to_string().contains("30m"),
"the message must show an accepted form: {error}"
);
}
#[test]
fn fingerprint_defaults_to_the_current_directory() {
let Some(Command::Fingerprint(args)) = Cli::try_parse_from(["basis", "fingerprint"])
.expect("parses")
.command
else {
panic!("fingerprint command should parse");
};
assert_eq!(args.workspace, None);
}
#[test]
fn fingerprint_takes_a_workspace_the_same_way_run_does() {
let Some(Command::Fingerprint(args)) =
Cli::try_parse_from(["basis", "fingerprint", "-C", "/repo"])
.expect("parses")
.command
else {
panic!("fingerprint command should parse");
};
assert_eq!(args.workspace, Some(PathBuf::from("/repo")));
}
#[test]
fn commands_are_on_unless_the_run_says_no_shell() {
assert!(!run_args(&[]).no_shell, "ADR-0013: on by default");
assert!(run_args(&["--no-shell"]).no_shell);
let Some(Command::Serve(serve)) =
Cli::try_parse_from(["basis", "serve", "--acp", "--no-shell"])
.expect("parses")
.command
else {
panic!("ACP command should parse");
};
assert!(serve.acp_args.no_shell);
}
#[test]
fn serving_requires_one_explicit_transport() {
assert!(Cli::try_parse_from(["basis", "serve"]).is_err());
assert!(Cli::try_parse_from(["basis", "serve", "--acp", "--bridge"]).is_err());
assert!(Cli::try_parse_from(["basis", "serve", "--acp"]).is_ok());
assert!(Cli::try_parse_from(["basis", "serve", "--bridge"]).is_ok());
}
#[test]
fn bridge_options_require_the_bridge_mode() {
let Some(Command::Serve(acp_with_bridge_flag)) =
Cli::try_parse_from(["basis", "serve", "--acp", "--allow-origin", "http://x"])
.expect("clap parses the complete shape")
.command
else {
panic!("serve command should parse");
};
assert!(acp_with_bridge_flag.has_bridge_options());
let Some(Command::Serve(serve)) =
Cli::try_parse_from(["basis", "serve", "--bridge", "--bind", "127.0.0.1:0"])
.expect("bridge parses")
.command
else {
panic!("serve command should parse");
};
assert!(serve.bridge);
assert_eq!(serve.bridge_args.bind, Some("127.0.0.1:0".parse().unwrap()));
}
#[test]
fn the_retired_grant_is_gone_rather_than_quietly_ignored() {
let error =
Cli::try_parse_from(["basis", "run", "prompt", "--allow-shell"]).expect_err("refused");
assert_eq!(error.exit_code(), i32::from(EXIT_USAGE));
}
}