use std::{
fs, io,
path::{Path, PathBuf},
process::Command,
};
use crate::config::SetupArgs;
const CONFIG_TEMPLATE: &str = include_str!("../templates/aphrodite.toml");
#[derive(Debug, thiserror::Error)]
pub enum SetupError {
#[error("I/O error: {0}")]
Io(#[from] io::Error),
#[error("{0}")]
HermesNotFound(String),
#[error("{0}")]
DylibNotFound(String),
#[error("{0}")]
PluginRegistrationFailed(String),
}
struct SetupCtx {
aphrodite_dir: PathBuf,
binaries_dir: PathBuf,
own_path: PathBuf,
own_hash: String,
}
pub fn run(args: &SetupArgs) -> Result<(), SetupError> {
let home =
dirs::home_dir().ok_or_else(|| SetupError::Io(io::Error::new(io::ErrorKind::NotFound, "$HOME not set")))?;
let own_path = std::env::current_exe().map_err(SetupError::Io)?;
let own_hash = self_hash(&own_path);
let ctx = SetupCtx {
aphrodite_dir: home.join(".hermes").join("aphrodite"),
binaries_dir: home.join(".hermes").join("aphrodite").join("binaries"),
own_path,
own_hash,
};
println!("aphrodite setup v{}", env!("CARGO_PKG_VERSION"));
println!(" self-hash: {}", ctx.own_hash);
verify_hermes()?;
fs::create_dir_all(&ctx.binaries_dir)?;
fs::create_dir_all(&ctx.aphrodite_dir)?;
let target_binary = ctx.binaries_dir.join(binary_name());
println!("copying binary -> {}", target_binary.display());
#[cfg(target_os = "macos")]
install_macos_artifact(&ctx.own_path, &target_binary, None, 0o700)?;
#[cfg(not(target_os = "macos"))]
{
fs::copy(&ctx.own_path, &target_binary)?;
secure_perms(&target_binary, 0o700)?;
}
copy_dylibs(&ctx)?;
let config_path = ctx.aphrodite_dir.join("aphrodite.toml");
if !config_path.exists() || args.force {
let config = CONFIG_TEMPLATE
.replace("{api_url}", &args.api_url)
.replace("{model}", &args.model)
.replace("{cache_port}", &args.cache_port.to_string())
.replace("{token_port}", &args.token_port.to_string());
println!("writing config -> {}", config_path.display());
fs::write(&config_path, &config)?;
secure_perms(&config_path, 0o600)?;
}
write_plugin_yaml(&ctx, args)?;
write_init_py(&ctx)?;
symlink_plugin(&ctx)?;
register_plugin(&ctx)?;
println!("aphrodite installed -> {}", ctx.aphrodite_dir.display());
Ok(())
}
#[cfg(target_os = "macos")]
fn install_macos_artifact(src: &Path, dest: &Path, dylib_id_name: Option<&str>, mode: u32) -> Result<(), SetupError> {
let _ = std::fs::remove_file(dest);
let ditto_ok = Command::new("ditto")
.args([src.to_str().unwrap_or(""), dest.to_str().unwrap_or("")])
.status()
.map(|s| s.success())
.unwrap_or(false);
if !ditto_ok {
fs::copy(src, dest)?;
match Command::new("xattr").args(["-c", dest.to_str().unwrap_or("")]).output() {
Ok(out) if out.status.success() => {},
_ => {
eprintln!(
"warning: xattr -c failed or unavailable for {} - Gatekeeper may still kill this artifact",
dest.display()
)
},
}
}
if let Some(name) = dylib_id_name {
let rpath = format!("@rpath/{name}");
match Command::new("install_name_tool")
.args(["-id", &rpath, dest.to_str().unwrap_or("")])
.output()
{
Ok(out) if out.status.success() => {},
_ => {
eprintln!(
"warning: install_name_tool failed or unavailable for {} - Gatekeeper may kill Hermes when \
loading this dylib; install Xcode Command Line Tools and re-run setup",
dest.display()
)
},
}
match Command::new("codesign")
.args(["-f", "-s", "-", dest.to_str().unwrap_or("")])
.output()
{
Ok(out) if out.status.success() => {},
_ => {
eprintln!(
"warning: codesign failed or unavailable for {} - Gatekeeper may still kill this dylib",
dest.display()
)
},
}
}
secure_perms(dest, mode)?;
Ok(())
}
fn self_hash(path: &Path) -> String {
match fs::read(path) {
Ok(bytes) => {
let hash = blake3::hash(&bytes);
hash.to_hex().to_string()
},
Err(_) => "unknown".into(),
}
}
fn verify_hermes() -> Result<(), SetupError> {
match Command::new("hermes").arg("--version").output() {
Ok(out) if out.status.success() => {
let version = String::from_utf8_lossy(&out.stdout).trim().to_string();
println!(" hermes found: {version}");
Ok(())
},
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr);
Err(SetupError::HermesNotFound(format!("hermes --version failed: {stderr}")))
},
Err(_) => Err(SetupError::HermesNotFound(
"hermes not found in PATH - install hermes agent first".into(),
)),
}
}
fn copy_dylibs(ctx: &SetupCtx) -> Result<(), SetupError> {
let dylib_names: &[&str] = if cfg!(target_os = "macos") {
&["libaphrodite.dylib", "libaphrodite_hermes.dylib"]
} else if cfg!(target_os = "linux") {
&["libaphrodite.so", "libaphrodite_hermes.so"]
} else {
&["aphrodite.dll", "aphrodite_hermes.dll"]
};
let exe_dir = ctx.own_path.parent().unwrap_or(Path::new("."));
let search_paths: Vec<PathBuf> = vec![
exe_dir.to_path_buf(),
exe_dir.join("deps"),
PathBuf::from("/usr/local/lib"),
PathBuf::from("/opt/homebrew/lib"),
];
let mut copied = 0u32;
for name in dylib_names {
let dest = ctx.binaries_dir.join(name);
let mut found = false;
for search_dir in &search_paths {
let src = search_dir.join(name);
if src.exists() {
println!("copying dylib {} -> {}", name, dest.display());
#[cfg(target_os = "macos")]
install_macos_artifact(&src, &dest, Some(name), 0o755)?;
#[cfg(not(target_os = "macos"))]
{
fs::copy(&src, &dest)?;
secure_perms(&dest, 0o755)?;
}
found = true;
copied += 1;
break;
}
}
if !found {
let target_release = exe_dir
.parent()
.unwrap_or(Path::new("."))
.parent()
.unwrap_or(Path::new("."))
.join("target")
.join("release")
.join(name);
if target_release.exists() {
println!("copying dylib {} -> {}", name, dest.display());
#[cfg(target_os = "macos")]
install_macos_artifact(&target_release, &dest, Some(name), 0o755)?;
#[cfg(not(target_os = "macos"))]
{
fs::copy(&target_release, &dest)?;
secure_perms(&dest, 0o755)?;
}
found = true;
copied += 1;
}
}
if !found {
if let Err(e) = download_dylib(name, &dest) {
return Err(SetupError::DylibNotFound(format!(
"dylib '{name}' not found locally and download failed: {e}. \
Build from source (cargo build --release -p aphrodite -p aphrodite-hermes) \
or download manually from \
https://github.com/PlayForm/Aphrodite/releases/tag/Aphrodite/v{version}",
version = env!("CARGO_PKG_VERSION"),
)));
}
copied += 1;
}
}
println!("copied {copied} dylib(s)");
Ok(())
}
fn download_dylib(dest_name: &str, dest: &Path) -> Result<(), String> {
let triple = target_triple();
let ext = if cfg!(windows) {
"dll"
} else if cfg!(target_os = "macos") {
"dylib"
} else {
"so"
};
let base = dest_name.strip_suffix(&format!(".{ext}")).unwrap_or(dest_name);
let remote_name = format!("{base}-{triple}.{ext}");
let version = env!("CARGO_PKG_VERSION");
let release_dir = format!("https://github.com/PlayForm/Aphrodite/releases/download/Aphrodite/v{version}");
let url = format!("{release_dir}/{remote_name}");
println!("downloading {remote_name} from GitHub Releases...");
println!(" url: {url}");
let status = if cfg!(windows) {
Command::new("powershell")
.args([
"-Command",
&format!("Invoke-WebRequest -Uri '{url}' -OutFile '{}'", dest.display()),
])
.status()
} else {
Command::new("curl")
.args(["-fsSL", "--retry", "3", "-o", dest.to_str().unwrap_or("dylib"), &url])
.status()
};
match status {
Ok(s) if s.success() => {
println!(" downloaded -> {}", dest.display());
if let Err(e) = verify_download_checksum(&release_dir, triple, &remote_name, dest) {
let _ = fs::remove_file(dest);
return Err(e);
}
#[cfg(unix)]
secure_perms(dest, 0o755).map_err(|e| e.to_string())?;
Ok(())
},
Ok(s) => Err(format!("download failed with exit code {}", s.code().unwrap_or(-1))),
Err(e) => Err(format!("could not run download command: {e}")),
}
}
fn verify_download_checksum(release_dir: &str, triple: &str, asset_name: &str, dest: &Path) -> Result<(), String> {
let sums_url = format!("{release_dir}/SHA256SUMS-{triple}.txt");
let sums_text = if cfg!(windows) {
Command::new("powershell")
.args([
"-Command",
&format!("(Invoke-WebRequest -Uri '{sums_url}' -UseBasicParsing).Content"),
])
.output()
} else {
Command::new("curl").args(["-fsSL", &sums_url]).output()
};
let sums_text = match sums_text {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).into_owned(),
_ => {
println!(" WARNING: {sums_url} not found - skipping checksum verification for this release");
return Ok(());
},
};
let expected = sums_text.lines().find_map(|line| {
let mut parts = line.split_whitespace();
let hash = parts.next()?;
let name = parts.next()?;
(name == asset_name).then(|| hash.to_lowercase())
});
let Some(expected) = expected else {
println!(" WARNING: {asset_name} has no entry in SHA256SUMS-{triple}.txt - skipping checksum check");
return Ok(());
};
let hash_output = if cfg!(windows) {
Command::new("powershell")
.args([
"-Command",
&format!("(Get-FileHash '{}' -Algorithm SHA256).Hash", dest.display()),
])
.output()
} else if Command::new("shasum").arg("--version").output().is_ok() {
Command::new("shasum").args(["-a", "256", dest.to_str().unwrap_or("")]).output()
} else {
Command::new("sha256sum").arg(dest.to_str().unwrap_or("")).output()
};
let actual = match hash_output {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
.split_whitespace()
.next()
.unwrap_or("")
.to_lowercase(),
_ => return Err("no shasum/sha256sum/Get-FileHash available to verify checksum".to_string()),
};
if actual != expected {
return Err(format!("checksum mismatch for {asset_name}: expected {expected}, got {actual}"));
}
println!(" checksum verified");
Ok(())
}
fn target_triple() -> &'static str {
if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
"aarch64-apple-darwin"
} else if cfg!(all(target_os = "macos", target_arch = "x86_64")) {
"x86_64-apple-darwin"
} else if cfg!(all(target_os = "linux", target_arch = "x86_64")) {
"x86_64-unknown-linux-gnu"
} else if cfg!(all(target_os = "windows", target_arch = "x86_64")) {
"x86_64-pc-windows-msvc"
} else {
"unknown"
}
}
fn write_plugin_yaml(ctx: &SetupCtx, args: &SetupArgs) -> Result<(), SetupError> {
let path = ctx.aphrodite_dir.join("plugin.yaml");
let yaml = format!(
r#"name: aphrodite
version: {version}
description: "CCR compression plugin - 13 tools, context engine, TOML-driven templates."
kind: standalone
min_hermes_version: "0.16.0"
requires_hooks: true
provides_hooks:
- on_session_start
- transform_tool_result
- pre_llm_call
- transform_terminal_output
- post_llm_call
provides_tools:
- aphrodite_retrieve
- aphrodite_compress
- aphrodite_stats
- aphrodite_rebuild
- aphrodite_files
- aphrodite_diff
- aphrodite_search
- aphrodite_directive
- aphrodite_test
- aphrodite_catalog
- aphrodite_reclassify
- aphrodite_prefetch
- aphrodite_prefetch_status
provides_context_engine: true
install_message: |
aphrodite v{version} - installed via `cargo install aphrodite` + `aphrodite setup`.
All logic in binaries/ - Rust-powered. Secure defaults.
Proxies: token (:{token_port}, SQLite), cache (:{cache_port}, in-memory).
"#,
version = env!("CARGO_PKG_VERSION"),
token_port = args.token_port,
cache_port = args.cache_port,
);
println!("writing plugin manifest -> {}", path.display());
fs::write(&path, &yaml)?;
secure_perms(&path, 0o644)?;
Ok(())
}
const HERMES_PLUGIN_SHIM: &str = include_str!("../templates/__init__.py");
fn write_init_py(ctx: &SetupCtx) -> Result<(), SetupError> {
let path = ctx.aphrodite_dir.join("__init__.py");
println!("writing __init__.py -> {}", path.display());
fs::write(&path, HERMES_PLUGIN_SHIM)?;
secure_perms(&path, 0o644)?;
Ok(())
}
fn symlink_plugin(ctx: &SetupCtx) -> Result<(), SetupError> {
let plugins_dir = dirs::home_dir()
.ok_or_else(|| SetupError::Io(io::Error::new(io::ErrorKind::NotFound, "$HOME not set")))?
.join(".hermes")
.join("plugins");
fs::create_dir_all(&plugins_dir)?;
let link = plugins_dir.join("aphrodite");
if link.exists() {
if link.is_symlink() {
let target = fs::read_link(&link)?;
if target == ctx.aphrodite_dir {
return Ok(());
}
fs::remove_file(&link)?;
} else {
return Err(SetupError::PluginRegistrationFailed(format!(
"{} exists and is not a symlink - manual cleanup required",
link.display()
)));
}
}
#[cfg(unix)]
std::os::unix::fs::symlink(&ctx.aphrodite_dir, &link)?;
#[cfg(windows)]
{
let status = Command::new("cmd")
.args(["/C", "mklink", "/J"])
.arg(&link)
.arg(&ctx.aphrodite_dir)
.status();
let junction_ok = matches!(status, Ok(s) if s.success());
if !junction_ok {
copy_dir_recursive(&ctx.aphrodite_dir, &link)?;
}
}
#[cfg(not(any(unix, windows)))]
{
let _ = (&ctx.aphrodite_dir, &link);
}
println!("symlinked plugin -> {}", link.display());
Ok(())
}
#[cfg(windows)]
fn copy_dir_recursive(src: &Path, dst: &Path) -> io::Result<()> {
fs::create_dir_all(dst)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let dest_path = dst.join(entry.file_name());
if entry.file_type()?.is_dir() {
copy_dir_recursive(&entry.path(), &dest_path)?;
} else {
fs::copy(entry.path(), &dest_path)?;
}
}
Ok(())
}
fn register_plugin(_ctx: &SetupCtx) -> Result<(), SetupError> {
let status = Command::new("hermes")
.args(["plugins", "enable", "aphrodite"])
.output()
.map_err(|e| SetupError::PluginRegistrationFailed(format!("hermes plugins enable: {e}")))?;
if !status.status.success() {
let stderr = String::from_utf8_lossy(&status.stderr);
eprintln!("warning: hermes plugins enable aphrodite: {stderr}");
} else {
println!("plugin registered with hermes");
}
Ok(())
}
fn secure_perms(path: &Path, mode: u32) -> io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(path)?.permissions();
perms.set_mode(mode);
fs::set_permissions(path, perms)?;
}
#[cfg(not(unix))]
let _ = (path, mode);
Ok(())
}
fn binary_name() -> &'static str {
if cfg!(target_os = "windows") { "aphrodite.exe" } else { "aphrodite" }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hermes_plugin_shim_has_no_stderr_devnull() {
assert!(
!HERMES_PLUGIN_SHIM.contains("stderr=subprocess.DEVNULL"),
"stderr must go to a log file, not DEVNULL (re-introduces the v1.2.1 silent-startup bug)"
);
}
#[test]
fn test_hermes_plugin_shim_reads_no_auto_launch() {
assert!(
HERMES_PLUGIN_SHIM.contains(r#"os.environ.get("APHRODITE_NO_AUTO_LAUNCH""#),
"the guard must be read, not just set"
);
}
#[test]
fn test_hermes_plugin_shim_reads_port_env_vars() {
assert!(HERMES_PLUGIN_SHIM.contains("APHRODITE_CACHE_PORT"));
assert!(HERMES_PLUGIN_SHIM.contains("APHRODITE_TOKEN_PORT"));
}
#[test]
fn test_hermes_plugin_shim_gates_context_engine_opt_in() {
assert!(HERMES_PLUGIN_SHIM.contains("APHRODITE_CONTEXT_ENGINE"));
}
#[test]
fn test_hermes_plugin_shim_registers_tools_with_toolset_arg() {
assert!(HERMES_PLUGIN_SHIM.contains(r#"ctx.register_tool(name, "aphrodite", schema, "#));
}
#[test]
fn test_hermes_plugin_shim_template_matches_live() {
let live_path = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("plugins")
.join("aphrodite")
.join("__init__.py");
let Ok(live) = fs::read_to_string(&live_path) else {
return;
};
let normalize = |s: &str| s.replace("\r\n", "\n");
assert_eq!(
normalize(&live),
normalize(HERMES_PLUGIN_SHIM),
"templates/__init__.py has drifted from the live plugins/aphrodite/__init__.py - re-copy the live plugin \
into crates/aphrodite/templates/__init__.py to keep the setup-embedded shim in sync"
);
}
#[test]
#[ignore = "hits the real GitHub release - run explicitly to verify"]
fn test_verify_download_checksum_against_real_release() {
let dir = std::env::temp_dir().join("aphrodite-checksum-test");
fs::create_dir_all(&dir).unwrap();
let release_dir = "https://github.com/PlayForm/Aphrodite/releases/download/Aphrodite/v1.3.2";
let triple = "aarch64-apple-darwin";
let asset = "aphrodite-aarch64-apple-darwin";
let dest = dir.join(asset);
let status = Command::new("curl")
.args(["-fsSL", "-o", dest.to_str().unwrap(), &format!("{release_dir}/{asset}")])
.status()
.unwrap();
assert!(status.success());
verify_download_checksum(release_dir, triple, asset, &dest).expect("real asset must verify clean");
fs::write(&dest, b"corrupted content").unwrap();
let result = verify_download_checksum(release_dir, triple, asset, &dest);
assert!(result.is_err(), "corrupted asset must fail checksum verification");
fs::remove_dir_all(&dir).unwrap();
}
}