🥇 export from upstream (ce9e2c1)

This commit is contained in:
2026-05-23 15:29:45 +02:00
parent ee5238cb0f
commit e5820ab3ff
15 changed files with 1172 additions and 81 deletions
+26
View File
@@ -0,0 +1,26 @@
# Sandcage project configuration
# Docs: https://github.com/user/sandcage
# Detected ecosystem: rust
env:
# EXAMPLE_VAR: value
toolchains:
rust: "stable"
packages:
- ripgrep
- fd-find
# mounts:
# - ~/.ssh:/home/agent/.ssh:ro
# - ~/.gitconfig:/home/agent/.gitconfig:ro
agent_args:
claude:
- "--dangerously-skip-permissions"
# shell: zsh
# container_workspace: /workspace/my-project
Generated
+149 -10
View File
@@ -62,7 +62,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -73,7 +73,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -220,6 +220,19 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "console"
version = "0.15.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8"
dependencies = [
"encode_unicode",
"libc",
"once_cell",
"unicode-width 0.2.2",
"windows-sys 0.59.0",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -239,6 +252,19 @@ dependencies = [
"typenum",
]
[[package]]
name = "dialoguer"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de"
dependencies = [
"console",
"shell-words",
"tempfile",
"thiserror 1.0.69",
"zeroize",
]
[[package]]
name = "difflib"
version = "0.4.0"
@@ -273,7 +299,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -282,6 +308,12 @@ version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
[[package]]
name = "encode_unicode"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]]
name = "env_home"
version = "0.1.0"
@@ -301,7 +333,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -682,7 +714,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
dependencies = [
"getrandom 0.2.17",
"libredox",
"thiserror",
"thiserror 2.0.18",
]
[[package]]
@@ -730,7 +762,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -744,6 +776,7 @@ name = "sandcage"
version = "0.1.0"
dependencies = [
"clap",
"dialoguer",
"dirs",
"figment",
"hex",
@@ -752,7 +785,8 @@ dependencies = [
"serde_yaml",
"sha2",
"tempfile",
"thiserror",
"thiserror 2.0.18",
"toml_edit",
"which",
]
@@ -848,6 +882,12 @@ dependencies = [
"digest",
]
[[package]]
name = "shell-words"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
[[package]]
name = "strsim"
version = "0.11.1"
@@ -896,7 +936,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -906,7 +946,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
dependencies = [
"rustix",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -925,13 +965,33 @@ dependencies = [
"unicode-width 0.2.2",
]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl 1.0.69",
]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl",
"thiserror-impl 2.0.18",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
@@ -1134,6 +1194,15 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -1143,6 +1212,70 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winnow"
version = "0.7.15"
@@ -1258,6 +1391,12 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]]
name = "zmij"
version = "1.0.21"
+3 -9
View File
@@ -4,10 +4,8 @@ services:
working_dir: ${SANDCAGE_CONTAINER_DIR}
user: "${SANDCAGE_UID}:${SANDCAGE_GID}"
volumes:
- ${SANDCAGE_HOME}/home:/home/agent
- ${SANDCAGE_WORKSPACE}:${SANDCAGE_CONTAINER_DIR}
- ${SANDCAGE_HOME}/.claude:/home/agent/.claude
- ${SANDCAGE_HOME}/.claude.json:/home/agent/.claude.json
- ${SANDCAGE_GLOBAL_JUSTFILE}:/home/agent/.justfile:ro
environment:
- HOME=/home/agent
tty: true
@@ -18,9 +16,8 @@ services:
working_dir: ${SANDCAGE_CONTAINER_DIR}
user: "${SANDCAGE_UID}:${SANDCAGE_GID}"
volumes:
- ${SANDCAGE_HOME}/home:/home/agent
- ${SANDCAGE_WORKSPACE}:${SANDCAGE_CONTAINER_DIR}
- ${SANDCAGE_HOME}/.codex:/home/agent/.codex
- ${SANDCAGE_GLOBAL_JUSTFILE}:/home/agent/.justfile:ro
environment:
- HOME=/home/agent
tty: true
@@ -31,11 +28,8 @@ services:
working_dir: ${SANDCAGE_CONTAINER_DIR}
user: "${SANDCAGE_UID}:${SANDCAGE_GID}"
volumes:
- ${SANDCAGE_HOME}/home:/home/agent
- ${SANDCAGE_WORKSPACE}:${SANDCAGE_CONTAINER_DIR}
- ${SANDCAGE_HOME}/.claude:/home/agent/.claude
- ${SANDCAGE_HOME}/.claude.json:/home/agent/.claude.json
- ${SANDCAGE_HOME}/.codex:/home/agent/.codex
- ${SANDCAGE_GLOBAL_JUSTFILE}:/home/agent/.justfile:ro
environment:
- HOME=/home/agent
tty: true
+2
View File
@@ -18,3 +18,5 @@ dirs = "6"
sha2 = "0.10"
hex = "0.4"
tempfile = "3"
dialoguer = "0.11"
toml_edit = "0.22"
+52 -1
View File
@@ -54,6 +54,8 @@ struct RawConfig {
trusted_projects: Option<Vec<PathBuf>>,
#[serde(default)]
container_workspace: Option<String>,
#[serde(default)]
agent_args: Option<HashMap<String, Vec<String>>>,
/// Absorb everything else so we can warn about unknown fields.
#[serde(flatten)]
@@ -85,13 +87,15 @@ pub struct SandcageConfig {
pub trusted_projects: Option<Vec<PathBuf>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub container_workspace: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_args: Option<HashMap<String, Vec<String>>>,
}
// ---------------------------------------------------------------------------
// Known keys — used to filter the flattened `extra` map
// ---------------------------------------------------------------------------
const KNOWN_KEYS: &[&str] = &["env", "packages", "toolchains", "mounts", "shell", "justfile", "dockerfiles", "trusted_projects", "container_workspace"];
const KNOWN_KEYS: &[&str] = &["env", "packages", "toolchains", "mounts", "shell", "justfile", "dockerfiles", "trusted_projects", "container_workspace", "agent_args"];
// ---------------------------------------------------------------------------
// Validation helpers
@@ -143,6 +147,7 @@ fn from_raw(raw: RawConfig) -> SandcageConfig {
dockerfiles: raw.dockerfiles,
trusted_projects: raw.trusted_projects,
container_workspace: raw.container_workspace,
agent_args: raw.agent_args,
}
}
@@ -519,4 +524,50 @@ EDITOR = "vim"
let cfg = load_from_str("").expect("parse empty");
assert!(cfg.container_workspace.is_none());
}
#[test]
fn agent_args_defaults_to_none() {
let cfg = load_from_str("").expect("parse empty");
assert!(cfg.agent_args.is_none());
}
#[test]
fn resolve_agent_args_project_overrides_global() {
let global_toml = r#"
[agent_args]
claude = ["--global-flag"]
"#;
let project_yaml = r#"
agent_args:
claude:
- "--project-flag"
"#;
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");
let agent_args = cfg.agent_args.expect("agent_args should be Some");
let claude_args = agent_args.get("claude").expect("claude args");
assert_eq!(claude_args, &vec!["--project-flag"], "project should override global");
}
#[test]
fn parse_agent_args() {
let yaml = r#"
agent_args:
claude:
- "--dangerously-skip-permissions"
codex:
- "--full-auto"
"#;
let cfg = load_from_str(yaml).expect("parse agent_args");
let agent_args = cfg.agent_args.expect("agent_args should be Some");
let claude_args = agent_args.get("claude").expect("claude args");
assert_eq!(claude_args, &vec!["--dangerously-skip-permissions"]);
let codex_args = agent_args.get("codex").expect("codex args");
assert_eq!(codex_args, &vec!["--full-auto"]);
}
}
+132 -25
View File
@@ -153,13 +153,6 @@ pub fn build_compose_env(workspace: &Path, config: &SandcageConfig) -> Result<Ha
"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(),
);
env.insert("SANDCAGE_CONTAINER_DIR".into(), container_dir);
Ok(env)
@@ -231,6 +224,15 @@ pub fn build_run_args(
}
}
if let Some(ref mount_list) = config.mounts {
for mount in mount_list {
if mount.contains(':') {
args.push("-v".to_string());
args.push(mount.clone());
}
}
}
if shell_override {
args.push("--entrypoint".to_string());
args.push("/bin/zsh".to_string());
@@ -238,6 +240,16 @@ pub fn build_run_args(
args.push(service.to_string());
if !shell_override {
if let Some(ref all_agent_args) = config.agent_args
&& let Some(default_args) = all_agent_args.get(service)
{
for arg in default_args {
args.push(arg.clone());
}
}
}
for arg in extra_args {
args.push(arg.clone());
}
@@ -555,10 +567,6 @@ mod tests {
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"
);
assert!(env.contains_key("SANDCAGE_CONTAINER_DIR"), "missing SANDCAGE_CONTAINER_DIR");
}
@@ -580,20 +588,6 @@ mod tests {
);
}
#[test]
fn build_compose_env_justfile_is_under_home() {
let workspace = PathBuf::from("/tmp");
let env = build_compose_env(&workspace, &SandcageConfig::default()).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");
@@ -821,6 +815,20 @@ mod tests {
assert!(help_pos > service_pos);
}
#[test]
fn build_run_args_shell_override_skips_agent_args() {
let mut agent_args = HashMap::new();
agent_args.insert("claude".to_string(), vec!["--dangerously-skip-permissions".to_string()]);
let config = SandcageConfig {
agent_args: Some(agent_args),
..Default::default()
};
let args = build_run_args("claude", "/tmp/compose.yml", &config, true, &[]);
assert!(!args.contains(&"--dangerously-skip-permissions".to_string()),
"agent_args must not be passed in shell mode");
assert!(args.contains(&"--entrypoint".to_string()));
}
#[test]
fn build_compose_env_container_dir_auto_derived() {
let workspace = PathBuf::from("/home/user/projects/my-app");
@@ -854,6 +862,105 @@ mod tests {
);
}
#[test]
fn build_run_args_with_mounts() {
let config = SandcageConfig {
mounts: Some(vec![
"~/.ssh:/home/agent/.ssh:ro".to_string(),
"~/.gitconfig:/home/agent/.gitconfig:ro".to_string(),
]),
..Default::default()
};
let args = build_run_args("claude", "/tmp/compose.yml", &config, false, &[]);
assert!(args.contains(&"-v".to_string()), "should contain -v flag");
assert!(args.contains(&"~/.ssh:/home/agent/.ssh:ro".to_string()));
assert!(args.contains(&"~/.gitconfig:/home/agent/.gitconfig:ro".to_string()));
let service_pos = args.iter().position(|a| a == "claude").unwrap();
let first_v = args.iter().position(|a| a == "-v").unwrap();
assert!(first_v < service_pos, "-v flags must come before the service name");
}
#[test]
fn build_run_args_skips_invalid_mounts() {
let config = SandcageConfig {
mounts: Some(vec![
"/valid:/mount:ro".to_string(),
"/no-colon-here".to_string(),
]),
..Default::default()
};
let args = build_run_args("claude", "/tmp/compose.yml", &config, false, &[]);
let v_count = args.iter().filter(|a| a.as_str() == "-v").count();
assert_eq!(v_count, 1, "only valid mounts should produce -v flags");
assert!(args.contains(&"/valid:/mount:ro".to_string()));
assert!(!args.contains(&"/no-colon-here".to_string()));
}
#[test]
fn build_run_args_mounts_with_env_and_extra_args() {
let mut env = HashMap::new();
env.insert("FOO".to_string(), "bar".to_string());
let config = SandcageConfig {
env: Some(env),
mounts: Some(vec!["~/.ssh:/home/agent/.ssh:ro".to_string()]),
..Default::default()
};
let args = build_run_args("claude", "/tmp/compose.yml", &config, false, &["--resume".to_string()]);
let service_pos = args.iter().position(|a| a == "claude").unwrap();
let env_pos = args.iter().position(|a| a == "FOO=bar").unwrap();
let mount_pos = args.iter().position(|a| a == "~/.ssh:/home/agent/.ssh:ro").unwrap();
let resume_pos = args.iter().position(|a| a == "--resume").unwrap();
assert!(env_pos < service_pos, "env before service");
assert!(mount_pos < service_pos, "mounts before service");
assert!(resume_pos > service_pos, "extra args after service");
}
#[test]
fn build_run_args_prepends_default_agent_args() {
let mut agent_args = HashMap::new();
agent_args.insert("claude".to_string(), vec!["--dangerously-skip-permissions".to_string()]);
let config = SandcageConfig {
agent_args: Some(agent_args),
..Default::default()
};
let args = build_run_args("claude", "/tmp/compose.yml", &config, false, &["--resume".to_string()]);
let service_pos = args.iter().position(|a| a == "claude").unwrap();
let default_pos = args.iter().position(|a| a == "--dangerously-skip-permissions").unwrap();
let resume_pos = args.iter().position(|a| a == "--resume").unwrap();
assert!(default_pos > service_pos, "default args come after service name");
assert!(default_pos < resume_pos, "default args come before CLI args");
}
#[test]
fn build_run_args_ignores_other_service_defaults() {
let mut agent_args = HashMap::new();
agent_args.insert("codex".to_string(), vec!["--full-auto".to_string()]);
let config = SandcageConfig {
agent_args: Some(agent_args),
..Default::default()
};
let args = build_run_args("claude", "/tmp/compose.yml", &config, false, &[]);
assert!(!args.contains(&"--full-auto".to_string()), "codex args should not appear for claude");
}
#[test]
fn build_run_args_no_defaults_cli_args_still_work() {
let config = SandcageConfig::default();
let args = build_run_args("claude", "/tmp/compose.yml", &config, false, &["--resume".to_string()]);
let service_pos = args.iter().position(|a| a == "claude").unwrap();
let resume_pos = args.iter().position(|a| a == "--resume").unwrap();
assert!(resume_pos > service_pos);
}
#[test]
fn build_compose_env_container_dir_root_fallback() {
let workspace = PathBuf::from("/");
+40 -21
View File
@@ -34,16 +34,20 @@ pub fn preseed() -> Result<()> {
pub fn preseed_with_home(home: &Path) -> Result<()> {
let sandcage_home = home.join(".sandcage");
let agent_home = sandcage_home.join("home");
// 1. ~/.sandcage/.claude/
let claude_dir = sandcage_home.join(".claude");
// 1. ~/.sandcage/home/ — persistent agent home directory
create_dir_all(&agent_home)?;
// 2. ~/.sandcage/home/.claude/
let claude_dir = agent_home.join(".claude");
create_dir_all(&claude_dir)?;
// 2. ~/.sandcage/.codex/
let codex_dir = sandcage_home.join(".codex");
// 3. ~/.sandcage/home/.codex/
let codex_dir = agent_home.join(".codex");
create_dir_all(&codex_dir)?;
// 3. Seed Claude settings.json from the bundled template
// 4. 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)
@@ -51,15 +55,15 @@ pub fn preseed_with_home(home: &Path) -> Result<()> {
println!("sandcage: seeded {} from template", settings_dest.display());
}
// 4. Seed .claude.json so the bind mount targets a file, not a directory
let claude_json = sandcage_home.join(".claude.json");
// 5. Seed .claude.json so the bind mount targets a file, not a directory
let claude_json = agent_home.join(".claude.json");
if !claude_json.exists() {
std::fs::write(&claude_json, "{}\n")
.map_err(|e| InitError::WriteFileFailed(claude_json.clone(), e))?;
}
// 5. Create an empty Justfile if absent
let justfile = sandcage_home.join("Justfile");
// 6. Create an empty .justfile if absent
let justfile = agent_home.join(".justfile");
if !justfile.exists() {
std::fs::write(&justfile, "")
.map_err(|e| InitError::WriteFileFailed(justfile.clone(), e))?;
@@ -146,7 +150,12 @@ fn build_yaml(ecosystem: Ecosystem) -> String {
{toolchain_and_packages}\
\n\
# mounts:\n\
# - /path/on/host:/path/in/container\n\
# - ~/.ssh:/home/agent/.ssh:ro\n\
# - ~/.gitconfig:/home/agent/.gitconfig:ro\n\
\n\
# agent_args:\n\
# claude:\n\
# - \"--dangerously-skip-permissions\"\n\
\n\
# shell: zsh\n\
\n\
@@ -190,17 +199,27 @@ mod tests {
preseed_with_home(home)
}
#[test]
fn creates_home_dir() {
let home = TempDir::new().expect("create tempdir");
run(home.path()).expect("preseed");
assert!(
home.path().join(".sandcage/home").is_dir(),
"home dir should exist"
);
}
#[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(),
home.path().join(".sandcage/home/.claude").is_dir(),
".claude dir should exist"
);
assert!(
home.path().join(".sandcage/.codex").is_dir(),
home.path().join(".sandcage/home/.codex").is_dir(),
".codex dir should exist"
);
}
@@ -210,7 +229,7 @@ mod tests {
let home = TempDir::new().expect("create tempdir");
run(home.path()).expect("preseed");
let settings = home.path().join(".sandcage/.claude/settings.json");
let settings = home.path().join(".sandcage/home/.claude/settings.json");
assert!(settings.exists(), "settings.json should be created");
let content = std::fs::read_to_string(&settings).expect("read settings");
@@ -225,7 +244,7 @@ mod tests {
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");
let claude_dir = home.path().join(".sandcage/home/.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");
@@ -244,7 +263,7 @@ mod tests {
let home = TempDir::new().expect("create tempdir");
run(home.path()).expect("preseed");
let justfile = home.path().join(".sandcage/Justfile");
let justfile = home.path().join(".sandcage/home/.justfile");
assert!(justfile.exists(), "Justfile should be created");
assert_eq!(
std::fs::read_to_string(&justfile).expect("read"),
@@ -257,9 +276,9 @@ mod tests {
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");
let sandcage_dir = home.path().join(".sandcage/home");
std::fs::create_dir_all(&sandcage_dir).expect("mkdir");
let justfile = sandcage_dir.join("Justfile");
let justfile = sandcage_dir.join(".justfile");
std::fs::write(&justfile, "default:\n\t@echo hello").expect("write");
run(home.path()).expect("preseed");
@@ -278,10 +297,10 @@ mod tests {
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());
assert!(home.path().join(".sandcage/home/.claude").is_dir());
assert!(home.path().join(".sandcage/home/.codex").is_dir());
assert!(home.path().join(".sandcage/home/.claude/settings.json").exists());
assert!(home.path().join(".sandcage/home/.justfile").exists());
}
// -----------------------------------------------------------------------
+1
View File
@@ -1,4 +1,5 @@
pub mod config;
pub mod docker;
pub mod init;
pub mod setup;
pub mod workspace;
+29
View File
@@ -6,6 +6,7 @@ use thiserror::Error;
use sandcage::config;
use sandcage::docker;
use sandcage::init;
use sandcage::setup;
use sandcage::workspace;
/// Sandboxed containers for AI coding agents
@@ -60,6 +61,25 @@ enum Commands {
},
/// Initialize a .sandcage.yml for a project
Init,
/// Guided configuration helpers
Setup {
#[command(subcommand)]
action: SetupAction,
},
}
#[derive(Subcommand, Debug)]
enum SetupAction {
/// Configure SSH key access for containers
Ssh {
/// Write to global config instead of project config
#[arg(long)]
global: bool,
/// Skip confirmation prompt
#[arg(long)]
yes: bool,
},
}
#[derive(Debug, Error, Diagnostic)]
@@ -79,6 +99,10 @@ enum AppError {
#[error(transparent)]
#[diagnostic(transparent)]
Docker(#[from] docker::DockerError),
#[error(transparent)]
#[diagnostic(transparent)]
Setup(#[from] setup::SetupError),
}
fn run_agent(service: &str, path: Option<PathBuf>, shell_override: bool, agent_args: Vec<String>) -> std::result::Result<(), AppError> {
@@ -119,6 +143,11 @@ fn main() -> miette::Result<()> {
let workspace = workspace::resolve_workspace(None)?;
init::scaffold_project(&workspace)?;
}
Commands::Setup { action } => match action {
SetupAction::Ssh { global, yes } => {
setup::run_ssh_setup(global, yes)?;
}
},
}
Ok(())
+540
View File
@@ -0,0 +1,540 @@
use std::path::{Path, PathBuf};
use miette::Diagnostic;
use thiserror::Error;
use crate::config::SandcageConfig;
#[derive(Debug, Error, Diagnostic)]
pub enum SetupError {
#[error("No SSH directory found at {0}")]
#[diagnostic(code(sandcage::setup::no_ssh_dir))]
NoSshDir(PathBuf),
#[error("Failed to read directory {0}: {1}")]
#[diagnostic(code(sandcage::setup::read_dir_failed))]
ReadDirFailed(PathBuf, #[source] std::io::Error),
#[error("No .sandcage.yml found. Run `sandcage init` first, or use `--global`.")]
#[diagnostic(code(sandcage::setup::no_project_config))]
NoProjectConfig,
#[error("Failed to read config file {0}: {1}")]
#[diagnostic(code(sandcage::setup::config_read_failed))]
ConfigReadFailed(PathBuf, #[source] std::io::Error),
#[error("Failed to parse config file {0}: {1}")]
#[diagnostic(code(sandcage::setup::config_parse_failed))]
ConfigParseFailed(PathBuf, String),
#[error("Failed to write config file {0}: {1}")]
#[diagnostic(code(sandcage::setup::config_write_failed))]
ConfigWriteFailed(PathBuf, #[source] std::io::Error),
#[error("Cannot determine home directory")]
#[diagnostic(code(sandcage::setup::no_home_dir))]
NoHomeDir,
}
pub type Result<T> = std::result::Result<T, SetupError>;
const SSH_MOUNT: &str = "~/.ssh:/home/agent/.ssh:ro";
const SSH_CONFIG_FILES: &[&str] = &["config", "known_hosts", "known_hosts.old", "authorized_keys"];
#[derive(Debug, Clone, PartialEq)]
pub struct SshScanResult {
pub keys: Vec<String>,
pub config_files: Vec<String>,
pub other_count: usize,
}
pub fn scan_ssh_dir(ssh_dir: &Path) -> Result<SshScanResult> {
if !ssh_dir.is_dir() {
return Err(SetupError::NoSshDir(ssh_dir.to_path_buf()));
}
let entries = std::fs::read_dir(ssh_dir)
.map_err(|e| SetupError::ReadDirFailed(ssh_dir.to_path_buf(), e))?;
let mut keys = Vec::new();
let mut config_files = Vec::new();
let mut other_count: usize = 0;
for entry in entries {
let entry = entry.map_err(|e| SetupError::ReadDirFailed(ssh_dir.to_path_buf(), e))?;
let file_name = entry.file_name().to_string_lossy().into_owned();
if file_name.ends_with(".pub") {
let base = file_name.strip_suffix(".pub").unwrap().to_string();
keys.push(base);
} else if SSH_CONFIG_FILES.contains(&file_name.as_str()) {
config_files.push(file_name);
} else {
other_count += 1;
}
}
keys.sort();
config_files.sort();
Ok(SshScanResult {
keys,
config_files,
other_count,
})
}
pub fn check_existing_mount(config: &SandcageConfig, mount: &str) -> bool {
config
.mounts
.as_ref()
.is_some_and(|m| m.iter().any(|entry| entry == mount))
}
pub fn add_mount_to_yaml(path: &Path, mount: &str) -> Result<()> {
let content = std::fs::read_to_string(path)
.map_err(|e| SetupError::ConfigReadFailed(path.to_path_buf(), e))?;
let mut config: SandcageConfig = if content.trim().is_empty() {
SandcageConfig::default()
} else {
serde_yaml::from_str(&content)
.map_err(|e| SetupError::ConfigParseFailed(path.to_path_buf(), e.to_string()))?
};
let mounts = config.mounts.get_or_insert_with(Vec::new);
mounts.push(mount.to_string());
let yaml = serde_yaml::to_string(&config)
.map_err(|e| SetupError::ConfigParseFailed(path.to_path_buf(), e.to_string()))?;
let output = rehydrate_yaml_comments(&content, &yaml);
std::fs::write(path, output)
.map_err(|e| SetupError::ConfigWriteFailed(path.to_path_buf(), e))?;
Ok(())
}
fn rehydrate_yaml_comments(original: &str, generated: &str) -> String {
use std::collections::HashMap;
let mut key_comments: Vec<(Option<String>, Vec<String>)> = Vec::new();
let mut pending: Vec<String> = Vec::new();
for line in original.lines() {
let trimmed = line.trim();
let is_blank = trimmed.is_empty();
let is_top_comment =
trimmed.starts_with('#') && !line.starts_with(' ') && !line.starts_with('\t');
let is_top_level_key = !is_blank
&& !is_top_comment
&& !line.starts_with(' ')
&& !line.starts_with('\t')
&& !line.starts_with('-')
&& line.contains(':');
if is_top_level_key {
let key = line.split(':').next().unwrap().trim().to_string();
key_comments.push((Some(key), std::mem::take(&mut pending)));
} else if is_blank || is_top_comment {
pending.push(line.to_string());
}
}
if !pending.is_empty() {
key_comments.push((None, pending));
}
let comment_map: HashMap<&str, &[String]> = key_comments
.iter()
.filter_map(|(k, v)| k.as_deref().map(|k| (k, v.as_slice())))
.collect();
let preamble = key_comments
.first()
.and_then(|(k, v)| if k.is_some() { Some(v.as_slice()) } else { None });
let trailer = key_comments
.last()
.and_then(|(k, v)| if k.is_none() { Some(v.as_slice()) } else { None });
let mut result = String::new();
let mut first_key = true;
for line in generated.lines() {
let trimmed = line.trim();
let is_top_level_key = !trimmed.is_empty()
&& !line.starts_with(' ')
&& !line.starts_with('\t')
&& !line.starts_with('-')
&& line.contains(':');
if is_top_level_key {
let key = line.split(':').next().unwrap().trim();
if first_key {
if let Some(pre) = preamble {
for c in pre {
result.push_str(c);
result.push('\n');
}
}
first_key = false;
} else if let Some(comments) = comment_map.get(key) {
for c in *comments {
result.push_str(c);
result.push('\n');
}
}
}
result.push_str(line);
result.push('\n');
}
if let Some(trail) = trailer {
for c in trail {
result.push_str(c);
result.push('\n');
}
}
result
}
pub fn add_mount_to_toml(path: &Path, mount: &str) -> Result<()> {
let content = if path.exists() {
std::fs::read_to_string(path)
.map_err(|e| SetupError::ConfigReadFailed(path.to_path_buf(), e))?
} else {
String::new()
};
let mut doc: toml_edit::DocumentMut = content
.parse()
.map_err(|e: toml_edit::TomlError| {
SetupError::ConfigParseFailed(path.to_path_buf(), e.to_string())
})?;
let mounts = doc.entry("mounts").or_insert_with(|| {
toml_edit::Item::Value(toml_edit::Value::Array(toml_edit::Array::new()))
});
if let Some(arr) = mounts.as_array_mut() {
arr.push(mount);
}
std::fs::write(path, doc.to_string())
.map_err(|e| SetupError::ConfigWriteFailed(path.to_path_buf(), e))?;
Ok(())
}
fn display_scan_result(result: &SshScanResult) {
if !result.keys.is_empty() {
eprintln!(" Keys: {}", result.keys.join(", "));
}
if !result.config_files.is_empty() {
eprintln!(" Config: {}", result.config_files.join(", "));
}
if result.other_count > 0 {
eprintln!(
" Other: {} other file{}",
result.other_count,
if result.other_count == 1 { "" } else { "s" }
);
}
if result.keys.is_empty() && result.config_files.is_empty() && result.other_count == 0 {
eprintln!(" (empty)");
}
eprintln!();
eprintln!("This will mount ~/.ssh into containers as read-only.");
}
pub fn run_ssh_setup(global: bool, yes: bool) -> Result<()> {
let home = dirs::home_dir().ok_or(SetupError::NoHomeDir)?;
let ssh_dir = home.join(".ssh");
let result = scan_ssh_dir(&ssh_dir)?;
eprintln!("sandcage: found SSH directory at ~/.ssh");
eprintln!();
display_scan_result(&result);
let (config_path, config_label) = if global {
(
home.join(".sandcage").join("config.toml"),
"~/.sandcage/config.toml".to_string(),
)
} else {
let cwd =
std::env::current_dir().map_err(|e| SetupError::ReadDirFailed(PathBuf::from("."), e))?;
let project_config = cwd.join(".sandcage.yml");
if !project_config.exists() {
return Err(SetupError::NoProjectConfig);
}
(project_config, ".sandcage.yml".to_string())
};
// Check if already configured
if config_path.exists() {
let existing = if global {
let content = std::fs::read_to_string(&config_path)
.map_err(|e| SetupError::ConfigReadFailed(config_path.clone(), e))?;
let doc: toml_edit::DocumentMut = content
.parse::<toml_edit::DocumentMut>()
.map_err(|e: toml_edit::TomlError| {
SetupError::ConfigParseFailed(config_path.clone(), e.to_string())
})?;
doc.get("mounts")
.and_then(|m: &toml_edit::Item| m.as_array())
.is_some_and(|arr: &toml_edit::Array| {
arr.iter()
.any(|v: &toml_edit::Value| v.as_str() == Some(SSH_MOUNT))
})
} else {
let cfg = crate::config::load(&config_path)
.map_err(|e| SetupError::ConfigParseFailed(config_path.clone(), e.to_string()))?;
check_existing_mount(&cfg, SSH_MOUNT)
};
if existing {
eprintln!("sandcage: SSH mount already configured in {config_label}");
return Ok(());
}
}
// Confirm
if !yes {
let confirmed = dialoguer::Confirm::new()
.with_prompt(format!("Add SSH mount to {config_label}?"))
.default(false)
.interact()
.unwrap_or(false);
if !confirmed {
return Ok(());
}
}
// Write
if global {
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| SetupError::ConfigWriteFailed(config_path.clone(), e))?;
}
add_mount_to_toml(&config_path, SSH_MOUNT)?;
} else {
add_mount_to_yaml(&config_path, SSH_MOUNT)?;
}
eprintln!("sandcage: added SSH mount to {config_label}");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::{NamedTempFile, TempDir};
fn touch(dir: &Path, name: &str) {
std::fs::write(dir.join(name), "").expect("touch file");
}
#[test]
fn scan_finds_keys_by_pub_extension() {
let dir = TempDir::new().unwrap();
touch(dir.path(), "id_ed25519");
touch(dir.path(), "id_ed25519.pub");
touch(dir.path(), "work_github");
touch(dir.path(), "work_github.pub");
let result = scan_ssh_dir(dir.path()).unwrap();
assert_eq!(result.keys, vec!["id_ed25519", "work_github"]);
}
#[test]
fn scan_finds_config_files() {
let dir = TempDir::new().unwrap();
touch(dir.path(), "config");
touch(dir.path(), "known_hosts");
touch(dir.path(), "authorized_keys");
let result = scan_ssh_dir(dir.path()).unwrap();
assert_eq!(
result.config_files,
vec!["authorized_keys", "config", "known_hosts"]
);
assert_eq!(result.keys, Vec::<String>::new());
}
#[test]
fn scan_counts_other_files() {
let dir = TempDir::new().unwrap();
touch(dir.path(), "id_rsa");
touch(dir.path(), "id_rsa.pub");
touch(dir.path(), "config");
touch(dir.path(), "environment");
touch(dir.path(), "random_file");
let result = scan_ssh_dir(dir.path()).unwrap();
assert_eq!(result.keys, vec!["id_rsa"]);
assert_eq!(result.config_files, vec!["config"]);
assert_eq!(result.other_count, 3);
}
#[test]
fn scan_empty_dir() {
let dir = TempDir::new().unwrap();
let result = scan_ssh_dir(dir.path()).unwrap();
assert_eq!(result.keys, Vec::<String>::new());
assert_eq!(result.config_files, Vec::<String>::new());
assert_eq!(result.other_count, 0);
}
#[test]
fn scan_nonexistent_dir_errors() {
let result = scan_ssh_dir(Path::new("/nonexistent/ssh/dir"));
assert!(matches!(result, Err(SetupError::NoSshDir(_))));
}
// -- check_existing_mount tests --
#[test]
fn check_existing_mount_found() {
let config = SandcageConfig {
mounts: Some(vec!["~/.ssh:/home/agent/.ssh:ro".to_string()]),
..Default::default()
};
assert!(check_existing_mount(&config, "~/.ssh:/home/agent/.ssh:ro"));
}
#[test]
fn check_existing_mount_not_found() {
let config = SandcageConfig {
mounts: Some(vec!["/data:/mnt:ro".to_string()]),
..Default::default()
};
assert!(!check_existing_mount(&config, "~/.ssh:/home/agent/.ssh:ro"));
}
#[test]
fn check_existing_mount_no_mounts() {
let config = SandcageConfig::default();
assert!(!check_existing_mount(&config, "~/.ssh:/home/agent/.ssh:ro"));
}
// -- add_mount_to_yaml tests --
#[test]
fn add_mount_to_yaml_with_existing_mounts() {
let yaml = "env:\n FOO: bar\nmounts:\n - /data:/mnt\n";
let mut tmp = NamedTempFile::new().unwrap();
write!(tmp, "{yaml}").unwrap();
add_mount_to_yaml(tmp.path(), "~/.ssh:/home/agent/.ssh:ro").unwrap();
let content = std::fs::read_to_string(tmp.path()).unwrap();
assert!(content.contains("~/.ssh:/home/agent/.ssh:ro"));
assert!(content.contains("/data:/mnt"));
assert!(content.contains("FOO"));
}
#[test]
fn add_mount_to_yaml_no_existing_mounts() {
let yaml = "env:\n FOO: bar\n";
let mut tmp = NamedTempFile::new().unwrap();
write!(tmp, "{yaml}").unwrap();
add_mount_to_yaml(tmp.path(), "~/.ssh:/home/agent/.ssh:ro").unwrap();
let content = std::fs::read_to_string(tmp.path()).unwrap();
assert!(content.contains("~/.ssh:/home/agent/.ssh:ro"));
assert!(content.contains("FOO"));
}
#[test]
fn add_mount_to_yaml_empty_file() {
let mut tmp = NamedTempFile::new().unwrap();
write!(tmp, "").unwrap();
add_mount_to_yaml(tmp.path(), "~/.ssh:/home/agent/.ssh:ro").unwrap();
let content = std::fs::read_to_string(tmp.path()).unwrap();
assert!(content.contains("~/.ssh:/home/agent/.ssh:ro"));
}
#[test]
fn add_mount_to_yaml_preserves_comments() {
let yaml = "# Environment\nenv:\n FOO: bar\n\n# Mount points\nmounts:\n- /data:/mnt\n";
let mut tmp = NamedTempFile::new().unwrap();
write!(tmp, "{yaml}").unwrap();
add_mount_to_yaml(tmp.path(), "~/.ssh:/home/agent/.ssh:ro").unwrap();
let content = std::fs::read_to_string(tmp.path()).unwrap();
assert!(content.contains("# Environment"), "preamble comment preserved");
assert!(content.contains("# Mount points"), "inline comment preserved");
assert!(content.contains("~/.ssh:/home/agent/.ssh:ro"));
assert!(content.contains("/data:/mnt"));
}
#[test]
fn add_mount_to_yaml_preserves_trailing_comments() {
let yaml = "mounts:\n- /data:/mnt\n\n# end of file\n";
let mut tmp = NamedTempFile::new().unwrap();
write!(tmp, "{yaml}").unwrap();
add_mount_to_yaml(tmp.path(), "~/.ssh:/home/agent/.ssh:ro").unwrap();
let content = std::fs::read_to_string(tmp.path()).unwrap();
assert!(content.contains("# end of file"), "trailing comment preserved");
}
#[test]
fn rehydrate_preserves_blank_lines_between_keys() {
let original = "env:\n A: 1\n\nmounts:\n- /data:/mnt\n";
let generated = "env:\n A: '1'\nmounts:\n- /data:/mnt\n- /new:/new\n";
let result = rehydrate_yaml_comments(original, generated);
assert!(result.contains("\n\nmounts:"), "blank line before mounts preserved");
}
// -- add_mount_to_toml tests --
#[test]
fn add_mount_to_toml_new_file() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("config.toml");
add_mount_to_toml(&path, "~/.ssh:/home/agent/.ssh:ro").unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("~/.ssh:/home/agent/.ssh:ro"));
}
#[test]
fn add_mount_to_toml_existing_file() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "shell = \"zsh\"\n").unwrap();
add_mount_to_toml(&path, "~/.ssh:/home/agent/.ssh:ro").unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("~/.ssh:/home/agent/.ssh:ro"));
assert!(content.contains("zsh"));
}
#[test]
fn add_mount_to_toml_existing_mounts() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "mounts = [\"/data:/mnt\"]\n").unwrap();
add_mount_to_toml(&path, "~/.ssh:/home/agent/.ssh:ro").unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("~/.ssh:/home/agent/.ssh:ro"));
assert!(content.contains("/data:/mnt"));
}
}
+4
View File
@@ -45,3 +45,7 @@ export *FLAGS:
# Dry-run export (inspect without pushing).
export-dry:
uv run python scripts/export.py push --dry-run
install:
cargo install --path crates/sandcage
sandcage build --force
+3 -1
View File
@@ -3,7 +3,9 @@ name = "sandcage"
version = "0.1.0"
description = "Tooling and scripts for the Sandcage project"
requires-python = ">=3.12"
dependencies = []
dependencies = [
"pillow>=12.2.0",
]
[dependency-groups]
dev = [
+3 -1
View File
@@ -148,7 +148,9 @@ def run_export(*, repo_root: Path, cfg: ExportConfig,
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",
git_name = _run(["git", "config", "user.name"], cwd=repo_root).stdout.strip()
git_email = _run(["git", "config", "user.email"], cwd=repo_root).stdout.strip()
_run(["git", "-c", f"user.email={git_email}", "-c", f"user.name={git_name}",
"commit", "-m",
f"\U0001f947 export from upstream ({source_sha})"],
cwd=worktree)
+103
View File
@@ -0,0 +1,103 @@
"""Generate icon variants from the sandcage source image."""
from pathlib import Path
from PIL import Image, ImageDraw
ROOT = Path(__file__).resolve().parent.parent
SRC = ROOT / ".workpad" / "assets" / "sandcage.png"
OUT = ROOT / "local"
def square_crop(src: Path, size: int = 1024) -> Image.Image:
img = Image.open(src).convert("RGBA")
w, h = img.size
side = min(w, h)
left = (w - side) // 2
top = (h - side) // 2
img = img.crop((left, top, left + side, top + side))
return img.resize((size, size), Image.LANCZOS)
def invert_rgb(img: Image.Image) -> Image.Image:
from PIL import ImageChops
r, g, b, a = img.split()
return Image.merge("RGBA", (ImageChops.invert(r), ImageChops.invert(g), ImageChops.invert(b), a))
def lighten_dune_interior(img: Image.Image, grey: int = 80) -> Image.Image:
img = img.copy()
draw = ImageDraw.Draw(img)
fill = (grey, grey, grey, 255)
# tips found at (56,567) and (973,567), dune bottom at y=652
# draw a filled arc (pieslice) from tip to tip curving below to seal the interior
# pieslice bbox: center the ellipse so it passes through both tips
cx = (56 + 973) // 2
half_w = (973 - 56) // 2
arc_depth = 120
bbox = (cx - half_w, 567 - arc_depth, cx + half_w, 567 + arc_depth)
# draw just the bottom arc in grey to create the barrier
draw.arc(bbox, 0, 180, fill=fill, width=4)
# flood fill the enclosed black region
ImageDraw.floodfill(img, (300, 620), fill, thresh=60)
return img
def apply_vertical_bars(img: Image.Image, bar_width: int = 48, gap_width: int = 48, opacity: float = 0.5) -> Image.Image:
img = img.copy()
r, g, b, a = img.split()
bar_mask = Image.new("L", img.size, 255)
draw = ImageDraw.Draw(bar_mask)
bar_alpha = int(255 * opacity)
x = 0
while x < img.size[0]:
draw.rectangle((x, 0, x + bar_width - 1, img.size[1]), fill=bar_alpha)
x += bar_width + gap_width
from PIL import ImageChops
a = ImageChops.multiply(a, bar_mask)
return Image.merge("RGBA", (r, g, b, a))
def apply_circle_mask(img: Image.Image, size: int = 1024) -> Image.Image:
# circle passes through dune tips (56,567) and (973,567)
# center at midpoint, radius = half the tip distance
cx = (56 + 973) // 2 # 514
cy = 567
r = (973 - 56) // 2 # 458
# crop to bounding box of this circle, then resize to output size
left = max(cx - r, 0)
top = max(cy - r, 0)
right = min(cx + r, img.size[0])
bottom = min(cy + r, img.size[1])
cropped = img.crop((left, top, right, bottom))
cropped = cropped.resize((size, size), Image.LANCZOS)
# apply circular mask
mask = Image.new("L", (size, size), 0)
draw = ImageDraw.Draw(mask)
draw.ellipse((0, 0, size, size), fill=255)
result = Image.new("RGBA", (size, size), (0, 0, 0, 0))
result.paste(cropped, (0, 0), mask)
return result
def save(img: Image.Image, path: Path) -> Path:
img.save(path)
print(f"Wrote {path}")
return path
if __name__ == "__main__":
OUT.mkdir(parents=True, exist_ok=True)
base = square_crop(SRC)
inverted = invert_rgb(base)
save(apply_circle_mask(base), OUT / "sandcage-round.png")
save(apply_circle_mask(inverted), OUT / "sandcage-round-inverted.png")
inverted_grey = lighten_dune_interior(inverted)
save(apply_circle_mask(inverted_grey), OUT / "sandcage-round-inverted-grey.png")
save(apply_circle_mask(apply_vertical_bars(base)), OUT / "sandcage-round-bars.png")
save(apply_circle_mask(apply_vertical_bars(inverted)), OUT / "sandcage-round-inverted-bars.png")
save(apply_circle_mask(apply_vertical_bars(inverted_grey)), OUT / "sandcage-round-inverted-grey-bars.png")
Generated
+85 -13
View File
@@ -1,50 +1,118 @@
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" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 }
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" },
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
]
[[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" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503 }
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" },
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 },
]
[[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" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134 }
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" },
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195 },
]
[[package]]
name = "pillow"
version = "12.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279 },
{ url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490 },
{ url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462 },
{ url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744 },
{ url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371 },
{ url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215 },
{ url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783 },
{ url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112 },
{ url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489 },
{ url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129 },
{ url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612 },
{ url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837 },
{ url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528 },
{ url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401 },
{ url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094 },
{ url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402 },
{ url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005 },
{ url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669 },
{ url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194 },
{ url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423 },
{ url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667 },
{ url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580 },
{ url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896 },
{ url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266 },
{ url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508 },
{ url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927 },
{ url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624 },
{ url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252 },
{ url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550 },
{ url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114 },
{ url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667 },
{ url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966 },
{ url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241 },
{ url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592 },
{ url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542 },
{ url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765 },
{ url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848 },
{ url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515 },
{ url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159 },
{ url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185 },
{ url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386 },
{ url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384 },
{ url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599 },
{ url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021 },
{ url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360 },
{ url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628 },
{ url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321 },
{ url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723 },
{ url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400 },
{ url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835 },
{ url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225 },
{ url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541 },
{ url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251 },
{ url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807 },
{ url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935 },
{ url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720 },
{ url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498 },
{ url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413 },
{ url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084 },
{ url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152 },
{ url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579 },
]
[[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" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 }
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" },
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 },
]
[[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" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991 }
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" },
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151 },
]
[[package]]
@@ -58,15 +126,18 @@ dependencies = [
{ 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" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165 }
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" },
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249 },
]
[[package]]
name = "sandcage"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "pillow" },
]
[package.dev-dependencies]
dev = [
@@ -74,6 +145,7 @@ dev = [
]
[package.metadata]
requires-dist = [{ name = "pillow", specifier = ">=12.2.0" }]
[package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=9.0.3" }]