use anyhow::{anyhow, Result};
use crate::keygen_release::cmd_keygen;
use crate::skills::cmd_install_skill;
use crate::transport::{git_ssh_command, parse_remote, validate_transport_key, Scheme};
use crate::{clonehome, config, gitcmd, schema};
use crate::join::{cmd_join, safe_clone_dir, warn_if_nested};
use crate::init::cmd_init;
use crate::valid_slug;
pub(crate) fn cmd_reconnect(
role: Option<String>,
hub: Option<String>,
dir: Option<String>,
host: Option<String>,
ssh_key: Option<String>,
force: bool,
) -> Result<()> {
if let Some(k) = &ssh_key {
validate_transport_key(k)?;
}
let root: std::path::PathBuf = match &hub {
Some(h) if std::path::Path::new(h).join(".git").exists() => std::fs::canonicalize(h)?,
Some(h) => {
let remote = parse_remote(h);
let name_src = remote.shorthand.clone().unwrap_or_else(|| h.clone());
let basename = name_src.rsplit('/').next().unwrap_or("hub").trim_end_matches(".git").to_string();
let clonedir = safe_clone_dir(dir.clone(), &basename);
let clonedir_abs = if std::path::Path::new(&clonedir).is_absolute() {
std::path::PathBuf::from(&clonedir)
} else {
std::env::current_dir()?.join(&clonedir)
};
if !clonedir_abs.join(".git").exists() {
cmd_init(h.clone(), Some(clonedir.clone()), None, Scheme::Auto, None, None, None, ssh_key.clone(), true, false)?;
}
clonedir_abs.canonicalize().unwrap_or(clonedir_abs)
}
None => match &dir {
Some(d) => std::fs::canonicalize(d)?,
None => config::repo_root().map_err(|_| {
anyhow!("no hub found — run inside your hub clone, or pass --hub <url|owner/repo> [--dir <path>]")
})?,
},
};
std::env::set_var("CONFER_HUB", &root);
warn_if_nested(&root);
if !root.join(".confer-version").exists() {
return Err(anyhow!(
"{} is a git repo but not a confer hub (no .confer-version marker) — refusing to join \
and push confer state into it. Point --hub at your confer hub, or run \
`confer init <url> --role <you>` to create one.",
root.display()
));
}
if let Some(k) = &ssh_key {
let _ = gitcmd::check(
&root,
&["config", "--local", "core.sshCommand", &git_ssh_command(k)],
);
}
let _ = gitcmd::integrate(&root); if let Some(r) = &role {
let sk = match config::signing_key(&root).map(|p| p.to_string_lossy().into_owned()) {
Some(existing) => Some(existing),
None => {
if !valid_slug(r) {
return Err(anyhow!(
"invalid role '{r}': must match [a-z0-9][a-z0-9-]* (≤64 chars)"
));
}
let kp = config::home()?.join(".confer").join("keys").join(r);
if !kp.exists() {
cmd_keygen(Some(r.clone()), false).map_err(|e| {
anyhow!(
"could not mint a signing key for '{r}': {e}\n\
install ssh-keygen (openssh) and ensure ~/.confer/keys is writable, \
or run `confer join --role {r} --signing-key <path>` with an existing key"
)
})?;
}
Some(kp.to_string_lossy().into_owned())
}
};
cmd_join(r.clone(), host.clone(), None, None, sk, force)?;
}
cmd_install_skill(
None,
Some(root.to_string_lossy().to_string()),
role.clone(),
false,
)?;
let r = role.unwrap_or_else(|| "<you>".into());
println!();
println!("✅ reconnected to hub {}", root.display());
print_reactive_next(&r);
Ok(())
}
pub(crate) fn print_reactive_next(role: &str) {
let role = schema::sanitize_term(role, false);
println!(" final step — arm your reactive watch: run /confer-watch");
println!(" (headless / no Monitor tool: confer watch --role {role} --replace)");
println!(
" (not Claude Code: loop `confer poll --role {role}` inside your agent's run loop)"
);
}
pub(crate) fn canonical_hub_id(input: &str) -> Option<String> {
let s = input.trim().trim_end_matches('/');
if s.is_empty() {
return None;
}
if s.starts_with(['/', '~', '.']) || std::path::Path::new(s).exists() {
let expanded = if s == "~" {
config::home().ok()?
} else if let Some(rest) = s.strip_prefix("~/") {
config::home().ok()?.join(rest)
} else {
std::path::PathBuf::from(s)
};
let canon = std::fs::canonicalize(&expanded).unwrap_or(expanded);
let c = canon.to_string_lossy();
return Some(format!("file:{}", c.trim_end_matches(".git").trim_end_matches('/')));
}
let (host, path) = if let Some(rest) = s.strip_prefix("git@") {
rest.split_once(':')?
} else if let Some((_scheme, after)) = s.split_once("://") {
let after = after.rsplit_once('@').map_or(after, |(_, h)| h); after.split_once('/')?
} else if !s.contains(':') && s.matches('/').count() == 1 {
("github.com", s) } else {
return None;
};
let host = host.split(':').next().unwrap_or(host); let path = path
.trim_start_matches('/')
.trim_end_matches('/')
.trim_end_matches(".git");
if host.is_empty() || path.is_empty() {
return None;
}
Some(format!(
"{}/{}",
host.to_ascii_lowercase(),
path.to_ascii_lowercase()
))
}
fn find_managed_clone(hub: &str, role: &str) -> Option<std::path::PathBuf> {
let want = canonical_hub_id(hub)?;
clonehome::list()
.into_iter()
.filter(|c| c.role == role)
.filter(|c| c.path.join(".confer-version").is_file() && c.path.join("threads").is_dir())
.find(|c| {
gitcmd::output(&c.path, &["config", "--get", "remote.origin.url"])
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.and_then(|o| canonical_hub_id(&o))
.as_deref()
== Some(want.as_str())
})
.map(|c| c.path)
}
pub(crate) fn cmd_onboard(role: Option<String>, hub: Option<String>) -> Result<()> {
let r = schema::sanitize_term(role.as_deref().unwrap_or("agent"), false);
let hub_display = hub.as_deref().map(|h| schema::sanitize_term(h, false));
println!("confer — a git-native coordination layer for AI agents.");
println!("A \"fleet\" is one private git repo (the hub). Each agent joins it with a signed");
println!(
"identity and coordinates by appending signed, verifiable messages — no server, no db."
);
println!();
match hub_display.as_deref() {
Some(h) => {
let existing = match (role.as_deref(), hub.as_deref()) {
(Some(rr), Some(hh)) => find_managed_clone(hh, rr),
_ => None,
};
if let Some(p) = existing {
println!("You're already joined to this fleet as {r} — in your managed clone:");
println!(" {}", p.display());
println!();
println!("Don't re-clone. Just RE-ARM your reactive watch from there:");
println!(" cd {} && confer watch --role {r} --replace", p.display());
println!(" (Claude Code: run /confer-watch from that directory — same thing.)");
} else {
println!("You were pointed at a fleet. JOIN it with one command:");
println!();
println!(" confer clone {h} --role {r} --managed");
println!();
println!(
"That clones the hub, mints your key, joins as {r}, and arms your reactive layer"
);
println!("— landing in a PER-ROLE managed clone (~/.confer/clones/…), so several roles");
println!("on ONE machine each get their own clone and never collide. One clone = one role.");
println!(
"Private hub authed by a deploy key (not your default SSH)? add: --ssh-key <path>"
);
println!();
println!(
"(Re-running is safe — `confer onboard --hub {h} --role {r}` finds your clone and"
);
println!(" points you at re-arming it instead of cloning twice.)");
}
}
None => {
println!("You have no fleet yet. START one with a single command (local, zero-setup):");
println!();
println!(" confer init ~/confer/team.git --role {r}");
println!();
println!(
"That scaffolds a local hub, mints your signing key, joins as {r}, and wires your"
);
println!("reactive layer — one idempotent command, no GitHub or network needed.");
println!();
println!(
"For agents on OTHER machines to join, start the hub on a PRIVATE repo instead:"
);
println!(
" confer init your-org/your-hub --role {r} # a private GitHub/GitLab repo"
);
println!(" # each peer then runs: confer clone your-org/your-hub --role frontend --managed");
println!();
println!("Private-hub auth — a headless watch needs non-interactive push credentials:");
println!(
" • deploy key / non-default SSH: add --ssh-key <path> (pinned to the clone)"
);
println!(
" • HTTPS + a GitHub App token: see confer credential / app-config --help"
);
println!(" • `confer doctor` flags a clone whose transport isn't self-contained");
}
}
println!();
if role.is_none() {
println!("(`{r}` is a placeholder — replace it with a role id for this agent: any lowercase name.)");
}
println!("Reactive layer: on Claude Code, `confer install-skill` wires `/confer-watch`.");
println!("On any other agent, loop `confer poll --role {r}` in your run loop instead.");
Ok(())
}
pub(crate) fn warn_reactive_arm_failed(e: &anyhow::Error, dir: &std::path::Path, role: &str) {
eprintln!(
"\nconfer: ⚠ joined as {role}, but arming the reactive layer FAILED ({e}) — your \
/confer-watch is NOT wired yet.\n arm it by hand: cd {} && confer install-skill --role \
{role} (then run /confer-watch)",
dir.display()
);
}