use std::io::{self, IsTerminal};
use std::path::Path;
use recall_hooks::client::{self, Client};
use recall_hooks::config::Source;
use recall_hooks::home::{self, Home};
use recall_hooks::{backfill, exit, settings, Disposition};
use crate::project as proj;
use crate::ui;
pub struct Args {
pub url: Option<String>,
pub name: Option<String>,
pub yes: bool,
}
struct Stop(i32);
type Step<T> = Result<T, Stop>;
pub async fn connect(args: Args) -> anyhow::Result<i32> {
match run(args).await {
Ok(()) => Ok(exit::OK),
Err(Stop(code)) => Ok(code),
}
}
async fn run(args: Args) -> Step<()> {
if proj::remote_session() {
return refuse(
"this is a remote session, and anything saved here is discarded with the \
container.",
"Set RECALL_URL and RECALL_TOKEN on the cloud environment instead — its \
variables are a secret store, and they are what Recall reads there.",
);
}
let explicit = args.url.as_deref().map(home::normalize_url);
if let Some(url) = &explicit {
check_url(url)?;
}
if let Some(raw) = &args.name {
named(raw)?;
}
let here = proj::resolve();
let Some(h) = home::locate(here.env.lookup()) else {
return refuse("no home directory to save into.", "Set RECALL_HOME.");
};
let (mut creds, mut config) = match load_both(&h) {
Ok(both) => both,
Err(e) => {
return refuse(
&e.to_string(),
"Nothing was changed. Fix or move it aside and run recall connect again.",
)
}
};
let interactive = io::stdin().is_terminal() && io::stderr().is_terminal();
let url = match explicit.or_else(|| config.server.clone()) {
Some(url) => url,
None if interactive => {
intro();
let typed: String = answer(
cliclack::input("Server URL")
.placeholder("https://recall.example.com")
.validate(|s: &String| {
if has_host(&home::normalize_url(s)) {
Ok(())
} else {
Err("a server URL starts with https://")
}
})
.interact(),
)?;
home::normalize_url(&typed)
}
None => {
return refuse(
"no server named, and none saved yet.",
"Name it: recall connect https://your-recall-host",
)
}
};
check_url(&url)?;
let saved = creds.token_for(&url).map(str::to_string);
if saved.is_none() && !interactive {
return refuse(
"reads the token from a terminal, without echoing it, and there is no \
terminal here.",
"Where something else holds the secret, set RECALL_TOKEN instead.",
);
}
intro();
if url.starts_with("http://") && !is_loopback(&url) {
say_warning(&format!(
"{url} is plain http, so the token crosses the network unencrypted on every \
request."
));
}
reach(&url).await?;
let token = match saved {
Some(token) if verify(&url, &token).await? => {
say_step("Kept the saved token — it still works");
token
}
Some(_) => {
say_warning("The saved token was rejected, so the server's token has changed.");
if !interactive {
return refuse(
"needs a new token, and there is no terminal to ask for it on.",
"Run recall connect in a terminal.",
);
}
ask_token(&url).await?
}
None => ask_token(&url).await?,
};
let name = machine_name(&args, &config, &here, interactive)?;
creds.insert(&url, &token);
config.server = Some(url.clone());
config.machine.name = Some(name.clone());
if let Err(e) = h
.save_credentials(&creds)
.and_then(|()| h.save_config(&config))
{
return refuse(
&format!("the token is valid, but saving it failed: {e}"),
"",
);
}
say_success(&format!(
"Saved to {} (the token is readable by you only)",
ui::tilde(&h.dir().display().to_string())
));
let here = proj::resolve();
let wiring = offer_init(&args, interactive)?;
if wiring == Wiring::JustNow {
offer_backfill(&here, &args, interactive).await?;
}
for line in environment_overrides(&here, &url, &name) {
say_warning(&line);
}
let redundant = redundant_variables(&here);
if !redundant.is_empty() {
let (vars, verb, it) = match redundant.as_slice() {
[one] => (one.to_string(), "says", "it"),
many => (many.join(" and "), "say", "them"),
};
let _ = cliclack::log::remark(format!(
"{vars} {verb} the same as the name, so you can remove {it} from your shell \
profile — left there, {it} would overrule a later rename."
));
}
let closing = match wiring {
Wiring::NoProject => "Run recall init in each project you want synced.",
Wiring::Declined => "This project is not synced until you run recall init.",
Wiring::Already | Wiring::JustNow => "This project syncs from its next session.",
};
let _ = cliclack::outro(format!(
"Connected as {name}. {closing}\n Check on it any time: recall doctor"
));
Ok(())
}
fn check_url(url: &str) -> Step<()> {
if has_host(url) {
Ok(())
} else {
refuse(
&format!("{url:?} is not a server URL."),
"Include the scheme: recall connect https://recall.example.com",
)
}
}
async fn reach(url: &str) -> Step<()> {
let spinner = spin(&format!("Reaching {url}"));
let result = match Client::new(url, "") {
Ok(client) => client.health().await.map_err(|e| e.to_string()),
Err(e) => Err(e.to_string()),
};
match result {
Ok(_) => {
spinner.stop(format!("Reached {url}"));
Ok(())
}
Err(e) => {
spinner.error(format!("Could not reach {url}"));
refuse(&format!("could not reach {url}: {e}"), "Nothing was saved.")
}
}
}
async fn verify(url: &str, token: &str) -> Step<bool> {
let spinner = spin("Checking the token");
let result = match Client::new(url, token) {
Ok(client) => client.check_token().await,
Err(e) => {
spinner.error("Could not check the token");
return refuse(&e.to_string(), "Nothing was saved.");
}
};
match result {
Ok(()) => {
spinner.stop("Token accepted");
Ok(true)
}
Err(client::Error::Status {
code: 401 | 403, ..
}) => {
spinner.error("Token rejected");
Ok(false)
}
Err(e) => {
spinner.error("Could not check the token");
refuse(
&format!("{url} answered /health but not an authenticated call: {e}"),
"Nothing was saved.",
)
}
}
}
const TOKEN_ATTEMPTS: usize = 3;
async fn ask_token(url: &str) -> Step<String> {
let _ = cliclack::log::remark(
"The token is the server's RECALL_TOKEN, the one its deploy/.env holds.",
);
for attempt in 1..=TOKEN_ATTEMPTS {
let typed: String = answer(
cliclack::password(format!("Token for {url}"))
.mask('▪')
.validate(|s: &String| {
if s.trim().is_empty() {
Err("paste the token, or press Ctrl-C to stop")
} else {
Ok(())
}
})
.interact(),
)?;
let token = typed.trim().to_string();
if verify(url, &token).await? {
return Ok(token);
}
if attempt < TOKEN_ATTEMPTS {
say_warning(&format!(
"{url} is up, and it rejected that token. Try again."
));
}
}
refuse(
&format!("{url} is up, and it rejected {TOKEN_ATTEMPTS} tokens in a row."),
"Nothing was saved.",
)
}
fn machine_name(
args: &Args,
config: &home::Config,
here: &proj::Resolved,
interactive: bool,
) -> Step<String> {
if let Some(raw) = &args.name {
return named(raw);
}
let suggested = suggested_name(
config.machine.name.as_deref(),
here.env.get("RECALL_MACHINE_KEY").as_deref(),
recall_hooks::config::this_hostname().as_deref(),
)
.unwrap_or_else(|| "this-machine".to_string());
if !interactive || args.yes {
say_step(&format!("Machine name: {suggested}"));
return Ok(suggested);
}
let typed: String = answer(
cliclack::input("Name this machine — it labels what this machine syncs")
.default_input(&suggested)
.validate(|s: &String| match home::machine_name(s) {
Some(_) => Ok(()),
None => Err("letters, digits, '.', '-' and '_' only"),
})
.interact(),
)?;
Ok(home::machine_name(&typed).unwrap_or(suggested))
}
fn named(raw: &str) -> Step<String> {
match home::machine_name(raw) {
Some(name) => Ok(name),
None => refuse(
&format!("{raw:?} is not a usable machine name."),
"Use letters, digits, '.', '-' and '_', at most 64 of them.",
),
}
}
fn suggested_name(
saved: Option<&str>,
machine_key: Option<&str>,
host: Option<&str>,
) -> Option<String> {
saved
.and_then(home::machine_name)
.or_else(|| machine_key.and_then(home::machine_name))
.or_else(|| {
let host = host?.trim().split('.').next()?.to_ascii_lowercase();
let cleaned: String = host
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '-' | '_') {
c
} else {
'-'
}
})
.take(64)
.collect();
home::machine_name(cleaned.trim_matches('-'))
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Wiring {
NoProject,
Already,
JustNow,
Declined,
}
fn offer_init(args: &Args, interactive: bool) -> Step<Wiring> {
let Some(root) = proj::git_root() else {
return Ok(Wiring::NoProject);
};
let settings_path = root.join(".claude").join("settings.json");
let wired = std::fs::read(&settings_path)
.map(|b| settings::is_wired(&b))
.unwrap_or(false);
if wired {
say_step(&format!(
"{} is already set up to sync",
project_name(&root)
));
return Ok(Wiring::Already);
}
let yes = args.yes
|| (interactive
&& answer(
cliclack::confirm(format!(
"Sync this project, {}? This adds Recall's hooks to .claude/settings.json",
project_name(&root)
))
.initial_value(true)
.interact(),
)?);
if !yes {
return Ok(Wiring::Declined);
}
if let Err(e) = settings::wire_file(&settings_path) {
return refuse(
&format!("could not wire {}: {e}", settings_path.display()),
"The connection is saved; fix that and run recall init.",
);
}
let root = ui::tilde(&root.display().to_string());
let _ = cliclack::note(
"Wired .claude/settings.json — now commit it",
format!(
"Committed, it makes fresh clones and cloud sessions sync too.\n\n\
git -C {root} add .claude/settings.json\n\
git -C {root} commit -m \"Enable Recall memory sync\""
),
);
Ok(Wiring::JustNow)
}
async fn offer_backfill(here: &proj::Resolved, args: &Args, interactive: bool) -> Step<()> {
let has_memory = std::fs::read_dir(here.memory_dir())
.map(|mut entries| entries.next().is_some())
.unwrap_or(false);
if !has_memory {
return Ok(());
}
let yes = args.yes
|| (interactive
&& answer(
cliclack::confirm("Send this project's existing memory to the server now?")
.initial_value(true)
.interact(),
)?);
if !yes {
let _ = cliclack::log::remark("Whenever you want to: recall backfill");
return Ok(());
}
let later = "The connection is saved; run recall backfill to try again.";
let ctx = match here.hook_context() {
Ok(ctx) => ctx,
Err(e) => return refuse(&e.to_string(), later),
};
let spinner = spin("Sending what the server does not have yet");
let outcome = match backfill(&ctx).await {
Ok(outcome) => outcome,
Err(e) => {
spinner.error("The first sync did not run");
return refuse(&e.to_string(), later);
}
};
let sent = outcome.count(Disposition::Sent);
let matches = outcome.count(Disposition::Matches);
spinner.stop(format!(
"First sync: {sent} sent, {matches} already on the server"
));
let left = outcome.entries.len() - sent - matches - outcome.count(Disposition::Internal);
if left > 0 || outcome.stopped.is_some() {
say_warning(&format!(
"{left} file(s) were not sent. recall backfill lists them, and why."
));
}
Ok(())
}
fn project_name(root: &Path) -> String {
root.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| root.display().to_string())
}
fn environment_overrides(here: &proj::Resolved, connected: &str, name: &str) -> Vec<String> {
let cfg = here.config();
let mut out = Vec::new();
if cfg.url_source == Source::Environment && home::normalize_url(&cfg.url) != connected {
out.push(format!(
"RECALL_URL is set to {}, so this machine keeps talking to that server. \
Unset it to use {connected}.",
cfg.url
));
}
if cfg.token_source == Source::Environment {
out.push(format!(
"{} It wins over the saved one; remove it to finish the move.",
environment_token_origin(here, "also supplies")
));
}
let overridden = crate::status::overrides(here, &cfg);
for o in overridden.iter().filter(|o| o.setting == "machine.name") {
out.push(format!(
"{}={} wins over the name {name}. Remove it from your shell profile; \
the name is all Recall needs now.",
o.variable, o.environment
));
}
out
}
fn redundant_variables(here: &proj::Resolved) -> Vec<&'static str> {
let cfg = here.config();
let overridden = crate::status::overrides(here, &cfg);
["RECALL_MACHINE_KEY", "RECALL_SOURCE_ENV"]
.into_iter()
.filter(|var| here.env.get(var).is_some_and(|v| !v.trim().is_empty()))
.filter(|var| !overridden.iter().any(|o| o.variable == *var))
.collect()
}
fn intro() {
use std::sync::atomic::{AtomicBool, Ordering};
static SHOWN: AtomicBool = AtomicBool::new(false);
if !SHOWN.swap(true, Ordering::Relaxed) {
let _ = cliclack::intro(" recall connect ");
}
}
struct Spinner(Option<cliclack::ProgressBar>);
impl Spinner {
fn stop(&self, message: impl std::fmt::Display) {
match &self.0 {
Some(bar) => bar.stop(message),
None => say_step(&message.to_string()),
}
}
fn error(&self, message: impl std::fmt::Display) {
match &self.0 {
Some(bar) => bar.error(message),
None => {
let _ = cliclack::log::error(message);
}
}
}
}
fn spin(message: &str) -> Spinner {
if !io::stderr().is_terminal() {
return Spinner(None);
}
let bar = cliclack::spinner();
bar.start(message);
Spinner(Some(bar))
}
fn say_step(message: &str) {
let _ = cliclack::log::step(message);
}
fn say_success(message: &str) {
let _ = cliclack::log::success(message);
}
fn say_warning(message: &str) {
let _ = cliclack::log::warning(message);
}
fn answer<T>(result: io::Result<T>) -> Step<T> {
result.map_err(|e| {
if e.kind() == io::ErrorKind::Interrupted {
let _ = cliclack::outro_cancel("Stopped. Nothing more was saved.");
} else {
eprintln!("recall connect: could not read the answer: {e}");
}
Stop(exit::CONFIG)
})
}
fn refuse<T>(what: &str, then: &str) -> Step<T> {
eprintln!("recall connect: {what}");
if !then.is_empty() {
eprintln!(" {then}");
}
Err(Stop(exit::CONFIG))
}
pub fn disconnect(url: Option<&str>) -> anyhow::Result<i32> {
let here = proj::resolve();
let Some(h) = home::locate(here.env.lookup()) else {
eprintln!("recall disconnect: no home directory, so nothing is saved — set RECALL_HOME");
return Ok(exit::CONFIG);
};
let (mut creds, mut config) = match load_both(&h) {
Ok(both) => both,
Err(e) => {
eprintln!("recall disconnect: {e}");
eprintln!(" Nothing was changed.");
return Ok(exit::CONFIG);
}
};
let path = h.credentials_path();
let target = match url {
Some(u) => Some(home::normalize_url(u)),
None => config.server.clone().or_else(|| {
(creds.servers.len() == 1)
.then(|| creds.servers.keys().next().cloned())
.flatten()
}),
};
match target {
None if creds.servers.is_empty() => {
println!("No token is saved in {}.", path.display());
}
None => {
eprintln!("recall disconnect: more than one server is saved; name one:");
for u in creds.servers.keys() {
eprintln!(" recall disconnect {u}");
}
return Ok(exit::CONFIG);
}
Some(target) => {
if creds.remove(&target) {
let result = if creds.servers.is_empty() {
h.delete_credentials()
} else {
h.save_credentials(&creds)
};
let result = result.and_then(|()| {
if config.server.as_deref() == Some(target.as_str()) {
config.server = None;
h.save_config(&config)
} else {
Ok(())
}
});
if let Err(e) = result {
eprintln!("recall disconnect: {e}");
return Ok(exit::CONFIG);
}
println!("Removed the token for {target} from {}.", path.display());
println!(
"The token itself still works on the server. To revoke it, rotate \
RECALL_TOKEN there and reconnect every machine."
);
} else {
println!("No token for {target} is saved in {}.", path.display());
}
}
}
let cfg = here.config();
if cfg.token_source == Source::Environment {
println!();
println!("{}", environment_token_origin(&here, "still supplies"));
}
Ok(exit::OK)
}
fn environment_token_origin(here: &proj::Resolved, verb: &str) -> String {
match here.env.declared(&["RECALL_TOKEN"]).into_iter().next() {
Some(d) => format!("{} {verb} RECALL_TOKEN.", d.file),
None => format!(
"Your shell {verb} RECALL_TOKEN — Recall can see the value but not which \
file exported it, so check your shell profile."
),
}
}
fn load_both(h: &Home) -> Result<(home::Credentials, home::Config), home::Error> {
h.migrate_legacy()?;
Ok((
h.load_credentials()?.unwrap_or_default(),
h.load_config()?.unwrap_or_default(),
))
}
fn has_host(url: &str) -> bool {
["https://", "http://"].iter().any(|scheme| {
url.strip_prefix(scheme)
.is_some_and(|rest| !rest.is_empty())
})
}
fn is_loopback(url: &str) -> bool {
let authority = url
.trim_start_matches("http://")
.split('/')
.next()
.unwrap_or_default();
let host = match authority.strip_prefix('[') {
Some(v6) => v6.split(']').next().unwrap_or_default(),
None => authority.split(':').next().unwrap_or_default(),
};
matches!(host, "localhost" | "127.0.0.1" | "::1")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_url_needs_a_scheme_and_a_host() {
assert!(has_host("https://recall.example.com"));
assert!(has_host("http://localhost:8787"));
for bad in ["recall.example.com", "https://", "ftp://x", ""] {
assert!(!has_host(bad), "{bad:?}");
}
}
#[test]
fn only_this_machine_is_exempt_from_the_plain_http_warning() {
for local in [
"http://localhost:8787",
"http://127.0.0.1:8787",
"http://[::1]:8787",
] {
assert!(is_loopback(local), "{local}");
}
for remote in ["http://recall.example.com", "http://10.0.0.5:8787"] {
assert!(!is_loopback(remote), "{remote}");
}
}
#[test]
fn a_saved_name_is_offered_before_an_old_key_before_the_hostname() {
let host = Some("Ekos-MacBook-Pro.local");
assert_eq!(
suggested_name(Some("jarvis"), Some("machine:mbp"), host).as_deref(),
Some("jarvis")
);
assert_eq!(
suggested_name(None, Some("machine:mbp"), host).as_deref(),
Some("mbp")
);
assert_eq!(
suggested_name(None, None, host).as_deref(),
Some("ekos-macbook-pro")
);
assert_eq!(
suggested_name(Some("my laptop"), None, host).as_deref(),
Some("ekos-macbook-pro")
);
}
#[test]
fn a_hostname_is_made_into_a_name() {
assert_eq!(
suggested_name(None, None, Some("Eko's Mac mini")).as_deref(),
Some("eko-s-mac-mini")
);
assert_eq!(suggested_name(None, None, Some(" ")), None);
assert_eq!(suggested_name(None, None, Some("...")), None);
assert_eq!(suggested_name(None, None, None), None);
}
}