use std::path::PathBuf;
use std::process::Command;
use recall_hooks::{
claude, client, declared_env, device, home, project, scope, ClientConfig, Context,
};
pub fn root() -> PathBuf {
git_toplevel().unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
}
pub fn remote() -> String {
git(&["remote", "get-url", "origin"]).unwrap_or_default()
}
pub fn git_root() -> Option<PathBuf> {
git_toplevel()
}
fn git_toplevel() -> Option<PathBuf> {
git(&["rev-parse", "--show-toplevel"]).map(|s| PathBuf::from(native_separators(s)))
}
#[cfg(windows)]
fn native_separators(path: String) -> String {
path.replace('/', "\\")
}
#[cfg(not(windows))]
fn native_separators(path: String) -> String {
path
}
pub fn remote_session() -> bool {
matches!(
std::env::var("CLAUDE_CODE_REMOTE").ok().as_deref(),
Some("true") | Some("1")
)
}
pub struct Resolved {
pub root: PathBuf,
pub env: declared_env::Environment,
}
pub fn resolve() -> Resolved {
resolve_at(root())
}
pub fn resolve_at(root: PathBuf) -> Resolved {
let env = declared_env::Environment::discover(&root);
Resolved { root, env }
}
impl Resolved {
pub fn memory_dir(&self) -> PathBuf {
claude::Env::from_lookup(self.env.lookup()).memory_dir(&self.root.to_string_lossy())
}
pub fn memory_root(&self) -> PathBuf {
claude::Env::from_lookup(self.env.lookup()).memory_root()
}
pub fn config(&self) -> ClientConfig {
if let Some(h) = home::locate(self.env.lookup()) {
let _ = h.migrate_legacy();
}
ClientConfig::from_lookup(self.env.lookup())
}
pub fn project_key(&self, cfg: &ClientConfig, remote: &str) -> String {
project::key_with_override(
cfg.project_key.as_deref(),
remote,
&self.root.to_string_lossy(),
)
}
pub fn hook_context(&self) -> anyhow::Result<Context> {
self.hook_context_for(&self.config())
}
pub fn hook_context_for(&self, cfg: &ClientConfig) -> anyhow::Result<Context> {
let client = cfg.client()?;
cfg.require()?;
let root_str = self.root.to_string_lossy().to_string();
Ok(Context {
memory_dir: cfg.claude.memory_dir(&root_str),
state_file: cfg.claude.state_file(&root_str),
scopes: scope::scopes(
self.project_key(cfg, &remote()),
cfg.global_key.clone(),
cfg.machine_key.clone(),
),
source_env: cfg.source_env.clone(),
client,
})
}
pub fn protect_device_key(&self, cfg: &ClientConfig, hook: &str) {
let Some(path) = cfg.device_file.as_deref() else {
return;
};
let shown = || crate::ui::tilde(&path.display().to_string());
match home::restrict_to_owner(path) {
Ok(false) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Ok(true) => {
eprintln!(
"{hook}: {} was readable by other users; it is now readable by you only",
shown()
);
eprintln!(
"{hook}: if anyone else can log in to this machine, revoke its device \
(recall devices revoke <name>) and run recall connect"
);
}
Err(e) => eprintln!(
"{hook}: could not make sure {} is readable by you only: {e}",
shown()
),
}
}
pub async fn enroll_if_needed(&self, cfg: ClientConfig, hook: &str) -> ClientConfig {
let Some(authkey) = cfg.authkey.clone() else {
return cfg;
};
if cfg.device.is_some() || cfg.url.is_empty() || cfg.device_error.is_some() {
return cfg;
}
let Some(h) = home::locate(self.env.lookup()) else {
eprintln!("{hook}: RECALL_AUTHKEY is set, but there is no home directory to keep a device key in");
return cfg;
};
let held = match h.lock_devices() {
Ok(held) => held,
Err(e) => {
eprintln!(
"{hook}: could not enrol with RECALL_AUTHKEY ({e}){}",
fallback(&cfg)
);
return cfg;
}
};
self.enroll_holding(&held, cfg, &authkey, hook).await
}
async fn enroll_holding(
&self,
held: &home::DevicesLock<'_>,
mut cfg: ClientConfig,
authkey: &str,
hook: &str,
) -> ClientConfig {
match held.load() {
Ok(devices) => {
if let Some(entry) = devices.for_url(&cfg.url) {
cfg.device = Some(entry.clone());
return cfg;
}
}
Err(e) => {
eprintln!("{hook}: could not enrol with RECALL_AUTHKEY ({e})");
cfg.device_error = Some(e.to_string());
return cfg;
}
}
match device::enrol_with_authkey(held, &cfg.url, authkey, &cfg.source_env).await {
Ok(entry) => {
eprintln!(
"{hook}: enrolled this session as device {} with RECALL_AUTHKEY",
entry.name
);
cfg.device = Some(entry);
}
Err(e) => {
eprintln!(
"{hook}: could not enrol with RECALL_AUTHKEY ({}){}",
enroll_failure(&e),
fallback(&cfg)
);
}
}
cfg
}
pub async fn after_refusal(
&self,
cfg: &ClientConfig,
hook: &str,
refusal: &client::Error,
) -> Option<ClientConfig> {
let refused = cfg.device.as_ref()?;
let why = refusal.reason();
let why = why.trim_start_matches("unauthorized: ");
let h = home::locate(self.env.lookup())?;
let held = match h.lock_devices() {
Ok(held) => held,
Err(e) => {
eprintln!("{hook}: the server refused this machine's device ({why}), and {e}");
return None;
}
};
let saved = match held.load() {
Ok(devices) => devices.for_url(&cfg.url).cloned(),
Err(e) => {
eprintln!("{hook}: the server refused this machine's device ({why}), and {e}");
return None;
}
};
if let Some(saved) = saved.filter(|s| s.device_id != refused.device_id) {
let mut retry = cfg.clone();
retry.device = Some(saved);
return Some(retry);
}
if refusal.device_revoked() {
eprintln!(
"{hook}: the server refused this machine's device {} ({why}), and it is not \
enrolled again by itself",
refused.name
);
let then = if remote_session() {
"a new session enrols afresh with RECALL_AUTHKEY, unless that authkey is revoked \
too"
} else {
"if it should be, run recall connect to enrol this machine again"
};
eprintln!("{hook}: {then}");
return None;
}
let authkey = match (&cfg.authkey, refused.ephemeral) {
(Some(authkey), true) => authkey.clone(),
(None, true) => {
eprintln!("{hook}: the server no longer accepts this machine's device key ({why})");
eprintln!(
"{hook}: run recall connect to enrol this machine again, or set \
RECALL_AUTHKEY to have a cloud session do it by itself"
);
return None;
}
(_, false) => {
eprintln!(
"{hook}: the server no longer knows this machine's device {} ({why})",
refused.name
);
eprintln!("{hook}: run recall connect to enrol this machine again");
return None;
}
};
if let Err(e) = held.forget_device(&cfg.url) {
eprintln!("{hook}: the server no longer knows this session's device ({why}), and {e}");
return None;
}
eprintln!(
"{hook}: the server no longer knows this session's device ({why}), enrolling again"
);
let mut fresh = cfg.clone();
fresh.device = None;
let fresh = self.enroll_holding(&held, fresh, &authkey, hook).await;
fresh.device.is_some().then_some(fresh)
}
}
fn fallback(cfg: &ClientConfig) -> &'static str {
if cfg.token.is_empty() {
""
} else {
", using RECALL_TOKEN instead"
}
}
fn enroll_failure(e: &device::Error) -> String {
match e {
device::Error::Client(recall_hooks::client::Error::Status { code: 404, .. }) => {
"this server does not enrol devices; it is older than 0.4.1".to_string()
}
device::Error::Client(c) => c.reason(),
other => other.to_string(),
}
}
fn git(args: &[&str]) -> Option<String> {
let out = Command::new("git").args(args).output().ok()?;
if !out.status.success() {
return None;
}
let value = String::from_utf8_lossy(&out.stdout).trim().to_string();
(!value.is_empty()).then_some(value)
}