@@ -1,7 +1,3 @@
3−pub use session_manager::{SessionError, SessionManager, SessionRecord};
7 ffi::OsString,
3 ffi::OsString,
@@ -119,41 +115,26 @@ impl DirectoryShare {
122−fn main() -> Result<(), Box<dyn std::error::Error>> {
118+pub fn run_cli() -> Result<(), Box<dyn std::error::Error>> {
123 let args = parse_cli()?;
119 let args = parse_cli()?;
125− if args.version {
121+ if args.version() {
126− println!("Vibe");
122+ print_version();
127− println!("https://github.com/lynaghk/vibe/");
123+ return Ok(());
128− println!("Git SHA: {}", env!("GIT_SHA"));
129− std::process::exit(0);
132− if args.help {
126+ if args.help() {
133− println!(
127+ print_help();
134− "Vibe is a quick way to spin up a Linux virtual machine on Mac to sandbox LLM agents.
128+ return Ok(());
136−vibe [OPTIONS] [disk-image.raw]
140− --help Print this help message.
141− --version Print the version (commit SHA).
142− --no-default-mounts Disable all default mounts.
143− --mount host-path:guest-path[:read-only | :read-write] Mount `host-path` inside VM at `guest-path`.
144− Defaults to read-write.
145− Errors if host-path does not exist.
146− --cpus <count> Number of virtual CPUs (default {DEFAULT_CPU_COUNT}).
147− --ram <megabytes> RAM size in megabytes (default {DEFAULT_RAM_MB}).
148− --script <path/to/script.sh> Run script in VM.
149− --send <some-command> Type `some-command` followed by newline into the VM.
150− --expect <string> [timeout-seconds] Wait for `string` to appear in console output before executing next `--script` or `--send`.
151− If `string` does not appear within timeout (default 30 seconds), shutdown VM with error.
154− std::process::exit(0);
131+ run_with_args(args, spawn_vm_io)
134+pub fn run_with_args<F>(args: CliArgs, io_handler: F) -> Result<(), Box<dyn std::error::Error>>
136+ F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
157 ensure_signed();
138 ensure_signed();
159 let project_root = env::current_dir()?;
140 let project_root = env::current_dir()?;
@@ -239,6 +220,11 @@ Options
240 DirectoryShare::new(home.join(".codex"), "/root/.codex".into(), false),
221 DirectoryShare::new(home.join(".codex"), "/root/.codex".into(), false),
241 DirectoryShare::new(home.join(".claude"), "/root/.claude".into(), false),
222 DirectoryShare::new(home.join(".claude"), "/root/.claude".into(), false),
223+ DirectoryShare::new(
224+ "/Users/zhangjie/Documents/Code/CompletePrograms/vibebox/.ssh".into(),
225+ "/root/.ssh".into(),
243 .into_iter()
229 .into_iter()
244 .flatten()
230 .flatten()
@@ -258,16 +244,47 @@ Options
258 // Any user-provided login actions must come after our system ones
244 // Any user-provided login actions must come after our system ones
259 login_actions.extend(args.login_actions);
245 login_actions.extend(args.login_actions);
261− run_vm(
247+ run_vm_with_io(
262 &disk_path,
248 &disk_path,
263 &login_actions,
249 &login_actions,
264 &directory_shares[..],
250 &directory_shares[..],
265 args.cpu_count,
251 args.cpu_count,
266 args.ram_bytes,
252 args.ram_bytes,
257+pub fn print_help() {
259+ "Vibe is a quick way to spin up a Linux virtual machine on Mac to sandbox LLM agents.
261+vibe [OPTIONS] [disk-image.raw]
265+ --help Print this help message.
266+ --version Print the version (commit SHA).
267+ --no-default-mounts Disable all default mounts.
268+ --mount host-path:guest-path[:read-only | :read-write] Mount `host-path` inside VM at `guest-path`.
269+ Defaults to read-write.
270+ Errors if host-path does not exist.
271+ --cpus <count> Number of virtual CPUs (default {DEFAULT_CPU_COUNT}).
272+ --ram <megabytes> RAM size in megabytes (default {DEFAULT_RAM_MB}).
273+ --script <path/to/script.sh> Run script in VM.
274+ --send <some-command> Type `some-command` followed by newline into the VM.
275+ --expect <string> [timeout-seconds] Wait for `string` to appear in console output before executing next `--script` or `--send`.
276+ If `string` does not appear within timeout (default 30 seconds), shutdown VM with error.
281+pub fn print_version() {
283+ println!("https://github.com/lynaghk/vibe/");
284+ println!("Git SHA: {}", env!("GIT_SHA"));
270−struct CliArgs {
287+pub struct CliArgs {
271 disk: Option<PathBuf>,
288 disk: Option<PathBuf>,
272 version: bool,
289 version: bool,
273 help: bool,
290 help: bool,
@@ -278,14 +295,43 @@ struct CliArgs {
278 ram_bytes: u64,
295 ram_bytes: u64,
299+ pub fn version(&self) -> bool {
303+ pub fn help(&self) -> bool {
307+ pub fn cpu_count(&self) -> usize {
311+ pub fn ram_bytes(&self) -> u64 {
315+ pub fn ram_mb(&self) -> u64 {
316+ self.ram_bytes / BYTES_PER_MB
281−fn parse_cli() -> Result<CliArgs, Box<dyn std::error::Error>> {
320+pub fn parse_cli() -> Result<CliArgs, Box<dyn std::error::Error>> {
321+ parse_cli_from(env::args_os())
324+pub fn parse_cli_from<I>(args: I) -> Result<CliArgs, Box<dyn std::error::Error>>
326+ I: IntoIterator<Item = OsString>,
282 fn os_to_string(value: OsString, flag: &str) -> Result<String, Box<dyn std::error::Error>> {
328 fn os_to_string(value: OsString, flag: &str) -> Result<String, Box<dyn std::error::Error>> {
284 .into_string()
330 .into_string()
285 .map_err(|_| format!("{flag} expects valid UTF-8").into())
331 .map_err(|_| format!("{flag} expects valid UTF-8").into())
288− let mut parser = lexopt::Parser::from_env();
334+ let mut parser = lexopt::Parser::from_iter(args);
289 let mut disk = None;
335 let mut disk = None;
290 let mut version = false;
336 let mut version = false;
291 let mut help = false;
337 let mut help = false;
@@ -621,11 +667,15 @@ pub fn create_pipe() -> (OwnedFd, OwnedFd) {
621 (read_stream.into(), write_stream.into())
667 (read_stream.into(), write_stream.into())
624−pub fn spawn_vm_io(
670+pub fn spawn_vm_io_with_line_handler<F>(
625 output_monitor: Arc<OutputMonitor>,
671 output_monitor: Arc<OutputMonitor>,
626 vm_output_fd: OwnedFd,
672 vm_output_fd: OwnedFd,
627 vm_input_fd: OwnedFd,
673 vm_input_fd: OwnedFd,
628−) -> IoContext {
675+) -> IoContext
677+ F: FnMut(&str) -> bool + ::std::marker::Send + 'static,
629 let (input_tx, input_rx): (Sender<VmInput>, Receiver<VmInput>) = mpsc::channel();
679 let (input_tx, input_rx): (Sender<VmInput>, Receiver<VmInput>) = mpsc::channel();
631 // raw_guard is set when we've put the user's terminal into raw mode because we've attached stdin/stdout to the VM.
681 // raw_guard is set when we've put the user's terminal into raw mode because we've attached stdin/stdout to the VM.
@@ -679,15 +729,41 @@ pub fn spawn_vm_io(
680 move || {
730 move || {
681 let mut buf = [0u8; 1024];
731 let mut buf = [0u8; 1024];
732+ let mut pending_command: Vec<u8> = Vec::new();
733+ let mut command_mode = false;
683 match poll_with_wakeup(libc::STDIN_FILENO, wakeup_read.as_raw_fd(), &mut buf) {
735 match poll_with_wakeup(libc::STDIN_FILENO, wakeup_read.as_raw_fd(), &mut buf) {
684 PollResult::Shutdown | PollResult::Error => break,
736 PollResult::Shutdown | PollResult::Error => break,
685 PollResult::Spurious => continue,
737 PollResult::Spurious => continue,
686 PollResult::Ready(bytes) => {
738 PollResult::Ready(bytes) => {
739+ let mut send_buf: Vec<u8> = Vec::new();
741+ if pending_command.is_empty() && !command_mode && b == b':' {
742+ command_mode = true;
746+ pending_command.push(b);
751+ if b == b'\n' && command_mode {
752+ let line = String::from_utf8_lossy(&pending_command);
753+ let trimmed = line.trim_end_matches(&['\r', '\n'][..]);
754+ let consumed = on_line(trimmed);
756+ send_buf.extend_from_slice(&pending_command);
758+ pending_command.clear();
759+ command_mode = false;
687 if raw_guard.lock().unwrap().is_none() {
762 if raw_guard.lock().unwrap().is_none() {
688 continue;
763 continue;
690− if input_tx.send(VmInput::Bytes(bytes.to_vec())).is_err() {
765+ if !send_buf.is_empty() && input_tx.send(VmInput::Bytes(send_buf)).is_err()
@@ -753,6 +829,14 @@ pub fn spawn_vm_io(
833+ output_monitor: Arc<OutputMonitor>,
834+ vm_output_fd: OwnedFd,
835+ vm_input_fd: OwnedFd,
837+ spawn_vm_io_with_line_handler(output_monitor, vm_output_fd, vm_input_fd, |_| false)
756impl IoContext {
840impl IoContext {
757 pub fn shutdown(self) {
841 pub fn shutdown(self) {
758 let _ = self.input_tx.send(VmInput::Shutdown);
842 let _ = self.input_tx.send(VmInput::Shutdown);
@@ -954,13 +1038,17 @@ fn spawn_login_actions_thread(
957−fn run_vm(
1041+fn run_vm_with_io<F>(
958 disk_path: &Path,
1042 disk_path: &Path,
959 login_actions: &[LoginAction],
1043 login_actions: &[LoginAction],
960 directory_shares: &[DirectoryShare],
1044 directory_shares: &[DirectoryShare],
961 cpu_count: usize,
1045 cpu_count: usize,
962 ram_bytes: u64,
1046 ram_bytes: u64,
963−) -> Result<(), Box<dyn std::error::Error>> {
1048+) -> Result<(), Box<dyn std::error::Error>>
1050+ F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
964 let (vm_reads_from, we_write_to) = create_pipe();
1052 let (vm_reads_from, we_write_to) = create_pipe();
965 let (we_read_from, vm_writes_to) = create_pipe();
1053 let (we_read_from, vm_writes_to) = create_pipe();
@@ -1021,7 +1109,7 @@ fn run_vm(
1021 println!("VM booting...");
1109 println!("VM booting...");
1023 let output_monitor = Arc::new(OutputMonitor::default());
1111 let output_monitor = Arc::new(OutputMonitor::default());
1024− let io_ctx = spawn_vm_io(output_monitor.clone(), we_read_from, we_write_to);
1112+ let io_ctx = io_handler(output_monitor.clone(), we_read_from, we_write_to);
1026 let mut all_login_actions = vec![
1114 let mut all_login_actions = vec![
1027 Expect {
1115 Expect {
@@ -1112,6 +1200,23 @@ fn run_vm(
1112 exit_result
1200 exit_result
1205+ login_actions: &[LoginAction],
1206+ directory_shares: &[DirectoryShare],
1209+) -> Result<(), Box<dyn std::error::Error>> {
1115fn nsurl_from_path(path: &Path) -> Result<Retained<NSURL>, Box<dyn std::error::Error>> {
1220fn nsurl_from_path(path: &Path) -> Result<Retained<NSURL>, Box<dyn std::error::Error>> {
1116 let abs_path = if path.is_absolute() {
1221 let abs_path = if path.is_absolute() {
1117 path.to_path_buf()
1222 path.to_path_buf()