sandlot/rust-sandlot/src/vm.rs
2026-04-10 11:13:00 -07:00

909 lines
30 KiB
Rust

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<String> = 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<serde_json::Value> = 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<Box<dyn std::future::Future<Output = Result<(i32, Option<String>)>> + 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<String> = vec![
format!("TERM={term}"),
format!("PATH={CONTAINER_PATH}"),
];
for (k, v) in CONTAINER_ENV {
env_args.push(format!("{k}={v}"));
}
let mut args: Vec<String> = 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<String> = 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<String> = 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<String> = vec![format!("PATH={CONTAINER_PATH}")];
for (k, v) in CONTAINER_ENV {
env_args.push(format!("{k}={v}"));
}
let mut args: Vec<String> = 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::<Vec<_>>()
.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
}