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")]
{
let _ = std::fs::remove_file(&target_binary);
if std::process::Command::new("ditto")
.args([ctx.own_path.to_str().unwrap_or(""), target_binary.to_str().unwrap_or("")])
.status()
.is_err()
{
fs::copy(&ctx.own_path, &target_binary)?;
let _ = std::process::Command::new("xattr")
.args(["-c", target_binary.to_str().unwrap_or("")])
.output();
}
}
#[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(())
}
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")]
{
let _ = std::fs::remove_file(&dest);
if std::process::Command::new("ditto")
.args([src.to_str().unwrap_or(""), dest.to_str().unwrap_or("")])
.status()
.is_err()
{
fs::copy(&src, &dest)?;
let _ = std::process::Command::new("xattr")
.args(["-c", dest.to_str().unwrap_or("")])
.output();
}
}
#[cfg(not(target_os = "macos"))]
fs::copy(&src, &dest)?;
#[cfg(target_os = "macos")]
{
let rpath = format!("@rpath/{}", name);
let _ = std::process::Command::new("install_name_tool")
.args(["-id", &rpath, dest.to_str().unwrap_or("")])
.output();
let _ = std::process::Command::new("xattr")
.args(["-c", dest.to_str().unwrap_or("")])
.output();
}
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());
fs::copy(&target_release, &dest)?;
secure_perms(&dest, 0o755)?;
found = true;
copied += 1;
}
}
if !found {
return Err(SetupError::DylibNotFound(format!(
"dylib '{name}' not found. Build from source or download from GitHub Releases."
)));
}
}
println!("copied {copied} dylib(s)");
Ok(())
}
fn write_plugin_yaml(ctx:&SetupCtx, args:&SetupArgs) -> Result<(), SetupError> {
let path = ctx.aphrodite_dir.join("plugin.yaml");
if path.exists() {
return Ok(());
}
let yaml = format!(
r#"name: aphrodite
version: {version}
description: "CCR compression plugin - 12 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_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");
if path.exists() {
return Ok(());
}
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, "#));
}
}