use anyhow::{Context, Result, anyhow, bail};
use serde_json::{Value, json};
use crate::config;
pub(crate) fn cmd_up(
relay_arg: Option<&str>,
offline: bool,
with_local: Option<&str>,
no_local: bool,
as_json: bool,
) -> Result<()> {
let relay_url = match relay_arg {
Some(r) => {
let r = r.trim_start_matches('@');
if r.starts_with("http://") || r.starts_with("https://") {
r.to_string()
} else {
format!("https://{r}")
}
}
None => crate::pair_invite::DEFAULT_RELAY.to_string(),
};
let relay_url = strip_relay_url_userinfo(&relay_url);
let mut report: Vec<(String, String)> = Vec::new();
let mut step = |stage: &str, detail: String| {
report.push((stage.to_string(), detail.clone()));
if !as_json {
eprintln!("wire up: {stage} — {detail}");
}
};
if config::is_initialized()? {
step("init", "already initialized".to_string());
} else if offline {
super::cmd_init(None, true, false)?;
step(
"init",
"created offline identity (no relay bound)".to_string(),
);
} else {
super::cmd_init(Some(&relay_url), false, false)?;
step("init", format!("created identity bound to {relay_url}"));
}
let canonical = {
let card = config::read_agent_card()?;
let did = card.get("did").and_then(Value::as_str).unwrap_or("");
crate::agent_card::display_handle_from_did(did).to_string()
};
step("identity", format!("persona is `{canonical}`"));
if offline {
step(
"offline",
format!(
"identity ready, no relay bound. Go online later: \
`wire up` (default relay) or `wire bind-relay <relay>`, \
then `wire claim {canonical}`."
),
);
if as_json {
let steps_json: Vec<_> = report
.iter()
.map(|(k, v)| json!({"stage": k, "detail": v}))
.collect();
println!(
"{}",
serde_json::to_string(&json!({
"nick": canonical,
"relay": Value::Null,
"offline": true,
"steps": steps_json,
}))?
);
}
return Ok(());
}
let relay_state = config::read_relay_state()?;
let bound_relay = relay_state
.get("self")
.and_then(|s| s.get("relay_url"))
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
if bound_relay.is_empty() {
super::cmd_bind_relay(
&relay_url, None, false, false, false,
)?;
step("bind-relay", format!("bound to {relay_url}"));
} else if bound_relay != relay_url {
step(
"bind-relay",
format!(
"WARNING: identity bound to {bound_relay} but you specified {relay_url}. \
Keeping existing binding. Run `wire bind-relay {relay_url}` to switch."
),
);
} else {
step("bind-relay", format!("already bound to {bound_relay}"));
}
match super::cmd_claim(
&canonical,
Some(&relay_url),
None,
false,
false,
) {
Ok(()) => step(
"claim",
format!("{canonical}@{} claimed", strip_proto(&relay_url)),
),
Err(e) => step(
"claim",
format!("WARNING: claim failed: {e}. You can retry `wire claim {canonical}`."),
),
}
if no_local {
step("local-slot", "skipped (--no-local)".to_string());
} else {
let local_url = with_local
.unwrap_or("http://127.0.0.1:8771")
.trim_end_matches('/');
let already_local = crate::endpoints::self_endpoints(
&config::read_relay_state().unwrap_or_else(|_| json!({})),
)
.iter()
.any(|e| e.relay_url == local_url);
if relay_url.trim_end_matches('/') == local_url || already_local {
step("local-slot", "already covered".to_string());
} else if crate::relay_client::RelayClient::new(local_url)
.check_healthz()
.is_ok()
{
match super::cmd_bind_relay(
local_url,
Some("local"),
false,
false,
false,
) {
Ok(()) => step(
"local-slot",
format!("dual-bound local relay {local_url} for sister routing"),
),
Err(e) => step("local-slot", format!("skipped local relay: {e}")),
}
} else {
step(
"local-slot",
format!(
"no local relay reachable at {local_url} — federation only \
(sisters resolve via session-list)"
),
);
}
}
match crate::ensure_up::ensure_daemon_running() {
Ok(true) => step("daemon", "started fresh background daemon".to_string()),
Ok(false) => step("daemon", "already running".to_string()),
Err(e) => step(
"daemon",
format!("WARNING: could not start daemon: {e}. Run `wire daemon &` manually."),
),
}
let summary = "ready. `wire dial <name> \"<msg>\"` to reach a peer, \
`wire here` to see who's around. \
Keep it alive across reboots: `wire service install`. \
See your face in Claude Code: `wire setup --statusline --apply`."
.to_string();
step("ready", summary.clone());
if as_json {
let steps_json: Vec<_> = report
.iter()
.map(|(k, v)| json!({"stage": k, "detail": v}))
.collect();
println!(
"{}",
serde_json::to_string(&json!({
"nick": canonical,
"relay": relay_url,
"steps": steps_json,
}))?
);
}
Ok(())
}
pub(crate) fn strip_proto(url: &str) -> String {
url.trim_start_matches("https://")
.trim_start_matches("http://")
.to_string()
}
pub(crate) fn strip_relay_url_userinfo(url: &str) -> String {
let authority_start = url.find("://").map(|i| i + 3).unwrap_or(0);
let rest = &url[authority_start..];
let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
let authority = &rest[..authority_end];
let Some(at_pos) = authority.find('@') else {
return url.to_string();
};
let userinfo = &authority[..at_pos];
let host = &authority[at_pos + 1..];
let scheme = &url[..authority_start];
let tail = &rest[authority_end..];
let cleaned = format!("{scheme}{host}{tail}");
eprintln!(
"wire: ignoring `{userinfo}@` prefix on relay URL `{url}` — \
in v0.11+ your handle is DID-derived (one-name rule), so the relay URL \
is just the bare relay. Binding to `{cleaned}` instead."
);
cleaned
}
pub(crate) fn assert_relay_url_clean_for_publish(url: &str) -> Result<()> {
let authority_start = url.find("://").map(|i| i + 3).unwrap_or(0);
let rest = &url[authority_start..];
let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
let authority = &rest[..authority_end];
if authority.contains('@') {
bail!(
"internal invariant violated: relay URL `{url}` still carries userinfo at \
the persist/publish boundary — `strip_relay_url_userinfo` must be called \
before this point. Refusing to publish a malformed endpoint."
);
}
Ok(())
}
pub(crate) fn cmd_setup(apply: bool) -> Result<()> {
use crate::adapters::harness::HARNESS_ADAPTERS;
use std::path::PathBuf;
let entry = json!({
"command": "wire",
"args": ["mcp"]
});
let entry_pretty = serde_json::to_string_pretty(&json!({"wire": &entry}))?;
let mut targets: Vec<(&str, PathBuf)> = Vec::new();
for adapter in HARNESS_ADAPTERS {
for path in (adapter.paths_fn)() {
targets.push((adapter.name, path));
}
}
println!("wire setup\n");
println!("MCP server snippet (add this to your client's mcpServers):");
println!();
println!("{entry_pretty}");
println!();
if !apply {
println!("Probable MCP host config locations on this machine:");
for (name, path) in &targets {
let marker = if path.exists() {
"✓ found"
} else {
" (would create)"
};
println!(" {marker:14} {name}: {}", path.display());
}
println!();
println!("Run `wire setup --apply` to merge wire into each config above.");
println!(
"Existing entries with a different command keep yours unchanged unless wire's exact entry is missing."
);
return Ok(());
}
let mut modified: Vec<String> = Vec::new();
let mut skipped: Vec<String> = Vec::new();
for adapter in HARNESS_ADAPTERS {
for path in (adapter.paths_fn)() {
match (adapter.upsert_fn)(&path, "wire", &entry) {
Ok(true) => {
modified.push(format!("✓ {} ({})", adapter.name, path.display()));
}
Ok(false) => skipped.push(format!(
" {} ({}): already configured",
adapter.name,
path.display()
)),
Err(e) => skipped.push(format!("✗ {} ({}): {e}", adapter.name, path.display())),
}
}
}
if !modified.is_empty() {
println!("Modified:");
for line in &modified {
println!(" {line}");
}
println!();
println!("Restart the app(s) above to load wire MCP.");
}
if !skipped.is_empty() {
println!();
println!("Skipped:");
for line in &skipped {
println!(" {line}");
}
}
Ok(())
}
pub(crate) const STATUSLINE_RENDERER: &str = include_str!("../../assets/wire-statusline.sh");
pub(crate) fn cmd_setup_statusline(apply: bool, remove: bool) -> Result<()> {
use std::path::PathBuf;
let cfg_dir: PathBuf = std::env::var_os("CLAUDE_CONFIG_DIR")
.map(PathBuf::from)
.or_else(|| dirs::home_dir().map(|h| h.join(".claude")))
.ok_or_else(|| anyhow!("cannot locate Claude config dir (set $CLAUDE_CONFIG_DIR)"))?;
let settings_path = cfg_dir.join("settings.json");
let script_path = cfg_dir.join("wire-statusline.sh");
let (command, command_warn) = statusline_command(&script_path);
println!("wire setup --statusline\n");
println!("Claude config dir: {}", cfg_dir.display());
println!(" renderer: {}", script_path.display());
println!(" settings: {}", settings_path.display());
if let Some(w) = &command_warn {
println!(" ⚠ {w}");
}
println!();
if remove {
if !apply {
println!("Would REMOVE the statusLine key from settings.json and delete the renderer.");
println!("Run `wire setup --statusline --remove --apply` to do it.");
return Ok(());
}
let dropped = remove_statusline_entry(&settings_path)?;
let script_gone = if script_path.exists() {
std::fs::remove_file(&script_path).is_ok()
} else {
false
};
println!(
"Removed: statusLine key {} · renderer {}",
if dropped { "dropped" } else { "absent" },
if script_gone { "deleted" } else { "absent" }
);
return Ok(());
}
if !apply {
println!("Would write the renderer above and merge into settings.json:");
println!();
println!(" \"statusLine\": {{ \"type\": \"command\", \"command\": \"{command}\" }}");
println!();
println!("Resulting statusline: ● <emoji> <nickname> · <cwd>");
println!("Run `wire setup --statusline --apply` to install.");
println!("(Existing settings.json keys are preserved; an invalid settings.json aborts.)");
return Ok(());
}
if let Some(parent) = script_path.parent() {
std::fs::create_dir_all(parent).context("creating Claude config dir")?;
}
std::fs::write(&script_path, STATUSLINE_RENDERER).context("writing renderer script")?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = std::fs::metadata(&script_path) {
let mut perms = meta.permissions();
perms.set_mode(0o755);
let _ = std::fs::set_permissions(&script_path, perms);
}
}
let changed = upsert_statusline_entry(&settings_path, &command)?;
println!("✓ renderer written: {}", script_path.display());
if changed {
println!("✓ merged statusLine into: {}", settings_path.display());
} else {
println!(
" settings.json already configured: {}",
settings_path.display()
);
}
println!();
println!("Restart Claude Code (or reopen the session) to see your persona in the statusline.");
Ok(())
}
pub(crate) fn upsert_statusline_entry(path: &std::path::Path, command: &str) -> Result<bool> {
let mut cfg: Value = if path.exists() {
let body = std::fs::read_to_string(path).context("reading settings.json")?;
if body.trim().is_empty() {
json!({})
} else {
serde_json::from_str(&body).context(
"settings.json exists but is not valid JSON — refusing to clobber; fix or remove it first",
)?
}
} else {
json!({})
};
if !cfg.is_object() {
bail!("settings.json root is not a JSON object — refusing to clobber");
}
let desired = json!({"type": "command", "command": command});
let root = cfg.as_object_mut().unwrap();
if root.get("statusLine") == Some(&desired) {
return Ok(false);
}
root.insert("statusLine".to_string(), desired);
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent).context("creating parent dir")?;
}
let out = serde_json::to_string_pretty(&cfg)? + "\n";
std::fs::write(path, out).context("writing settings.json")?;
Ok(true)
}
pub(crate) fn remove_statusline_entry(path: &std::path::Path) -> Result<bool> {
if !path.exists() {
return Ok(false);
}
let body = std::fs::read_to_string(path).context("reading settings.json")?;
if body.trim().is_empty() {
return Ok(false);
}
let mut cfg: Value = serde_json::from_str(&body)
.context("settings.json is not valid JSON — refusing to edit")?;
let Some(root) = cfg.as_object_mut() else {
return Ok(false);
};
if root.remove("statusLine").is_none() {
return Ok(false);
}
let out = serde_json::to_string_pretty(&cfg)? + "\n";
std::fs::write(path, out).context("writing settings.json")?;
Ok(true)
}
fn statusline_command(script_path: &std::path::Path) -> (String, Option<String>) {
#[cfg(windows)]
{
match resolve_git_bash() {
Some(bash) => (format!("\"{}\" \"{}\"", bash, script_path.display()), None),
None => (
format!("bash \"{}\"", script_path.display()),
Some(
"could not locate git-bash; using bare `bash`. On Windows that may resolve to \
WSL (System32\\bash.exe) and the statusline will be blank — install Git for \
Windows or set statusLine.command to your git-bash bash.exe path."
.to_string(),
),
),
}
}
#[cfg(unix)]
{
(format!("bash \"{}\"", script_path.display()), None)
}
}
#[cfg(windows)]
fn resolve_git_bash() -> Option<String> {
use std::path::PathBuf;
if let Ok(out) = std::process::Command::new("where.exe").arg("bash").output()
&& out.status.success()
{
for line in String::from_utf8_lossy(&out.stdout).lines() {
let p = line.trim();
if !p.is_empty() && !p.to_lowercase().contains("\\system32\\") {
return Some(p.to_string());
}
}
}
let candidates = [
std::env::var("ProgramFiles")
.ok()
.map(|p| format!("{p}\\Git\\bin\\bash.exe")),
std::env::var("ProgramFiles(x86)")
.ok()
.map(|p| format!("{p}\\Git\\bin\\bash.exe")),
std::env::var("LocalAppData")
.ok()
.map(|p| format!("{p}\\Programs\\Git\\bin\\bash.exe")),
];
candidates
.into_iter()
.flatten()
.find(|c| PathBuf::from(c).exists())
}
#[cfg(test)]
mod statusline_tests {
use super::*;
#[test]
fn statusline_merge_preserves_keys_and_is_idempotent() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("settings.json");
std::fs::write(&path, r#"{"theme":"dark","model":"opus"}"#).unwrap();
assert!(upsert_statusline_entry(&path, "bash /x.sh").unwrap());
let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(v["theme"], "dark");
assert_eq!(v["model"], "opus");
assert_eq!(v["statusLine"]["type"], "command");
assert_eq!(v["statusLine"]["command"], "bash /x.sh");
assert!(!upsert_statusline_entry(&path, "bash /x.sh").unwrap());
assert!(remove_statusline_entry(&path).unwrap());
let v2: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(v2["theme"], "dark");
assert!(v2.get("statusLine").is_none());
assert!(!remove_statusline_entry(&path).unwrap());
}
#[test]
fn statusline_merge_refuses_to_clobber_invalid_json() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("settings.json");
std::fs::write(&path, "this is not json {").unwrap();
let err = upsert_statusline_entry(&path, "bash /x.sh").unwrap_err();
assert!(
format!("{err:#}").contains("not valid JSON"),
"err: {err:#}"
);
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"this is not json {"
);
}
#[test]
fn statusline_creates_settings_when_absent() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("settings.json");
assert!(upsert_statusline_entry(&path, "bash /x.sh").unwrap());
let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(v["statusLine"]["command"], "bash /x.sh");
}
}
#[cfg(test)]
mod relay_url_tests {
use super::*;
#[test]
fn strip_relay_url_userinfo_strips_handle_and_returns_cleaned() {
assert_eq!(
strip_relay_url_userinfo("https://copilot-agent@wireup.net"),
"https://wireup.net",
"https URL with handle userinfo is stripped to the bare host"
);
assert_eq!(
strip_relay_url_userinfo("http://copilot-agent@127.0.0.1:8771"),
"http://127.0.0.1:8771",
"http + port + userinfo is stripped, port preserved"
);
assert_eq!(strip_relay_url_userinfo("https://u:p@host"), "https://host");
assert_eq!(
strip_relay_url_userinfo("https://nick@host:8443"),
"https://host:8443"
);
assert_eq!(strip_relay_url_userinfo("nick@wireup.net"), "wireup.net");
assert_eq!(
strip_relay_url_userinfo("https://nick@wireup.net/v1/events?x=1#frag"),
"https://wireup.net/v1/events?x=1#frag"
);
}
#[test]
fn strip_relay_url_userinfo_passes_clean_urls_through_unchanged() {
for ok in [
"https://wireup.net",
"http://wireup.net",
"http://127.0.0.1:8771",
"https://relay.example.com:9443/v1/wire",
"https://wireup.net/?env=prod",
"https://wireup.net/users/me@example.com",
"https://wireup.net/?to=me@example.com",
"https://wireup.net/#contact@me",
"http://[::1]:8771",
"wireup.net",
"wireup.net:8443",
] {
assert_eq!(
strip_relay_url_userinfo(ok),
ok,
"clean URL `{ok}` must pass through unchanged"
);
}
}
#[test]
fn assert_relay_url_clean_for_publish_blocks_userinfo_at_persist_site() {
assert!(assert_relay_url_clean_for_publish("https://wireup.net").is_ok());
assert!(assert_relay_url_clean_for_publish("http://127.0.0.1:8771").is_ok());
assert!(
assert_relay_url_clean_for_publish("https://wireup.net/?to=me@example.com").is_ok()
);
let err = assert_relay_url_clean_for_publish("https://nick@wireup.net")
.unwrap_err()
.to_string();
assert!(
err.contains("invariant violated"),
"persist-site failure must be flagged as an internal invariant violation, not user error: {err}"
);
assert!(
err.contains("strip_relay_url_userinfo"),
"error must name the upstream filter so the caller can audit the bypass: {err}"
);
assert!(assert_relay_url_clean_for_publish("https://u:p@host").is_err());
assert!(assert_relay_url_clean_for_publish("https://nick@host:8443").is_err());
}
#[test]
fn strip_proto_no_longer_doubles_handle_after_userinfo_fix() {
let after_strip = strip_relay_url_userinfo("https://nick@wireup.net");
assert_eq!(after_strip, "https://wireup.net");
assert_eq!(strip_proto(&after_strip), "wireup.net");
assert!(
strip_proto("https://nick@wireup.net").contains('@'),
"strip_proto preserves userinfo by design; the userinfo guard upstream is what prevents the doubled echo"
);
}
}