use anyhow::{Result, bail}; use std::path::Path; use tokio::process::Command; use uuid::Uuid; const CONTAINER_NAME: &str = "sandlot"; const USER: &str = "ubuntu"; const CLAUDE_BIN: &str = "/home/ubuntu/.local/bin/claude"; const CONTAINER_PATH: &str = "/sandlot/bin:/sandlot/.cargo/bin:/sandlot/.go/bin:/sandlot/.gopath/bin:/home/ubuntu/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; const CONTAINER_ENV: &[(&str, &str)] = &[ ("RUSTUP_HOME", "/sandlot/.rustup"), ("CARGO_HOME", "/sandlot/.cargo"), ("GOROOT", "/sandlot/.go"), ("GOPATH", "/sandlot/.gopath"), ("RUSTC_WRAPPER", "/sandlot/.cargo/bin/sccache"), ("SCCACHE_DIR", "/sandlot/.sccache"), ]; fn debug_mode() -> bool { std::env::var("DEBUG").is_ok_and(|v| !v.is_empty()) } fn home_dir() -> String { dirs::home_dir() .expect("cannot find home directory") .to_string_lossy() .to_string() } fn cache_dir() -> String { format!("{}/.sandlot/.cache", home_dir()) } /// Translate a host path to its corresponding container path. pub fn container_path(host_path: &str) -> String { let home = home_dir(); let sandlot_prefix = format!("{home}/.sandlot"); let dev_prefix = format!("{home}/dev"); let code_prefix = format!("{home}/code"); if host_path.starts_with(&sandlot_prefix) { return format!("/sandlot{}", &host_path[sandlot_prefix.len()..]); } if host_path.starts_with(&dev_prefix) { return format!("/host/dev{}", &host_path[dev_prefix.len()..]); } if host_path.starts_with(&code_prefix) { return format!("/host/code{}", &host_path[code_prefix.len()..]); } host_path.to_string() } fn require_container() { if which::which("container").is_err() { eprintln!("\u{2716} Apple Container is not installed. Install it with: brew install container"); std::process::exit(1); } } /// Run a shell command, returning error on failure. async fn run(args: &[&str], step: &str) -> Result<()> { let mut cmd = Command::new(args[0]); cmd.args(&args[1..]); if !debug_mode() { cmd.stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()); } let output = cmd.output().await?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); let detail = if !stderr.is_empty() { stderr } else if !stdout.is_empty() { stdout } else { "(no output)".to_string() }; bail!("{step} failed (exit {}):\n{detail}", output.status.code().unwrap_or(1)); } Ok(()) } /// Check which host source directories exist. fn host_mounts(home: &str) -> (bool, bool) { let dev = Path::new(&format!("{home}/dev")).exists(); let code = Path::new(&format!("{home}/code")).exists(); (dev, code) } /// Check whether the package cache is populated. async fn has_cached_tooling() -> bool { let cache = cache_dir(); for f in &["bun", "claude", "neofetch", "nvim.tar.gz"] { if !Path::new(&format!("{cache}/{f}")).exists() { return false; } } true } async fn create_container(home: &str) -> Result<()> { let (dev, code) = host_mounts(home); let memory = match crate::config::get_memory().await { Some(m) => match crate::config::validate_memory(&m) { Ok(v) => v, Err(e) => { crate::fmt::info(&format!("Invalid memory config, using default: {e}")); crate::config::DEFAULTS_MEMORY.to_string() } }, None => crate::config::DEFAULTS_MEMORY.to_string(), }; let mut args: Vec = vec![ "container".into(), "run".into(), "-d".into(), "--name".into(), CONTAINER_NAME.into(), "-m".into(), memory, ]; if dev { args.push("--mount".into()); args.push(format!( "type=bind,source={home}/dev,target=/host/dev,readonly" )); } if code { args.push("--mount".into()); args.push(format!( "type=bind,source={home}/code,target=/host/code,readonly" )); } args.push("-v".into()); args.push(format!("{home}/.sandlot:/sandlot")); args.push("ubuntu:24.04".into()); args.push("sleep".into()); args.push("infinity".into()); let mut cmd = Command::new(&args[0]); cmd.args(&args[1..]); if !debug_mode() { cmd.stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()); } let output = cmd.output().await?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); bail!( "Container creation failed (exit {}):\n{}", output.status.code().unwrap_or(1), if !stderr.is_empty() { stderr } else if !stdout.is_empty() { stdout } else { "(no output)".to_string() } ); } Ok(()) } async fn install_packages(cached: bool) -> Result<()> { let packages = if cached { "curl git fish build-essential" } else { "curl git fish unzip build-essential" }; run( &[ "container", "exec", CONTAINER_NAME, "bash", "-c", &format!("apt update && apt install -y {packages}"), ], "Package installation", ) .await } async fn create_host_symlinks(home: &str) -> Result<()> { let (dev, code) = host_mounts(home); let mut cmds = vec![ format!("mkdir -p '{home}'"), format!("ln -s /sandlot '{home}/.sandlot'"), ]; if dev { cmds.push(format!("ln -s /host/dev '{home}/dev'")); } if code { cmds.push(format!("ln -s /host/code '{home}/code'")); } run( &[ "container", "exec", CONTAINER_NAME, "bash", "-c", &cmds.join(" && "), ], "Symlink creation", ) .await } async fn install_tooling(cached: bool, log: &dyn Fn(&str)) -> Result<()> { // Ensure cache directory exists tokio::fs::create_dir_all(cache_dir()).await.ok(); if cached { log("Installing packages (cached)"); run( &[ "container", "exec", "--user", USER, CONTAINER_NAME, "bash", "-c", "mkdir -p ~/.local/bin", ], "Create bin directory", ) .await?; run( &[ "container", "exec", "--user", USER, CONTAINER_NAME, "bash", "-c", "cp /sandlot/.cache/bun /sandlot/.cache/claude /sandlot/.cache/neofetch ~/.local/bin/ && chmod +x ~/.local/bin/bun ~/.local/bin/claude ~/.local/bin/neofetch && ln -sf bun ~/.local/bin/bunx", ], "Install cached binaries", ) .await?; run( &[ "container", "exec", "--user", USER, CONTAINER_NAME, "bash", "-c", "tar xzf /sandlot/.cache/nvim.tar.gz -C ~/.local --strip-components=1", ], "Install cached Neovim", ) .await?; return Ok(()); } log("Installing Bun"); run( &[ "container", "exec", "--user", USER, CONTAINER_NAME, "env", &format!("BUN_INSTALL=/home/{USER}/.local"), "bash", "-c", "curl -fsSL https://bun.sh/install | bash", ], "Bun installation", ) .await?; log("Installing Claude Code"); run( &[ "container", "exec", "--user", USER, CONTAINER_NAME, "bash", "-c", "curl -fsSL https://claude.ai/install.sh | bash", ], "Claude Code installation", ) .await?; log("Installing neofetch"); run( &[ "container", "exec", "--user", USER, CONTAINER_NAME, "bash", "-c", "curl -fsSL https://raw.githubusercontent.com/dylanaraps/neofetch/master/neofetch -o ~/.local/bin/neofetch && chmod +x ~/.local/bin/neofetch", ], "neofetch installation", ) .await?; log("Installing Neovim"); run( &[ "container", "exec", "--user", USER, CONTAINER_NAME, "bash", "-c", "curl -fsSL https://github.com/neovim/neovim/releases/latest/download/nvim-linux-arm64.tar.gz -o /tmp/nvim.tar.gz && tar xzf /tmp/nvim.tar.gz -C ~/.local --strip-components=1", ], "Neovim installation", ) .await?; // Cache binaries let _ = Command::new("container") .args([ "exec", "--user", USER, CONTAINER_NAME, "bash", "-c", "cp ~/.local/bin/bun ~/.local/bin/claude ~/.local/bin/neofetch /sandlot/.cache/ && cp /tmp/nvim.tar.gz /sandlot/.cache/nvim.tar.gz", ]) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .output() .await; install_persistent_tooling(log).await?; Ok(()) } async fn install_persistent_tooling(log: &dyn Fn(&str)) -> Result<()> { // Rust let has_rust = Command::new("container") .args(["exec", "--user", USER, CONTAINER_NAME, "test", "-x", "/sandlot/.cargo/bin/rustc"]) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status() .await?; if !has_rust.success() { log("Installing Rust"); run( &[ "container", "exec", "--user", USER, CONTAINER_NAME, "env", &format!("RUSTUP_HOME={}", CONTAINER_ENV.iter().find(|e| e.0 == "RUSTUP_HOME").unwrap().1), &format!("CARGO_HOME={}", CONTAINER_ENV.iter().find(|e| e.0 == "CARGO_HOME").unwrap().1), "bash", "-c", "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y", ], "Rust installation", ) .await?; // Add musl target let cargo_home = CONTAINER_ENV.iter().find(|e| e.0 == "CARGO_HOME").unwrap().1; let rustup_home = CONTAINER_ENV.iter().find(|e| e.0 == "RUSTUP_HOME").unwrap().1; run( &[ "container", "exec", "--user", USER, CONTAINER_NAME, "env", &format!("RUSTUP_HOME={rustup_home}"), &format!("CARGO_HOME={cargo_home}"), &format!("PATH={cargo_home}/bin:$PATH"), "rustup", "target", "add", "aarch64-unknown-linux-musl", ], "Rust musl target", ) .await?; } // Cargo config let has_cargo_config = Command::new("container") .args(["exec", "--user", USER, CONTAINER_NAME, "test", "-f", "/sandlot/.cargo/config.toml"]) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status() .await?; if !has_cargo_config.success() { let cargo_config = r#"[target.aarch64-unknown-linux-musl]\nlinker = "rust-lld"\n\n[build]\ntarget = "aarch64-unknown-linux-musl"\n"#; let _ = Command::new("container") .args([ "exec", "--user", USER, CONTAINER_NAME, "bash", "-c", &format!("echo -e '{cargo_config}' > /sandlot/.cargo/config.toml"), ]) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .output() .await; } // sccache let has_sccache = Command::new("container") .args(["exec", "--user", USER, CONTAINER_NAME, "test", "-x", "/sandlot/.cargo/bin/sccache"]) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status() .await?; if !has_sccache.success() { log("Installing sccache"); let sccache_version = "v0.14.0"; let sccache_archive = format!("sccache-{sccache_version}-aarch64-unknown-linux-musl.tar.gz"); let sccache_url = format!("https://github.com/mozilla/sccache/releases/download/{sccache_version}/{sccache_archive}"); run( &[ "container", "exec", "--user", USER, CONTAINER_NAME, "bash", "-c", &format!( "curl -fsSL {sccache_url} | tar xz -C /tmp && cp /tmp/sccache-{sccache_version}-aarch64-unknown-linux-musl/sccache /sandlot/.cargo/bin/sccache && chmod +x /sandlot/.cargo/bin/sccache && rm -rf /tmp/sccache-{sccache_version}-aarch64-unknown-linux-musl" ), ], "sccache installation", ) .await?; let _ = Command::new("container") .args(["exec", "--user", USER, CONTAINER_NAME, "bash", "-c", "mkdir -p /sandlot/.sccache"]) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .output() .await; } // Go let has_go = Command::new("container") .args(["exec", "--user", USER, CONTAINER_NAME, "test", "-x", "/sandlot/.go/bin/go"]) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status() .await?; if !has_go.success() { log("Installing Go"); run( &[ "container", "exec", "--user", USER, CONTAINER_NAME, "bash", "-c", "mkdir -p /sandlot/.go && curl -fsSL https://go.dev/dl/go1.24.1.linux-arm64.tar.gz | tar xz -C /sandlot/.go --strip-components=1", ], "Go installation", ) .await?; let _ = Command::new("container") .args(["exec", "--user", USER, CONTAINER_NAME, "bash", "-c", "mkdir -p /sandlot/.gopath"]) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .output() .await; } Ok(()) } async fn install_script(home: &str, name: &str, content: &str) -> Result<()> { let tmp = format!("{home}/.sandlot/.{name}.tmp"); tokio::fs::write(&tmp, content).await?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; tokio::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755)).await?; } let _ = Command::new("container") .args([ "exec", "--user", USER, CONTAINER_NAME, "bash", "-c", &format!("cp /sandlot/.{name}.tmp ~/.local/bin/{name}"), ]) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .output() .await; tokio::fs::remove_file(&tmp).await.ok(); Ok(()) } async fn configure_environment(home: &str, api_key: &str) -> Result<()> { // Git identity let git_name = Command::new("git") .args(["config", "user.name"]) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::null()) .output() .await .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) .unwrap_or_default(); let git_email = Command::new("git") .args(["config", "user.email"]) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::null()) .output() .await .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) .unwrap_or_default(); if !git_name.is_empty() { let _ = Command::new("container") .args(["exec", "--user", USER, CONTAINER_NAME, "git", "config", "--global", "user.name", &git_name]) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .output() .await; } if !git_email.is_empty() { let _ = Command::new("container") .args(["exec", "--user", USER, CONTAINER_NAME, "git", "config", "--global", "user.email", &git_email]) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .output() .await; } // Claude settings let activity_bin = format!("/home/{USER}/.local/bin/sandlot-activity"); let hooks = serde_json::json!({ "UserPromptSubmit": [{"hooks": [{"type": "command", "command": format!("{activity_bin} active")}]}], "PreToolUse": [{"hooks": [{"type": "command", "command": format!("{activity_bin} active")}]}], }); let status_line = serde_json::json!({ "type": "command", "command": format!("/home/{USER}/.local/bin/sandlot-statusline"), }); let settings = serde_json::json!({ "apiKeyHelper": "~/.claude/api-key-helper.sh", "skipDangerousModePermissionPrompt": true, "hooks": hooks, "statusLine": status_line, }); let claude_json = serde_json::json!({ "hasCompletedOnboarding": true, "effortCalloutDismissed": true, "projects": { "/": { "hasTrustDialogAccepted": true } }, }); let settings_json = serde_json::to_string(&settings)?; let claude_json_str = serde_json::to_string(&claude_json)?; // API key helper (write to temp file so key never appears in ps) let escaped_key = api_key.replace('\'', "'\\''"); let tmp = format!("{home}/.sandlot/.api-key-helper.tmp"); tokio::fs::write(&tmp, format!("#!/bin/sh\necho '{escaped_key}'\n")).await?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; tokio::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755)).await?; } let _ = Command::new("container") .args([ "exec", "--user", USER, CONTAINER_NAME, "bash", "-c", "mkdir -p ~/.claude && cp /sandlot/.api-key-helper.tmp ~/.claude/api-key-helper.sh", ]) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .output() .await; tokio::fs::remove_file(&tmp).await.ok(); // Activity hook script install_script( home, "sandlot-activity", "#!/bin/bash\nP=\"${CLAUDE_PROJECT_DIR%/}\"\necho \"$1\" > \"$(dirname \"$P\")/.activity-$(basename \"$P\")\"\n", ) .await?; // Statusline script install_script( home, "sandlot-statusline", "#!/bin/bash\ninput=$(cat)\ncwd=$(echo \"$input\" | grep -oP '\"cwd\"\\s*:\\s*\"\\K[^\"]+' | head -1)\n[ -n \"$cwd\" ] && printf '\\033[36m\u{2387} %s\\033[0m\\n' \"$(basename \"$cwd\")\"\n", ) .await?; // Write Claude settings let _ = Command::new("container") .args([ "exec", "--user", USER, CONTAINER_NAME, "bash", "-c", &format!( "mkdir -p ~/.claude\necho '{settings_json}' > ~/.claude/settings.json\necho '{claude_json_str}' > ~/.claude.json\n" ), ]) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .output() .await; Ok(()) } // ── Public API ────────────────────────────────────────────────────── /// Create and provision the container from scratch. pub async fn create(log: &dyn Fn(&str)) -> Result<()> { require_container(); let api_key = crate::env::require_api_key().await; let s = status().await; if s != "missing" { bail!("Container already exists. Use 'sandlot vm destroy' first to recreate it."); } let home = home_dir(); let cached = has_cached_tooling().await; log("Pulling image & creating container"); create_container(&home).await?; log("Installing packages"); install_packages(cached).await?; create_host_symlinks(&home).await?; install_tooling(cached, log).await?; log("Configuring environment"); configure_environment(&home, &api_key).await?; Ok(()) } /// Start a stopped container. pub async fn start() -> Result<()> { require_container(); let s = status().await; if s == "running" { return Ok(()); } if s == "missing" { bail!("Container does not exist. Use 'sandlot vm create' first."); } run(&["container", "start", CONTAINER_NAME], "Container start").await } /// Ensure the sandlot container exists and is running. pub async fn ensure(log: &dyn Fn(&str)) -> Result<()> { require_container(); crate::env::require_api_key().await; // Ensure container daemon is running let mut cmd = Command::new("container"); cmd.args(["system", "start", "--enable-kernel-install"]); if !debug_mode() { cmd.stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()); } let _ = cmd.output().await; let s = status().await; if s == "running" { return Ok(()); } if s == "stopped" { return start().await; } create(log).await } /// Check container status. pub async fn status() -> &'static str { let output = Command::new("container") .args(["list", "--format", "json", "--all"]) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::null()) .output() .await; let output = match output { Ok(o) => o, Err(_) => return "missing", }; let text = String::from_utf8_lossy(&output.stdout); let containers: Vec = match serde_json::from_str(text.trim()) { Ok(v) => v, Err(_) => return "missing", }; for c in &containers { if c.get("configuration") .and_then(|cfg| cfg.get("id")) .and_then(|id| id.as_str()) == Some(CONTAINER_NAME) { let status_str = c .get("status") .and_then(|s| s.as_str()) .unwrap_or("") .to_lowercase(); return if status_str == "running" { "running" } else { "stopped" }; } } "missing" } /// Launch claude in the container at the given workdir. pub fn claude<'a>( workdir: &'a str, prompt: Option<&'a str>, print: Option<&'a str>, continue_session: bool, ) -> std::pin::Pin)>> + Send + 'a>> { Box::pin(async move { let cwd = container_path(workdir); let home = home_dir(); let (dev, code) = host_mounts(&home); let mut system_prompt_lines = vec![ "You are running inside a sandlot container (Apple Container, ubuntu:24.04).".to_string(), format!("Your working directory is {cwd}, a git worktree managed by sandlot."), ]; if dev { system_prompt_lines.push("The host's ~/dev is mounted read-only at /host/dev.".to_string()); } if code { system_prompt_lines.push("The host's ~/code is mounted read-only at /host/code.".to_string()); } system_prompt_lines.push("The host's ~/.sandlot is mounted at /sandlot.".to_string()); system_prompt_lines.push("Bun is installed at ~/.local/bin/bun. Use bun instead of node/npm.".to_string()); system_prompt_lines.push("Rust (cargo/rustc) is installed at /sandlot/.cargo/. Go is installed at /sandlot/.go/. sccache is configured as RUSTC_WRAPPER for build caching.".to_string()); if print.is_some() { system_prompt_lines.push("IMPORTANT: Do not use plan mode. Do not call the EnterPlanMode tool. Proceed directly with the task.".to_string()); } let system_prompt = system_prompt_lines.join("\n"); let term = std::env::var("TERM").unwrap_or_else(|_| "xterm-256color".to_string()); let mut env_args: Vec = vec![ format!("TERM={term}"), format!("PATH={CONTAINER_PATH}"), ]; for (k, v) in CONTAINER_ENV { env_args.push(format!("{k}={v}")); } let mut args: Vec = vec![ "container".into(), "exec".into(), "-it".into(), "--user".into(), USER.into(), "--workdir".into(), cwd.clone(), CONTAINER_NAME.into(), "env".into(), ]; args.extend(env_args); args.extend([ CLAUDE_BIN.into(), "--dangerously-skip-permissions".into(), "--model".into(), "claude-opus-4-6".into(), "--effort".into(), "max".into(), "--append-system-prompt".into(), system_prompt, ]); if continue_session { args.push("--continue".into()); } if let Some(p) = print { args.push("-p".into()); args.push(p.into()); } else if let Some(p) = prompt { args.push(p.into()); } if print.is_some() { let mut cmd = std::process::Command::new(&args[0]); cmd.args(&args[1..]) .stdin(std::process::Stdio::inherit()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::inherit()); let child = cmd.spawn()?; let output = child.wait_with_output()?; let exit_code = output.status.code().unwrap_or(1); let stdout = String::from_utf8_lossy(&output.stdout).to_string(); if exit_code != 0 && continue_session { crate::fmt::info("Retrying without --continue"); return claude(workdir, prompt, print, false).await; } return Ok((exit_code, Some(stdout))); } let mut cmd = std::process::Command::new(&args[0]); cmd.args(&args[1..]) .stdin(std::process::Stdio::inherit()) .stdout(std::process::Stdio::inherit()) .stderr(std::process::Stdio::inherit()); let status = cmd.spawn()?.wait()?; let exit_code = status.code().unwrap_or(1); if exit_code != 0 && continue_session { crate::fmt::info("Retrying without --continue"); return claude(workdir, prompt, print, false).await; } Ok((exit_code, None)) }) } /// Open an interactive fish shell in the container. pub async fn shell(workdir: Option<&str>) -> Result<()> { let mut args: Vec = vec![ "container".into(), "exec".into(), "-it".into(), "--user".into(), USER.into(), ]; if let Some(wd) = workdir { args.push("--workdir".into()); args.push(container_path(wd)); } let mut env_args: Vec = vec![ "TERM=xterm-256color".into(), format!("PATH={CONTAINER_PATH}"), ]; for (k, v) in CONTAINER_ENV { env_args.push(format!("{k}={v}")); } args.push(CONTAINER_NAME.into()); args.push("env".into()); args.extend(env_args); args.push("fish".into()); args.push("--login".into()); let status = std::process::Command::new(&args[0]) .args(&args[1..]) .stdin(std::process::Stdio::inherit()) .stdout(std::process::Stdio::inherit()) .stderr(std::process::Stdio::inherit()) .spawn()? .wait()?; let _ = status; Ok(()) } /// Run neofetch in the container. pub async fn neofetch() -> Result<()> { let mut env_args: Vec = vec![format!("PATH={CONTAINER_PATH}")]; for (k, v) in CONTAINER_ENV { env_args.push(format!("{k}={v}")); } let mut args: Vec = vec![ "container".into(), "exec".into(), "--user".into(), USER.into(), CONTAINER_NAME.into(), "env".into(), ]; args.extend(env_args); args.push("neofetch".into()); let status = std::process::Command::new(&args[0]) .args(&args[1..]) .stdin(std::process::Stdio::inherit()) .stdout(std::process::Stdio::inherit()) .stderr(std::process::Stdio::inherit()) .spawn()? .wait()?; let _ = status; Ok(()) } /// Run a bash command in the container at the given workdir, capturing output. pub async fn exec(workdir: &str, command: &str) -> (i32, String, String) { let env_exports: String = CONTAINER_ENV .iter() .map(|(k, v)| format!("export {k}={v}")) .collect::>() .join("; "); let full_cmd = format!("export PATH={CONTAINER_PATH}; {env_exports}; {command}"); let output = Command::new("container") .args([ "exec", "--user", USER, "--workdir", &container_path(workdir), CONTAINER_NAME, "bash", "-c", &full_cmd, ]) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .output() .await; match output { Ok(o) => ( o.status.code().unwrap_or(1), String::from_utf8_lossy(&o.stdout).trim().to_string(), String::from_utf8_lossy(&o.stderr).trim().to_string(), ), Err(_) => (1, String::new(), String::new()), } } /// Pipe input text to Claude in the container with a prompt, returning the output. pub async fn claude_pipe(input: &str, prompt: &str) -> (i32, String, String) { let tmp_name = format!(".claude-pipe-{}", Uuid::new_v4()); let home = home_dir(); let tmp_path = format!("{home}/.sandlot/{tmp_name}"); tokio::fs::write(&tmp_path, input).await.ok(); let escaped_prompt = prompt.replace('"', "\\\""); let result = exec( &format!("{home}/.sandlot"), &format!( "cat /sandlot/{tmp_name} | claude --model claude-opus-4-6 --effort max -p \"{escaped_prompt}\"" ), ) .await; tokio::fs::remove_file(&tmp_path).await.ok(); result } /// Check if Claude is actively working in the given worktree. pub async fn is_claude_active(worktree: &str, branch: &str) -> bool { let parent = Path::new(worktree).parent().unwrap_or(Path::new(".")); let file = parent.join(format!(".activity-{branch}")); match tokio::fs::read_to_string(&file).await { Ok(content) => content.trim() == "active", Err(_) => false, } } /// Set the activity marker for a worktree. pub async fn set_activity(worktree: &str, branch: &str) { let parent = Path::new(worktree).parent().unwrap_or(Path::new(".")); let file = parent.join(format!(".activity-{branch}")); tokio::fs::write(&file, "active\n").await.ok(); } /// Remove the activity marker file for a worktree. pub async fn clear_activity(worktree: &str, branch: &str) { let parent = Path::new(worktree).parent().unwrap_or(Path::new(".")); let file = parent.join(format!(".activity-{branch}")); tokio::fs::remove_file(&file).await.ok(); } /// Stop the container. pub async fn stop() -> Result<()> { let _ = Command::new("container") .args(["stop", CONTAINER_NAME]) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .output() .await; Ok(()) } /// Stop and delete the container. pub async fn destroy() -> Result<()> { stop().await?; let _ = Command::new("container") .args(["delete", CONTAINER_NAME]) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .output() .await; Ok(()) } /// Clear the package cache. pub async fn clear_cache() -> bool { let cache = cache_dir(); let existed = Path::new(&format!("{cache}/bun")).exists(); tokio::fs::remove_dir_all(&cache).await.ok(); existed }