use std::collections::BTreeMap;
use std::ffi::OsString;
use std::io::Read as _;
use std::path::{Path, PathBuf};
use std::sync::{Arc, OnceLock};
use base64::Engine as _;
use rhema_core::{Image, ImageMediaType, ToolResult};
pub const NAME: &str = "see";
pub const MODE: &str = "__vision";
pub const VAR_BASH_ENV: &str = "BASH_ENV";
pub const VAR_BIN: &str = "RHEMA_VISION_BIN";
pub const VAR_STAGING: &str = "RHEMA_VISION_STAGING";
pub const MAX_IMAGE_BYTES: usize = 5 * 1024 * 1024;
const MAX_IMAGES_PER_RESULT: usize = 8;
pub const PROMPT_SECTION: &str = "You can view images by piping bytes into `see`, or by passing file paths:
curl -sL https://example.com/photo.png | see
see screenshots/capture.png
Staged images attach to that bash call's result and you see them as images. Formats: png, jpeg, gif, webp; up to 5 MB per image.";
#[derive(Debug, thiserror::Error)]
pub enum StageError {
#[error("no bytes arrived on stdin")]
Empty,
#[error("not a recognized image (png, jpeg, gif, webp)")]
NotAnImage,
#[error("image too large: {0} bytes, the limit is {MAX_IMAGE_BYTES}")]
TooLarge(usize),
#[error("staging failed: {0}")]
Io(#[from] std::io::Error),
}
pub struct Wiring {
pub shell_env: BTreeMap<String, String>,
pub section: &'static str,
pub init_path: PathBuf,
dir: PathBuf,
}
impl Drop for Wiring {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
static WIRED: OnceLock<Option<Arc<Wiring>>> = OnceLock::new();
pub fn wire(bash_env: Option<OsString>) -> Option<&'static Arc<Wiring>> {
WIRED
.get_or_init(|| {
let bin = std::env::current_exe().ok()?;
provision_with_bin(bash_env, &bin).map(Arc::new).ok()
})
.as_ref()
}
pub fn provision_with_bin(bash_env: Option<OsString>, bin: &Path) -> std::io::Result<Wiring> {
let dir = std::env::temp_dir().join(format!(
"rhema-vision-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
std::fs::create_dir_all(&dir)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))?;
}
let staging = dir.join("staging");
std::fs::create_dir_all(&staging)?;
let init_path = dir.join("init.bash");
let mut text = String::from("# generated by rhema-agent-tools; do not edit\n");
if let Some(inherited) = bash_env {
let quoted = inherited.to_string_lossy().replace('\'', "'\\\''");
text.push_str(&format!("source '{quoted}' || true\n"));
}
text.push_str(&format!(
"{NAME}() {{ \"$RHEMA_VISION_BIN\" {MODE} \"$@\"; }}\n"
));
std::fs::write(&init_path, text)?;
let mut shell_env = BTreeMap::new();
shell_env.insert(VAR_BASH_ENV.to_owned(), init_path.display().to_string());
shell_env.insert(VAR_BIN.to_owned(), bin.display().to_string());
shell_env.insert(VAR_STAGING.to_owned(), staging.display().to_string());
Ok(Wiring {
shell_env,
section: PROMPT_SECTION,
init_path,
dir,
})
}
pub fn cli_run(argv: &[OsString]) -> i32 {
let Some(staging) = std::env::var_os(VAR_STAGING) else {
eprintln!("{NAME}: not wired in this shell (no {VAR_STAGING})");
return 4;
};
let staging = PathBuf::from(staging);
let paths: Vec<OsString> = argv.iter().skip(1).cloned().collect();
let mut failed = 0;
if paths.is_empty() {
let mut bytes = Vec::new();
if let Err(err) = std::io::stdin()
.take((MAX_IMAGE_BYTES + 1) as u64)
.read_to_end(&mut bytes)
{
eprintln!("{NAME}: reading stdin failed: {err}");
return 3;
}
return match stage_bytes(&staging, &bytes, "0") {
Ok(_) => {
println!("[image staged for viewing: {} bytes]", bytes.len());
0
}
Err(err) => {
eprintln!("{NAME}: {err}");
exit_code(&err)
}
};
}
for (index, path) in paths.iter().enumerate() {
let bytes = match std::fs::read(path) {
Ok(bytes) => bytes,
Err(err) => {
eprintln!("{NAME}: {}: {err}", path.display());
failed += 1;
continue;
}
};
match stage_bytes(&staging, &bytes, &index.to_string()) {
Ok(_) => println!("[image staged for viewing: {}]", path.display()),
Err(err) => {
eprintln!("{NAME}: {}: {err}", path.display());
failed += 1;
}
}
}
if failed > 0 { 2 } else { 0 }
}
fn exit_code(err: &StageError) -> i32 {
match err {
StageError::Empty => 1,
StageError::NotAnImage | StageError::TooLarge(_) => 2,
StageError::Io(_) => 3,
}
}
pub fn stage_bytes(
staging: &Path,
bytes: &[u8],
disambiguator: &str,
) -> Result<PathBuf, StageError> {
if bytes.is_empty() {
return Err(StageError::Empty);
}
if bytes.len() > MAX_IMAGE_BYTES {
return Err(StageError::TooLarge(bytes.len()));
}
if ImageMediaType::sniff(bytes).is_none() {
return Err(StageError::NotAnImage);
}
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let path = staging.join(format!(
"{}-{nanos}-{disambiguator}.img",
std::process::id()
));
std::fs::write(&path, bytes)?;
Ok(path)
}
pub(crate) fn attach(staging: &Path, result: &mut ToolResult) {
let Ok(entries) = std::fs::read_dir(staging) else {
return;
};
let mut paths: Vec<PathBuf> = entries.flatten().map(|entry| entry.path()).collect();
paths.sort();
let mut attached: Vec<Image> = Vec::new();
let mut dropped: Vec<String> = Vec::new();
for path in paths {
let bytes = std::fs::read(&path).ok();
let _ = std::fs::remove_file(&path);
let Some(bytes) = bytes else {
continue;
};
if bytes.len() > MAX_IMAGE_BYTES {
dropped.push(format!(
"{} bytes over the {} byte limit",
bytes.len(),
MAX_IMAGE_BYTES
));
continue;
}
let Some(media_type) = ImageMediaType::sniff(&bytes) else {
dropped.push(format!("{} bytes, not a recognized image", bytes.len()));
continue;
};
if attached.len() == MAX_IMAGES_PER_RESULT {
dropped.push(format!(
"1 more image ({}) dropped: at most {MAX_IMAGES_PER_RESULT} attach per result",
media_type.as_str()
));
continue;
}
attached.push(Image::new(
media_type,
base64::engine::general_purpose::STANDARD.encode(&bytes),
));
}
if attached.is_empty() && dropped.is_empty() {
return;
}
let count = attached.len();
result.images.extend(attached);
result.content.push_str(&format!(
"\n[{count} image(s) attached and visible to you{}]",
if dropped.is_empty() {
String::new()
} else {
format!("; dropped: {}", dropped.join("; "))
}
));
}
#[cfg(test)]
mod tests {
use super::*;
fn png_bytes() -> Vec<u8> {
let mut bytes = b"\x89PNG\r\n\x1a\n".to_vec();
bytes.extend_from_slice(&[0, 0, 0, 13]);
bytes.extend_from_slice(b"IHDR");
bytes
}
#[test]
fn staging_sniffs_and_names_uniquely() {
let dir = tempfile::tempdir().unwrap();
let first = stage_bytes(dir.path(), &png_bytes(), "0").unwrap();
let second = stage_bytes(dir.path(), &png_bytes(), "1").unwrap();
assert_ne!(first, second);
assert_eq!(std::fs::read(&first).unwrap(), png_bytes());
let err = stage_bytes(dir.path(), b"definitely not an image", "2").unwrap_err();
assert!(err.to_string().contains("not a recognized image"), "{err}");
let err = stage_bytes(dir.path(), &[], "3").unwrap_err();
assert!(err.to_string().contains("no bytes"), "{err}");
let err = stage_bytes(dir.path(), &vec![0u8; MAX_IMAGE_BYTES + 1], "4").unwrap_err();
assert!(err.to_string().contains("too large"), "{err}");
}
#[test]
fn attach_moves_staged_images_into_the_result_and_empties_the_dir() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("a.img"), png_bytes()).unwrap();
std::fs::write(dir.path().join("b.img"), b"not an image").unwrap();
let mut result = ToolResult::success("c1", "out");
attach(dir.path(), &mut result);
assert_eq!(result.images.len(), 1);
assert_eq!(result.images[0].media_type, ImageMediaType::Png);
assert!(
result.content.contains("1 image(s) attached"),
"{}",
result.content
);
assert!(
result.content.contains("not a recognized image"),
"{}",
result.content
);
assert!(
std::fs::read_dir(dir.path()).unwrap().next().is_none(),
"the staging dir empties"
);
attach(dir.path(), &mut result);
assert_eq!(result.images.len(), 1);
assert_eq!(result.content.matches("attached").count(), 1);
}
#[test]
fn attach_caps_the_images_per_result() {
let dir = tempfile::tempdir().unwrap();
for index in 0..=MAX_IMAGES_PER_RESULT {
std::fs::write(dir.path().join(format!("{index}.img")), png_bytes()).unwrap();
}
let mut result = ToolResult::success("c1", "out");
attach(dir.path(), &mut result);
assert_eq!(result.images.len(), MAX_IMAGES_PER_RESULT);
assert!(
result
.content
.contains(&format!("at most {MAX_IMAGES_PER_RESULT} attach")),
"{}",
result.content
);
}
#[test]
fn an_unreadable_staged_file_drops_without_poisoning_the_result() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("dir.img")).unwrap();
let mut result = ToolResult::success("c1", "out");
attach(dir.path(), &mut result);
assert_eq!(result.images, Vec::new());
assert_eq!(result.content, "out");
}
#[test]
fn the_init_file_bakes_the_predecessor_in_and_defines_the_function() {
let wiring = provision_with_bin(
Some(OsString::from("/user/init.bash")),
Path::new("/usr/local/bin/rhema"),
)
.unwrap();
let text = std::fs::read_to_string(&wiring.init_path).unwrap();
assert!(
text.contains("source '/user/init.bash' || true\n"),
"{text}"
);
assert!(
text.contains(&format!(
"{NAME}() {{ \"$RHEMA_VISION_BIN\" {MODE} \"$@\"; }}"
)),
"{text}"
);
assert!(
!wiring.shell_env.contains_key("RHEMA_INHERITED_BASH_ENV"),
"{:?}",
wiring.shell_env
);
let staging = PathBuf::from(wiring.shell_env.get(VAR_STAGING).unwrap());
assert!(staging.is_dir(), "{staging:?} exists");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let dir = staging.parent().unwrap();
let mode = std::fs::metadata(dir).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o700, "the dir is 0700, got {mode:o}");
}
let bare = provision_with_bin(None, Path::new("/usr/local/bin/rhema")).unwrap();
let text = std::fs::read_to_string(&bare.init_path).unwrap();
assert_eq!(
text,
"# generated by rhema-agent-tools; do not edit\nsee() { \"$RHEMA_VISION_BIN\" __vision \"$@\"; }\n"
);
}
#[test]
fn the_sourced_chain_terminates_where_an_env_var_chain_would_recurse() {
let outer = tempfile::tempdir().unwrap();
let outer_init = outer.path().join("outer-init.bash");
std::fs::write(
&outer_init,
"[ -n \"${RHEMA_INHERITED_BASH_ENV:-}\" ] && \\\n source \"$RHEMA_INHERITED_BASH_ENV\" || true\nouter() { :; }\n",
)
.unwrap();
let wiring = provision_with_bin(
Some(OsString::from(outer_init.display().to_string())),
Path::new("/usr/local/bin/rhema"),
)
.unwrap();
let inner_init = wiring.init_path.display().to_string();
let staging = wiring.shell_env.get(VAR_STAGING).unwrap().clone();
let script = format!(
"env BASH_ENV={inner_init:?} RHEMA_VISION_BIN=/usr/bin/true RHEMA_VISION_STAGING={staging:?} bash -c 'echo alive'"
);
let output = std::process::Command::new("bash")
.arg("-c")
.arg(&script)
.output()
.unwrap();
assert!(output.status.success(), "{output:?}");
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "alive");
}
}