use std::ffi::OsString; use std::path::PathBuf; use clap::{Args, Parser, Subcommand}; use lazaret_core::records::StateResetScopeV1; use crate::state::{StateCommand, StateOperation}; use crate::{ClientCommand, DEFAULT_STARTUP_TIMEOUT}; #[derive(Debug, Parser)] #[command( name = "lazaret", version, about = "Run development tools in an isolated agent sandbox", long_about = "Run development tools in an isolated gVisor sandbox while keeping workspace editing, persistent tool state, controlled network access, and developer-approved host actions available.", override_usage = "lazaret [OPTIONS] [-- ...]\n lazaret ", after_help = "Launch a profile directly with `lazaret PROFILE`. Add `-- COMMAND...` to override the default /bin/bash environment entry." )] struct Cli { #[command(subcommand)] command: PublicCommand, } #[derive(Debug, Subcommand)] enum PublicCommand { /// Run a command in the sandbox for a profile and workspace. Run(RunArguments), /// Inspect, verify, or reset persistent sandbox state. State { #[command(subcommand)] operation: StateArguments, }, } #[derive(Debug, Args)] struct RunArguments { #[command(flatten)] domain: DomainArguments, /// Maximum time to wait for Domain publication, in seconds. #[arg( long, value_name = "SECONDS", default_value_t = DEFAULT_STARTUP_TIMEOUT.as_secs(), value_parser = clap::value_parser!(u64).range(1..) )] startup_timeout_seconds: u64, /// Command and arguments to run instead of the default `/bin/bash`. #[arg(last = true, num_args = 1.., value_name = "COMMAND")] command: Vec, } #[derive(Debug, Args)] struct DomainArguments { /// Agent profile to use from the Lazaret configuration. #[arg(long, value_name = "NAME")] profile: String, /// Project directory to share with the sandbox. #[arg(long, value_name = "PATH")] workspace: Option, /// Lazaret configuration file to load instead of the user default. #[arg(long, value_name = "PATH")] config: Option, } #[derive(Debug, Subcommand)] enum StateArguments { /// List configured persistent sandbox paths without reading file contents. List(StateReadArguments), /// Show structural changes in persistent sandbox paths without file contents. Inspect(StateReadArguments), /// Verify persistent sandbox state while the domain is inactive. Verify(StateReadArguments), /// Discard persistent sandbox state for selected configured paths. Reset(StateResetArguments), } #[derive(Debug, Args)] struct StateReadArguments { #[command(flatten)] domain: DomainArguments, /// Write a stable machine-readable JSON report. #[arg(long)] json: bool, } #[derive(Debug, Args)] struct StateResetArguments { #[command(flatten)] domain: DomainArguments, /// Write a stable machine-readable JSON report. #[arg(long)] json: bool, /// Reset without asking for interactive confirmation. #[arg(long)] yes: bool, #[command(flatten)] scope: ResetScope, } #[derive(Debug, Args)] #[group(id = "scope", required = true, multiple = false)] struct ResetScope { /// Reset every configured persistent sandbox path in the domain. #[arg(long)] all: bool, /// Reset one exact configured container path, such as `/usr/local`. #[arg(long, value_name = "CONTAINER_PATH")] path: Option, } #[derive(Debug)] pub enum Command { Run(ClientCommand), State(StateCommand), } /// Parses the public command tree while preserving non-UTF-8 host paths and command arguments. /// /// # Errors /// Returns clap's structured usage or help result. pub fn parse( arguments: impl IntoIterator, current_workspace: PathBuf, ) -> Result { let arguments = expand_profile_launch(arguments); let cli = Cli::try_parse_from(std::iter::once(OsString::from("lazaret")).chain(arguments))?; Ok(match cli.command { PublicCommand::Run(arguments) => Command::Run(ClientCommand { profile: arguments.domain.profile, workspace: arguments.domain.workspace.unwrap_or(current_workspace), configuration: arguments.domain.config, startup_timeout: std::time::Duration::from_secs(arguments.startup_timeout_seconds), argv: command_argv(arguments.command), }), PublicCommand::State { operation } => { Command::State(state_command(operation, current_workspace)) } }) } fn expand_profile_launch(arguments: impl IntoIterator) -> Vec { let mut arguments = arguments.into_iter().collect::>(); if arguments.first().is_some_and(|first| { first != "run" && first != "state" && first.to_str().is_some_and(|first| !first.starts_with('-')) }) { arguments.insert(0, OsString::from("--profile")); arguments.insert(0, OsString::from("run")); } arguments } fn command_argv(command: Vec) -> Vec> { if command.is_empty() { return vec![b"/bin/bash".to_vec()]; } command .into_iter() .map(std::os::unix::ffi::OsStringExt::into_vec) .collect() } fn state_command(operation: StateArguments, current_workspace: PathBuf) -> StateCommand { let (operation, domain, machine, yes, scope) = match operation { StateArguments::List(arguments) => ( StateOperation::List, arguments.domain, arguments.json, false, None, ), StateArguments::Inspect(arguments) => ( StateOperation::Inspect, arguments.domain, arguments.json, false, None, ), StateArguments::Verify(arguments) => ( StateOperation::Verify, arguments.domain, arguments.json, false, None, ), StateArguments::Reset(arguments) => { let scope = match arguments.scope.path { Some(path) => StateResetScopeV1::Selected(path.into_bytes()), None => StateResetScopeV1::All, }; ( StateOperation::Reset, arguments.domain, arguments.json, arguments.yes, Some(scope), ) } }; StateCommand { operation, context: ClientCommand { profile: domain.profile, workspace: domain.workspace.unwrap_or(current_workspace), configuration: domain.config, startup_timeout: DEFAULT_STARTUP_TIMEOUT, argv: Vec::new(), }, machine, yes, scope, } }