diff --git a/.sandcage.yml b/.sandcage.yml new file mode 100644 index 0000000..2fd3957 --- /dev/null +++ b/.sandcage.yml @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 89bed47..f01db83 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/compose/docker-compose.yml b/compose/docker-compose.yml index bfc228e..569ec15 100644 --- a/compose/docker-compose.yml +++ b/compose/docker-compose.yml @@ -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 diff --git a/crates/sandcage/Cargo.toml b/crates/sandcage/Cargo.toml index 2633233..fa17f27 100644 --- a/crates/sandcage/Cargo.toml +++ b/crates/sandcage/Cargo.toml @@ -18,3 +18,5 @@ dirs = "6" sha2 = "0.10" hex = "0.4" tempfile = "3" +dialoguer = "0.11" +toml_edit = "0.22" diff --git a/crates/sandcage/src/config.rs b/crates/sandcage/src/config.rs index 95203bb..059d219 100644 --- a/crates/sandcage/src/config.rs +++ b/crates/sandcage/src/config.rs @@ -54,6 +54,8 @@ struct RawConfig { trusted_projects: Option>, #[serde(default)] container_workspace: Option, + #[serde(default)] + agent_args: Option>>, /// Absorb everything else so we can warn about unknown fields. #[serde(flatten)] @@ -85,13 +87,15 @@ pub struct SandcageConfig { pub trusted_projects: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub container_workspace: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_args: Option>>, } // --------------------------------------------------------------------------- // 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"]); + } } diff --git a/crates/sandcage/src/docker.rs b/crates/sandcage/src/docker.rs index f140b36..f164710 100644 --- a/crates/sandcage/src/docker.rs +++ b/crates/sandcage/src/docker.rs @@ -153,13 +153,6 @@ pub fn build_compose_env(workspace: &Path, config: &SandcageConfig) -> Result 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("/"); diff --git a/crates/sandcage/src/init.rs b/crates/sandcage/src/init.rs index 8d408ed..9bf38ca 100644 --- a/crates/sandcage/src/init.rs +++ b/crates/sandcage/src/init.rs @@ -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()); } // ----------------------------------------------------------------------- diff --git a/crates/sandcage/src/lib.rs b/crates/sandcage/src/lib.rs index 83efa18..9e49be3 100644 --- a/crates/sandcage/src/lib.rs +++ b/crates/sandcage/src/lib.rs @@ -1,4 +1,5 @@ pub mod config; pub mod docker; pub mod init; +pub mod setup; pub mod workspace; diff --git a/crates/sandcage/src/main.rs b/crates/sandcage/src/main.rs index 9047cd1..6fe6401 100644 --- a/crates/sandcage/src/main.rs +++ b/crates/sandcage/src/main.rs @@ -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, shell_override: bool, agent_args: Vec) -> 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(()) diff --git a/crates/sandcage/src/setup.rs b/crates/sandcage/src/setup.rs new file mode 100644 index 0000000..55212c0 --- /dev/null +++ b/crates/sandcage/src/setup.rs @@ -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 = std::result::Result; + +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, + pub config_files: Vec, + pub other_count: usize, +} + +pub fn scan_ssh_dir(ssh_dir: &Path) -> Result { + 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, Vec)> = Vec::new(); + let mut pending: Vec = 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::() + .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::::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::::new()); + assert_eq!(result.config_files, Vec::::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")); + } +} diff --git a/justfile b/justfile index 4a217b8..f001b1f 100644 --- a/justfile +++ b/justfile @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 888c8b9..6dedf1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ diff --git a/scripts/export.py b/scripts/export.py index 0a9664a..1fd00fe 100644 --- a/scripts/export.py +++ b/scripts/export.py @@ -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) diff --git a/scripts/make_icon.py b/scripts/make_icon.py new file mode 100644 index 0000000..a84dda3 --- /dev/null +++ b/scripts/make_icon.py @@ -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") diff --git a/uv.lock b/uv.lock index 09b3168..7bf86d2 100644 --- a/uv.lock +++ b/uv.lock @@ -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" }]