use anyhow::{bail, Context, Result};
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::ContainerCommands;
pub fn run(command: ContainerCommands) -> Result<()> {
match command {
ContainerCommands::Build {
force,
tag,
dockerfile,
} => build(force, tag.as_deref(), dockerfile.as_deref()),
ContainerCommands::Start {
worktree,
name,
prompt,
issue,
memory,
} => {
let path = PathBuf::from(&worktree);
start(
&path,
name.as_deref(),
prompt.as_deref(),
issue,
memory.as_deref(),
)
}
ContainerCommands::Ps => ps(),
ContainerCommands::Logs { name, follow, tail } => logs(&name, follow, tail),
ContainerCommands::Stop { name } => stop(&name),
ContainerCommands::Rm { name } => rm(&name),
ContainerCommands::Kill { name } => kill(&name),
ContainerCommands::Shell { name } => shell(&name),
ContainerCommands::Snapshot { name, tag } => snapshot(&name, tag.as_deref()),
}
}
const IMAGE_NAME: &str = "crosslink-agent";
const IMAGE_TAG: &str = "latest";
const CONTAINER_PREFIX: &str = "crosslink-task-";
const LABEL_AGENT: &str = "crosslink-agent=true";
const DOCKERFILE: &str = include_str!("../../resources/container/Dockerfile");
const ENTRYPOINT: &str = include_str!("../../resources/container/entrypoint.sh");
pub fn docker_available() -> bool {
Command::new("docker")
.args(["info"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|s| s.success())
}
fn find_crosslink_binary() -> Result<PathBuf> {
std::env::current_exe().context("Could not determine crosslink binary path")
}
fn file_hash(path: &Path) -> Result<String> {
use std::io::Read;
let mut file = std::fs::File::open(path)?;
let mut buf = vec![0u8; 65536];
let n = file.read(&mut buf)?;
buf.truncate(n);
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for &byte in &buf {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(0x0100_0000_01b3);
}
Ok(format!("{hash:016x}"))
}
fn resolve_repo_root() -> Result<PathBuf> {
let output = Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.output()
.context("Failed to run git rev-parse")?;
if !output.status.success() {
bail!("Not in a git repository");
}
let path = String::from_utf8(output.stdout)?.trim().to_string();
Ok(PathBuf::from(path))
}
fn resolve_git_common_dir() -> Result<PathBuf> {
let output = Command::new("git")
.args(["rev-parse", "--git-common-dir"])
.output()
.context("Failed to run git rev-parse --git-common-dir")?;
if !output.status.success() {
bail!("Not in a git repository");
}
let path_str = String::from_utf8(output.stdout)?.trim().to_string();
let path = PathBuf::from(&path_str);
if path.is_absolute() {
Ok(path)
} else {
let cwd = std::env::current_dir()?;
Ok(cwd.join(path).canonicalize()?)
}
}
fn detect_host_memory_gb() -> Option<u64> {
if let Ok(content) = std::fs::read_to_string("/proc/meminfo") {
for line in content.lines() {
if line.starts_with("MemTotal:") {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 {
if let Ok(kb) = parts[1].parse::<u64>() {
return Some(kb / 1024 / 1024);
}
}
}
}
}
let output = Command::new("sysctl")
.args(["-n", "hw.memsize"])
.output()
.ok()?;
if output.status.success() {
let bytes_str = String::from_utf8(output.stdout).ok()?.trim().to_string();
let bytes: u64 = bytes_str.parse().ok()?;
return Some(bytes / 1024 / 1024 / 1024);
}
None
}
fn compute_memory_limit(config_override: Option<&str>) -> String {
if let Some(val) = config_override {
if val != "auto" {
return val.to_string();
}
}
detect_host_memory_gb().map_or_else(
|| "8g".to_string(), |host_gb| {
let container_gb = if host_gb > 6 {
host_gb - 2
} else {
4.max(host_gb)
};
format!("{container_gb}g")
},
)
}
fn get_image_hash() -> Option<String> {
let output = Command::new("docker")
.args([
"inspect",
"--format",
"{{index .Config.Labels \"crosslink-binary-hash\"}}",
&format!("{IMAGE_NAME}:{IMAGE_TAG}"),
])
.output()
.ok()?;
if output.status.success() {
let hash = String::from_utf8(output.stdout).ok()?.trim().to_string();
if !hash.is_empty() && hash != "<no value>" {
return Some(hash);
}
}
None
}
fn check_staleness() {
let Ok(binary_hash) = find_crosslink_binary().and_then(|p| file_hash(&p)) else {
return;
};
if let Some(image_hash) = get_image_hash() {
if image_hash != binary_hash {
tracing::warn!(
"container image is stale (built from a different crosslink binary). Run 'crosslink container build' to update."
);
}
}
}
struct BuildDirCleanup(PathBuf);
impl Drop for BuildDirCleanup {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
pub fn build(force: bool, tag: Option<&str>, dockerfile: Option<&str>) -> Result<()> {
if !docker_available() {
bail!("Docker is not available. Install Docker and ensure the daemon is running.");
}
let tag = tag.unwrap_or(IMAGE_TAG);
let image = format!("{IMAGE_NAME}:{tag}");
let build_path =
std::env::temp_dir().join(format!("crosslink-container-build-{}", std::process::id()));
std::fs::create_dir_all(&build_path).context("Failed to create temp build directory")?;
let _cleanup = BuildDirCleanup(build_path.clone());
let dockerfile_content = if let Some(path) = dockerfile {
std::fs::read_to_string(path)
.with_context(|| format!("Failed to read custom Dockerfile: {path}"))?
} else {
DOCKERFILE.to_string()
};
std::fs::write(build_path.join("Dockerfile"), &dockerfile_content)?;
std::fs::write(build_path.join("entrypoint.sh"), ENTRYPOINT)?;
let binary = find_crosslink_binary()?;
std::fs::copy(&binary, build_path.join("crosslink"))
.context("Failed to copy crosslink binary to build context")?;
let binary_hash = file_hash(&binary).unwrap_or_else(|_| "unknown".to_string());
println!("Building container image: {image}");
let mut cmd = Command::new("docker");
cmd.args(["build", "-t", &image]);
cmd.args(["--label", LABEL_AGENT]);
cmd.args(["--label", &format!("crosslink-binary-hash={binary_hash}")]);
if force {
cmd.arg("--no-cache");
}
cmd.arg(".");
cmd.current_dir(build_path);
let status = cmd.status().context("Failed to run docker build")?;
if !status.success() {
bail!("Docker build failed");
}
println!("Image built successfully: {image}");
println!("Binary hash: {binary_hash}");
Ok(())
}
pub fn start(
worktree_path: &Path,
name: Option<&str>,
prompt_file: Option<&str>,
issue_id: Option<i64>,
memory: Option<&str>,
) -> Result<()> {
if !docker_available() {
bail!("Docker is not available. Install Docker and ensure the daemon is running.");
}
check_staleness();
let worktree_abs = std::fs::canonicalize(worktree_path)
.with_context(|| format!("Worktree not found: {}", worktree_path.display()))?;
let worktree_slug = worktree_abs
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown");
let container_name = name.map_or_else(
|| format!("{CONTAINER_PREFIX}{worktree_slug}"),
ToString::to_string,
);
let git_common_dir = resolve_git_common_dir()?;
let repo_root = resolve_repo_root()?;
let hub_cache = repo_root.join(".crosslink").join(".hub-cache");
let prompt_path = prompt_file.map_or_else(|| worktree_abs.join("KICKOFF.md"), PathBuf::from);
if !prompt_path.exists() {
bail!(
"Prompt file not found: {}. Write a KICKOFF.md in the worktree first.",
prompt_path.display()
);
}
let prompt = std::fs::read_to_string(&prompt_path).context("Failed to read prompt file")?;
let home = std::env::var("HOME").unwrap_or_else(|_| "/home/user".to_string());
let credentials_path = PathBuf::from(&home)
.join(".claude")
.join(".credentials.json");
if !credentials_path.exists() {
bail!(
"Claude credentials not found at {}. Run 'claude' to authenticate first.",
credentials_path.display()
);
}
let memory_limit = compute_memory_limit(memory);
let agent_id = format!("container--{worktree_slug}");
let image = format!("{IMAGE_NAME}:{IMAGE_TAG}");
println!("Starting task container: {container_name}");
println!(" Worktree: {}", worktree_abs.display());
println!(" Memory: {memory_limit}");
println!(" Agent: {agent_id}");
let mut cmd = Command::new("docker");
cmd.args(["run", "-d"]);
cmd.args(["--name", &container_name]);
cmd.args(["--label", LABEL_AGENT]);
cmd.args(["--label", &format!("crosslink-task={worktree_slug}")]);
if let Some(id) = issue_id {
cmd.args(["--label", &format!("crosslink-issue={id}")]);
}
cmd.args(["--memory", &memory_limit]);
cmd.args([
"-v",
&format!("{}:/workspaces/{}", worktree_abs.display(), worktree_slug),
]);
cmd.args(["-v", &format!("{}:/repo/.git:rw", git_common_dir.display())]);
let dot_git_path = worktree_abs.join(".git");
if dot_git_path.is_file() {
let fixup_dir = worktree_abs.join(".crosslink").join("container-git-fixup");
std::fs::create_dir_all(&fixup_dir).context("Failed to create git fixup dir")?;
let container_workspace = format!("/workspaces/{worktree_slug}");
let container_gitdir = format!("/repo/.git/worktrees/{worktree_slug}");
let override_dot_git = fixup_dir.join("dot-git");
std::fs::write(&override_dot_git, format!("gitdir: {container_gitdir}\n"))?;
let override_gitdir = fixup_dir.join("gitdir");
std::fs::write(&override_gitdir, format!("{container_workspace}/.git\n"))?;
cmd.args([
"-v",
&format!(
"{}:{}/.git:ro",
override_dot_git.display(),
container_workspace
),
]);
cmd.args([
"-v",
&format!(
"{}:{}/gitdir:ro",
override_gitdir.display(),
container_gitdir
),
]);
}
if hub_cache.exists() {
cmd.args([
"-v",
&format!("{}:/repo/.crosslink/.hub-cache:rw", hub_cache.display()),
]);
}
cmd.args([
"-v",
&format!(
"{}:/host-auth/.credentials.json:ro",
credentials_path.display()
),
]);
cmd.args(["-e", &format!("AGENT_ID={agent_id}")]);
cmd.args(["-e", "CLAUDE_CONFIG_DIR=/home/agent/.claude"]);
if let Ok(uid_output) = Command::new("id").arg("-u").output() {
if uid_output.status.success() {
let uid = String::from_utf8_lossy(&uid_output.stdout)
.trim()
.to_string();
cmd.args(["-e", &format!("HOST_UID={uid}")]);
}
}
if let Ok(gid_output) = Command::new("id").arg("-g").output() {
if gid_output.status.success() {
let gid = String::from_utf8_lossy(&gid_output.stdout)
.trim()
.to_string();
cmd.args(["-e", &format!("HOST_GID={gid}")]);
}
}
cmd.arg(&image);
cmd.args([
"claude",
"--dangerously-skip-permissions",
"--model",
"opus",
"--",
&prompt,
]);
let output = cmd.output().context("Failed to start container")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Failed to start container: {}", stderr.trim());
}
let container_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
println!(
" Container ID: {}...",
&container_id[..12.min(container_id.len())]
);
let id_file = worktree_abs.join(".crosslink").join("container-id");
if let Some(parent) = id_file.parent() {
std::fs::create_dir_all(parent).ok();
}
std::fs::write(&id_file, &container_id).ok();
println!();
println!("Task container started.");
println!(" Check status: crosslink container ps");
println!(" View logs: crosslink container logs {container_name}");
println!(" Shell in: crosslink container shell {container_name}");
println!(" Stop: crosslink container stop {container_name}");
Ok(())
}
pub fn ps() -> Result<()> {
if !docker_available() {
bail!("Docker is not available.");
}
let output = Command::new("docker")
.args([
"ps",
"-a",
"--filter",
&format!("label={LABEL_AGENT}"),
"--format",
"table {{.Names}}\t{{.Status}}\t{{.Label \"crosslink-task\"}}\t{{.Label \"crosslink-issue\"}}",
])
.output()
.context("Failed to list containers")?;
if !output.status.success() {
bail!("Failed to list containers");
}
let stdout = String::from_utf8_lossy(&output.stdout);
if stdout.trim().is_empty() || stdout.lines().count() <= 1 {
println!("No crosslink task containers found.");
} else {
print!("{stdout}");
}
Ok(())
}
pub fn logs(name: &str, follow: bool, tail: Option<u32>) -> Result<()> {
if !docker_available() {
bail!("Docker is not available.");
}
let mut cmd = Command::new("docker");
cmd.args(["logs"]);
if follow {
cmd.arg("--follow");
}
let tail_str = tail.unwrap_or(100).to_string();
cmd.args(["--tail", &tail_str]);
cmd.arg(name);
let status = cmd.status().context("Failed to read container logs")?;
if !status.success() {
bail!("Failed to read logs for container '{name}'. Does it exist?");
}
Ok(())
}
pub fn stop(name: &str) -> Result<()> {
if !docker_available() {
bail!("Docker is not available.");
}
println!("Stopping container: {name}");
let status = Command::new("docker")
.args(["stop", name])
.status()
.context("Failed to stop container")?;
if !status.success() {
bail!("Failed to stop container '{name}'");
}
println!("Container stopped.");
Ok(())
}
pub fn rm(name: &str) -> Result<()> {
if !docker_available() {
bail!("Docker is not available.");
}
println!("Removing container: {name}");
let status = Command::new("docker")
.args(["rm", name])
.status()
.context("Failed to remove container")?;
if !status.success() {
bail!("Failed to remove container '{name}'");
}
println!("Container removed.");
Ok(())
}
pub fn kill(name: &str) -> Result<()> {
if !docker_available() {
bail!("Docker is not available.");
}
println!("Stopping and removing container: {name}");
let _ = Command::new("docker").args(["stop", name]).status();
let status = Command::new("docker")
.args(["rm", "-f", name])
.status()
.context("Failed to remove container")?;
if !status.success() {
bail!("Failed to remove container '{name}'");
}
println!("Container removed.");
Ok(())
}
pub fn shell(name: &str) -> Result<()> {
if !docker_available() {
bail!("Docker is not available.");
}
let status = Command::new("docker")
.args(["exec", "-it", name, "/bin/bash"])
.status()
.context("Failed to exec into container")?;
if !status.success() {
bail!("Shell exited with error");
}
Ok(())
}
pub fn snapshot(name: &str, tag: Option<&str>) -> Result<()> {
if !docker_available() {
bail!("Docker is not available.");
}
let tag = tag.unwrap_or("cached");
let image = format!("{IMAGE_NAME}:{tag}");
println!("Snapshotting container '{name}' as '{image}'...");
let status = Command::new("docker")
.args(["commit", name, &image])
.status()
.context("Failed to snapshot container")?;
if !status.success() {
bail!("Failed to snapshot container '{name}'");
}
println!("Snapshot saved: {image}");
println!("Use with: crosslink container start --image {image}");
Ok(())
}