🥇 export from upstream (be15b0d)

This commit is contained in:
sandcage-export
2026-05-21 23:15:51 +02:00
commit 29225e08fb
19 changed files with 3254 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
*.md text eol=lf
*.rs text eol=lf
*.py text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.toml text eol=lf
+33
View File
@@ -0,0 +1,33 @@
# Generated by Cargo
# will have compiled files and executables
/target
.DS_Store
.env
# Patterns
*.rs.bk
*.tmp
*.bak
*.pyc
*.pyo
__pycache__
*.log
*.pdb
# Python / uv
.venv/
# Export worktrees
.export-worktrees/
# Build pipelines and scratch
/dist/
/local/**
# Misc
/.vscode/*
.obsidian
settings.local.json
.claude/worktrees/
CLAUDE.local.md
+1
View File
@@ -0,0 +1 @@
3.13
Generated
+1110
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "sandcage"
version = "0.1.0"
edition = "2024"
description = "Sandboxed containers for AI coding agents"
license = "MIT"
keywords = ["docker", "sandbox", "ai", "agent"]
[dependencies]
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_yaml = "0.9"
figment = { version = "0.10", features = ["toml", "env"] }
miette = { version = "7", features = ["fancy"] }
thiserror = "2"
which = "7"
dirs = "6"
sha2 = "0.10"
hex = "0.4"
tempfile = "3"
+88
View File
@@ -0,0 +1,88 @@
# Sandcage
Sandcage runs AI coding agents (Claude Code, Codex) in isolated Docker containers. Each agent gets a full development environment with your project mounted as a workspace, while your host session and credentials stay private.
## Why
Running AI agents directly on your machine means they share your shell, your credentials, and your session history. Sandcage gives each agent its own container with the tools it needs, while keeping your host environment untouched.
Agents in different containers can still see each other's work through shared sandbox state (~/.sandcage/), enabling session handoffs between agents working on different branches or worktrees.
## Quick Start
### Prerequisites
- Docker (daemon must be running)
- Rust toolchain (cargo) — or download a prebuilt binary from [Releases](https://github.com/dirigence/sandcage/releases)
### Install
```bash
cargo install --git https://github.com/dirigence/sandcage
```
Or from a local checkout:
```bash
cargo install --path .
```
### Build the images
```bash
sandcage build
```
This builds three images: `sandcage-base`, `sandcage-claude`, and `sandcage-codex`. Images whose Dockerfile hasn't changed are skipped automatically. Use `--force` to rebuild unconditionally.
### Run an agent
```bash
sandcage claude # Claude Code in current directory
sandcage codex ~/project # Codex in a specific project
sandcage shell # interactive shell, same environment
```
The workspace is resolved to the git repo root automatically. Inside a git worktree, the worktree root is used instead.
### Initialize a project
```bash
sandcage init
```
Detects the language ecosystem (Rust, Node, Python, Go) and generates a `.sandcage.yml` with suggested configuration.
## Configuration
Configuration is layered: compiled defaults → `~/.sandcage/config.toml``.sandcage.yml` → CLI flags
### Project configuration (.sandcage.yml)
```yaml
env:
DATABASE_URL: "postgres://localhost:5432/dev"
packages:
- ripgrep
- fd-find
toolchains:
rust: "stable"
node: "20"
mounts:
- /data/models:/models:ro
shell: zsh
```
## Architecture
### Images (3-tier)
| Image | Base | Adds |
|-------|------|------|
| sandcage-base | Debian bookworm-slim | git, ripgrep, fd, jq, curl, zsh, bash, sudo, just, uv |
| sandcage-claude | sandcage-base | Claude Code CLI |
| sandcage-codex | sandcage-base | Codex binary (multi-arch) |
## License
MIT
+41
View File
@@ -0,0 +1,41 @@
services:
claude:
build: ../images/claude
working_dir: /workspace
user: "${SANDCAGE_UID}:${SANDCAGE_GID}"
volumes:
- ${SANDCAGE_WORKSPACE}:/workspace
- ${SANDCAGE_HOME}/.claude:/home/agent/.claude
- ${SANDCAGE_GLOBAL_JUSTFILE}:/home/agent/.justfile:ro
environment:
- HOME=/home/agent
tty: true
stdin_open: true
codex:
build: ../images/codex
working_dir: /workspace
user: "${SANDCAGE_UID}:${SANDCAGE_GID}"
volumes:
- ${SANDCAGE_WORKSPACE}:/workspace
- ${SANDCAGE_HOME}/.codex:/home/agent/.codex
- ${SANDCAGE_GLOBAL_JUSTFILE}:/home/agent/.justfile:ro
environment:
- HOME=/home/agent
tty: true
stdin_open: true
shell:
build: ../images/base
working_dir: /workspace
user: "${SANDCAGE_UID}:${SANDCAGE_GID}"
volumes:
- ${SANDCAGE_WORKSPACE}:/workspace
- ${SANDCAGE_HOME}/.claude:/home/agent/.claude
- ${SANDCAGE_HOME}/.codex:/home/agent/.codex
- ${SANDCAGE_GLOBAL_JUSTFILE}:/home/agent/.justfile:ro
environment:
- HOME=/home/agent
tty: true
stdin_open: true
entrypoint: ["/bin/zsh"]
+6
View File
@@ -0,0 +1,6 @@
def main():
print("Hello from sandcage!")
if __name__ == "__main__":
main()
+11
View File
@@ -0,0 +1,11 @@
[project]
name = "sandcage"
version = "0.1.0"
description = "Tooling and scripts for the Sandcage project"
requires-python = ">=3.12"
dependencies = []
[dependency-groups]
dev = [
"pytest>=9.0.3",
]
View File
View File
+186
View File
@@ -0,0 +1,186 @@
"""Export sandcage repo to a public standalone repository.
Usage:
uv run python scripts/export.py push [--dry-run] [--remote URL]
"""
from __future__ import annotations
import argparse
import contextlib
import os
import shutil
import subprocess
import sys
from dataclasses import dataclass, field
from pathlib import Path
try:
import tomllib
except ModuleNotFoundError:
import tomli as tomllib # type: ignore[no-redef]
SCRIPT_DIR = Path(__file__).resolve().parent
REPO_ROOT = SCRIPT_DIR.parent
CONFIG_PATH = SCRIPT_DIR / "export.toml"
@dataclass(frozen=True)
class ExportConfig:
branch: str
remote_env: str
assets: Path
exclude: list[str] = field(default_factory=list)
def load_config(path: Path = CONFIG_PATH) -> ExportConfig:
with open(path, "rb") as f:
raw = tomllib.load(f)
entry = raw["export"]
return ExportConfig(
branch=entry["branch"],
remote_env=entry["remote_env"],
assets=REPO_ROOT / entry["assets"],
exclude=entry.get("exclude", []),
)
def load_asset_bundle(assets_dir: Path) -> dict[str, bytes]:
bundle: dict[str, bytes] = {}
if not assets_dir.exists():
return bundle
for path in assets_dir.rglob("*"):
if path.is_file():
rel = path.relative_to(assets_dir).as_posix()
bundle[rel] = path.read_bytes()
return bundle
def materialize_assets(target: Path, bundle: dict[str, bytes]) -> None:
for relpath, content in bundle.items():
dst = target / relpath
dst.parent.mkdir(parents=True, exist_ok=True)
dst.write_bytes(content)
def remove_excluded_paths(target: Path, exclude: list[str]) -> None:
for pattern in exclude:
path = target / pattern
if path.is_dir():
shutil.rmtree(path)
elif path.is_file():
path.unlink()
def _run(cmd: list[str], cwd: Path, check: bool = True) -> subprocess.CompletedProcess:
result = subprocess.run(cmd, cwd=cwd, check=False, text=True, capture_output=True)
if check and result.returncode != 0:
detail = (result.stderr or result.stdout or "").strip()
if detail:
print(f" stderr: {detail}", file=sys.stderr)
raise SystemExit(
f"error: command failed (exit {result.returncode}): {' '.join(cmd)}"
)
return result
@contextlib.contextmanager
def export_worktree(repo_root: Path):
wt_path = repo_root / ".export-worktrees" / "sandcage"
wt_path.parent.mkdir(parents=True, exist_ok=True)
_run(["git", "worktree", "prune"], cwd=repo_root, check=False)
_run(["git", "worktree", "remove", "--force", str(wt_path)],
cwd=repo_root, check=False)
if wt_path.exists():
shutil.rmtree(wt_path, ignore_errors=True)
_run(["git", "worktree", "add", "--detach", str(wt_path), "HEAD"],
cwd=repo_root)
try:
yield wt_path
finally:
_run(["git", "worktree", "remove", "--force", str(wt_path)],
cwd=repo_root, check=False)
if wt_path.exists():
shutil.rmtree(wt_path, ignore_errors=True)
_run(["git", "worktree", "prune"], cwd=repo_root, check=False)
def _git_push(remote: str, branch: str, target: str, cwd: Path) -> None:
fetch = _run(["git", "fetch", remote, target], cwd=cwd, check=False)
if fetch.returncode == 0:
expected = _run(["git", "rev-parse", "FETCH_HEAD"], cwd=cwd).stdout.strip()
_run(["git", "push", f"--force-with-lease={target}:{expected}",
remote, f"{branch}:{target}"], cwd=cwd)
else:
_run(["git", "push", remote, f"{branch}:{target}"], cwd=cwd)
def run_export(*, repo_root: Path, cfg: ExportConfig,
remote: str | None, dry_run: bool) -> None:
if not dry_run and remote is None:
raise SystemExit(
f"error: remote required for push (set {cfg.remote_env} or pass --remote)"
)
bundle = load_asset_bundle(cfg.assets)
source_sha = _run(
["git", "rev-parse", "--short", "HEAD"], cwd=repo_root,
).stdout.strip()
with export_worktree(repo_root) as worktree:
branch_exists = _run(
["git", "rev-parse", "--verify", cfg.branch],
cwd=repo_root, check=False,
).returncode == 0
if branch_exists:
_run(["git", "checkout", cfg.branch], cwd=worktree)
_run(["git", "rm", "-rf", "."], cwd=worktree, check=False)
_run(["git", "checkout", source_sha, "--", "."], cwd=worktree)
else:
_run(["git", "checkout", "--orphan", cfg.branch], cwd=worktree)
remove_excluded_paths(worktree, cfg.exclude)
materialize_assets(worktree, bundle)
_run(["git", "add", "-A"], cwd=worktree)
diff = _run(["git", "diff", "--cached", "--quiet"],
cwd=worktree, check=False)
if diff.returncode != 0:
_run(["git", "-c", "user.email=export@sandcage", "-c", "user.name=sandcage-export",
"commit", "-m",
f"\U0001f947 export from upstream ({source_sha})"],
cwd=worktree)
if dry_run:
print(f"dry-run: skipping push. Branch '{cfg.branch}' is ready locally.")
return
assert remote is not None
print(f"force-with-lease push {cfg.branch} -> {remote}:main")
_git_push(remote, cfg.branch, "main", worktree)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
prog="export", description="Export sandcage to public repo",
)
sub = parser.add_subparsers(dest="cmd", required=True)
p_push = sub.add_parser("push", help="export to standalone repo")
p_push.add_argument("--remote", default=None)
p_push.add_argument("--dry-run", action="store_true")
args = parser.parse_args(argv)
cfg = load_config()
if args.cmd == "push":
remote = args.remote or os.environ.get(cfg.remote_env)
run_export(repo_root=REPO_ROOT, cfg=cfg, remote=remote, dry_run=args.dry_run)
return 0
if __name__ == "__main__":
sys.exit(main())
+480
View File
@@ -0,0 +1,480 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use figment::providers::{Format, Serialized, Toml};
use figment::Figment;
use miette::Diagnostic;
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Error, Diagnostic)]
pub enum ConfigError {
#[error("Failed to read config file {0}: {1}")]
#[diagnostic(code(sandcage::config::read_failed))]
ReadFailed(PathBuf, #[source] std::io::Error),
#[error("Failed to parse YAML in {0}: {1}")]
#[diagnostic(code(sandcage::config::parse_failed))]
ParseFailed(PathBuf, #[source] serde_yaml::Error),
#[error("Failed to parse YAML: {0}")]
#[diagnostic(code(sandcage::config::parse_failed_str))]
ParseFailedStr(#[source] serde_yaml::Error),
#[error("Failed to merge configuration layers: {0}")]
#[diagnostic(code(sandcage::config::merge_failed))]
MergeFailed(#[source] figment::Error),
}
pub type Result<T> = std::result::Result<T, ConfigError>;
// ---------------------------------------------------------------------------
// Raw deserialization type — captures unknown keys
// ---------------------------------------------------------------------------
/// Intermediate representation that keeps leftover keys so we can warn about them.
#[derive(Debug, Deserialize)]
struct RawConfig {
#[serde(default)]
env: Option<HashMap<String, String>>,
#[serde(default)]
packages: Option<Vec<String>>,
#[serde(default)]
toolchains: Option<HashMap<String, String>>,
#[serde(default)]
mounts: Option<Vec<String>>,
#[serde(default)]
shell: Option<String>,
#[serde(default)]
justfile: Option<PathBuf>,
/// Absorb everything else so we can warn about unknown fields.
#[serde(flatten)]
extra: HashMap<String, serde_yaml::Value>,
}
// ---------------------------------------------------------------------------
// Public config struct
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct SandcageConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub env: Option<HashMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub packages: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub toolchains: Option<HashMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mounts: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub shell: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub justfile: Option<PathBuf>,
}
// ---------------------------------------------------------------------------
// Known keys — used to filter the flattened `extra` map
// ---------------------------------------------------------------------------
const KNOWN_KEYS: &[&str] = &["env", "packages", "toolchains", "mounts", "shell", "justfile"];
// ---------------------------------------------------------------------------
// Validation helpers
// ---------------------------------------------------------------------------
/// Returns `true` if a mount string is well-formed (must contain `:`).
fn is_valid_mount(mount: &str) -> bool {
mount.contains(':')
}
/// Validate and warn; does not return an error — warnings are non-fatal.
fn validate_and_warn(raw: &RawConfig) {
// Warn about unknown top-level keys
for key in raw.extra.keys() {
if !KNOWN_KEYS.contains(&key.as_str()) {
eprintln!(
"sandcage: warning: unknown config key '{key}' will be ignored. \
Known keys: {}",
KNOWN_KEYS.join(", ")
);
}
}
// Validate mount strings
if let Some(mounts) = &raw.mounts {
for mount in mounts {
if !is_valid_mount(mount) {
eprintln!(
"sandcage: warning: mount '{mount}' is missing ':' separator. \
Expected format: <source>:<target> or <source>:<target>:<options>"
);
}
}
}
}
// ---------------------------------------------------------------------------
// Conversion from raw to public struct
// ---------------------------------------------------------------------------
fn from_raw(raw: RawConfig) -> SandcageConfig {
SandcageConfig {
env: raw.env,
packages: raw.packages,
toolchains: raw.toolchains,
mounts: raw.mounts,
shell: raw.shell,
justfile: raw.justfile,
}
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/// Parse a `.sandcage.yml` from the given path.
pub fn load(path: &Path) -> Result<SandcageConfig> {
let content = std::fs::read_to_string(path)
.map_err(|e| ConfigError::ReadFailed(path.to_path_buf(), e))?;
let raw: RawConfig = serde_yaml::from_str(&content)
.map_err(|e| ConfigError::ParseFailed(path.to_path_buf(), e))?;
validate_and_warn(&raw);
Ok(from_raw(raw))
}
/// Parse a `.sandcage.yml` from an in-memory string (useful for testing).
pub fn load_from_str(content: &str) -> Result<SandcageConfig> {
// Empty / whitespace-only content → all fields None
if content.trim().is_empty() {
return Ok(SandcageConfig::default());
}
let raw: RawConfig =
serde_yaml::from_str(content).map_err(ConfigError::ParseFailedStr)?;
validate_and_warn(&raw);
Ok(from_raw(raw))
}
// ---------------------------------------------------------------------------
// Layered config resolution via figment
// ---------------------------------------------------------------------------
/// Resolve the final `SandcageConfig` by merging all configuration layers in order:
///
/// 1. Compiled defaults (all `None`)
/// 2. Global config from `global_config_path` (TOML, e.g. `~/.sandcage/config.toml`)
/// 3. Project config from `project_config_path` (YAML, e.g. `.sandcage.yml`)
///
/// Later layers win: project values override global values, which override defaults.
///
/// Pass `None` for either path to skip that layer (e.g. when the file doesn't exist).
pub fn resolve_config(
global_config_path: Option<&Path>,
project_config_path: Option<&Path>,
) -> Result<SandcageConfig> {
// Start with compiled defaults — all fields None.
let mut figment = Figment::from(Serialized::defaults(SandcageConfig::default()));
// Layer 2: global TOML config (if provided).
if let Some(global_path) = global_config_path {
if global_path.exists() {
figment = figment.merge(Toml::file(global_path));
}
}
// Layer 3: project YAML config (if provided).
// figment has no built-in YAML provider, so we parse with serde_yaml first
// and inject via Serialized.
if let Some(project_path) = project_config_path {
if project_path.exists() {
let project_cfg = load(project_path)?;
figment = figment.merge(Serialized::defaults(project_cfg));
}
}
figment.extract().map_err(ConfigError::MergeFailed)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
const FULL_CONFIG: &str = r#"
env:
RUST_LOG: debug
NODE_ENV: development
packages:
- ripgrep
- fd-find
toolchains:
rust: "1.78"
node: "20"
mounts:
- /data/models:/models:ro
- /tmp/cache:/cache
shell: zsh
justfile: ./project.justfile
"#;
// -----------------------------------------------------------------------
// Happy-path tests
// -----------------------------------------------------------------------
#[test]
fn parse_full_config() {
let cfg = load_from_str(FULL_CONFIG).expect("parse full config");
// env
let env = cfg.env.expect("env should be Some");
assert_eq!(env.get("RUST_LOG").map(String::as_str), Some("debug"));
assert_eq!(env.get("NODE_ENV").map(String::as_str), Some("development"));
// packages
let pkgs = cfg.packages.expect("packages should be Some");
assert_eq!(pkgs, vec!["ripgrep", "fd-find"]);
// toolchains
let tc = cfg.toolchains.expect("toolchains should be Some");
assert_eq!(tc.get("rust").map(String::as_str), Some("1.78"));
assert_eq!(tc.get("node").map(String::as_str), Some("20"));
// mounts
let mounts = cfg.mounts.expect("mounts should be Some");
assert_eq!(mounts[0], "/data/models:/models:ro");
assert_eq!(mounts[1], "/tmp/cache:/cache");
// shell
assert_eq!(cfg.shell.as_deref(), Some("zsh"));
// justfile
assert_eq!(cfg.justfile, Some(PathBuf::from("./project.justfile")));
}
#[test]
fn parse_minimal_config_only_env() {
let yaml = "env:\n KEY: value\n";
let cfg = load_from_str(yaml).expect("parse minimal config");
let env = cfg.env.expect("env should be Some");
assert_eq!(env.get("KEY").map(String::as_str), Some("value"));
assert!(cfg.packages.is_none());
assert!(cfg.toolchains.is_none());
assert!(cfg.mounts.is_none());
assert!(cfg.shell.is_none());
assert!(cfg.justfile.is_none());
}
#[test]
fn parse_empty_file_all_fields_none() {
let cfg = load_from_str("").expect("parse empty file");
assert!(cfg.env.is_none());
assert!(cfg.packages.is_none());
assert!(cfg.toolchains.is_none());
assert!(cfg.mounts.is_none());
assert!(cfg.shell.is_none());
assert!(cfg.justfile.is_none());
}
#[test]
fn parse_whitespace_only_file() {
let cfg = load_from_str(" \n\n \t\n").expect("parse whitespace-only file");
assert!(cfg.env.is_none());
}
#[test]
fn load_from_file() {
let mut tmp = NamedTempFile::new().expect("create tmpfile");
write!(tmp, "{FULL_CONFIG}").expect("write tmpfile");
let cfg = load(tmp.path()).expect("load from file");
assert!(cfg.env.is_some());
}
#[test]
fn load_missing_file_returns_error() {
let result = load(Path::new("/nonexistent/path/.sandcage.yml"));
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, ConfigError::ReadFailed(..)));
}
// -----------------------------------------------------------------------
// Error / warning path tests
// -----------------------------------------------------------------------
#[test]
fn invalid_yaml_returns_parse_error() {
let bad_yaml = "env: [this: is: not: valid\n yaml:";
let result = load_from_str(bad_yaml);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
matches!(err, ConfigError::ParseFailedStr(..)),
"expected ParseFailedStr, got: {err:?}"
);
}
#[test]
fn unknown_keys_are_absorbed_without_hard_error() {
// Unknown keys should not cause a parse failure — they emit warnings.
let yaml = "env:\n KEY: val\nunknown_field: surprise\n";
let result = load_from_str(yaml);
assert!(
result.is_ok(),
"unknown keys should not cause a hard error: {result:?}"
);
let cfg = result.unwrap();
// Legitimate fields still parsed
assert!(cfg.env.is_some());
}
#[test]
fn invalid_mount_no_colon_does_not_hard_error() {
// A mount without ':' is a warning, not an error.
let yaml = "mounts:\n - /data/models\n - /valid:/mount\n";
let result = load_from_str(yaml);
assert!(result.is_ok(), "invalid mount should not be a hard error");
let cfg = result.unwrap();
let mounts = cfg.mounts.unwrap();
assert_eq!(mounts.len(), 2);
}
#[test]
fn is_valid_mount_helper() {
assert!(is_valid_mount("/src:/dst"));
assert!(is_valid_mount("/src:/dst:ro"));
assert!(!is_valid_mount("/no_colon_here"));
assert!(!is_valid_mount(""));
}
#[test]
fn all_valid_mounts_are_accepted() {
let yaml = "mounts:\n - /data:/mnt/data\n - /tmp:/tmp:ro\n";
let cfg = load_from_str(yaml).unwrap();
let mounts = cfg.mounts.unwrap();
assert!(mounts.iter().all(|m| is_valid_mount(m)));
}
#[test]
fn extra_fields_detected() {
// Verify the `extra` map in RawConfig captures unknown keys correctly.
let yaml = "env:\n A: 1\nfoo: bar\nbaz: 42\n";
let raw: RawConfig = serde_yaml::from_str(yaml).unwrap();
assert!(raw.extra.contains_key("foo"), "extra should contain 'foo'");
assert!(raw.extra.contains_key("baz"), "extra should contain 'baz'");
assert!(!raw.extra.contains_key("env"), "env should not be in extra");
}
// -----------------------------------------------------------------------
// resolve_config layered merge tests
// -----------------------------------------------------------------------
#[test]
fn resolve_defaults_only_all_none() {
let cfg = resolve_config(None, None).expect("resolve with no files");
assert!(cfg.env.is_none());
assert!(cfg.packages.is_none());
assert!(cfg.toolchains.is_none());
assert!(cfg.mounts.is_none());
assert!(cfg.shell.is_none());
assert!(cfg.justfile.is_none());
}
#[test]
fn resolve_global_config_only() {
let toml_content = r#"
shell = "zsh"
[env]
EDITOR = "vim"
"#;
let mut global_tmp = NamedTempFile::new().expect("create global tmpfile");
write!(global_tmp, "{toml_content}").expect("write global tmpfile");
let cfg = resolve_config(Some(global_tmp.path()), None).expect("resolve global only");
assert_eq!(cfg.shell.as_deref(), Some("zsh"));
let env = cfg.env.expect("env should be Some from global");
assert_eq!(env.get("EDITOR").map(String::as_str), Some("vim"));
assert!(cfg.packages.is_none());
}
#[test]
fn resolve_project_config_only() {
let yaml_content = "shell: bash\npackages:\n - ripgrep\n";
let mut project_tmp = NamedTempFile::new().expect("create project tmpfile");
write!(project_tmp, "{yaml_content}").expect("write project tmpfile");
let cfg = resolve_config(None, Some(project_tmp.path())).expect("resolve project only");
assert_eq!(cfg.shell.as_deref(), Some("bash"));
let pkgs = cfg.packages.expect("packages should be Some");
assert_eq!(pkgs, vec!["ripgrep"]);
assert!(cfg.env.is_none());
}
#[test]
fn resolve_project_overrides_global() {
// Global sets shell=zsh; project sets shell=bash — project wins.
let global_toml = r#"shell = "zsh""#;
let project_yaml = "shell: bash\n";
let mut global_tmp = NamedTempFile::new().expect("create global tmpfile");
write!(global_tmp, "{global_toml}").expect("write global tmpfile");
let mut project_tmp = NamedTempFile::new().expect("create project tmpfile");
write!(project_tmp, "{project_yaml}").expect("write project tmpfile");
let cfg = resolve_config(Some(global_tmp.path()), Some(project_tmp.path()))
.expect("resolve both");
assert_eq!(cfg.shell.as_deref(), Some("bash"), "project shell should override global");
}
#[test]
fn resolve_partial_overlap_fields_merge() {
// Global sets shell + env; project sets packages only.
// Expect: shell from global, env from global, packages from project.
let global_toml = "shell = \"zsh\"\n[env]\nEDITOR = \"vim\"\n";
let project_yaml = "packages:\n - fd-find\n";
let mut global_tmp = NamedTempFile::new().expect("create global tmpfile");
write!(global_tmp, "{global_toml}").expect("write global tmpfile");
let mut project_tmp = NamedTempFile::new().expect("create project tmpfile");
write!(project_tmp, "{project_yaml}").expect("write project tmpfile");
let cfg = resolve_config(Some(global_tmp.path()), Some(project_tmp.path()))
.expect("resolve partial overlap");
assert_eq!(cfg.shell.as_deref(), Some("zsh"), "shell should come from global");
let env = cfg.env.expect("env should be Some from global");
assert_eq!(env.get("EDITOR").map(String::as_str), Some("vim"));
let pkgs = cfg.packages.expect("packages should be Some from project");
assert_eq!(pkgs, vec!["fd-find"]);
}
#[test]
fn resolve_nonexistent_paths_skipped() {
// Paths that don't exist should be silently skipped (not errors).
let cfg = resolve_config(
Some(Path::new("/nonexistent/config.toml")),
Some(Path::new("/nonexistent/.sandcage.yml")),
)
.expect("nonexistent files should not error");
assert!(cfg.shell.is_none());
}
}
+604
View File
@@ -0,0 +1,604 @@
use std::collections::HashMap;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Command;
use miette::Diagnostic;
use sha2::{Digest, Sha256};
use thiserror::Error;
use crate::config::SandcageConfig;
// ---------------------------------------------------------------------------
// Bundled Dockerfiles
// ---------------------------------------------------------------------------
const DOCKERFILE_BASE: &str = include_str!("../images/base/Dockerfile");
const DOCKERFILE_CLAUDE: &str = include_str!("../images/claude/Dockerfile");
const DOCKERFILE_CODEX: &str = include_str!("../images/codex/Dockerfile");
const COMPOSE_YAML: &str = include_str!("../compose/docker-compose.yml");
#[derive(Debug, Error, Diagnostic)]
pub enum DockerError {
#[error("docker executable not found in PATH")]
#[diagnostic(
code(sandcage::docker::not_found),
help("Install Docker: https://docs.docker.com/get-docker/")
)]
DockerNotFound,
#[error("docker compose plugin not available")]
#[diagnostic(
code(sandcage::docker::compose_not_found),
help("Docker Compose V2 is required. Install it via Docker Desktop or 'docker compose' plugin.")
)]
ComposeNotFound,
#[error("Failed to determine current user UID/GID: {0}")]
#[diagnostic(code(sandcage::docker::id_failed))]
IdFailed(String),
#[error("Cannot determine home directory")]
#[diagnostic(code(sandcage::docker::no_home_dir))]
NoHomeDir,
#[error("Failed to write temporary compose file: {0}")]
#[diagnostic(code(sandcage::docker::tempfile_failed))]
TempfileFailed(#[source] std::io::Error),
#[error("Failed to spawn docker compose: {0}")]
#[diagnostic(code(sandcage::docker::spawn_failed))]
SpawnFailed(#[source] std::io::Error),
#[error("Service '{service}' exited with status {code}")]
#[diagnostic(code(sandcage::docker::service_failed))]
ServiceFailed { service: String, code: i32 },
#[error("Failed to create temporary build directory: {0}")]
#[diagnostic(code(sandcage::docker::temp_dir_failed))]
TempDirFailed(#[source] std::io::Error),
#[error("Failed to write Dockerfile to temp directory: {0}")]
#[diagnostic(code(sandcage::docker::dockerfile_write_failed))]
DockerfileWriteFailed(#[source] std::io::Error),
#[error("Failed to read build-hashes file: {0}")]
#[diagnostic(code(sandcage::docker::hash_read_failed))]
HashReadFailed(#[source] std::io::Error),
#[error("Failed to write build-hashes file: {0}")]
#[diagnostic(code(sandcage::docker::hash_write_failed))]
HashWriteFailed(#[source] std::io::Error),
#[error("docker build for '{image}' exited with status {code}")]
#[diagnostic(code(sandcage::docker::build_failed))]
BuildFailed { image: String, code: i32 },
#[error("Image '{image}' not found locally")]
#[diagnostic(
code(sandcage::docker::image_not_found),
help("Run `sandcage build` to build the required images.")
)]
ImageNotFound { image: String },
}
pub type Result<T> = std::result::Result<T, DockerError>;
fn require_docker() -> Result<PathBuf> {
which::which("docker").map_err(|_| DockerError::DockerNotFound)
}
fn require_compose(docker: &Path) -> Result<()> {
let output = Command::new(docker)
.args(["compose", "version"])
.output()
.map_err(|_| DockerError::ComposeNotFound)?;
if !output.status.success() {
return Err(DockerError::ComposeNotFound);
}
Ok(())
}
fn id_flag(flag: &str) -> Result<String> {
let output = Command::new("id")
.arg(flag)
.output()
.map_err(|e| DockerError::IdFailed(e.to_string()))?;
if !output.status.success() {
return Err(DockerError::IdFailed(format!(
"id {flag} exited with {}",
output.status
)));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub fn build_compose_env(workspace: &Path) -> Result<HashMap<String, String>> {
let home = dirs::home_dir().ok_or(DockerError::NoHomeDir)?;
let sandcage_home = home.join(".sandcage");
let uid = id_flag("-u")?;
let gid = id_flag("-g")?;
let mut env = HashMap::new();
env.insert("SANDCAGE_UID".into(), uid);
env.insert("SANDCAGE_GID".into(), gid);
env.insert(
"SANDCAGE_WORKSPACE".into(),
workspace.to_string_lossy().into_owned(),
);
env.insert(
"SANDCAGE_HOME".into(),
sandcage_home.to_string_lossy().into_owned(),
);
env.insert(
"SANDCAGE_GLOBAL_JUSTFILE".into(),
sandcage_home
.join("Justfile")
.to_string_lossy()
.into_owned(),
);
Ok(env)
}
fn write_compose_tempfile() -> Result<tempfile::NamedTempFile> {
let mut tmp = tempfile::Builder::new()
.prefix("sandcage-compose-")
.suffix(".yml")
.tempfile()
.map_err(DockerError::TempfileFailed)?;
tmp.write_all(COMPOSE_YAML.as_bytes())
.map_err(DockerError::TempfileFailed)?;
tmp.flush().map_err(DockerError::TempfileFailed)?;
Ok(tmp)
}
fn image_for_service(service: &str) -> &'static str {
match service {
"claude" => "sandcage-claude",
"codex" => "sandcage-codex",
_ => "sandcage-base",
}
}
fn require_image(docker: &Path, image: &str) -> Result<()> {
let output = Command::new(docker)
.args(["image", "inspect", image])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
match output {
Ok(status) if status.success() => Ok(()),
_ => Err(DockerError::ImageNotFound {
image: image.to_string(),
}),
}
}
pub fn run_service(service: &str, workspace: &Path, config: &SandcageConfig) -> Result<()> {
let docker = require_docker()?;
require_compose(&docker)?;
let image = image_for_service(service);
require_image(&docker, image)?;
let compose_file = write_compose_tempfile()?;
let compose_path = compose_file.path().to_string_lossy().into_owned();
let compose_env = build_compose_env(workspace)?;
// Build the command
let mut cmd = Command::new(&docker);
cmd.args(["compose", "-f", &compose_path, "run", "--rm"]);
// Add extra -e flags from the merged config env map
if let Some(ref env_map) = config.env {
for (key, value) in env_map {
cmd.args(["-e", &format!("{key}={value}")]);
}
}
cmd.arg(service);
// Inject compose environment variables
cmd.envs(&compose_env);
// Inherit stdio for interactive use
cmd.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit());
let status = cmd.status().map_err(DockerError::SpawnFailed)?;
if !status.success() {
return Err(DockerError::ServiceFailed {
service: service.to_string(),
code: status.code().unwrap_or(-1),
});
}
Ok(())
}
// ---------------------------------------------------------------------------
// Build with cache awareness
// ---------------------------------------------------------------------------
/// SHA-256 hex digest of the given string content.
fn sha256_hex(content: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(content.as_bytes());
hex::encode(hasher.finalize())
}
/// Path to the build-hashes file: `~/.sandcage/.build-hashes`.
fn build_hashes_path() -> Result<PathBuf> {
let home = dirs::home_dir().ok_or(DockerError::NoHomeDir)?;
Ok(home.join(".sandcage").join(".build-hashes"))
}
/// Read the stored hashes from `~/.sandcage/.build-hashes`.
/// Returns an empty map if the file does not exist.
fn read_stored_hashes(path: &Path) -> Result<HashMap<String, String>> {
if !path.exists() {
return Ok(HashMap::new());
}
let content = std::fs::read_to_string(path).map_err(DockerError::HashReadFailed)?;
let mut map = HashMap::new();
for line in content.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Some((name, hash)) = line.split_once(':') {
map.insert(name.to_string(), hash.to_string());
}
}
Ok(map)
}
/// Persist the full hashes map back to the file.
fn write_stored_hashes(path: &Path, hashes: &HashMap<String, String>) -> Result<()> {
// Ensure parent directory exists.
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(DockerError::HashWriteFailed)?;
}
// Build deterministic output (sorted keys).
let mut lines: Vec<String> = hashes
.iter()
.map(|(k, v)| format!("{k}:{v}"))
.collect();
lines.sort();
let content = lines.join("\n") + "\n";
std::fs::write(path, content).map_err(DockerError::HashWriteFailed)?;
Ok(())
}
/// Build a single image.
/// Writes the `dockerfile_content` to a temp directory, then invokes
/// `docker build -t <image>:latest <temp_dir>`.
fn build_one_image(docker: &Path, image: &str, dockerfile_content: &str) -> Result<()> {
let tmp_dir = tempfile::Builder::new()
.prefix("sandcage-build-")
.tempdir()
.map_err(DockerError::TempDirFailed)?;
let dockerfile_path = tmp_dir.path().join("Dockerfile");
std::fs::write(&dockerfile_path, dockerfile_content)
.map_err(DockerError::DockerfileWriteFailed)?;
let status = Command::new(docker)
.args(["build", "-t", &format!("{image}:latest")])
.arg(tmp_dir.path())
.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit())
.status()
.map_err(DockerError::SpawnFailed)?;
if !status.success() {
return Err(DockerError::BuildFailed {
image: image.to_string(),
code: status.code().unwrap_or(-1),
});
}
Ok(())
}
/// Build all three sandcage images with cache awareness.
///
/// * When `force` is `true`, every image is rebuilt regardless of the stored hash.
/// * Otherwise, an image is skipped when its Dockerfile hash matches the stored value.
/// * After a successful build the hash is updated in `~/.sandcage/.build-hashes`.
pub fn build_images(force: bool) -> Result<()> {
let docker = require_docker()?;
let images: &[(&str, &str)] = &[
("sandcage-base", DOCKERFILE_BASE),
("sandcage-claude", DOCKERFILE_CLAUDE),
("sandcage-codex", DOCKERFILE_CODEX),
];
let hashes_path = build_hashes_path()?;
let mut stored = read_stored_hashes(&hashes_path)?;
for (image, dockerfile) in images {
let current_hash = sha256_hex(dockerfile);
let needs_build = force || stored.get(*image).map_or(true, |h| h != &current_hash);
if needs_build {
if force {
eprintln!("{image}: rebuilding (--force)");
} else {
eprintln!("{image}: rebuilding (Dockerfile changed)");
}
build_one_image(&docker, image, dockerfile)?;
stored.insert(image.to_string(), current_hash);
write_stored_hashes(&hashes_path, &stored)?;
} else {
eprintln!("{image}: up to date");
}
}
Ok(())
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn compose_yaml_is_bundled() {
assert!(
COMPOSE_YAML.contains("services:"),
"bundled compose YAML should contain 'services:'"
);
assert!(
COMPOSE_YAML.contains("claude:"),
"bundled compose YAML should define 'claude' service"
);
assert!(
COMPOSE_YAML.contains("codex:"),
"bundled compose YAML should define 'codex' service"
);
assert!(
COMPOSE_YAML.contains("shell:"),
"bundled compose YAML should define 'shell' service"
);
}
#[test]
fn build_compose_env_contains_required_keys() {
let workspace = PathBuf::from("/tmp/test-workspace");
let env = build_compose_env(&workspace).expect("build_compose_env");
assert!(env.contains_key("SANDCAGE_UID"), "missing SANDCAGE_UID");
assert!(env.contains_key("SANDCAGE_GID"), "missing SANDCAGE_GID");
assert!(env.contains_key("SANDCAGE_WORKSPACE"), "missing SANDCAGE_WORKSPACE");
assert!(env.contains_key("SANDCAGE_HOME"), "missing SANDCAGE_HOME");
assert!(
env.contains_key("SANDCAGE_GLOBAL_JUSTFILE"),
"missing SANDCAGE_GLOBAL_JUSTFILE"
);
}
#[test]
fn build_compose_env_workspace_matches() {
let workspace = PathBuf::from("/my/project");
let env = build_compose_env(&workspace).expect("build_compose_env");
assert_eq!(env["SANDCAGE_WORKSPACE"], "/my/project");
}
#[test]
fn build_compose_env_home_ends_with_sandcage() {
let workspace = PathBuf::from("/tmp");
let env = build_compose_env(&workspace).expect("build_compose_env");
assert!(
env["SANDCAGE_HOME"].ends_with(".sandcage"),
"SANDCAGE_HOME should end with .sandcage, got: {}",
env["SANDCAGE_HOME"]
);
}
#[test]
fn build_compose_env_justfile_is_under_home() {
let workspace = PathBuf::from("/tmp");
let env = build_compose_env(&workspace).expect("build_compose_env");
assert!(
env["SANDCAGE_GLOBAL_JUSTFILE"].starts_with(&env["SANDCAGE_HOME"]),
"SANDCAGE_GLOBAL_JUSTFILE should be under SANDCAGE_HOME"
);
assert!(
env["SANDCAGE_GLOBAL_JUSTFILE"].ends_with("Justfile"),
"SANDCAGE_GLOBAL_JUSTFILE should end with Justfile"
);
}
#[test]
fn uid_gid_are_numeric() {
let workspace = PathBuf::from("/tmp");
let env = build_compose_env(&workspace).expect("build_compose_env");
let uid: u32 = env["SANDCAGE_UID"]
.parse()
.expect("UID should be numeric");
let gid: u32 = env["SANDCAGE_GID"]
.parse()
.expect("GID should be numeric");
// Basic sanity — UIDs/GIDs are non-negative (they're u32)
assert!(uid < 1_000_000, "UID seems unreasonably large: {uid}");
assert!(gid < 1_000_000, "GID seems unreasonably large: {gid}");
}
#[test]
fn write_compose_tempfile_creates_valid_yaml() {
let tmp = write_compose_tempfile().expect("write_compose_tempfile");
let content = std::fs::read_to_string(tmp.path()).expect("read tempfile");
assert_eq!(content, COMPOSE_YAML, "tempfile content should match bundled YAML");
}
#[test]
fn require_docker_finds_docker_or_fails_cleanly() {
// This test documents the behavior — it either finds docker or returns
// DockerNotFound. We don't assert which, since CI may or may not have docker.
let result = require_docker();
match result {
Ok(path) => assert!(path.to_string_lossy().contains("docker")),
Err(DockerError::DockerNotFound) => { /* expected on machines without docker */ }
Err(e) => panic!("unexpected error: {e}"),
}
}
// -----------------------------------------------------------------------
// build_images / cache-awareness tests
// -----------------------------------------------------------------------
#[test]
fn dockerfiles_are_bundled() {
assert!(!DOCKERFILE_BASE.is_empty(), "base Dockerfile should not be empty");
assert!(!DOCKERFILE_CLAUDE.is_empty(), "claude Dockerfile should not be empty");
assert!(!DOCKERFILE_CODEX.is_empty(), "codex Dockerfile should not be empty");
}
#[test]
fn sha256_hex_is_deterministic() {
let content = "FROM ubuntu:22.04\nRUN echo hello\n";
let h1 = sha256_hex(content);
let h2 = sha256_hex(content);
assert_eq!(h1, h2, "sha256_hex must be deterministic");
// SHA-256 produces 64 hex chars
assert_eq!(h1.len(), 64, "sha256_hex should produce 64-char hex string");
}
#[test]
fn sha256_hex_differs_for_different_input() {
let h1 = sha256_hex("FROM ubuntu:22.04\n");
let h2 = sha256_hex("FROM debian:bookworm\n");
assert_ne!(h1, h2, "different content must yield different hash");
}
#[test]
fn hash_file_roundtrip() {
let tmp = tempfile::tempdir().expect("create temp dir");
let path = tmp.path().join(".build-hashes");
let mut hashes = HashMap::new();
hashes.insert("sandcage-base".to_string(), "aabbcc".to_string());
hashes.insert("sandcage-claude".to_string(), "ddeeff".to_string());
write_stored_hashes(&path, &hashes).expect("write hashes");
let loaded = read_stored_hashes(&path).expect("read hashes");
assert_eq!(loaded.get("sandcage-base").map(String::as_str), Some("aabbcc"));
assert_eq!(loaded.get("sandcage-claude").map(String::as_str), Some("ddeeff"));
}
#[test]
fn read_stored_hashes_returns_empty_when_file_missing() {
let tmp = tempfile::tempdir().expect("create temp dir");
let path = tmp.path().join("nonexistent");
let result = read_stored_hashes(&path).expect("read hashes on missing file");
assert!(result.is_empty(), "should return empty map when file does not exist");
}
#[test]
fn cache_hit_detection_same_hash() {
let dockerfile = "FROM ubuntu:22.04\n";
let hash = sha256_hex(dockerfile);
let mut stored: HashMap<String, String> = HashMap::new();
stored.insert("sandcage-base".to_string(), hash.clone());
let needs_build = stored.get("sandcage-base").map_or(true, |h| h != &hash);
assert!(!needs_build, "same hash should be a cache hit (no rebuild needed)");
}
#[test]
fn cache_miss_detection_different_hash() {
let dockerfile = "FROM ubuntu:22.04\n";
let current_hash = sha256_hex(dockerfile);
let mut stored: HashMap<String, String> = HashMap::new();
stored.insert("sandcage-base".to_string(), "old_stale_hash".to_string());
let needs_build = stored.get("sandcage-base").map_or(true, |h| h != &current_hash);
assert!(needs_build, "different hash should be a cache miss (rebuild needed)");
}
#[test]
fn cache_miss_detection_missing_entry() {
let dockerfile = "FROM ubuntu:22.04\n";
let current_hash = sha256_hex(dockerfile);
let stored: HashMap<String, String> = HashMap::new();
let needs_build = stored.get("sandcage-base").map_or(true, |h| h != &current_hash);
assert!(needs_build, "missing entry should be treated as a cache miss");
}
#[test]
fn force_flag_bypasses_cache() {
// Even when hash matches, force=true means needs_build is true
let dockerfile = "FROM ubuntu:22.04\n";
let hash = sha256_hex(dockerfile);
let mut stored: HashMap<String, String> = HashMap::new();
stored.insert("sandcage-base".to_string(), hash.clone());
let force = true;
let needs_build = force || stored.get("sandcage-base").map_or(true, |h| h != &hash);
assert!(needs_build, "force flag should always trigger a rebuild");
}
#[test]
fn write_stored_hashes_creates_parent_dirs() {
let tmp = tempfile::tempdir().expect("create temp dir");
// Nested path that doesn't exist yet
let path = tmp.path().join("a").join("b").join(".build-hashes");
let mut hashes = HashMap::new();
hashes.insert("sandcage-base".to_string(), "abc123".to_string());
write_stored_hashes(&path, &hashes).expect("should create parent dirs and write");
assert!(path.exists(), "hash file should have been created");
}
#[test]
fn image_for_service_maps_correctly() {
assert_eq!(image_for_service("claude"), "sandcage-claude");
assert_eq!(image_for_service("codex"), "sandcage-codex");
assert_eq!(image_for_service("shell"), "sandcage-base");
assert_eq!(image_for_service("anything-else"), "sandcage-base");
}
#[test]
fn require_image_fails_for_nonexistent_image() {
let docker = match require_docker() {
Ok(path) => path,
Err(_) => return, // skip if docker not installed
};
let result = require_image(&docker, "sandcage-nonexistent-image-abc123");
assert!(
matches!(result, Err(DockerError::ImageNotFound { .. })),
"should fail for nonexistent image: {result:?}"
);
}
}
+368
View File
@@ -0,0 +1,368 @@
use std::path::{Path, PathBuf};
use miette::Diagnostic;
use thiserror::Error;
const CLAUDE_SETTINGS_TEMPLATE: &str =
include_str!("../templates/claude/settings.json");
#[derive(Debug, Error, Diagnostic)]
pub enum InitError {
#[error("Failed to create directory {0}: {1}")]
#[diagnostic(code(sandcage::init::create_dir_failed))]
CreateDirFailed(PathBuf, #[source] std::io::Error),
#[error("Failed to write file {0}: {1}")]
#[diagnostic(code(sandcage::init::write_file_failed))]
WriteFileFailed(PathBuf, #[source] std::io::Error),
#[error("Cannot determine home directory")]
#[diagnostic(code(sandcage::init::no_home_dir))]
NoHomeDir,
#[error(".sandcage.yml already exists at {0}; remove it manually to re-initialise")]
#[diagnostic(code(sandcage::init::config_already_exists))]
ConfigAlreadyExists(PathBuf),
}
pub type Result<T> = std::result::Result<T, InitError>;
pub fn preseed() -> Result<()> {
let home = dirs::home_dir().ok_or(InitError::NoHomeDir)?;
preseed_with_home(&home)
}
pub fn preseed_with_home(home: &Path) -> Result<()> {
let sandcage_home = home.join(".sandcage");
// 1. ~/.sandcage/.claude/
let claude_dir = sandcage_home.join(".claude");
create_dir_all(&claude_dir)?;
// 2. ~/.sandcage/.codex/
let codex_dir = sandcage_home.join(".codex");
create_dir_all(&codex_dir)?;
// 3. Seed Claude settings.json from the bundled template
let settings_dest = claude_dir.join("settings.json");
if !settings_dest.exists() {
std::fs::write(&settings_dest, CLAUDE_SETTINGS_TEMPLATE)
.map_err(|e| InitError::WriteFileFailed(settings_dest.clone(), e))?;
println!("sandcage: seeded {} from template", settings_dest.display());
}
// 4. Create an empty Justfile if absent
let justfile = sandcage_home.join("Justfile");
if !justfile.exists() {
std::fs::write(&justfile, "")
.map_err(|e| InitError::WriteFileFailed(justfile.clone(), e))?;
}
Ok(())
}
fn create_dir_all(path: &Path) -> Result<()> {
std::fs::create_dir_all(path)
.map_err(|e| InitError::CreateDirFailed(path.to_path_buf(), e))
}
// ---------------------------------------------------------------------------
// Project scaffolding — `sandcage init`
// ---------------------------------------------------------------------------
/// Language ecosystems we can detect from marker files.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Ecosystem {
Rust,
Node,
Python,
Go,
Generic,
}
impl Ecosystem {
fn as_str(self) -> &'static str {
match self {
Ecosystem::Rust => "rust",
Ecosystem::Node => "node",
Ecosystem::Python => "python",
Ecosystem::Go => "go",
Ecosystem::Generic => "generic",
}
}
}
/// Detect the primary language ecosystem from files present in `dir`.
pub fn detect_ecosystem(dir: &Path) -> Ecosystem {
if dir.join("Cargo.toml").exists() {
Ecosystem::Rust
} else if dir.join("package.json").exists() {
Ecosystem::Node
} else if dir.join("pyproject.toml").exists() || dir.join("requirements.txt").exists() {
Ecosystem::Python
} else if dir.join("go.mod").exists() {
Ecosystem::Go
} else {
Ecosystem::Generic
}
}
/// Build the YAML content for a `.sandcage.yml` file.
fn build_yaml(ecosystem: Ecosystem) -> String {
let toolchain_and_packages = match ecosystem {
Ecosystem::Rust => {
"toolchains:\n rust: \"stable\"\n\npackages:\n - ripgrep\n - fd-find\n"
}
Ecosystem::Node => {
"toolchains:\n node: \"20\"\n\npackages:\n - git\n - curl\n"
}
Ecosystem::Python => {
"toolchains:\n python: \"3.12\"\n\npackages:\n - git\n - curl\n"
}
Ecosystem::Go => {
"toolchains:\n go: \"1.22\"\n\npackages:\n - git\n - curl\n"
}
Ecosystem::Generic => {
"packages:\n - git\n - curl\n"
}
};
format!(
"# Sandcage project configuration\n\
# Docs: https://github.com/user/sandcage\n\
\n\
# Detected ecosystem: {ecosystem}\n\
\n\
env:\n\
# EXAMPLE_VAR: value\n\
\n\
{toolchain_and_packages}\
\n\
# mounts:\n\
# - /path/on/host:/path/in/container\n\
\n\
# shell: zsh\n",
ecosystem = ecosystem.as_str(),
)
}
/// Generate a `.sandcage.yml` in `path` with suggested content based on the
/// detected language ecosystem. Returns an error if the file already exists.
pub fn scaffold_project(path: &Path) -> Result<()> {
let config_path = path.join(".sandcage.yml");
if config_path.exists() {
return Err(InitError::ConfigAlreadyExists(config_path));
}
let ecosystem = detect_ecosystem(path);
let yaml = build_yaml(ecosystem);
std::fs::write(&config_path, &yaml)
.map_err(|e| InitError::WriteFileFailed(config_path.clone(), e))?;
println!(
"sandcage: initialised {} (detected ecosystem: {})",
config_path.display(),
ecosystem.as_str(),
);
println!();
print!("{yaml}");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn run(home: &Path) -> Result<()> {
preseed_with_home(home)
}
#[test]
fn creates_claude_and_codex_dirs() {
let home = TempDir::new().expect("create tempdir");
run(home.path()).expect("preseed");
assert!(
home.path().join(".sandcage/.claude").is_dir(),
".claude dir should exist"
);
assert!(
home.path().join(".sandcage/.codex").is_dir(),
".codex dir should exist"
);
}
#[test]
fn seeds_claude_settings_when_absent() {
let home = TempDir::new().expect("create tempdir");
run(home.path()).expect("preseed");
let settings = home.path().join(".sandcage/.claude/settings.json");
assert!(settings.exists(), "settings.json should be created");
let content = std::fs::read_to_string(&settings).expect("read settings");
assert_eq!(
content.trim(),
CLAUDE_SETTINGS_TEMPLATE.trim(),
"settings.json content should match the template"
);
}
#[test]
fn does_not_overwrite_existing_claude_settings() {
let home = TempDir::new().expect("create tempdir");
// Create the directory and put custom content in settings.json first
let claude_dir = home.path().join(".sandcage/.claude");
std::fs::create_dir_all(&claude_dir).expect("mkdir");
let settings = claude_dir.join("settings.json");
std::fs::write(&settings, r#"{"existing": true}"#).expect("write");
run(home.path()).expect("preseed");
let content = std::fs::read_to_string(&settings).expect("read");
assert_eq!(
content, r#"{"existing": true}"#,
"existing settings.json must not be overwritten"
);
}
#[test]
fn creates_empty_justfile_when_absent() {
let home = TempDir::new().expect("create tempdir");
run(home.path()).expect("preseed");
let justfile = home.path().join(".sandcage/Justfile");
assert!(justfile.exists(), "Justfile should be created");
assert_eq!(
std::fs::read_to_string(&justfile).expect("read"),
"",
"Justfile should be empty"
);
}
#[test]
fn does_not_overwrite_existing_justfile() {
let home = TempDir::new().expect("create tempdir");
// Seed a Justfile with content first
let sandcage_dir = home.path().join(".sandcage");
std::fs::create_dir_all(&sandcage_dir).expect("mkdir");
let justfile = sandcage_dir.join("Justfile");
std::fs::write(&justfile, "default:\n\t@echo hello").expect("write");
run(home.path()).expect("preseed");
let content = std::fs::read_to_string(&justfile).expect("read");
assert_eq!(
content, "default:\n\t@echo hello",
"existing Justfile must not be overwritten"
);
}
#[test]
fn idempotent_second_run() {
let home = TempDir::new().expect("create tempdir");
run(home.path()).expect("first preseed");
run(home.path()).expect("second preseed should also succeed");
// All artefacts should still exist after two runs
assert!(home.path().join(".sandcage/.claude").is_dir());
assert!(home.path().join(".sandcage/.codex").is_dir());
assert!(home.path().join(".sandcage/.claude/settings.json").exists());
assert!(home.path().join(".sandcage/Justfile").exists());
}
// -----------------------------------------------------------------------
// scaffold_project / ecosystem detection tests
// -----------------------------------------------------------------------
fn touch(dir: &Path, name: &str) {
std::fs::write(dir.join(name), "").expect("touch file");
}
#[test]
fn detects_rust_from_cargo_toml() {
let dir = TempDir::new().expect("tempdir");
touch(dir.path(), "Cargo.toml");
assert_eq!(detect_ecosystem(dir.path()), Ecosystem::Rust);
}
#[test]
fn detects_node_from_package_json() {
let dir = TempDir::new().expect("tempdir");
touch(dir.path(), "package.json");
assert_eq!(detect_ecosystem(dir.path()), Ecosystem::Node);
}
#[test]
fn detects_python_from_pyproject_toml() {
let dir = TempDir::new().expect("tempdir");
touch(dir.path(), "pyproject.toml");
assert_eq!(detect_ecosystem(dir.path()), Ecosystem::Python);
}
#[test]
fn detects_python_from_requirements_txt() {
let dir = TempDir::new().expect("tempdir");
touch(dir.path(), "requirements.txt");
assert_eq!(detect_ecosystem(dir.path()), Ecosystem::Python);
}
#[test]
fn detects_go_from_go_mod() {
let dir = TempDir::new().expect("tempdir");
touch(dir.path(), "go.mod");
assert_eq!(detect_ecosystem(dir.path()), Ecosystem::Go);
}
#[test]
fn falls_back_to_generic_when_no_markers() {
let dir = TempDir::new().expect("tempdir");
assert_eq!(detect_ecosystem(dir.path()), Ecosystem::Generic);
}
#[test]
fn scaffold_creates_sandcage_yml() {
let dir = TempDir::new().expect("tempdir");
touch(dir.path(), "Cargo.toml");
scaffold_project(dir.path()).expect("scaffold");
assert!(dir.path().join(".sandcage.yml").exists());
}
#[test]
fn scaffold_refuses_to_overwrite_existing_config() {
let dir = TempDir::new().expect("tempdir");
touch(dir.path(), ".sandcage.yml");
let result = scaffold_project(dir.path());
assert!(
matches!(result, Err(InitError::ConfigAlreadyExists(_))),
"expected ConfigAlreadyExists, got {result:?}"
);
}
#[test]
fn scaffold_generated_yaml_is_valid() {
let dir = TempDir::new().expect("tempdir");
touch(dir.path(), "Cargo.toml");
scaffold_project(dir.path()).expect("scaffold");
let content = std::fs::read_to_string(dir.path().join(".sandcage.yml"))
.expect("read .sandcage.yml");
// Must be parseable as YAML (serde_yaml returns Value on success)
let parsed: serde_yaml::Value =
serde_yaml::from_str(&content).expect("YAML must be valid");
// Confirm expected keys are present
let map = parsed.as_mapping().expect("top-level YAML must be a mapping");
assert!(
map.contains_key(&serde_yaml::Value::String("toolchains".to_string())),
"expected 'toolchains' key in generated YAML"
);
}
}
+106
View File
@@ -0,0 +1,106 @@
use clap::{Parser, Subcommand};
use miette::Diagnostic;
use std::path::PathBuf;
use thiserror::Error;
mod config;
mod docker;
mod init;
mod workspace;
/// Sandboxed containers for AI coding agents
#[derive(Parser, Debug)]
#[command(name = "sandcage", version, about, long_about = None)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand, Debug)]
enum Commands {
/// Run Claude Code agent in a sandboxed container
Claude {
/// Path to the project directory (defaults to current directory)
path: Option<PathBuf>,
},
/// Run Codex agent in a sandboxed container
Codex {
/// Path to the project directory (defaults to current directory)
path: Option<PathBuf>,
},
/// Interactive shell with the same environment
Shell {
/// Path to the project directory (defaults to current directory)
path: Option<PathBuf>,
},
/// Build all container images
Build {
/// Force rebuild even if images are up to date
#[arg(long, short)]
force: bool,
},
/// Initialize a .sandcage.yml for a project
Init,
}
#[derive(Debug, Error, Diagnostic)]
enum AppError {
#[error(transparent)]
#[diagnostic(transparent)]
Workspace(#[from] workspace::WorkspaceError),
#[error(transparent)]
#[diagnostic(transparent)]
Init(#[from] init::InitError),
#[error(transparent)]
#[diagnostic(transparent)]
Config(#[from] config::ConfigError),
#[error(transparent)]
#[diagnostic(transparent)]
Docker(#[from] docker::DockerError),
}
fn run_agent(service: &str, path: Option<PathBuf>) -> std::result::Result<(), AppError> {
// 1. Resolve workspace
let workspace = workspace::resolve_workspace(path.as_deref())?;
eprintln!("sandcage: workspace \u{2192} {}", workspace.display());
// 2. Preseed ~/.sandcage directories
init::preseed()?;
// 3. Resolve layered config
let home = dirs::home_dir().ok_or(init::InitError::NoHomeDir)?;
let global_config = home.join(".sandcage").join("config.toml");
let project_config = workspace.join(".sandcage.yml");
let cfg = config::resolve_config(
Some(&global_config),
Some(&project_config),
)?;
// 4. Run the service
eprintln!("sandcage: service \u{2192} {service}");
docker::run_service(service, &workspace, &cfg)?;
Ok(())
}
fn main() -> miette::Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Claude { path } => run_agent("claude", path)?,
Commands::Codex { path } => run_agent("codex", path)?,
Commands::Shell { path } => run_agent("shell", path)?,
Commands::Build { force } => {
docker::build_images(force)?;
}
Commands::Init => {
let workspace = workspace::resolve_workspace(None)?;
init::scaffold_project(&workspace)?;
}
}
Ok(())
}
+114
View File
@@ -0,0 +1,114 @@
use std::path::{Path, PathBuf};
use std::process::Command;
use miette::Diagnostic;
use thiserror::Error;
#[derive(Debug, Error, Diagnostic)]
pub enum WorkspaceError {
#[error("Failed to resolve absolute path for {0}: {1}")]
#[diagnostic(code(sandcage::workspace::canonicalize_failed))]
CanonicalizeFailed(PathBuf, #[source] std::io::Error),
}
pub type Result<T> = std::result::Result<T, WorkspaceError>;
pub fn resolve_workspace(path: Option<&Path>) -> Result<PathBuf> {
// Determine starting path
let raw = match path {
Some(p) => p.to_path_buf(),
None => std::env::current_dir()
.map_err(|e| WorkspaceError::CanonicalizeFailed(PathBuf::from("."), e))?,
};
// Resolve to absolute, canonical path (follows symlinks, checks existence)
let absolute = raw
.canonicalize()
.map_err(|e| WorkspaceError::CanonicalizeFailed(raw.clone(), e))?;
// Ask git for the worktree root; silently fall back if not a git repo
let git_root = Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.current_dir(&absolute)
.output();
match git_root {
Ok(output) if output.status.success() => {
let root = String::from_utf8_lossy(&output.stdout)
.trim()
.to_string();
Ok(PathBuf::from(root))
}
// Not a git repo, or git not installed — return the absolute path as-is
_ => Ok(absolute),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
use tempfile::TempDir;
/// Create a temporary directory and initialise it as a git repository.
/// Returns the `TempDir` guard (keep it alive for the test) and the path.
fn make_git_repo() -> (TempDir, PathBuf) {
let dir = TempDir::new().expect("create tempdir");
let path = dir.path().to_path_buf();
// git init
let status = Command::new("git")
.args(["init"])
.current_dir(&path)
.status()
.expect("run git init");
assert!(status.success(), "git init failed");
(dir, path)
}
#[test]
fn returns_repo_root_when_inside_git_repo() {
let (_guard, repo_root) = make_git_repo();
// Create a nested directory inside the repo
let nested = repo_root.join("a").join("b");
std::fs::create_dir_all(&nested).expect("create nested dirs");
// resolve_workspace from the nested dir should return the repo root
let resolved = resolve_workspace(Some(&nested)).expect("resolve_workspace");
assert_eq!(
resolved.canonicalize().unwrap(),
repo_root.canonicalize().unwrap(),
"expected repo root, got {resolved:?}"
);
}
#[test]
fn returns_directory_itself_when_not_in_git_repo() {
// Create a temp dir that is NOT a git repo (no git init)
let dir = TempDir::new().expect("create tempdir");
// Make sure there is no ancestor git repo that would pollute the result.
// tempfile creates dirs under the system temp folder, which is typically
// outside any user repo, so this should be safe.
let path = dir.path().to_path_buf();
let resolved = resolve_workspace(Some(&path)).expect("resolve_workspace");
assert_eq!(
resolved.canonicalize().unwrap(),
path.canonicalize().unwrap(),
"expected the directory itself, got {resolved:?}"
);
}
#[test]
fn none_path_uses_current_directory() {
// When None is passed the function should not error; it should return
// something (either the cwd or its git root).
let result = resolve_workspace(None);
assert!(result.is_ok(), "resolve_workspace(None) returned error: {result:?}");
let resolved = result.unwrap();
assert!(resolved.is_absolute(), "result should be absolute: {resolved:?}");
}
}
+1
View File
@@ -0,0 +1 @@
{}
Generated
+79
View File
@@ -0,0 +1,79 @@
version = 1
revision = 3
requires-python = ">=3.12"
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "packaging"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pytest"
version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
name = "sandcage"
version = "0.1.0"
source = { virtual = "." }
[package.dev-dependencies]
dev = [
{ name = "pytest" },
]
[package.metadata]
[package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=9.0.3" }]