1use anyhow::{Context, Result, anyhow, bail};
17use clap::{Parser, Subcommand};
18use serde_json::{Value, json};
19
20use crate::{
21 agent_card::{build_agent_card, sign_agent_card},
22 config,
23 signing::{fingerprint, generate_keypair, make_key_id, sign_message_v31, verify_message_v31},
24 trust::{add_self_to_trust, empty_trust},
25};
26
27#[derive(Parser, Debug)]
29#[command(name = "wire", version, about = "Magic-wormhole for AI agents — bilateral signed-message bus", long_about = None)]
30pub struct Cli {
31 #[command(subcommand)]
32 pub command: Command,
33}
34
35#[derive(Subcommand, Debug)]
36pub enum Command {
37 Init {
39 handle: String,
41 #[arg(long)]
43 name: Option<String>,
44 #[arg(long)]
47 relay: Option<String>,
48 #[arg(long)]
50 json: bool,
51 },
52 Whoami {
56 #[arg(long)]
57 json: bool,
58 },
59 Peers {
61 #[arg(long)]
62 json: bool,
63 },
64 Send {
72 peer: String,
74 kind_or_body: String,
79 body: Option<String>,
83 #[arg(long)]
85 deadline: Option<String>,
86 #[arg(long)]
88 json: bool,
89 },
90 Tail {
92 peer: Option<String>,
94 #[arg(long)]
96 json: bool,
97 #[arg(long, default_value_t = 0)]
99 limit: usize,
100 },
101 Monitor {
112 #[arg(long)]
114 peer: Option<String>,
115 #[arg(long)]
117 json: bool,
118 #[arg(long)]
121 include_handshake: bool,
122 #[arg(long, default_value_t = 500)]
124 interval_ms: u64,
125 #[arg(long, default_value_t = 0)]
127 replay: usize,
128 },
129 Verify {
131 path: String,
133 #[arg(long)]
135 json: bool,
136 },
137 Mcp,
141 RelayServer {
143 #[arg(long, default_value = "127.0.0.1:8770")]
145 bind: String,
146 },
147 BindRelay {
149 url: String,
151 #[arg(long)]
152 json: bool,
153 },
154 AddPeerSlot {
157 handle: String,
159 url: String,
161 slot_id: String,
163 slot_token: String,
165 #[arg(long)]
166 json: bool,
167 },
168 Push {
170 peer: Option<String>,
172 #[arg(long)]
173 json: bool,
174 },
175 Pull {
177 #[arg(long)]
178 json: bool,
179 },
180 Status {
183 #[arg(long)]
185 peer: Option<String>,
186 #[arg(long)]
187 json: bool,
188 },
189 Responder {
191 #[command(subcommand)]
192 command: ResponderCommand,
193 },
194 Pin {
197 card_file: String,
199 #[arg(long)]
200 json: bool,
201 },
202 RotateSlot {
213 #[arg(long)]
216 no_announce: bool,
217 #[arg(long)]
218 json: bool,
219 },
220 ForgetPeer {
224 handle: String,
226 #[arg(long)]
228 purge: bool,
229 #[arg(long)]
230 json: bool,
231 },
232 Daemon {
236 #[arg(long, default_value_t = 5)]
238 interval: u64,
239 #[arg(long)]
241 once: bool,
242 #[arg(long)]
243 json: bool,
244 },
245 PairHost {
250 #[arg(long)]
252 relay: String,
253 #[arg(long)]
257 yes: bool,
258 #[arg(long, default_value_t = 300)]
260 timeout: u64,
261 #[arg(long)]
267 detach: bool,
268 #[arg(long)]
270 json: bool,
271 },
272 #[command(alias = "join")]
276 PairJoin {
277 code_phrase: String,
279 #[arg(long)]
281 relay: String,
282 #[arg(long)]
283 yes: bool,
284 #[arg(long, default_value_t = 300)]
285 timeout: u64,
286 #[arg(long)]
288 detach: bool,
289 #[arg(long)]
291 json: bool,
292 },
293 PairConfirm {
297 code_phrase: String,
299 digits: String,
301 #[arg(long)]
303 json: bool,
304 },
305 PairList {
307 #[arg(long)]
309 json: bool,
310 #[arg(long)]
314 watch: bool,
315 #[arg(long, default_value_t = 1)]
317 watch_interval: u64,
318 },
319 PairCancel {
321 code_phrase: String,
322 #[arg(long)]
323 json: bool,
324 },
325 PairWatch {
335 code_phrase: String,
336 #[arg(long, default_value = "sas_ready")]
338 status: String,
339 #[arg(long, default_value_t = 300)]
341 timeout: u64,
342 #[arg(long)]
344 json: bool,
345 },
346 Pair {
355 handle: String,
358 #[arg(long)]
361 code: Option<String>,
362 #[arg(long, default_value = "https://wireup.net")]
364 relay: String,
365 #[arg(long)]
367 yes: bool,
368 #[arg(long, default_value_t = 300)]
370 timeout: u64,
371 #[arg(long)]
374 no_setup: bool,
375 #[arg(long)]
380 detach: bool,
381 },
382 PairAbandon {
388 code_phrase: String,
390 #[arg(long, default_value = "https://wireup.net")]
392 relay: String,
393 },
394 PairReject {
401 peer: String,
403 #[arg(long)]
405 json: bool,
406 },
407 PairListInbound {
413 #[arg(long)]
415 json: bool,
416 },
417 Setup {
422 #[arg(long)]
424 apply: bool,
425 },
426 Whois {
430 handle: Option<String>,
432 #[arg(long)]
433 json: bool,
434 #[arg(long)]
437 relay: Option<String>,
438 },
439 Add {
445 handle: String,
447 #[arg(long)]
449 relay: Option<String>,
450 #[arg(long)]
451 json: bool,
452 },
453 Up {
463 handle: String,
466 #[arg(long)]
468 name: Option<String>,
469 #[arg(long)]
470 json: bool,
471 },
472 Doctor {
479 #[arg(long)]
481 json: bool,
482 #[arg(long, default_value_t = 5)]
484 recent_rejections: usize,
485 },
486 Upgrade {
491 #[arg(long)]
494 check: bool,
495 #[arg(long)]
496 json: bool,
497 },
498 Service {
503 #[command(subcommand)]
504 action: ServiceAction,
505 },
506 Diag {
511 #[command(subcommand)]
512 action: DiagAction,
513 },
514 Claim {
518 nick: String,
519 #[arg(long)]
521 relay: Option<String>,
522 #[arg(long)]
524 public_url: Option<String>,
525 #[arg(long)]
526 json: bool,
527 },
528 Profile {
538 #[command(subcommand)]
539 action: ProfileAction,
540 },
541 Invite {
545 #[arg(long, default_value = "https://wireup.net")]
547 relay: String,
548 #[arg(long, default_value_t = 86_400)]
550 ttl: u64,
551 #[arg(long, default_value_t = 1)]
554 uses: u32,
555 #[arg(long)]
559 share: bool,
560 #[arg(long)]
562 json: bool,
563 },
564 Accept {
567 url: String,
569 #[arg(long)]
571 json: bool,
572 },
573 Reactor {
579 #[arg(long)]
581 on_event: String,
582 #[arg(long)]
584 peer: Option<String>,
585 #[arg(long)]
587 kind: Option<String>,
588 #[arg(long, default_value_t = true)]
590 verified_only: bool,
591 #[arg(long, default_value_t = 2)]
593 interval: u64,
594 #[arg(long)]
596 once: bool,
597 #[arg(long)]
599 dry_run: bool,
600 #[arg(long, default_value_t = 6)]
604 max_per_minute: u32,
605 #[arg(long, default_value_t = 1)]
609 max_chain_depth: u32,
610 },
611 Notify {
616 #[arg(long, default_value_t = 2)]
618 interval: u64,
619 #[arg(long)]
621 peer: Option<String>,
622 #[arg(long)]
624 once: bool,
625 #[arg(long)]
629 json: bool,
630 },
631}
632
633#[derive(Subcommand, Debug)]
634pub enum DiagAction {
635 Tail {
637 #[arg(long, default_value_t = 20)]
638 limit: usize,
639 #[arg(long)]
640 json: bool,
641 },
642 Enable,
645 Disable,
647 Status {
649 #[arg(long)]
650 json: bool,
651 },
652}
653
654#[derive(Subcommand, Debug)]
655pub enum ServiceAction {
656 Install {
659 #[arg(long)]
660 json: bool,
661 },
662 Uninstall {
666 #[arg(long)]
667 json: bool,
668 },
669 Status {
671 #[arg(long)]
672 json: bool,
673 },
674}
675
676#[derive(Subcommand, Debug)]
677pub enum ResponderCommand {
678 Set {
680 status: String,
682 #[arg(long)]
684 reason: Option<String>,
685 #[arg(long)]
687 json: bool,
688 },
689 Get {
691 peer: Option<String>,
693 #[arg(long)]
695 json: bool,
696 },
697}
698
699#[derive(Subcommand, Debug)]
700pub enum ProfileAction {
701 Set {
705 field: String,
706 value: String,
707 #[arg(long)]
708 json: bool,
709 },
710 Get {
712 #[arg(long)]
713 json: bool,
714 },
715 Clear {
717 field: String,
718 #[arg(long)]
719 json: bool,
720 },
721}
722
723pub fn run() -> Result<()> {
725 let cli = Cli::parse();
726 match cli.command {
727 Command::Init {
728 handle,
729 name,
730 relay,
731 json,
732 } => cmd_init(&handle, name.as_deref(), relay.as_deref(), json),
733 Command::Status { peer, json } => {
734 if let Some(peer) = peer {
735 cmd_status_peer(&peer, json)
736 } else {
737 cmd_status(json)
738 }
739 }
740 Command::Whoami { json } => cmd_whoami(json),
741 Command::Peers { json } => cmd_peers(json),
742 Command::Send {
743 peer,
744 kind_or_body,
745 body,
746 deadline,
747 json,
748 } => {
749 let (kind, body) = match body {
752 Some(real_body) => (kind_or_body, real_body),
753 None => ("claim".to_string(), kind_or_body),
754 };
755 cmd_send(&peer, &kind, &body, deadline.as_deref(), json)
756 }
757 Command::Tail { peer, json, limit } => cmd_tail(peer.as_deref(), json, limit),
758 Command::Monitor {
759 peer,
760 json,
761 include_handshake,
762 interval_ms,
763 replay,
764 } => cmd_monitor(peer.as_deref(), json, include_handshake, interval_ms, replay),
765 Command::Verify { path, json } => cmd_verify(&path, json),
766 Command::Responder { command } => match command {
767 ResponderCommand::Set {
768 status,
769 reason,
770 json,
771 } => cmd_responder_set(&status, reason.as_deref(), json),
772 ResponderCommand::Get { peer, json } => cmd_responder_get(peer.as_deref(), json),
773 },
774 Command::Mcp => cmd_mcp(),
775 Command::RelayServer { bind } => cmd_relay_server(&bind),
776 Command::BindRelay { url, json } => cmd_bind_relay(&url, json),
777 Command::AddPeerSlot {
778 handle,
779 url,
780 slot_id,
781 slot_token,
782 json,
783 } => cmd_add_peer_slot(&handle, &url, &slot_id, &slot_token, json),
784 Command::Push { peer, json } => cmd_push(peer.as_deref(), json),
785 Command::Pull { json } => cmd_pull(json),
786 Command::Pin { card_file, json } => cmd_pin(&card_file, json),
787 Command::RotateSlot { no_announce, json } => cmd_rotate_slot(no_announce, json),
788 Command::ForgetPeer {
789 handle,
790 purge,
791 json,
792 } => cmd_forget_peer(&handle, purge, json),
793 Command::Daemon {
794 interval,
795 once,
796 json,
797 } => cmd_daemon(interval, once, json),
798 Command::PairHost {
799 relay,
800 yes,
801 timeout,
802 detach,
803 json,
804 } => {
805 if detach {
806 cmd_pair_host_detach(&relay, json)
807 } else {
808 cmd_pair_host(&relay, yes, timeout)
809 }
810 }
811 Command::PairJoin {
812 code_phrase,
813 relay,
814 yes,
815 timeout,
816 detach,
817 json,
818 } => {
819 if detach {
820 cmd_pair_join_detach(&code_phrase, &relay, json)
821 } else {
822 cmd_pair_join(&code_phrase, &relay, yes, timeout)
823 }
824 }
825 Command::PairConfirm {
826 code_phrase,
827 digits,
828 json,
829 } => cmd_pair_confirm(&code_phrase, &digits, json),
830 Command::PairList {
831 json,
832 watch,
833 watch_interval,
834 } => cmd_pair_list(json, watch, watch_interval),
835 Command::PairCancel { code_phrase, json } => cmd_pair_cancel(&code_phrase, json),
836 Command::PairWatch {
837 code_phrase,
838 status,
839 timeout,
840 json,
841 } => cmd_pair_watch(&code_phrase, &status, timeout, json),
842 Command::Pair {
843 handle,
844 code,
845 relay,
846 yes,
847 timeout,
848 no_setup,
849 detach,
850 } => {
851 if handle.contains('@') && code.is_none() {
858 cmd_pair_megacommand(&handle, Some(&relay), timeout, false)
859 } else if detach {
860 cmd_pair_detach(&handle, code.as_deref(), &relay)
861 } else {
862 cmd_pair(&handle, code.as_deref(), &relay, yes, timeout, no_setup)
863 }
864 }
865 Command::PairAbandon { code_phrase, relay } => cmd_pair_abandon(&code_phrase, &relay),
866 Command::PairReject { peer, json } => cmd_pair_reject(&peer, json),
867 Command::PairListInbound { json } => cmd_pair_list_inbound(json),
868 Command::Invite {
869 relay,
870 ttl,
871 uses,
872 share,
873 json,
874 } => cmd_invite(&relay, ttl, uses, share, json),
875 Command::Accept { url, json } => cmd_accept(&url, json),
876 Command::Whois {
877 handle,
878 json,
879 relay,
880 } => cmd_whois(handle.as_deref(), json, relay.as_deref()),
881 Command::Add {
882 handle,
883 relay,
884 json,
885 } => cmd_add(&handle, relay.as_deref(), json),
886 Command::Up {
887 handle,
888 name,
889 json,
890 } => cmd_up(&handle, name.as_deref(), json),
891 Command::Doctor {
892 json,
893 recent_rejections,
894 } => cmd_doctor(json, recent_rejections),
895 Command::Upgrade { check, json } => cmd_upgrade(check, json),
896 Command::Service { action } => cmd_service(action),
897 Command::Diag { action } => cmd_diag(action),
898 Command::Claim {
899 nick,
900 relay,
901 public_url,
902 json,
903 } => cmd_claim(&nick, relay.as_deref(), public_url.as_deref(), json),
904 Command::Profile { action } => cmd_profile(action),
905 Command::Setup { apply } => cmd_setup(apply),
906 Command::Reactor {
907 on_event,
908 peer,
909 kind,
910 verified_only,
911 interval,
912 once,
913 dry_run,
914 max_per_minute,
915 max_chain_depth,
916 } => cmd_reactor(
917 &on_event,
918 peer.as_deref(),
919 kind.as_deref(),
920 verified_only,
921 interval,
922 once,
923 dry_run,
924 max_per_minute,
925 max_chain_depth,
926 ),
927 Command::Notify {
928 interval,
929 peer,
930 once,
931 json,
932 } => cmd_notify(interval, peer.as_deref(), once, json),
933 }
934}
935
936fn cmd_init(handle: &str, name: Option<&str>, relay: Option<&str>, as_json: bool) -> Result<()> {
939 if !handle
940 .chars()
941 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
942 {
943 bail!("handle must be ASCII alphanumeric / '-' / '_' (got {handle:?})");
944 }
945 if config::is_initialized()? {
946 bail!(
947 "already initialized — config exists at {:?}. Delete it first if you want a fresh identity.",
948 config::config_dir()?
949 );
950 }
951
952 config::ensure_dirs()?;
953 let (sk_seed, pk_bytes) = generate_keypair();
954 config::write_private_key(&sk_seed)?;
955
956 let card = build_agent_card(handle, &pk_bytes, name, None, None);
957 let signed = sign_agent_card(&card, &sk_seed);
958 config::write_agent_card(&signed)?;
959
960 let mut trust = empty_trust();
961 add_self_to_trust(&mut trust, handle, &pk_bytes);
962 config::write_trust(&trust)?;
963
964 let fp = fingerprint(&pk_bytes);
965 let key_id = make_key_id(handle, &pk_bytes);
966
967 let mut relay_info: Option<(String, String)> = None;
969 if let Some(url) = relay {
970 let normalized = url.trim_end_matches('/');
971 let client = crate::relay_client::RelayClient::new(normalized);
972 client.check_healthz()?;
973 let alloc = client.allocate_slot(Some(handle))?;
974 let mut state = config::read_relay_state()?;
975 state["self"] = json!({
976 "relay_url": normalized,
977 "slot_id": alloc.slot_id.clone(),
978 "slot_token": alloc.slot_token,
979 });
980 config::write_relay_state(&state)?;
981 relay_info = Some((normalized.to_string(), alloc.slot_id));
982 }
983
984 let did_str = crate::agent_card::did_for_with_key(handle, &pk_bytes);
985 if as_json {
986 let mut out = json!({
987 "did": did_str.clone(),
988 "fingerprint": fp,
989 "key_id": key_id,
990 "config_dir": config::config_dir()?.to_string_lossy(),
991 });
992 if let Some((url, slot_id)) = &relay_info {
993 out["relay_url"] = json!(url);
994 out["slot_id"] = json!(slot_id);
995 }
996 println!("{}", serde_json::to_string(&out)?);
997 } else {
998 println!("generated {did_str} (ed25519:{key_id})");
999 println!(
1000 "config written to {}",
1001 config::config_dir()?.to_string_lossy()
1002 );
1003 if let Some((url, slot_id)) = &relay_info {
1004 println!("bound to relay {url} (slot {slot_id})");
1005 println!();
1006 println!(
1007 "next step: `wire pair-host --relay {url}` to print a code phrase for a peer."
1008 );
1009 } else {
1010 println!();
1011 println!(
1012 "next step: `wire pair-host --relay <url>` to bind a relay + open a pair-slot."
1013 );
1014 }
1015 }
1016 Ok(())
1017}
1018
1019fn cmd_status(as_json: bool) -> Result<()> {
1022 let initialized = config::is_initialized()?;
1023
1024 let mut summary = json!({
1025 "initialized": initialized,
1026 });
1027
1028 if initialized {
1029 let card = config::read_agent_card()?;
1030 let did = card
1031 .get("did")
1032 .and_then(Value::as_str)
1033 .unwrap_or("")
1034 .to_string();
1035 let handle = card
1039 .get("handle")
1040 .and_then(Value::as_str)
1041 .map(str::to_string)
1042 .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
1043 let pk_b64 = card
1044 .get("verify_keys")
1045 .and_then(Value::as_object)
1046 .and_then(|m| m.values().next())
1047 .and_then(|v| v.get("key"))
1048 .and_then(Value::as_str)
1049 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
1050 let pk_bytes = crate::signing::b64decode(pk_b64)?;
1051 summary["did"] = json!(did);
1052 summary["handle"] = json!(handle);
1053 summary["fingerprint"] = json!(fingerprint(&pk_bytes));
1054 summary["capabilities"] = card
1055 .get("capabilities")
1056 .cloned()
1057 .unwrap_or_else(|| json!([]));
1058
1059 let trust = config::read_trust()?;
1060 let relay_state_for_tier = config::read_relay_state().unwrap_or_else(|_| json!({"peers": {}}));
1061 let mut peers = Vec::new();
1062 if let Some(agents) = trust.get("agents").and_then(Value::as_object) {
1063 for (peer_handle, _agent) in agents {
1064 if peer_handle == &handle {
1065 continue; }
1067 peers.push(json!({
1072 "handle": peer_handle,
1073 "tier": effective_peer_tier(&trust, &relay_state_for_tier, peer_handle),
1074 }));
1075 }
1076 }
1077 summary["peers"] = json!(peers);
1078
1079 let relay_state = config::read_relay_state()?;
1080 summary["self_relay"] = relay_state.get("self").cloned().unwrap_or(Value::Null);
1081 if !summary["self_relay"].is_null() {
1082 if let Some(obj) = summary["self_relay"].as_object_mut() {
1084 obj.remove("slot_token");
1085 }
1086 }
1087 summary["peer_slots_count"] = json!(
1088 relay_state
1089 .get("peers")
1090 .and_then(Value::as_object)
1091 .map(|m| m.len())
1092 .unwrap_or(0)
1093 );
1094
1095 let outbox = config::outbox_dir()?;
1097 let inbox = config::inbox_dir()?;
1098 summary["outbox"] = json!(scan_jsonl_dir(&outbox)?);
1099 summary["inbox"] = json!(scan_jsonl_dir(&inbox)?);
1100
1101 let record = crate::ensure_up::read_pid_record("daemon");
1109 let pidfile_pid = record.pid();
1110 let pidfile_alive = pidfile_pid
1111 .map(|pid| {
1112 #[cfg(target_os = "linux")]
1113 {
1114 std::path::Path::new(&format!("/proc/{pid}")).exists()
1115 }
1116 #[cfg(not(target_os = "linux"))]
1117 {
1118 std::process::Command::new("kill")
1119 .args(["-0", &pid.to_string()])
1120 .output()
1121 .map(|o| o.status.success())
1122 .unwrap_or(false)
1123 }
1124 })
1125 .unwrap_or(false);
1126
1127 let pgrep_pids: Vec<u32> = std::process::Command::new("pgrep")
1129 .args(["-f", "wire daemon"])
1130 .output()
1131 .ok()
1132 .filter(|o| o.status.success())
1133 .map(|o| {
1134 String::from_utf8_lossy(&o.stdout)
1135 .split_whitespace()
1136 .filter_map(|s| s.parse::<u32>().ok())
1137 .collect()
1138 })
1139 .unwrap_or_default();
1140 let orphan_pids: Vec<u32> = pgrep_pids
1141 .iter()
1142 .filter(|p| Some(**p) != pidfile_pid)
1143 .copied()
1144 .collect();
1145
1146 let mut daemon = json!({
1147 "running": pidfile_alive,
1148 "pid": pidfile_pid,
1149 "all_running_pids": pgrep_pids,
1150 "orphans": orphan_pids,
1151 });
1152 if let crate::ensure_up::PidRecord::Json(d) = &record {
1153 daemon["version"] = json!(d.version);
1154 daemon["bin_path"] = json!(d.bin_path);
1155 daemon["did"] = json!(d.did);
1156 daemon["relay_url"] = json!(d.relay_url);
1157 daemon["started_at"] = json!(d.started_at);
1158 daemon["schema"] = json!(d.schema);
1159 if d.version != env!("CARGO_PKG_VERSION") {
1160 daemon["version_mismatch"] = json!({
1161 "daemon": d.version.clone(),
1162 "cli": env!("CARGO_PKG_VERSION"),
1163 });
1164 }
1165 } else if matches!(record, crate::ensure_up::PidRecord::LegacyInt(_)) {
1166 daemon["pidfile_form"] = json!("legacy-int");
1167 daemon["version_mismatch"] = json!({
1168 "daemon": "<pre-0.5.11>",
1169 "cli": env!("CARGO_PKG_VERSION"),
1170 });
1171 }
1172 summary["daemon"] = daemon;
1173
1174 let pending = crate::pending_pair::list_pending().unwrap_or_default();
1176 let mut counts: std::collections::BTreeMap<String, u32> = Default::default();
1177 for p in &pending {
1178 *counts.entry(p.status.clone()).or_default() += 1;
1179 }
1180 let pending_inbound =
1182 crate::pending_inbound_pair::list_pending_inbound().unwrap_or_default();
1183 let inbound_handles: Vec<&str> = pending_inbound
1184 .iter()
1185 .map(|p| p.peer_handle.as_str())
1186 .collect();
1187 summary["pending_pairs"] = json!({
1188 "total": pending.len(),
1189 "by_status": counts,
1190 "inbound_count": pending_inbound.len(),
1191 "inbound_handles": inbound_handles,
1192 });
1193 }
1194
1195 if as_json {
1196 println!("{}", serde_json::to_string(&summary)?);
1197 } else if !initialized {
1198 println!("not initialized — run `wire init <handle>` first");
1199 } else {
1200 println!("did: {}", summary["did"].as_str().unwrap_or("?"));
1201 println!(
1202 "fingerprint: {}",
1203 summary["fingerprint"].as_str().unwrap_or("?")
1204 );
1205 println!("capabilities: {}", summary["capabilities"]);
1206 if !summary["self_relay"].is_null() {
1207 println!(
1208 "self relay: {} (slot {})",
1209 summary["self_relay"]["relay_url"].as_str().unwrap_or("?"),
1210 summary["self_relay"]["slot_id"].as_str().unwrap_or("?")
1211 );
1212 } else {
1213 println!("self relay: (not bound — run `wire pair-host --relay <url>` to bind)");
1214 }
1215 println!(
1216 "peers: {}",
1217 summary["peers"].as_array().map(|a| a.len()).unwrap_or(0)
1218 );
1219 for p in summary["peers"].as_array().unwrap_or(&Vec::new()) {
1220 println!(
1221 " - {:<20} tier={}",
1222 p["handle"].as_str().unwrap_or(""),
1223 p["tier"].as_str().unwrap_or("?")
1224 );
1225 }
1226 println!(
1227 "outbox: {} file(s), {} event(s) queued",
1228 summary["outbox"]["files"].as_u64().unwrap_or(0),
1229 summary["outbox"]["events"].as_u64().unwrap_or(0)
1230 );
1231 println!(
1232 "inbox: {} file(s), {} event(s) received",
1233 summary["inbox"]["files"].as_u64().unwrap_or(0),
1234 summary["inbox"]["events"].as_u64().unwrap_or(0)
1235 );
1236 let daemon_running = summary["daemon"]["running"].as_bool().unwrap_or(false);
1237 let daemon_pid = summary["daemon"]["pid"]
1238 .as_u64()
1239 .map(|p| p.to_string())
1240 .unwrap_or_else(|| "—".to_string());
1241 let daemon_version = summary["daemon"]["version"].as_str().unwrap_or("");
1242 let version_suffix = if !daemon_version.is_empty() {
1243 format!(" v{daemon_version}")
1244 } else {
1245 String::new()
1246 };
1247 println!(
1248 "daemon: {} (pid {}{})",
1249 if daemon_running { "running" } else { "DOWN" },
1250 daemon_pid,
1251 version_suffix,
1252 );
1253 if let Some(mm) = summary["daemon"].get("version_mismatch") {
1255 println!(
1256 " !! version mismatch: daemon={} CLI={}. \
1257 run `wire upgrade` to swap atomically.",
1258 mm["daemon"].as_str().unwrap_or("?"),
1259 mm["cli"].as_str().unwrap_or("?"),
1260 );
1261 }
1262 if let Some(orphans) = summary["daemon"]["orphans"].as_array()
1263 && !orphans.is_empty()
1264 {
1265 let pids: Vec<String> = orphans
1266 .iter()
1267 .filter_map(|v| v.as_u64().map(|p| p.to_string()))
1268 .collect();
1269 println!(
1270 " !! orphan daemon process(es): pids {}. \
1271 pgrep saw them but pidfile didn't — likely stale process from \
1272 prior install. Multiple daemons race the relay cursor.",
1273 pids.join(", ")
1274 );
1275 }
1276 let pending_total = summary["pending_pairs"]["total"].as_u64().unwrap_or(0);
1277 let inbound_count = summary["pending_pairs"]["inbound_count"]
1278 .as_u64()
1279 .unwrap_or(0);
1280 if pending_total > 0 {
1281 print!("pending pairs: {pending_total}");
1282 if let Some(obj) = summary["pending_pairs"]["by_status"].as_object() {
1283 let parts: Vec<String> = obj
1284 .iter()
1285 .map(|(k, v)| format!("{}={}", k, v.as_u64().unwrap_or(0)))
1286 .collect();
1287 if !parts.is_empty() {
1288 print!(" ({})", parts.join(", "));
1289 }
1290 }
1291 println!();
1292 } else if inbound_count == 0 {
1293 println!("pending pairs: none");
1294 }
1295 if inbound_count > 0 {
1299 let handles: Vec<String> = summary["pending_pairs"]["inbound_handles"]
1300 .as_array()
1301 .map(|a| {
1302 a.iter()
1303 .filter_map(|v| v.as_str().map(str::to_string))
1304 .collect()
1305 })
1306 .unwrap_or_default();
1307 println!(
1308 "inbound pair requests ({inbound_count}): {} — `wire pair-list` to inspect, `wire add <peer>@<relay>` to accept, `wire pair-reject <peer>` to refuse",
1309 handles.join(", "),
1310 );
1311 }
1312 }
1313 Ok(())
1314}
1315
1316fn scan_jsonl_dir(dir: &std::path::Path) -> Result<Value> {
1317 if !dir.exists() {
1318 return Ok(json!({"files": 0, "events": 0}));
1319 }
1320 let mut files = 0usize;
1321 let mut events = 0usize;
1322 for entry in std::fs::read_dir(dir)? {
1323 let path = entry?.path();
1324 if path.extension().map(|x| x == "jsonl").unwrap_or(false) {
1325 files += 1;
1326 if let Ok(body) = std::fs::read_to_string(&path) {
1327 events += body.lines().filter(|l| !l.trim().is_empty()).count();
1328 }
1329 }
1330 }
1331 Ok(json!({"files": files, "events": events}))
1332}
1333
1334fn responder_status_allowed(status: &str) -> bool {
1337 matches!(
1338 status,
1339 "online" | "offline" | "oauth_locked" | "rate_limited" | "degraded"
1340 )
1341}
1342
1343fn relay_slot_for(peer: Option<&str>) -> Result<(String, String, String, String)> {
1344 let state = config::read_relay_state()?;
1345 let (label, slot_info) = match peer {
1346 Some(peer) => (
1347 peer.to_string(),
1348 state
1349 .get("peers")
1350 .and_then(|p| p.get(peer))
1351 .ok_or_else(|| {
1352 anyhow!(
1353 "unknown peer {peer:?} in relay state — pair with them first:\n \
1354 wire add {peer}@wireup.net (or {peer}@<their-relay>)\n\
1355 (`wire peers` lists who you've already paired with.)"
1356 )
1357 })?,
1358 ),
1359 None => (
1360 "self".to_string(),
1361 state.get("self").filter(|v| !v.is_null()).ok_or_else(|| {
1362 anyhow!("self slot not bound — run `wire bind-relay <url>` first")
1363 })?,
1364 ),
1365 };
1366 let relay_url = slot_info["relay_url"]
1367 .as_str()
1368 .ok_or_else(|| anyhow!("{label} relay_url missing"))?
1369 .to_string();
1370 let slot_id = slot_info["slot_id"]
1371 .as_str()
1372 .ok_or_else(|| anyhow!("{label} slot_id missing"))?
1373 .to_string();
1374 let slot_token = slot_info["slot_token"]
1375 .as_str()
1376 .ok_or_else(|| anyhow!("{label} slot_token missing"))?
1377 .to_string();
1378 Ok((label, relay_url, slot_id, slot_token))
1379}
1380
1381fn cmd_responder_set(status: &str, reason: Option<&str>, as_json: bool) -> Result<()> {
1382 if !responder_status_allowed(status) {
1383 bail!("status must be one of: online, offline, oauth_locked, rate_limited, degraded");
1384 }
1385 let (_label, relay_url, slot_id, slot_token) = relay_slot_for(None)?;
1386 let now = time::OffsetDateTime::now_utc()
1387 .format(&time::format_description::well_known::Rfc3339)
1388 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
1389 let mut record = json!({
1390 "status": status,
1391 "set_at": now,
1392 });
1393 if let Some(reason) = reason {
1394 record["reason"] = json!(reason);
1395 }
1396 if status == "online" {
1397 record["last_success_at"] = json!(now);
1398 }
1399 let client = crate::relay_client::RelayClient::new(&relay_url);
1400 let saved = client.responder_health_set(&slot_id, &slot_token, &record)?;
1401 if as_json {
1402 println!("{}", serde_json::to_string(&saved)?);
1403 } else {
1404 let reason = saved
1405 .get("reason")
1406 .and_then(Value::as_str)
1407 .map(|r| format!(" — {r}"))
1408 .unwrap_or_default();
1409 println!(
1410 "responder {}{}",
1411 saved
1412 .get("status")
1413 .and_then(Value::as_str)
1414 .unwrap_or(status),
1415 reason
1416 );
1417 }
1418 Ok(())
1419}
1420
1421fn cmd_responder_get(peer: Option<&str>, as_json: bool) -> Result<()> {
1422 let (label, relay_url, slot_id, slot_token) = relay_slot_for(peer)?;
1423 let client = crate::relay_client::RelayClient::new(&relay_url);
1424 let health = client.responder_health_get(&slot_id, &slot_token)?;
1425 if as_json {
1426 println!(
1427 "{}",
1428 serde_json::to_string(&json!({
1429 "target": label,
1430 "responder_health": health,
1431 }))?
1432 );
1433 } else if health.is_null() {
1434 println!("{label}: responder health not reported");
1435 } else {
1436 let status = health
1437 .get("status")
1438 .and_then(Value::as_str)
1439 .unwrap_or("unknown");
1440 let reason = health
1441 .get("reason")
1442 .and_then(Value::as_str)
1443 .map(|r| format!(" — {r}"))
1444 .unwrap_or_default();
1445 let last_success = health
1446 .get("last_success_at")
1447 .and_then(Value::as_str)
1448 .map(|t| format!(" (last_success: {t})"))
1449 .unwrap_or_default();
1450 println!("{label}: {status}{reason}{last_success}");
1451 }
1452 Ok(())
1453}
1454
1455fn cmd_status_peer(peer: &str, as_json: bool) -> Result<()> {
1456 let (_label, relay_url, slot_id, slot_token) = relay_slot_for(Some(peer))?;
1457 let client = crate::relay_client::RelayClient::new(&relay_url);
1458
1459 let started = std::time::Instant::now();
1460 let transport_ok = client.healthz().unwrap_or(false);
1461 let latency_ms = started.elapsed().as_millis() as u64;
1462
1463 let (event_count, last_pull_at_unix) = client.slot_state(&slot_id, &slot_token)?;
1464 let now = std::time::SystemTime::now()
1465 .duration_since(std::time::UNIX_EPOCH)
1466 .map(|d| d.as_secs())
1467 .unwrap_or(0);
1468 let attention = match last_pull_at_unix {
1469 Some(last) if now.saturating_sub(last) <= 300 => json!({
1470 "status": "ok",
1471 "last_pull_at_unix": last,
1472 "age_seconds": now.saturating_sub(last),
1473 "event_count": event_count,
1474 }),
1475 Some(last) => json!({
1476 "status": "stale",
1477 "last_pull_at_unix": last,
1478 "age_seconds": now.saturating_sub(last),
1479 "event_count": event_count,
1480 }),
1481 None => json!({
1482 "status": "never_pulled",
1483 "last_pull_at_unix": Value::Null,
1484 "event_count": event_count,
1485 }),
1486 };
1487
1488 let responder_health = client.responder_health_get(&slot_id, &slot_token)?;
1489 let responder = if responder_health.is_null() {
1490 json!({"status": "not_reported", "record": Value::Null})
1491 } else {
1492 json!({
1493 "status": responder_health
1494 .get("status")
1495 .and_then(Value::as_str)
1496 .unwrap_or("unknown"),
1497 "record": responder_health,
1498 })
1499 };
1500
1501 let report = json!({
1502 "peer": peer,
1503 "transport": {
1504 "status": if transport_ok { "ok" } else { "error" },
1505 "relay_url": relay_url,
1506 "latency_ms": latency_ms,
1507 },
1508 "attention": attention,
1509 "responder": responder,
1510 });
1511
1512 if as_json {
1513 println!("{}", serde_json::to_string(&report)?);
1514 } else {
1515 let transport_line = if transport_ok {
1516 format!("ok relay reachable ({latency_ms}ms)")
1517 } else {
1518 "error relay unreachable".to_string()
1519 };
1520 println!("transport {transport_line}");
1521 match report["attention"]["status"].as_str().unwrap_or("unknown") {
1522 "ok" => println!(
1523 "attention ok last pull {}s ago",
1524 report["attention"]["age_seconds"].as_u64().unwrap_or(0)
1525 ),
1526 "stale" => println!(
1527 "attention stale last pull {}m ago",
1528 report["attention"]["age_seconds"].as_u64().unwrap_or(0) / 60
1529 ),
1530 "never_pulled" => println!("attention never pulled since relay reset"),
1531 other => println!("attention {other}"),
1532 }
1533 if report["responder"]["status"] == "not_reported" {
1534 println!("auto-responder not reported");
1535 } else {
1536 let record = &report["responder"]["record"];
1537 let status = record
1538 .get("status")
1539 .and_then(Value::as_str)
1540 .unwrap_or("unknown");
1541 let reason = record
1542 .get("reason")
1543 .and_then(Value::as_str)
1544 .map(|r| format!(" — {r}"))
1545 .unwrap_or_default();
1546 println!("auto-responder {status}{reason}");
1547 }
1548 }
1549 Ok(())
1550}
1551
1552fn cmd_whoami(as_json: bool) -> Result<()> {
1557 if !config::is_initialized()? {
1558 bail!("not initialized — run `wire init <handle>` first");
1559 }
1560 let card = config::read_agent_card()?;
1561 let did = card
1562 .get("did")
1563 .and_then(Value::as_str)
1564 .unwrap_or("")
1565 .to_string();
1566 let handle = card
1567 .get("handle")
1568 .and_then(Value::as_str)
1569 .map(str::to_string)
1570 .unwrap_or_else(|| crate::agent_card::display_handle_from_did(&did).to_string());
1571 let pk_b64 = card
1572 .get("verify_keys")
1573 .and_then(Value::as_object)
1574 .and_then(|m| m.values().next())
1575 .and_then(|v| v.get("key"))
1576 .and_then(Value::as_str)
1577 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
1578 let pk_bytes = crate::signing::b64decode(pk_b64)?;
1579 let fp = fingerprint(&pk_bytes);
1580 let key_id = make_key_id(&handle, &pk_bytes);
1581 let capabilities = card
1582 .get("capabilities")
1583 .cloned()
1584 .unwrap_or_else(|| json!(["wire/v3.1"]));
1585
1586 if as_json {
1587 println!(
1588 "{}",
1589 serde_json::to_string(&json!({
1590 "did": did,
1591 "handle": handle,
1592 "fingerprint": fp,
1593 "key_id": key_id,
1594 "public_key_b64": pk_b64,
1595 "capabilities": capabilities,
1596 "config_dir": config::config_dir()?.to_string_lossy(),
1597 }))?
1598 );
1599 } else {
1600 println!("{did} (ed25519:{key_id})");
1601 println!("fingerprint: {fp}");
1602 println!("capabilities: {capabilities}");
1603 }
1604 Ok(())
1605}
1606
1607fn effective_peer_tier(trust: &Value, relay_state: &Value, handle: &str) -> String {
1622 let raw = crate::trust::get_tier(trust, handle);
1623 if raw != "VERIFIED" {
1624 return raw.to_string();
1625 }
1626 let token = relay_state
1627 .get("peers")
1628 .and_then(|p| p.get(handle))
1629 .and_then(|p| p.get("slot_token"))
1630 .and_then(Value::as_str)
1631 .unwrap_or("");
1632 if token.is_empty() {
1633 "PENDING_ACK".to_string()
1634 } else {
1635 raw.to_string()
1636 }
1637}
1638
1639fn cmd_peers(as_json: bool) -> Result<()> {
1640 let trust = config::read_trust()?;
1641 let agents = trust
1642 .get("agents")
1643 .and_then(Value::as_object)
1644 .cloned()
1645 .unwrap_or_default();
1646 let relay_state = config::read_relay_state().unwrap_or_else(|_| json!({"peers": {}}));
1647
1648 let mut self_did: Option<String> = None;
1649 if let Ok(card) = config::read_agent_card() {
1650 self_did = card.get("did").and_then(Value::as_str).map(str::to_string);
1651 }
1652
1653 let mut peers = Vec::new();
1654 for (handle, agent) in agents.iter() {
1655 let did = agent
1656 .get("did")
1657 .and_then(Value::as_str)
1658 .unwrap_or("")
1659 .to_string();
1660 if Some(did.as_str()) == self_did.as_deref() {
1661 continue; }
1663 let tier = effective_peer_tier(&trust, &relay_state, handle);
1664 let capabilities = agent
1665 .get("card")
1666 .and_then(|c| c.get("capabilities"))
1667 .cloned()
1668 .unwrap_or_else(|| json!([]));
1669 peers.push(json!({
1670 "handle": handle,
1671 "did": did,
1672 "tier": tier,
1673 "capabilities": capabilities,
1674 }));
1675 }
1676
1677 if as_json {
1678 println!("{}", serde_json::to_string(&peers)?);
1679 } else if peers.is_empty() {
1680 println!("no peers pinned (run `wire join <code>` to pair)");
1681 } else {
1682 for p in &peers {
1683 println!(
1684 "{:<20} {:<10} {}",
1685 p["handle"].as_str().unwrap_or(""),
1686 p["tier"].as_str().unwrap_or(""),
1687 p["did"].as_str().unwrap_or(""),
1688 );
1689 }
1690 }
1691 Ok(())
1692}
1693
1694fn maybe_warn_peer_attentiveness(peer: &str) {
1704 let state = match config::read_relay_state() {
1705 Ok(s) => s,
1706 Err(_) => return,
1707 };
1708 let p = state.get("peers").and_then(|p| p.get(peer));
1709 let slot_id = match p.and_then(|p| p.get("slot_id")).and_then(Value::as_str) {
1710 Some(s) if !s.is_empty() => s,
1711 _ => return,
1712 };
1713 let slot_token = match p.and_then(|p| p.get("slot_token")).and_then(Value::as_str) {
1714 Some(s) if !s.is_empty() => s,
1715 _ => return,
1716 };
1717 let relay_url = match p.and_then(|p| p.get("relay_url")).and_then(Value::as_str) {
1718 Some(s) if !s.is_empty() => s.to_string(),
1719 _ => match state
1720 .get("self")
1721 .and_then(|s| s.get("relay_url"))
1722 .and_then(Value::as_str)
1723 {
1724 Some(s) if !s.is_empty() => s.to_string(),
1725 _ => return,
1726 },
1727 };
1728 let client = crate::relay_client::RelayClient::new(&relay_url);
1729 let (_count, last_pull) = match client.slot_state(slot_id, slot_token) {
1730 Ok(t) => t,
1731 Err(_) => return,
1732 };
1733 let now = std::time::SystemTime::now()
1734 .duration_since(std::time::UNIX_EPOCH)
1735 .map(|d| d.as_secs())
1736 .unwrap_or(0);
1737 match last_pull {
1738 None => {
1739 eprintln!(
1740 "phyllis: {peer}'s line is silent — relay sees no pulls yet. message will queue, but they may not be listening."
1741 );
1742 }
1743 Some(t) if now.saturating_sub(t) > 300 => {
1744 let mins = now.saturating_sub(t) / 60;
1745 eprintln!(
1746 "phyllis: {peer} hasn't picked up in {mins}m — message will queue, but they may be away."
1747 );
1748 }
1749 _ => {}
1750 }
1751}
1752
1753pub(crate) fn parse_deadline_until(input: &str) -> Result<String> {
1754 let trimmed = input.trim();
1755 if time::OffsetDateTime::parse(trimmed, &time::format_description::well_known::Rfc3339).is_ok()
1756 {
1757 return Ok(trimmed.to_string());
1758 }
1759 let (amount, unit) = trimmed.split_at(trimmed.len().saturating_sub(1));
1760 let n: i64 = amount
1761 .parse()
1762 .with_context(|| format!("deadline must be `30m`, `2h`, `1d`, or RFC3339: {input:?}"))?;
1763 if n <= 0 {
1764 bail!("deadline duration must be positive: {input:?}");
1765 }
1766 let duration = match unit {
1767 "m" => time::Duration::minutes(n),
1768 "h" => time::Duration::hours(n),
1769 "d" => time::Duration::days(n),
1770 _ => bail!("deadline must end in m, h, d, or be RFC3339: {input:?}"),
1771 };
1772 Ok((time::OffsetDateTime::now_utc() + duration)
1773 .format(&time::format_description::well_known::Rfc3339)
1774 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()))
1775}
1776
1777fn cmd_send(
1778 peer: &str,
1779 kind: &str,
1780 body_arg: &str,
1781 deadline: Option<&str>,
1782 as_json: bool,
1783) -> Result<()> {
1784 if !config::is_initialized()? {
1785 bail!("not initialized — run `wire init <handle>` first");
1786 }
1787 let peer = crate::agent_card::bare_handle(peer);
1788 let sk_seed = config::read_private_key()?;
1789 let card = config::read_agent_card()?;
1790 let did = card.get("did").and_then(Value::as_str).unwrap_or("");
1791 let handle = crate::agent_card::display_handle_from_did(did).to_string();
1792 let pk_b64 = card
1793 .get("verify_keys")
1794 .and_then(Value::as_object)
1795 .and_then(|m| m.values().next())
1796 .and_then(|v| v.get("key"))
1797 .and_then(Value::as_str)
1798 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?;
1799 let pk_bytes = crate::signing::b64decode(pk_b64)?;
1800
1801 let body_value: Value = if body_arg == "-" {
1806 use std::io::Read;
1807 let mut raw = String::new();
1808 std::io::stdin()
1809 .read_to_string(&mut raw)
1810 .with_context(|| "reading body from stdin")?;
1811 serde_json::from_str(raw.trim_end()).unwrap_or(Value::String(raw))
1814 } else if let Some(path) = body_arg.strip_prefix('@') {
1815 let raw =
1816 std::fs::read_to_string(path).with_context(|| format!("reading body file {path:?}"))?;
1817 serde_json::from_str(&raw).unwrap_or(Value::String(raw))
1818 } else {
1819 Value::String(body_arg.to_string())
1820 };
1821
1822 let kind_id = parse_kind(kind)?;
1823
1824 let now = time::OffsetDateTime::now_utc()
1825 .format(&time::format_description::well_known::Rfc3339)
1826 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
1827
1828 let mut event = json!({
1829 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
1830 "timestamp": now,
1831 "from": did,
1832 "to": format!("did:wire:{peer}"),
1833 "type": kind,
1834 "kind": kind_id,
1835 "body": body_value,
1836 });
1837 if let Some(deadline) = deadline {
1838 event["time_sensitive_until"] = json!(parse_deadline_until(deadline)?);
1839 }
1840 let signed = sign_message_v31(&event, &sk_seed, &pk_bytes, &handle)?;
1841 let event_id = signed["event_id"].as_str().unwrap_or("").to_string();
1842
1843 maybe_warn_peer_attentiveness(peer);
1848
1849 let line = serde_json::to_vec(&signed)?;
1854 let outbox = config::append_outbox_record(peer, &line)?;
1855
1856 if as_json {
1857 println!(
1858 "{}",
1859 serde_json::to_string(&json!({
1860 "event_id": event_id,
1861 "status": "queued",
1862 "peer": peer,
1863 "outbox": outbox.to_string_lossy(),
1864 }))?
1865 );
1866 } else {
1867 println!(
1868 "queued event {event_id} → {peer} (outbox: {})",
1869 outbox.display()
1870 );
1871 }
1872 Ok(())
1873}
1874
1875fn parse_kind(s: &str) -> Result<u32> {
1876 if let Ok(n) = s.parse::<u32>() {
1877 return Ok(n);
1878 }
1879 for (id, name) in crate::signing::kinds() {
1880 if *name == s {
1881 return Ok(*id);
1882 }
1883 }
1884 Ok(1)
1886}
1887
1888fn cmd_tail(peer: Option<&str>, as_json: bool, limit: usize) -> Result<()> {
1891 let inbox = config::inbox_dir()?;
1892 if !inbox.exists() {
1893 if !as_json {
1894 eprintln!("no inbox yet — daemon hasn't run, or no events received");
1895 }
1896 return Ok(());
1897 }
1898 let trust = config::read_trust()?;
1899 let mut count = 0usize;
1900
1901 let entries: Vec<_> = std::fs::read_dir(&inbox)?
1902 .filter_map(|e| e.ok())
1903 .map(|e| e.path())
1904 .filter(|p| {
1905 p.extension().map(|x| x == "jsonl").unwrap_or(false)
1906 && match peer {
1907 Some(want) => p.file_stem().and_then(|s| s.to_str()) == Some(want),
1908 None => true,
1909 }
1910 })
1911 .collect();
1912
1913 for path in entries {
1914 let body = std::fs::read_to_string(&path)?;
1915 for line in body.lines() {
1916 let event: Value = match serde_json::from_str(line) {
1917 Ok(v) => v,
1918 Err(_) => continue,
1919 };
1920 let verified = verify_message_v31(&event, &trust).is_ok();
1921 if as_json {
1922 let mut event_with_meta = event.clone();
1923 if let Some(obj) = event_with_meta.as_object_mut() {
1924 obj.insert("verified".into(), json!(verified));
1925 }
1926 println!("{}", serde_json::to_string(&event_with_meta)?);
1927 } else {
1928 let ts = event
1929 .get("timestamp")
1930 .and_then(Value::as_str)
1931 .unwrap_or("?");
1932 let from = event.get("from").and_then(Value::as_str).unwrap_or("?");
1933 let kind = event.get("kind").and_then(Value::as_u64).unwrap_or(0);
1934 let kind_name = event.get("type").and_then(Value::as_str).unwrap_or("?");
1935 let summary = event
1936 .get("body")
1937 .map(|b| match b {
1938 Value::String(s) => s.clone(),
1939 _ => b.to_string(),
1940 })
1941 .unwrap_or_default();
1942 let mark = if verified { "✓" } else { "✗" };
1943 let deadline = event
1944 .get("time_sensitive_until")
1945 .and_then(Value::as_str)
1946 .map(|d| format!(" deadline: {d}"))
1947 .unwrap_or_default();
1948 println!("[{ts} {from} kind={kind} {kind_name}{deadline}] {summary} | sig {mark}");
1949 }
1950 count += 1;
1951 if limit > 0 && count >= limit {
1952 return Ok(());
1953 }
1954 }
1955 }
1956 Ok(())
1957}
1958
1959fn monitor_is_noise_kind(kind: &str) -> bool {
1965 matches!(kind, "pair_drop" | "pair_drop_ack" | "heartbeat")
1966}
1967
1968fn monitor_render(e: &crate::inbox_watch::InboxEvent, as_json: bool) -> Result<String> {
1972 if as_json {
1973 Ok(serde_json::to_string(e)?)
1974 } else {
1975 let eid_short: String = e.event_id.chars().take(12).collect();
1976 let body = e.body_preview.replace('\n', " ");
1977 let ts: String = e.timestamp.chars().take(19).collect();
1978 Ok(format!("[{ts}] {}/{} ({eid_short}) {body}", e.peer, e.kind))
1979 }
1980}
1981
1982fn cmd_monitor(
1998 peer_filter: Option<&str>,
1999 as_json: bool,
2000 include_handshake: bool,
2001 interval_ms: u64,
2002 replay: usize,
2003) -> Result<()> {
2004 let inbox_dir = config::inbox_dir()?;
2005 if !inbox_dir.exists() {
2006 if !as_json {
2007 eprintln!(
2008 "wire monitor: inbox dir {inbox_dir:?} missing — has the daemon ever run?"
2009 );
2010 }
2011 }
2013
2014 if replay > 0 && inbox_dir.exists() {
2018 let mut all: Vec<crate::inbox_watch::InboxEvent> = Vec::new();
2019 for entry in std::fs::read_dir(&inbox_dir)?.flatten() {
2020 let path = entry.path();
2021 if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
2022 continue;
2023 }
2024 let peer = match path.file_stem().and_then(|s| s.to_str()) {
2025 Some(s) => s.to_string(),
2026 None => continue,
2027 };
2028 if let Some(filter) = peer_filter {
2029 if peer != filter {
2030 continue;
2031 }
2032 }
2033 let body = std::fs::read_to_string(&path).unwrap_or_default();
2034 for line in body.lines() {
2035 let line = line.trim();
2036 if line.is_empty() {
2037 continue;
2038 }
2039 let signed: Value = match serde_json::from_str(line) {
2040 Ok(v) => v,
2041 Err(_) => continue,
2042 };
2043 let ev = crate::inbox_watch::InboxEvent::from_signed(
2044 &peer,
2045 signed,
2046 true,
2047 );
2048 if !include_handshake && monitor_is_noise_kind(&ev.kind) {
2049 continue;
2050 }
2051 all.push(ev);
2052 }
2053 }
2054 all.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
2057 let start = all.len().saturating_sub(replay);
2058 for ev in &all[start..] {
2059 println!("{}", monitor_render(ev, as_json)?);
2060 }
2061 use std::io::Write;
2062 std::io::stdout().flush().ok();
2063 }
2064
2065 let mut w = crate::inbox_watch::InboxWatcher::from_head()?;
2068 let sleep_dur = std::time::Duration::from_millis(interval_ms.max(50));
2069
2070 loop {
2071 let events = w.poll()?;
2072 let mut wrote = false;
2073 for ev in events {
2074 if let Some(filter) = peer_filter {
2075 if ev.peer != filter {
2076 continue;
2077 }
2078 }
2079 if !include_handshake && monitor_is_noise_kind(&ev.kind) {
2080 continue;
2081 }
2082 println!("{}", monitor_render(&ev, as_json)?);
2083 wrote = true;
2084 }
2085 if wrote {
2086 use std::io::Write;
2087 std::io::stdout().flush().ok();
2088 }
2089 std::thread::sleep(sleep_dur);
2090 }
2091}
2092
2093#[cfg(test)]
2094mod tier_tests {
2095 use super::*;
2096 use serde_json::json;
2097
2098 fn trust_with(handle: &str, tier: &str) -> Value {
2099 json!({
2100 "version": 1,
2101 "agents": {
2102 handle: {
2103 "tier": tier,
2104 "did": format!("did:wire:{handle}"),
2105 "card": {"capabilities": ["wire/v3.1"]}
2106 }
2107 }
2108 })
2109 }
2110
2111 #[test]
2112 fn pending_ack_when_verified_but_no_slot_token() {
2113 let trust = trust_with("willard", "VERIFIED");
2117 let relay_state = json!({
2118 "peers": {
2119 "willard": {
2120 "relay_url": "https://relay",
2121 "slot_id": "abc",
2122 "slot_token": "",
2123 }
2124 }
2125 });
2126 assert_eq!(
2127 effective_peer_tier(&trust, &relay_state, "willard"),
2128 "PENDING_ACK"
2129 );
2130 }
2131
2132 #[test]
2133 fn verified_when_slot_token_present() {
2134 let trust = trust_with("willard", "VERIFIED");
2135 let relay_state = json!({
2136 "peers": {
2137 "willard": {
2138 "relay_url": "https://relay",
2139 "slot_id": "abc",
2140 "slot_token": "tok123",
2141 }
2142 }
2143 });
2144 assert_eq!(
2145 effective_peer_tier(&trust, &relay_state, "willard"),
2146 "VERIFIED"
2147 );
2148 }
2149
2150 #[test]
2151 fn raw_tier_passes_through_for_non_verified() {
2152 let trust = trust_with("willard", "UNTRUSTED");
2155 let relay_state = json!({
2156 "peers": {"willard": {"slot_token": ""}}
2157 });
2158 assert_eq!(
2159 effective_peer_tier(&trust, &relay_state, "willard"),
2160 "UNTRUSTED"
2161 );
2162 }
2163
2164 #[test]
2165 fn pending_ack_when_relay_state_missing_peer() {
2166 let trust = trust_with("willard", "VERIFIED");
2170 let relay_state = json!({"peers": {}});
2171 assert_eq!(
2172 effective_peer_tier(&trust, &relay_state, "willard"),
2173 "PENDING_ACK"
2174 );
2175 }
2176}
2177
2178#[cfg(test)]
2179mod monitor_tests {
2180 use super::*;
2181 use crate::inbox_watch::InboxEvent;
2182 use serde_json::Value;
2183
2184 fn ev(peer: &str, kind: &str, body: &str) -> InboxEvent {
2185 InboxEvent {
2186 peer: peer.to_string(),
2187 event_id: "abcd1234567890ef".to_string(),
2188 kind: kind.to_string(),
2189 body_preview: body.to_string(),
2190 verified: true,
2191 timestamp: "2026-05-15T23:14:07.123456Z".to_string(),
2192 raw: Value::Null,
2193 }
2194 }
2195
2196 #[test]
2197 fn monitor_filter_drops_handshake_kinds_by_default() {
2198 assert!(monitor_is_noise_kind("pair_drop"));
2203 assert!(monitor_is_noise_kind("pair_drop_ack"));
2204 assert!(monitor_is_noise_kind("heartbeat"));
2205
2206 assert!(!monitor_is_noise_kind("claim"));
2208 assert!(!monitor_is_noise_kind("decision"));
2209 assert!(!monitor_is_noise_kind("ack"));
2210 assert!(!monitor_is_noise_kind("request"));
2211 assert!(!monitor_is_noise_kind("note"));
2212 assert!(!monitor_is_noise_kind("future_kind_we_dont_know"));
2216 }
2217
2218 #[test]
2219 fn monitor_render_plain_is_one_short_line() {
2220 let e = ev("willard", "claim", "real v8 train shipped 1350 steps");
2221 let line = monitor_render(&e, false).unwrap();
2222 assert!(!line.contains('\n'), "render must be one line: {line}");
2224 assert!(line.contains("willard"));
2226 assert!(line.contains("claim"));
2227 assert!(line.contains("real v8 train"));
2228 assert!(line.contains("abcd12345678"));
2230 assert!(!line.contains("abcd1234567890ef"), "should truncate full id");
2231 assert!(line.contains("2026-05-15T23:14:07"));
2233 }
2234
2235 #[test]
2236 fn monitor_render_strips_newlines_from_body() {
2237 let e = ev("spark", "claim", "line one\nline two\nline three");
2242 let line = monitor_render(&e, false).unwrap();
2243 assert!(!line.contains('\n'), "newlines must be stripped: {line}");
2244 assert!(line.contains("line one line two line three"));
2245 }
2246
2247 #[test]
2248 fn monitor_render_json_is_valid_jsonl() {
2249 let e = ev("spark", "claim", "hi");
2250 let line = monitor_render(&e, true).unwrap();
2251 assert!(!line.contains('\n'));
2252 let parsed: Value = serde_json::from_str(&line).expect("valid JSONL");
2253 assert_eq!(parsed["peer"], "spark");
2254 assert_eq!(parsed["kind"], "claim");
2255 assert_eq!(parsed["body_preview"], "hi");
2256 }
2257
2258 #[test]
2259 fn monitor_does_not_drop_on_verified_null() {
2260 let mut e = ev("spark", "claim", "from disk with verified=null");
2271 e.verified = false; let line = monitor_render(&e, false).unwrap();
2273 assert!(line.contains("from disk with verified=null"));
2274 assert!(!monitor_is_noise_kind("claim"));
2276 }
2277}
2278
2279fn cmd_verify(path: &str, as_json: bool) -> Result<()> {
2282 let body = if path == "-" {
2283 let mut buf = String::new();
2284 use std::io::Read;
2285 std::io::stdin().read_to_string(&mut buf)?;
2286 buf
2287 } else {
2288 std::fs::read_to_string(path).with_context(|| format!("reading {path}"))?
2289 };
2290 let event: Value = serde_json::from_str(&body)?;
2291 let trust = config::read_trust()?;
2292 match verify_message_v31(&event, &trust) {
2293 Ok(()) => {
2294 if as_json {
2295 println!("{}", serde_json::to_string(&json!({"verified": true}))?);
2296 } else {
2297 println!("verified ✓");
2298 }
2299 Ok(())
2300 }
2301 Err(e) => {
2302 let reason = e.to_string();
2303 if as_json {
2304 println!(
2305 "{}",
2306 serde_json::to_string(&json!({"verified": false, "reason": reason}))?
2307 );
2308 } else {
2309 eprintln!("FAILED: {reason}");
2310 }
2311 std::process::exit(1);
2312 }
2313 }
2314}
2315
2316fn cmd_mcp() -> Result<()> {
2319 crate::mcp::run()
2320}
2321
2322fn cmd_relay_server(bind: &str) -> Result<()> {
2323 let state_dir = if let Ok(home) = std::env::var("WIRE_HOME") {
2327 std::path::PathBuf::from(home)
2328 .join("state")
2329 .join("wire-relay")
2330 } else {
2331 dirs::state_dir()
2332 .or_else(dirs::data_local_dir)
2333 .ok_or_else(|| anyhow::anyhow!("could not resolve XDG_STATE_HOME — set WIRE_HOME"))?
2334 .join("wire-relay")
2335 };
2336 let runtime = tokio::runtime::Builder::new_multi_thread()
2337 .enable_all()
2338 .build()?;
2339 runtime.block_on(crate::relay_server::serve(bind, state_dir))
2340}
2341
2342fn cmd_bind_relay(url: &str, as_json: bool) -> Result<()> {
2345 if !config::is_initialized()? {
2346 bail!("not initialized — run `wire init <handle>` first");
2347 }
2348 let card = config::read_agent_card()?;
2349 let did = card.get("did").and_then(Value::as_str).unwrap_or("");
2350 let handle = crate::agent_card::display_handle_from_did(did).to_string();
2351
2352 let normalized = url.trim_end_matches('/');
2353 let client = crate::relay_client::RelayClient::new(normalized);
2354 client.check_healthz()?;
2355 let alloc = client.allocate_slot(Some(&handle))?;
2356 let mut state = config::read_relay_state()?;
2357 state["self"] = json!({
2358 "relay_url": url,
2359 "slot_id": alloc.slot_id,
2360 "slot_token": alloc.slot_token,
2361 });
2362 config::write_relay_state(&state)?;
2363
2364 if as_json {
2365 println!(
2366 "{}",
2367 serde_json::to_string(&json!({
2368 "relay_url": url,
2369 "slot_id": alloc.slot_id,
2370 "slot_token_present": true,
2371 }))?
2372 );
2373 } else {
2374 println!("bound to relay {url}");
2375 println!("slot_id: {}", alloc.slot_id);
2376 println!(
2377 "(slot_token written to {} mode 0600)",
2378 config::relay_state_path()?.display()
2379 );
2380 }
2381 Ok(())
2382}
2383
2384fn cmd_add_peer_slot(
2387 handle: &str,
2388 url: &str,
2389 slot_id: &str,
2390 slot_token: &str,
2391 as_json: bool,
2392) -> Result<()> {
2393 let mut state = config::read_relay_state()?;
2394 let peers = state["peers"]
2395 .as_object_mut()
2396 .ok_or_else(|| anyhow!("relay state missing 'peers' object"))?;
2397 peers.insert(
2398 handle.to_string(),
2399 json!({
2400 "relay_url": url,
2401 "slot_id": slot_id,
2402 "slot_token": slot_token,
2403 }),
2404 );
2405 config::write_relay_state(&state)?;
2406 if as_json {
2407 println!(
2408 "{}",
2409 serde_json::to_string(&json!({
2410 "handle": handle,
2411 "relay_url": url,
2412 "slot_id": slot_id,
2413 "added": true,
2414 }))?
2415 );
2416 } else {
2417 println!("pinned peer slot for {handle} at {url} ({slot_id})");
2418 }
2419 Ok(())
2420}
2421
2422fn cmd_push(peer_filter: Option<&str>, as_json: bool) -> Result<()> {
2425 let state = config::read_relay_state()?;
2426 let peers = state["peers"].as_object().cloned().unwrap_or_default();
2427 if peers.is_empty() {
2428 bail!(
2429 "no peer slots pinned — run `wire add-peer-slot <handle> <url> <slot_id> <token>` first"
2430 );
2431 }
2432 let outbox_dir = config::outbox_dir()?;
2433 if outbox_dir.exists() {
2438 let pinned: std::collections::HashSet<String> = peers.keys().cloned().collect();
2439 for entry in std::fs::read_dir(&outbox_dir)?.flatten() {
2440 let path = entry.path();
2441 if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
2442 continue;
2443 }
2444 let stem = match path.file_stem().and_then(|s| s.to_str()) {
2445 Some(s) => s.to_string(),
2446 None => continue,
2447 };
2448 if pinned.contains(&stem) {
2449 continue;
2450 }
2451 let bare = crate::agent_card::bare_handle(&stem);
2454 if pinned.contains(bare) {
2455 eprintln!(
2456 "wire push: WARN stale outbox file `{}.jsonl` not enumerated (pinned peer is `{bare}`). \
2457 Merge with: `cat {} >> {}` then delete the FQDN file.",
2458 stem,
2459 path.display(),
2460 outbox_dir.join(format!("{bare}.jsonl")).display(),
2461 );
2462 }
2463 }
2464 }
2465 if !outbox_dir.exists() {
2466 if as_json {
2467 println!(
2468 "{}",
2469 serde_json::to_string(&json!({"pushed": [], "skipped": []}))?
2470 );
2471 } else {
2472 println!("phyllis: nothing to dial out — write a message first with `wire send`");
2473 }
2474 return Ok(());
2475 }
2476
2477 let mut pushed = Vec::new();
2478 let mut skipped = Vec::new();
2479
2480 for (peer_handle, slot_info) in peers.iter() {
2481 if let Some(want) = peer_filter
2482 && peer_handle != want
2483 {
2484 continue;
2485 }
2486 let outbox = outbox_dir.join(format!("{peer_handle}.jsonl"));
2487 if !outbox.exists() {
2488 continue;
2489 }
2490 let url = slot_info["relay_url"]
2491 .as_str()
2492 .ok_or_else(|| anyhow!("peer {peer_handle} missing relay_url"))?;
2493 let slot_id = slot_info["slot_id"]
2494 .as_str()
2495 .ok_or_else(|| anyhow!("peer {peer_handle} missing slot_id"))?;
2496 let slot_token = slot_info["slot_token"]
2497 .as_str()
2498 .ok_or_else(|| anyhow!("peer {peer_handle} missing slot_token"))?;
2499 let client = crate::relay_client::RelayClient::new(url);
2500 let body = std::fs::read_to_string(&outbox)?;
2501 for line in body.lines() {
2502 let event: Value = match serde_json::from_str(line) {
2503 Ok(v) => v,
2504 Err(_) => continue,
2505 };
2506 let event_id = event
2507 .get("event_id")
2508 .and_then(Value::as_str)
2509 .unwrap_or("")
2510 .to_string();
2511 match client.post_event(slot_id, slot_token, &event) {
2512 Ok(resp) => {
2513 if resp.status == "duplicate" {
2514 skipped.push(json!({"peer": peer_handle, "event_id": event_id, "reason": "duplicate"}));
2515 } else {
2516 pushed.push(json!({"peer": peer_handle, "event_id": event_id}));
2517 }
2518 }
2519 Err(e) => {
2520 let reason = crate::relay_client::format_transport_error(&e);
2524 skipped.push(
2525 json!({"peer": peer_handle, "event_id": event_id, "reason": reason}),
2526 );
2527 }
2528 }
2529 }
2530 }
2531
2532 if as_json {
2533 println!(
2534 "{}",
2535 serde_json::to_string(&json!({"pushed": pushed, "skipped": skipped}))?
2536 );
2537 } else {
2538 println!(
2539 "pushed {} event(s); skipped {} ({})",
2540 pushed.len(),
2541 skipped.len(),
2542 if skipped.is_empty() {
2543 "none"
2544 } else {
2545 "see --json for detail"
2546 }
2547 );
2548 }
2549 Ok(())
2550}
2551
2552fn cmd_pull(as_json: bool) -> Result<()> {
2555 let state = config::read_relay_state()?;
2556 let self_state = state.get("self").cloned().unwrap_or(Value::Null);
2557 if self_state.is_null() {
2558 bail!("self slot not bound — run `wire bind-relay <url>` first");
2559 }
2560 let url = self_state["relay_url"]
2561 .as_str()
2562 .ok_or_else(|| anyhow!("self.relay_url missing"))?;
2563 let slot_id = self_state["slot_id"]
2564 .as_str()
2565 .ok_or_else(|| anyhow!("self.slot_id missing"))?;
2566 let slot_token = self_state["slot_token"]
2567 .as_str()
2568 .ok_or_else(|| anyhow!("self.slot_token missing"))?;
2569 let last_event_id = self_state
2570 .get("last_pulled_event_id")
2571 .and_then(Value::as_str)
2572 .map(str::to_string);
2573
2574 let client = crate::relay_client::RelayClient::new(url);
2575 let events = client.list_events(slot_id, slot_token, last_event_id.as_deref(), Some(1000))?;
2576
2577 let inbox_dir = config::inbox_dir()?;
2578 config::ensure_dirs()?;
2579
2580 let result = crate::pull::process_events(&events, last_event_id, &inbox_dir)?;
2583
2584 if let Some(eid) = &result.advance_cursor_to {
2589 let eid = eid.clone();
2590 config::update_relay_state(|state| {
2591 if let Some(self_obj) = state.get_mut("self").and_then(Value::as_object_mut) {
2592 self_obj.insert("last_pulled_event_id".into(), Value::String(eid));
2593 }
2594 Ok(())
2595 })?;
2596 }
2597
2598 if as_json {
2599 println!(
2600 "{}",
2601 serde_json::to_string(&json!({
2602 "written": result.written,
2603 "rejected": result.rejected,
2604 "total_seen": events.len(),
2605 "cursor_blocked": result.blocked,
2606 "cursor_advanced_to": result.advance_cursor_to,
2607 }))?
2608 );
2609 } else {
2610 let blocking = result
2611 .rejected
2612 .iter()
2613 .filter(|r| r.get("blocks_cursor").and_then(Value::as_bool) == Some(true))
2614 .count();
2615 if blocking > 0 {
2616 println!(
2617 "pulled {} event(s); wrote {}; rejected {} ({} BLOCKING cursor — see `wire pull --json`)",
2618 events.len(),
2619 result.written.len(),
2620 result.rejected.len(),
2621 blocking,
2622 );
2623 } else {
2624 println!(
2625 "pulled {} event(s); wrote {}; rejected {}",
2626 events.len(),
2627 result.written.len(),
2628 result.rejected.len(),
2629 );
2630 }
2631 }
2632 Ok(())
2633}
2634
2635fn cmd_rotate_slot(no_announce: bool, as_json: bool) -> Result<()> {
2638 if !config::is_initialized()? {
2639 bail!("not initialized — run `wire init <handle>` first");
2640 }
2641 let mut state = config::read_relay_state()?;
2642 let self_state = state.get("self").cloned().unwrap_or(Value::Null);
2643 if self_state.is_null() {
2644 bail!("self slot not bound — run `wire bind-relay <url>` first (nothing to rotate)");
2645 }
2646 let url = self_state["relay_url"]
2647 .as_str()
2648 .ok_or_else(|| anyhow!("self.relay_url missing"))?
2649 .to_string();
2650 let old_slot_id = self_state["slot_id"]
2651 .as_str()
2652 .ok_or_else(|| anyhow!("self.slot_id missing"))?
2653 .to_string();
2654 let old_slot_token = self_state["slot_token"]
2655 .as_str()
2656 .ok_or_else(|| anyhow!("self.slot_token missing"))?
2657 .to_string();
2658
2659 let card = config::read_agent_card()?;
2661 let did = card
2662 .get("did")
2663 .and_then(Value::as_str)
2664 .unwrap_or("")
2665 .to_string();
2666 let handle = crate::agent_card::display_handle_from_did(&did).to_string();
2667 let pk_b64 = card
2668 .get("verify_keys")
2669 .and_then(Value::as_object)
2670 .and_then(|m| m.values().next())
2671 .and_then(|v| v.get("key"))
2672 .and_then(Value::as_str)
2673 .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?
2674 .to_string();
2675 let pk_bytes = crate::signing::b64decode(&pk_b64)?;
2676 let sk_seed = config::read_private_key()?;
2677
2678 let normalized = url.trim_end_matches('/').to_string();
2680 let client = crate::relay_client::RelayClient::new(&normalized);
2681 client
2682 .check_healthz()
2683 .context("aborting rotation; old slot still valid")?;
2684 let alloc = client.allocate_slot(Some(&handle))?;
2685 let new_slot_id = alloc.slot_id.clone();
2686 let new_slot_token = alloc.slot_token.clone();
2687
2688 let mut announced: Vec<String> = Vec::new();
2695 if !no_announce {
2696 let now = time::OffsetDateTime::now_utc()
2697 .format(&time::format_description::well_known::Rfc3339)
2698 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
2699 let body = json!({
2700 "reason": "operator-initiated slot rotation",
2701 "new_relay_url": url,
2702 "new_slot_id": new_slot_id,
2703 });
2707 let peers = state["peers"].as_object().cloned().unwrap_or_default();
2708 for (peer_handle, _peer_info) in peers.iter() {
2709 let event = json!({
2710 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
2711 "timestamp": now.clone(),
2712 "from": did,
2713 "to": format!("did:wire:{peer_handle}"),
2714 "type": "wire_close",
2715 "kind": 1201,
2716 "body": body.clone(),
2717 });
2718 let signed = match sign_message_v31(&event, &sk_seed, &pk_bytes, &handle) {
2719 Ok(s) => s,
2720 Err(e) => {
2721 eprintln!("warn: could not sign wire_close for {peer_handle}: {e}");
2722 continue;
2723 }
2724 };
2725 let peer_info = match state["peers"].get(peer_handle) {
2730 Some(p) => p.clone(),
2731 None => continue,
2732 };
2733 let peer_url = peer_info["relay_url"].as_str().unwrap_or(&url);
2734 let peer_slot_id = peer_info["slot_id"].as_str().unwrap_or("");
2735 let peer_slot_token = peer_info["slot_token"].as_str().unwrap_or("");
2736 if peer_slot_id.is_empty() || peer_slot_token.is_empty() {
2737 continue;
2738 }
2739 let peer_client = if peer_url == url {
2740 client.clone()
2741 } else {
2742 crate::relay_client::RelayClient::new(peer_url)
2743 };
2744 match peer_client.post_event(peer_slot_id, peer_slot_token, &signed) {
2745 Ok(_) => announced.push(peer_handle.clone()),
2746 Err(e) => eprintln!("warn: announce to {peer_handle} failed: {e}"),
2747 }
2748 }
2749 }
2750
2751 state["self"] = json!({
2753 "relay_url": url,
2754 "slot_id": new_slot_id,
2755 "slot_token": new_slot_token,
2756 });
2757 config::write_relay_state(&state)?;
2758
2759 if as_json {
2760 println!(
2761 "{}",
2762 serde_json::to_string(&json!({
2763 "rotated": true,
2764 "old_slot_id": old_slot_id,
2765 "new_slot_id": new_slot_id,
2766 "relay_url": url,
2767 "announced_to": announced,
2768 }))?
2769 );
2770 } else {
2771 println!("rotated slot on {url}");
2772 println!(
2773 " old slot_id: {old_slot_id} (orphaned — abusive bearer-holders lose their leverage)"
2774 );
2775 println!(" new slot_id: {new_slot_id}");
2776 if !announced.is_empty() {
2777 println!(
2778 " announced wire_close (kind=1201) to: {}",
2779 announced.join(", ")
2780 );
2781 }
2782 println!();
2783 println!("next steps:");
2784 println!(" - peers see the wire_close event in their next `wire pull`");
2785 println!(
2786 " - paired peers must re-issue: tell them to run `wire add-peer-slot {handle} {url} {new_slot_id} <new-token>`"
2787 );
2788 println!(" (or full re-pair via `wire pair-host`/`wire join`)");
2789 println!(" - until they do, you'll receive but they won't be able to reach you");
2790 let _ = old_slot_token;
2792 }
2793 Ok(())
2794}
2795
2796fn cmd_forget_peer(handle: &str, purge: bool, as_json: bool) -> Result<()> {
2799 let mut trust = config::read_trust()?;
2800 let mut removed_from_trust = false;
2801 if let Some(agents) = trust.get_mut("agents").and_then(Value::as_object_mut)
2802 && agents.remove(handle).is_some()
2803 {
2804 removed_from_trust = true;
2805 }
2806 config::write_trust(&trust)?;
2807
2808 let mut state = config::read_relay_state()?;
2809 let mut removed_from_relay = false;
2810 if let Some(peers) = state.get_mut("peers").and_then(Value::as_object_mut)
2811 && peers.remove(handle).is_some()
2812 {
2813 removed_from_relay = true;
2814 }
2815 config::write_relay_state(&state)?;
2816
2817 let mut purged: Vec<String> = Vec::new();
2818 if purge {
2819 for dir in [config::inbox_dir()?, config::outbox_dir()?] {
2820 let path = dir.join(format!("{handle}.jsonl"));
2821 if path.exists() {
2822 std::fs::remove_file(&path).with_context(|| format!("removing {path:?}"))?;
2823 purged.push(path.to_string_lossy().into());
2824 }
2825 }
2826 }
2827
2828 if !removed_from_trust && !removed_from_relay {
2829 if as_json {
2830 println!(
2831 "{}",
2832 serde_json::to_string(&json!({
2833 "removed": false,
2834 "reason": format!("peer {handle:?} not pinned"),
2835 }))?
2836 );
2837 } else {
2838 eprintln!("peer {handle:?} not found in trust or relay state — nothing to forget");
2839 }
2840 return Ok(());
2841 }
2842
2843 if as_json {
2844 println!(
2845 "{}",
2846 serde_json::to_string(&json!({
2847 "handle": handle,
2848 "removed_from_trust": removed_from_trust,
2849 "removed_from_relay_state": removed_from_relay,
2850 "purged_files": purged,
2851 }))?
2852 );
2853 } else {
2854 println!("forgot peer {handle:?}");
2855 if removed_from_trust {
2856 println!(" - removed from trust.json");
2857 }
2858 if removed_from_relay {
2859 println!(" - removed from relay.json");
2860 }
2861 if !purged.is_empty() {
2862 for p in &purged {
2863 println!(" - deleted {p}");
2864 }
2865 } else if !purge {
2866 println!(" (inbox/outbox files preserved; pass --purge to delete them)");
2867 }
2868 }
2869 Ok(())
2870}
2871
2872fn cmd_daemon(interval_secs: u64, once: bool, as_json: bool) -> Result<()> {
2875 if !config::is_initialized()? {
2876 bail!("not initialized — run `wire init <handle>` first");
2877 }
2878 let interval = std::time::Duration::from_secs(interval_secs.max(1));
2879
2880 if !as_json {
2881 if once {
2882 eprintln!("wire daemon: single sync cycle, then exit");
2883 } else {
2884 eprintln!("wire daemon: syncing every {interval_secs}s. SIGINT to stop.");
2885 }
2886 }
2887
2888 if let Err(e) = crate::pending_pair::cleanup_on_startup() {
2892 eprintln!("daemon: pending-pair cleanup_on_startup error: {e:#}");
2893 }
2894
2895 let (wake_tx, wake_rx) = std::sync::mpsc::channel::<()>();
2901 if !once {
2902 crate::daemon_stream::spawn_stream_subscriber(wake_tx);
2903 }
2904
2905 loop {
2906 let pushed = run_sync_push().unwrap_or_else(|e| {
2907 eprintln!("daemon: push error: {e:#}");
2908 json!({"pushed": [], "skipped": [{"error": e.to_string()}]})
2909 });
2910 let pulled = run_sync_pull().unwrap_or_else(|e| {
2911 eprintln!("daemon: pull error: {e:#}");
2912 json!({"written": [], "rejected": [], "total_seen": 0, "error": e.to_string()})
2913 });
2914 let pairs = crate::pending_pair::tick().unwrap_or_else(|e| {
2915 eprintln!("daemon: pending-pair tick error: {e:#}");
2916 json!({"transitions": []})
2917 });
2918
2919 if as_json {
2920 println!(
2921 "{}",
2922 serde_json::to_string(&json!({
2923 "ts": time::OffsetDateTime::now_utc()
2924 .format(&time::format_description::well_known::Rfc3339)
2925 .unwrap_or_default(),
2926 "push": pushed,
2927 "pull": pulled,
2928 "pairs": pairs,
2929 }))?
2930 );
2931 } else {
2932 let pushed_n = pushed["pushed"].as_array().map(|a| a.len()).unwrap_or(0);
2933 let written_n = pulled["written"].as_array().map(|a| a.len()).unwrap_or(0);
2934 let rejected_n = pulled["rejected"].as_array().map(|a| a.len()).unwrap_or(0);
2935 let pair_transitions = pairs["transitions"]
2936 .as_array()
2937 .map(|a| a.len())
2938 .unwrap_or(0);
2939 if pushed_n > 0 || written_n > 0 || rejected_n > 0 || pair_transitions > 0 {
2940 eprintln!(
2941 "daemon: pushed={pushed_n} pulled={written_n} rejected={rejected_n} pair-transitions={pair_transitions}"
2942 );
2943 }
2944 if let Some(arr) = pairs["transitions"].as_array() {
2946 for t in arr {
2947 eprintln!(
2948 " pair {} : {} → {}",
2949 t.get("code").and_then(Value::as_str).unwrap_or("?"),
2950 t.get("from").and_then(Value::as_str).unwrap_or("?"),
2951 t.get("to").and_then(Value::as_str).unwrap_or("?")
2952 );
2953 if let Some(sas) = t.get("sas").and_then(Value::as_str)
2954 && t.get("to").and_then(Value::as_str) == Some("sas_ready")
2955 {
2956 eprintln!(" SAS digits: {}-{}", &sas[..3], &sas[3..]);
2957 eprintln!(
2958 " Run: wire pair-confirm {} {}",
2959 t.get("code").and_then(Value::as_str).unwrap_or("?"),
2960 sas
2961 );
2962 }
2963 }
2964 }
2965 }
2966
2967 if once {
2968 return Ok(());
2969 }
2970 let _ = wake_rx.recv_timeout(interval);
2975 while wake_rx.try_recv().is_ok() {}
2976 }
2977}
2978
2979fn run_sync_push() -> Result<Value> {
2982 let state = config::read_relay_state()?;
2983 let peers = state["peers"].as_object().cloned().unwrap_or_default();
2984 if peers.is_empty() {
2985 return Ok(json!({"pushed": [], "skipped": []}));
2986 }
2987 let outbox_dir = config::outbox_dir()?;
2988 if !outbox_dir.exists() {
2989 return Ok(json!({"pushed": [], "skipped": []}));
2990 }
2991 let mut pushed = Vec::new();
2992 let mut skipped = Vec::new();
2993 for (peer_handle, slot_info) in peers.iter() {
2994 let outbox = outbox_dir.join(format!("{peer_handle}.jsonl"));
2995 if !outbox.exists() {
2996 continue;
2997 }
2998 let url = slot_info["relay_url"].as_str().unwrap_or("");
2999 let slot_id = slot_info["slot_id"].as_str().unwrap_or("");
3000 let slot_token = slot_info["slot_token"].as_str().unwrap_or("");
3001 if url.is_empty() || slot_id.is_empty() || slot_token.is_empty() {
3002 continue;
3003 }
3004 let client = crate::relay_client::RelayClient::new(url);
3005 let body = std::fs::read_to_string(&outbox)?;
3006 for line in body.lines() {
3007 let event: Value = match serde_json::from_str(line) {
3008 Ok(v) => v,
3009 Err(_) => continue,
3010 };
3011 let event_id = event
3012 .get("event_id")
3013 .and_then(Value::as_str)
3014 .unwrap_or("")
3015 .to_string();
3016 match client.post_event(slot_id, slot_token, &event) {
3017 Ok(resp) => {
3018 if resp.status == "duplicate" {
3019 skipped.push(json!({"peer": peer_handle, "event_id": event_id, "reason": "duplicate"}));
3020 } else {
3021 pushed.push(json!({"peer": peer_handle, "event_id": event_id}));
3022 }
3023 }
3024 Err(e) => {
3025 let reason = crate::relay_client::format_transport_error(&e);
3029 skipped.push(
3030 json!({"peer": peer_handle, "event_id": event_id, "reason": reason}),
3031 );
3032 }
3033 }
3034 }
3035 }
3036 Ok(json!({"pushed": pushed, "skipped": skipped}))
3037}
3038
3039fn run_sync_pull() -> Result<Value> {
3041 let state = config::read_relay_state()?;
3042 let self_state = state.get("self").cloned().unwrap_or(Value::Null);
3043 if self_state.is_null() {
3044 return Ok(json!({"written": [], "rejected": [], "total_seen": 0}));
3045 }
3046 let url = self_state["relay_url"].as_str().unwrap_or("");
3047 let slot_id = self_state["slot_id"].as_str().unwrap_or("");
3048 let slot_token = self_state["slot_token"].as_str().unwrap_or("");
3049 let last_event_id = self_state
3050 .get("last_pulled_event_id")
3051 .and_then(Value::as_str)
3052 .map(str::to_string);
3053 if url.is_empty() {
3054 return Ok(json!({"written": [], "rejected": [], "total_seen": 0}));
3055 }
3056 let client = crate::relay_client::RelayClient::new(url);
3057 let events = client.list_events(slot_id, slot_token, last_event_id.as_deref(), Some(1000))?;
3058 let inbox_dir = config::inbox_dir()?;
3059 config::ensure_dirs()?;
3060
3061 let result = crate::pull::process_events(&events, last_event_id, &inbox_dir)?;
3065
3066 if let Some(eid) = &result.advance_cursor_to {
3068 let eid = eid.clone();
3069 config::update_relay_state(|state| {
3070 if let Some(self_obj) = state.get_mut("self").and_then(Value::as_object_mut) {
3071 self_obj.insert("last_pulled_event_id".into(), Value::String(eid));
3072 }
3073 Ok(())
3074 })?;
3075 }
3076
3077 Ok(json!({
3078 "written": result.written,
3079 "rejected": result.rejected,
3080 "total_seen": events.len(),
3081 "cursor_blocked": result.blocked,
3082 "cursor_advanced_to": result.advance_cursor_to,
3083 }))
3084}
3085
3086fn cmd_pin(card_file: &str, as_json: bool) -> Result<()> {
3089 let body =
3090 std::fs::read_to_string(card_file).with_context(|| format!("reading {card_file}"))?;
3091 let card: Value =
3092 serde_json::from_str(&body).with_context(|| format!("parsing {card_file}"))?;
3093 crate::agent_card::verify_agent_card(&card)
3094 .map_err(|e| anyhow!("peer card signature invalid: {e}"))?;
3095
3096 let mut trust = config::read_trust()?;
3097 crate::trust::add_agent_card_pin(&mut trust, &card, Some("VERIFIED"));
3098
3099 let did = card.get("did").and_then(Value::as_str).unwrap_or("");
3100 let handle = crate::agent_card::display_handle_from_did(did).to_string();
3101 config::write_trust(&trust)?;
3102
3103 if as_json {
3104 println!(
3105 "{}",
3106 serde_json::to_string(&json!({
3107 "handle": handle,
3108 "did": did,
3109 "tier": "VERIFIED",
3110 "pinned": true,
3111 }))?
3112 );
3113 } else {
3114 println!("pinned {handle} ({did}) at tier VERIFIED");
3115 }
3116 Ok(())
3117}
3118
3119fn cmd_pair_host(relay_url: &str, auto_yes: bool, timeout_secs: u64) -> Result<()> {
3122 pair_orchestrate(relay_url, None, "host", auto_yes, timeout_secs)
3123}
3124
3125fn cmd_pair_join(
3126 code_phrase: &str,
3127 relay_url: &str,
3128 auto_yes: bool,
3129 timeout_secs: u64,
3130) -> Result<()> {
3131 pair_orchestrate(
3132 relay_url,
3133 Some(code_phrase),
3134 "guest",
3135 auto_yes,
3136 timeout_secs,
3137 )
3138}
3139
3140fn pair_orchestrate(
3146 relay_url: &str,
3147 code_in: Option<&str>,
3148 role: &str,
3149 auto_yes: bool,
3150 timeout_secs: u64,
3151) -> Result<()> {
3152 use crate::pair_session::{pair_session_finalize, pair_session_open, pair_session_try_sas};
3153
3154 let mut s = pair_session_open(role, relay_url, code_in)?;
3155
3156 if role == "host" {
3157 eprintln!();
3158 eprintln!("share this code phrase with your peer:");
3159 eprintln!();
3160 eprintln!(" {}", s.code);
3161 eprintln!();
3162 eprintln!(
3163 "waiting for peer to run `wire pair-join {} --relay {relay_url}` ...",
3164 s.code
3165 );
3166 } else {
3167 eprintln!();
3168 eprintln!("joined pair-slot on {relay_url} — waiting for host's SPAKE2 message ...");
3169 }
3170
3171 const HEARTBEAT_SECS: u64 = 10;
3176 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
3177 let started = std::time::Instant::now();
3178 let mut last_heartbeat = started;
3179 let formatted = loop {
3180 if let Some(sas) = pair_session_try_sas(&mut s)? {
3181 break sas;
3182 }
3183 let now = std::time::Instant::now();
3184 if now >= deadline {
3185 return Err(anyhow!(
3186 "timeout after {timeout_secs}s waiting for peer's SPAKE2 message"
3187 ));
3188 }
3189 if now.duration_since(last_heartbeat).as_secs() >= HEARTBEAT_SECS {
3190 let elapsed = now.duration_since(started).as_secs();
3191 eprintln!(" ... still waiting ({elapsed}s / {timeout_secs}s)");
3192 last_heartbeat = now;
3193 }
3194 std::thread::sleep(std::time::Duration::from_millis(250));
3195 };
3196
3197 eprintln!();
3198 eprintln!("SAS digits (must match peer's terminal):");
3199 eprintln!();
3200 eprintln!(" {formatted}");
3201 eprintln!();
3202
3203 if !auto_yes {
3206 eprint!("does this match your peer's terminal? [y/N]: ");
3207 use std::io::Write;
3208 std::io::stderr().flush().ok();
3209 let mut input = String::new();
3210 std::io::stdin().read_line(&mut input)?;
3211 let trimmed = input.trim().to_lowercase();
3212 if trimmed != "y" && trimmed != "yes" {
3213 bail!("SAS confirmation declined — aborting pairing");
3214 }
3215 }
3216 s.sas_confirmed = true;
3217
3218 let result = pair_session_finalize(&mut s, timeout_secs)?;
3220
3221 let peer_did = result["paired_with"].as_str().unwrap_or("");
3222 let peer_role = if role == "host" { "guest" } else { "host" };
3223 eprintln!("paired with {peer_did} (peer role: {peer_role})");
3224 eprintln!("peer card pinned at tier VERIFIED");
3225 eprintln!(
3226 "peer relay slot saved to {}",
3227 config::relay_state_path()?.display()
3228 );
3229
3230 println!("{}", serde_json::to_string(&result)?);
3231 Ok(())
3232}
3233
3234fn cmd_pair(
3240 handle: &str,
3241 code: Option<&str>,
3242 relay: &str,
3243 auto_yes: bool,
3244 timeout_secs: u64,
3245 no_setup: bool,
3246) -> Result<()> {
3247 let init_result = crate::pair_session::init_self_idempotent(handle, None, None)?;
3250 let did = init_result
3251 .get("did")
3252 .and_then(|v| v.as_str())
3253 .unwrap_or("(unknown)")
3254 .to_string();
3255 let already = init_result
3256 .get("already_initialized")
3257 .and_then(|v| v.as_bool())
3258 .unwrap_or(false);
3259 if already {
3260 println!("(identity {did} already initialized — reusing)");
3261 } else {
3262 println!("initialized {did}");
3263 }
3264 println!();
3265
3266 match code {
3268 None => {
3269 println!("hosting pair on {relay} (no code = host) ...");
3270 cmd_pair_host(relay, auto_yes, timeout_secs)?;
3271 }
3272 Some(c) => {
3273 println!("joining pair with code {c} on {relay} ...");
3274 cmd_pair_join(c, relay, auto_yes, timeout_secs)?;
3275 }
3276 }
3277
3278 if !no_setup {
3280 println!();
3281 println!("registering wire as MCP server in detected client configs ...");
3282 if let Err(e) = cmd_setup(true) {
3283 eprintln!("warn: setup --apply failed: {e}");
3285 eprintln!(" pair succeeded; you can re-run `wire setup --apply` manually.");
3286 }
3287 }
3288
3289 println!();
3290 println!("pair complete. Next steps:");
3291 println!(" wire daemon start # background sync of inbox/outbox vs relay");
3292 println!(" wire send <peer> claim <msg> # send your peer something");
3293 println!(" wire tail # watch incoming events");
3294 Ok(())
3295}
3296
3297fn cmd_pair_detach(handle: &str, code: Option<&str>, relay: &str) -> Result<()> {
3303 let init_result = crate::pair_session::init_self_idempotent(handle, None, None)?;
3304 let did = init_result
3305 .get("did")
3306 .and_then(|v| v.as_str())
3307 .unwrap_or("(unknown)")
3308 .to_string();
3309 let already = init_result
3310 .get("already_initialized")
3311 .and_then(|v| v.as_bool())
3312 .unwrap_or(false);
3313 if already {
3314 println!("(identity {did} already initialized — reusing)");
3315 } else {
3316 println!("initialized {did}");
3317 }
3318 println!();
3319 match code {
3320 None => cmd_pair_host_detach(relay, false),
3321 Some(c) => cmd_pair_join_detach(c, relay, false),
3322 }
3323}
3324
3325fn cmd_pair_host_detach(relay_url: &str, as_json: bool) -> Result<()> {
3326 if !config::is_initialized()? {
3327 bail!("not initialized — run `wire init <handle>` first");
3328 }
3329 let daemon_spawned = match crate::ensure_up::ensure_daemon_running() {
3330 Ok(b) => b,
3331 Err(e) => {
3332 if !as_json {
3333 eprintln!(
3334 "warn: could not auto-start daemon: {e}; pair will queue but not advance"
3335 );
3336 }
3337 false
3338 }
3339 };
3340 let code = crate::sas::generate_code_phrase();
3341 let code_hash = crate::pair_session::derive_code_hash(&code);
3342 let now = time::OffsetDateTime::now_utc()
3343 .format(&time::format_description::well_known::Rfc3339)
3344 .unwrap_or_default();
3345 let p = crate::pending_pair::PendingPair {
3346 code: code.clone(),
3347 code_hash,
3348 role: "host".to_string(),
3349 relay_url: relay_url.to_string(),
3350 status: "request_host".to_string(),
3351 sas: None,
3352 peer_did: None,
3353 created_at: now,
3354 last_error: None,
3355 pair_id: None,
3356 our_slot_id: None,
3357 our_slot_token: None,
3358 spake2_seed_b64: None,
3359 };
3360 crate::pending_pair::write_pending(&p)?;
3361 if as_json {
3362 println!(
3363 "{}",
3364 serde_json::to_string(&json!({
3365 "state": "queued",
3366 "code_phrase": code,
3367 "relay_url": relay_url,
3368 "role": "host",
3369 "daemon_spawned": daemon_spawned,
3370 }))?
3371 );
3372 } else {
3373 if daemon_spawned {
3374 println!("(started wire daemon in background)");
3375 }
3376 println!("detached pair-host queued. Share this code with your peer:\n");
3377 println!(" {code}\n");
3378 println!("Next steps:");
3379 println!(" wire pair-list # check status");
3380 println!(" wire pair-confirm {code} <digits> # when SAS shows up");
3381 println!(" wire pair-cancel {code} # to abort");
3382 }
3383 Ok(())
3384}
3385
3386fn cmd_pair_join_detach(code_phrase: &str, relay_url: &str, as_json: bool) -> Result<()> {
3387 if !config::is_initialized()? {
3388 bail!("not initialized — run `wire init <handle>` first");
3389 }
3390 let daemon_spawned = match crate::ensure_up::ensure_daemon_running() {
3391 Ok(b) => b,
3392 Err(e) => {
3393 if !as_json {
3394 eprintln!(
3395 "warn: could not auto-start daemon: {e}; pair will queue but not advance"
3396 );
3397 }
3398 false
3399 }
3400 };
3401 let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
3402 let code_hash = crate::pair_session::derive_code_hash(&code);
3403 let now = time::OffsetDateTime::now_utc()
3404 .format(&time::format_description::well_known::Rfc3339)
3405 .unwrap_or_default();
3406 let p = crate::pending_pair::PendingPair {
3407 code: code.clone(),
3408 code_hash,
3409 role: "guest".to_string(),
3410 relay_url: relay_url.to_string(),
3411 status: "request_guest".to_string(),
3412 sas: None,
3413 peer_did: None,
3414 created_at: now,
3415 last_error: None,
3416 pair_id: None,
3417 our_slot_id: None,
3418 our_slot_token: None,
3419 spake2_seed_b64: None,
3420 };
3421 crate::pending_pair::write_pending(&p)?;
3422 if as_json {
3423 println!(
3424 "{}",
3425 serde_json::to_string(&json!({
3426 "state": "queued",
3427 "code_phrase": code,
3428 "relay_url": relay_url,
3429 "role": "guest",
3430 "daemon_spawned": daemon_spawned,
3431 }))?
3432 );
3433 } else {
3434 if daemon_spawned {
3435 println!("(started wire daemon in background)");
3436 }
3437 println!("detached pair-join queued for code {code}.");
3438 println!(
3439 "Run `wire pair-list` to watch for SAS, then `wire pair-confirm {code} <digits>`."
3440 );
3441 }
3442 Ok(())
3443}
3444
3445fn cmd_pair_confirm(code_phrase: &str, typed_digits: &str, as_json: bool) -> Result<()> {
3446 let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
3447 let typed: String = typed_digits
3448 .chars()
3449 .filter(|c| c.is_ascii_digit())
3450 .collect();
3451 if typed.len() != 6 {
3452 bail!(
3453 "expected 6 digits (got {} after stripping non-digits)",
3454 typed.len()
3455 );
3456 }
3457 let mut p = crate::pending_pair::read_pending(&code)?
3458 .ok_or_else(|| anyhow!("no pending pair found for code {code}"))?;
3459 if p.status != "sas_ready" {
3460 bail!(
3461 "pair {code} not in sas_ready state (current: {}). Run `wire pair-list` to see what's going on.",
3462 p.status
3463 );
3464 }
3465 let stored = p
3466 .sas
3467 .as_ref()
3468 .ok_or_else(|| anyhow!("pending file has status=sas_ready but no sas field"))?
3469 .clone();
3470 if stored == typed {
3471 p.status = "confirmed".to_string();
3472 crate::pending_pair::write_pending(&p)?;
3473 if as_json {
3474 println!(
3475 "{}",
3476 serde_json::to_string(&json!({
3477 "state": "confirmed",
3478 "code_phrase": code,
3479 }))?
3480 );
3481 } else {
3482 println!("digits match. Daemon will finalize the handshake on its next tick.");
3483 println!("Run `wire peers` after a few seconds to confirm.");
3484 }
3485 } else {
3486 p.status = "aborted".to_string();
3487 p.last_error = Some(format!(
3488 "SAS digit mismatch (typed {typed}, expected {stored})"
3489 ));
3490 let client = crate::relay_client::RelayClient::new(&p.relay_url);
3491 let _ = client.pair_abandon(&p.code_hash);
3492 crate::pending_pair::write_pending(&p)?;
3493 crate::os_notify::toast(
3494 &format!("wire — pair aborted ({})", p.code),
3495 p.last_error.as_deref().unwrap_or("digits mismatch"),
3496 );
3497 if as_json {
3498 println!(
3499 "{}",
3500 serde_json::to_string(&json!({
3501 "state": "aborted",
3502 "code_phrase": code,
3503 "error": "digits mismatch",
3504 }))?
3505 );
3506 }
3507 bail!("digits mismatch — pair aborted. Re-issue with a fresh `wire pair-host --detach`.");
3508 }
3509 Ok(())
3510}
3511
3512fn cmd_pair_list(as_json: bool, watch: bool, watch_interval_secs: u64) -> Result<()> {
3513 if watch {
3514 return cmd_pair_list_watch(watch_interval_secs);
3515 }
3516 let spake2_items = crate::pending_pair::list_pending()?;
3517 let inbound_items = crate::pending_inbound_pair::list_pending_inbound()?;
3518 if as_json {
3519 println!("{}", serde_json::to_string(&spake2_items)?);
3524 return Ok(());
3525 }
3526 if spake2_items.is_empty() && inbound_items.is_empty() {
3527 println!("no pending pair sessions.");
3528 return Ok(());
3529 }
3530 if !inbound_items.is_empty() {
3533 println!("PENDING INBOUND (v0.5.14 zero-paste pair_drop awaiting your accept)");
3534 println!(
3535 "{:<20} {:<35} {:<25} NEXT STEP",
3536 "PEER", "RELAY", "RECEIVED"
3537 );
3538 for p in &inbound_items {
3539 println!(
3540 "{:<20} {:<35} {:<25} `wire add {peer}@{relay_host}` to accept; `wire pair-reject {peer}` to refuse",
3541 p.peer_handle,
3542 p.peer_relay_url,
3543 p.received_at,
3544 peer = p.peer_handle,
3545 relay_host = p
3546 .peer_relay_url
3547 .trim_start_matches("https://")
3548 .trim_start_matches("http://")
3549 .trim_end_matches('/'),
3550 );
3551 }
3552 println!();
3553 }
3554 if !spake2_items.is_empty() {
3555 println!("SPAKE2 SESSIONS");
3556 println!(
3557 "{:<15} {:<8} {:<18} {:<10} NOTE",
3558 "CODE", "ROLE", "STATUS", "SAS"
3559 );
3560 for p in spake2_items {
3561 let sas = p
3562 .sas
3563 .as_ref()
3564 .map(|d| format!("{}-{}", &d[..3], &d[3..]))
3565 .unwrap_or_else(|| "—".to_string());
3566 let note = p
3567 .last_error
3568 .as_deref()
3569 .or(p.peer_did.as_deref())
3570 .unwrap_or("");
3571 println!(
3572 "{:<15} {:<8} {:<18} {:<10} {}",
3573 p.code, p.role, p.status, sas, note
3574 );
3575 }
3576 }
3577 Ok(())
3578}
3579
3580fn cmd_pair_list_watch(interval_secs: u64) -> Result<()> {
3592 use std::collections::HashMap;
3593 use std::io::Write;
3594 let interval = std::time::Duration::from_secs(interval_secs.max(1));
3595 let mut prev: HashMap<String, String> = HashMap::new();
3598 {
3599 let items = crate::pending_pair::list_pending()?;
3600 for p in &items {
3601 println!("{}", serde_json::to_string(&p)?);
3602 prev.insert(p.code.clone(), p.status.clone());
3603 }
3604 let _ = std::io::stdout().flush();
3606 }
3607 loop {
3608 std::thread::sleep(interval);
3609 let items = match crate::pending_pair::list_pending() {
3610 Ok(v) => v,
3611 Err(_) => continue,
3612 };
3613 let mut cur: HashMap<String, String> = HashMap::new();
3614 for p in &items {
3615 cur.insert(p.code.clone(), p.status.clone());
3616 match prev.get(&p.code) {
3617 None => {
3618 println!("{}", serde_json::to_string(&p)?);
3620 }
3621 Some(prev_status) if prev_status != &p.status => {
3622 println!("{}", serde_json::to_string(&p)?);
3624 }
3625 _ => {}
3626 }
3627 }
3628 for code in prev.keys() {
3629 if !cur.contains_key(code) {
3630 println!(
3633 "{}",
3634 serde_json::to_string(&json!({
3635 "code": code,
3636 "status": "removed",
3637 "_synthetic": true,
3638 }))?
3639 );
3640 }
3641 }
3642 let _ = std::io::stdout().flush();
3643 prev = cur;
3644 }
3645}
3646
3647fn cmd_pair_watch(
3651 code_phrase: &str,
3652 target_status: &str,
3653 timeout_secs: u64,
3654 as_json: bool,
3655) -> Result<()> {
3656 let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
3657 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
3658 let mut last_seen_status: Option<String> = None;
3659 loop {
3660 let p_opt = crate::pending_pair::read_pending(&code)?;
3661 let now = std::time::Instant::now();
3662 match p_opt {
3663 None => {
3664 if last_seen_status.is_some() {
3668 if as_json {
3669 println!(
3670 "{}",
3671 serde_json::to_string(&json!({"state": "finalized", "code": code}))?
3672 );
3673 } else {
3674 println!("pair {code} finalized (file removed)");
3675 }
3676 return Ok(());
3677 } else {
3678 if as_json {
3679 println!(
3680 "{}",
3681 serde_json::to_string(&json!({"error": "no such pair", "code": code}))?
3682 );
3683 }
3684 std::process::exit(1);
3685 }
3686 }
3687 Some(p) => {
3688 let cur = p.status.clone();
3689 if Some(cur.clone()) != last_seen_status {
3690 if as_json {
3691 println!("{}", serde_json::to_string(&p)?);
3693 }
3694 last_seen_status = Some(cur.clone());
3695 }
3696 if cur == target_status {
3697 if !as_json {
3698 let sas_str = p
3699 .sas
3700 .as_ref()
3701 .map(|s| format!("{}-{}", &s[..3], &s[3..]))
3702 .unwrap_or_else(|| "—".to_string());
3703 println!("pair {code} reached {target_status} (SAS: {sas_str})");
3704 }
3705 return Ok(());
3706 }
3707 if cur == "aborted" || cur == "aborted_restart" {
3708 if !as_json {
3709 let err = p.last_error.as_deref().unwrap_or("(no detail)");
3710 eprintln!("pair {code} {cur}: {err}");
3711 }
3712 std::process::exit(1);
3713 }
3714 }
3715 }
3716 if now >= deadline {
3717 if !as_json {
3718 eprintln!(
3719 "timeout after {timeout_secs}s waiting for pair {code} to reach {target_status}"
3720 );
3721 }
3722 std::process::exit(2);
3723 }
3724 std::thread::sleep(std::time::Duration::from_millis(250));
3725 }
3726}
3727
3728fn cmd_pair_cancel(code_phrase: &str, as_json: bool) -> Result<()> {
3729 let code = crate::sas::parse_code_phrase(code_phrase)?.to_string();
3730 let p = crate::pending_pair::read_pending(&code)?
3731 .ok_or_else(|| anyhow!("no pending pair for code {code}"))?;
3732 let client = crate::relay_client::RelayClient::new(&p.relay_url);
3733 let _ = client.pair_abandon(&p.code_hash);
3734 crate::pending_pair::delete_pending(&code)?;
3735 if as_json {
3736 println!(
3737 "{}",
3738 serde_json::to_string(&json!({
3739 "state": "cancelled",
3740 "code_phrase": code,
3741 }))?
3742 );
3743 } else {
3744 println!("cancelled pending pair {code} (relay slot released, file removed).");
3745 }
3746 Ok(())
3747}
3748
3749fn cmd_pair_abandon(code_phrase: &str, relay_url: &str) -> Result<()> {
3752 let code = crate::sas::parse_code_phrase(code_phrase)?;
3755 let code_hash = crate::pair_session::derive_code_hash(code);
3756 let client = crate::relay_client::RelayClient::new(relay_url);
3757 client.pair_abandon(&code_hash)?;
3758 println!("abandoned pair-slot for code {code_phrase} on {relay_url}");
3759 println!("host can now issue a fresh code; guest can re-join.");
3760 Ok(())
3761}
3762
3763fn cmd_invite(relay: &str, ttl: u64, uses: u32, share: bool, as_json: bool) -> Result<()> {
3766 let url = crate::pair_invite::mint_invite(Some(ttl), uses, Some(relay))?;
3767
3768 let share_payload: Option<Value> = if share {
3771 let client = reqwest::blocking::Client::new();
3772 let single_use = if uses == 1 { Some(1u32) } else { None };
3773 let body = json!({
3774 "invite_url": url,
3775 "ttl_seconds": ttl,
3776 "uses": single_use,
3777 });
3778 let endpoint = format!("{}/v1/invite/register", relay.trim_end_matches('/'));
3779 let resp = client.post(&endpoint).json(&body).send()?;
3780 if !resp.status().is_success() {
3781 let code = resp.status();
3782 let txt = resp.text().unwrap_or_default();
3783 bail!("relay {code} on /v1/invite/register: {txt}");
3784 }
3785 let parsed: Value = resp.json()?;
3786 let token = parsed
3787 .get("token")
3788 .and_then(Value::as_str)
3789 .ok_or_else(|| anyhow::anyhow!("relay reply missing token"))?
3790 .to_string();
3791 let share_url = format!("{}/i/{}", relay.trim_end_matches('/'), token);
3792 let curl_line = format!("curl -fsSL {share_url} | sh");
3793 Some(json!({
3794 "token": token,
3795 "share_url": share_url,
3796 "curl": curl_line,
3797 "expires_unix": parsed.get("expires_unix"),
3798 }))
3799 } else {
3800 None
3801 };
3802
3803 if as_json {
3804 let mut out = json!({
3805 "invite_url": url,
3806 "ttl_secs": ttl,
3807 "uses": uses,
3808 "relay": relay,
3809 });
3810 if let Some(s) = &share_payload {
3811 out["share"] = s.clone();
3812 }
3813 println!("{}", serde_json::to_string(&out)?);
3814 } else if let Some(s) = share_payload {
3815 let curl = s.get("curl").and_then(Value::as_str).unwrap_or("");
3816 eprintln!("# One-curl onboarding. Share this single line — installs wire if missing,");
3817 eprintln!("# accepts the invite, pairs both sides. TTL: {ttl}s. Uses: {uses}.");
3818 println!("{curl}");
3819 } else {
3820 eprintln!("# Share this URL with one peer. Pasting it = pair complete on their side.");
3821 eprintln!("# TTL: {ttl}s. Uses: {uses}.");
3822 println!("{url}");
3823 }
3824 Ok(())
3825}
3826
3827fn cmd_accept(url: &str, as_json: bool) -> Result<()> {
3828 let resolved = if url.starts_with("http://") || url.starts_with("https://") {
3832 let sep = if url.contains('?') { '&' } else { '?' };
3833 let resolve_url = format!("{url}{sep}format=url");
3834 let client = reqwest::blocking::Client::new();
3835 let resp = client
3836 .get(&resolve_url)
3837 .send()
3838 .with_context(|| format!("GET {resolve_url}"))?;
3839 if !resp.status().is_success() {
3840 bail!("could not resolve short URL {url} (HTTP {})", resp.status());
3841 }
3842 let body = resp.text().unwrap_or_default().trim().to_string();
3843 if !body.starts_with("wire://pair?") {
3844 bail!(
3845 "short URL {url} did not resolve to a wire:// invite. \
3846 (got: {}{})",
3847 body.chars().take(80).collect::<String>(),
3848 if body.chars().count() > 80 { "…" } else { "" }
3849 );
3850 }
3851 body
3852 } else {
3853 url.to_string()
3854 };
3855
3856 let result = crate::pair_invite::accept_invite(&resolved)?;
3857 if as_json {
3858 println!("{}", serde_json::to_string(&result)?);
3859 } else {
3860 let did = result
3861 .get("paired_with")
3862 .and_then(Value::as_str)
3863 .unwrap_or("?");
3864 println!("paired with {did}");
3865 println!(
3866 "you can now: wire send {} <kind> <body>",
3867 crate::agent_card::display_handle_from_did(did)
3868 );
3869 }
3870 Ok(())
3871}
3872
3873fn cmd_whois(handle: Option<&str>, as_json: bool, relay_override: Option<&str>) -> Result<()> {
3876 if let Some(h) = handle {
3877 let parsed = crate::pair_profile::parse_handle(h)?;
3878 if config::is_initialized()? {
3881 let card = config::read_agent_card()?;
3882 let local_handle = card
3883 .get("profile")
3884 .and_then(|p| p.get("handle"))
3885 .and_then(Value::as_str)
3886 .map(str::to_string);
3887 if local_handle.as_deref() == Some(h) {
3888 return cmd_whois(None, as_json, None);
3889 }
3890 }
3891 let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)?;
3893 if as_json {
3894 println!("{}", serde_json::to_string(&resolved)?);
3895 } else {
3896 print_resolved_profile(&resolved);
3897 }
3898 return Ok(());
3899 }
3900 let card = config::read_agent_card()?;
3901 if as_json {
3902 let profile = card.get("profile").cloned().unwrap_or(Value::Null);
3903 println!(
3904 "{}",
3905 serde_json::to_string(&json!({
3906 "did": card.get("did").cloned().unwrap_or(Value::Null),
3907 "profile": profile,
3908 }))?
3909 );
3910 } else {
3911 print!("{}", crate::pair_profile::render_self_summary()?);
3912 }
3913 Ok(())
3914}
3915
3916fn print_resolved_profile(resolved: &Value) {
3917 let did = resolved.get("did").and_then(Value::as_str).unwrap_or("?");
3918 let nick = resolved.get("nick").and_then(Value::as_str).unwrap_or("?");
3919 let relay = resolved
3920 .get("relay_url")
3921 .and_then(Value::as_str)
3922 .unwrap_or("");
3923 let slot = resolved
3924 .get("slot_id")
3925 .and_then(Value::as_str)
3926 .unwrap_or("");
3927 let profile = resolved
3928 .get("card")
3929 .and_then(|c| c.get("profile"))
3930 .cloned()
3931 .unwrap_or(Value::Null);
3932 println!("{did}");
3933 println!(" nick: {nick}");
3934 if !relay.is_empty() {
3935 println!(" relay_url: {relay}");
3936 }
3937 if !slot.is_empty() {
3938 println!(" slot_id: {slot}");
3939 }
3940 let pick =
3941 |k: &str| -> Option<String> { profile.get(k).and_then(Value::as_str).map(str::to_string) };
3942 if let Some(s) = pick("display_name") {
3943 println!(" display_name: {s}");
3944 }
3945 if let Some(s) = pick("emoji") {
3946 println!(" emoji: {s}");
3947 }
3948 if let Some(s) = pick("motto") {
3949 println!(" motto: {s}");
3950 }
3951 if let Some(arr) = profile.get("vibe").and_then(Value::as_array) {
3952 let joined: Vec<String> = arr
3953 .iter()
3954 .filter_map(|v| v.as_str().map(str::to_string))
3955 .collect();
3956 println!(" vibe: {}", joined.join(", "));
3957 }
3958 if let Some(s) = pick("pronouns") {
3959 println!(" pronouns: {s}");
3960 }
3961}
3962
3963fn cmd_add(handle_arg: &str, relay_override: Option<&str>, as_json: bool) -> Result<()> {
3969 let parsed = crate::pair_profile::parse_handle(handle_arg)?;
3970
3971 let (our_did, our_relay, our_slot_id, our_slot_token) =
3973 crate::pair_invite::ensure_self_with_relay(relay_override)?;
3974 if our_did == format!("did:wire:{}", parsed.nick) {
3975 bail!("refusing to add self (handle matches own DID)");
3977 }
3978
3979 if let Some(pending) = crate::pending_inbound_pair::read_pending_inbound(&parsed.nick)? {
3989 return cmd_add_accept_pending(
3990 handle_arg,
3991 &parsed.nick,
3992 &pending,
3993 &our_relay,
3994 &our_slot_id,
3995 &our_slot_token,
3996 as_json,
3997 );
3998 }
3999
4000 let resolved = crate::pair_profile::resolve_handle(&parsed, relay_override)?;
4002 let peer_card = resolved
4003 .get("card")
4004 .cloned()
4005 .ok_or_else(|| anyhow!("resolved missing card"))?;
4006 let peer_did = resolved
4007 .get("did")
4008 .and_then(Value::as_str)
4009 .ok_or_else(|| anyhow!("resolved missing did"))?
4010 .to_string();
4011 let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
4012 let peer_slot_id = resolved
4013 .get("slot_id")
4014 .and_then(Value::as_str)
4015 .ok_or_else(|| anyhow!("resolved missing slot_id"))?
4016 .to_string();
4017 let peer_relay = resolved
4018 .get("relay_url")
4019 .and_then(Value::as_str)
4020 .map(str::to_string)
4021 .or_else(|| relay_override.map(str::to_string))
4022 .unwrap_or_else(|| format!("https://{}", parsed.domain));
4023
4024 let mut trust = config::read_trust()?;
4026 crate::trust::add_agent_card_pin(&mut trust, &peer_card, Some("VERIFIED"));
4027 config::write_trust(&trust)?;
4028 let mut relay_state = config::read_relay_state()?;
4029 let existing_token = relay_state
4030 .get("peers")
4031 .and_then(|p| p.get(&peer_handle))
4032 .and_then(|p| p.get("slot_token"))
4033 .and_then(Value::as_str)
4034 .map(str::to_string)
4035 .unwrap_or_default();
4036 relay_state["peers"][&peer_handle] = json!({
4037 "relay_url": peer_relay,
4038 "slot_id": peer_slot_id,
4039 "slot_token": existing_token, });
4041 config::write_relay_state(&relay_state)?;
4042
4043 let our_card = config::read_agent_card()?;
4046 let sk_seed = config::read_private_key()?;
4047 let our_handle = crate::agent_card::display_handle_from_did(&our_did).to_string();
4048 let pk_b64 = our_card
4049 .get("verify_keys")
4050 .and_then(Value::as_object)
4051 .and_then(|m| m.values().next())
4052 .and_then(|v| v.get("key"))
4053 .and_then(Value::as_str)
4054 .ok_or_else(|| anyhow!("our card missing verify_keys[*].key"))?;
4055 let pk_bytes = crate::signing::b64decode(pk_b64)?;
4056 let now = time::OffsetDateTime::now_utc()
4057 .format(&time::format_description::well_known::Rfc3339)
4058 .unwrap_or_default();
4059 let event = json!({
4060 "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
4061 "timestamp": now,
4062 "from": our_did,
4063 "to": peer_did,
4064 "type": "pair_drop",
4065 "kind": 1100u32,
4066 "body": {
4067 "card": our_card,
4068 "relay_url": our_relay,
4069 "slot_id": our_slot_id,
4070 "slot_token": our_slot_token,
4071 },
4072 });
4073 let signed = crate::signing::sign_message_v31(&event, &sk_seed, &pk_bytes, &our_handle)?;
4074
4075 let client = crate::relay_client::RelayClient::new(&peer_relay);
4077 let resp = client.handle_intro(&parsed.nick, &signed)?;
4078 let event_id = signed
4079 .get("event_id")
4080 .and_then(Value::as_str)
4081 .unwrap_or("")
4082 .to_string();
4083
4084 if as_json {
4085 println!(
4086 "{}",
4087 serde_json::to_string(&json!({
4088 "handle": handle_arg,
4089 "paired_with": peer_did,
4090 "peer_handle": peer_handle,
4091 "event_id": event_id,
4092 "drop_response": resp,
4093 "status": "drop_sent",
4094 }))?
4095 );
4096 } else {
4097 println!(
4098 "→ resolved {handle_arg} (did={peer_did})\n→ pinned peer locally\n→ intro dropped to {peer_relay}\nawaiting pair_drop_ack from {peer_handle} to complete bilateral pin."
4099 );
4100 }
4101 Ok(())
4102}
4103
4104fn cmd_add_accept_pending(
4111 handle_arg: &str,
4112 peer_nick: &str,
4113 pending: &crate::pending_inbound_pair::PendingInboundPair,
4114 _our_relay: &str,
4115 _our_slot_id: &str,
4116 _our_slot_token: &str,
4117 as_json: bool,
4118) -> Result<()> {
4119 let mut trust = config::read_trust()?;
4122 crate::trust::add_agent_card_pin(&mut trust, &pending.peer_card, Some("VERIFIED"));
4123 config::write_trust(&trust)?;
4124
4125 let mut relay_state = config::read_relay_state()?;
4128 relay_state["peers"][&pending.peer_handle] = json!({
4129 "relay_url": pending.peer_relay_url,
4130 "slot_id": pending.peer_slot_id,
4131 "slot_token": pending.peer_slot_token,
4132 });
4133 config::write_relay_state(&relay_state)?;
4134
4135 crate::pair_invite::send_pair_drop_ack(
4137 &pending.peer_handle,
4138 &pending.peer_relay_url,
4139 &pending.peer_slot_id,
4140 &pending.peer_slot_token,
4141 )
4142 .with_context(|| {
4143 format!(
4144 "pair_drop_ack send to {} @ {} slot {} failed",
4145 pending.peer_handle, pending.peer_relay_url, pending.peer_slot_id
4146 )
4147 })?;
4148
4149 crate::pending_inbound_pair::consume_pending_inbound(peer_nick)?;
4151
4152 if as_json {
4153 println!(
4154 "{}",
4155 serde_json::to_string(&json!({
4156 "handle": handle_arg,
4157 "paired_with": pending.peer_did,
4158 "peer_handle": pending.peer_handle,
4159 "status": "bilateral_accepted",
4160 "via": "pending_inbound",
4161 }))?
4162 );
4163 } else {
4164 println!(
4165 "→ accepted pending pair from {peer}\n→ pinned VERIFIED, slot_token recorded\n→ shipped our slot_token back via pair_drop_ack\nbilateral pair complete. Send with `wire send {peer} \"...\"`.",
4166 peer = pending.peer_handle,
4167 );
4168 }
4169 Ok(())
4170}
4171
4172fn cmd_pair_list_inbound(as_json: bool) -> Result<()> {
4175 let items = crate::pending_inbound_pair::list_pending_inbound()?;
4176 if as_json {
4177 println!("{}", serde_json::to_string(&items)?);
4178 return Ok(());
4179 }
4180 if items.is_empty() {
4181 println!("no pending inbound pair requests.");
4182 return Ok(());
4183 }
4184 println!("{:<20} {:<35} {:<25} DID", "PEER", "RELAY", "RECEIVED");
4185 for p in items {
4186 println!(
4187 "{:<20} {:<35} {:<25} {}",
4188 p.peer_handle, p.peer_relay_url, p.received_at, p.peer_did,
4189 );
4190 }
4191 println!(
4192 "→ accept with `wire add <peer>@<relay-host>`; refuse with `wire pair-reject <peer>`."
4193 );
4194 Ok(())
4195}
4196
4197fn cmd_pair_reject(peer_nick: &str, as_json: bool) -> Result<()> {
4201 let nick = crate::agent_card::bare_handle(peer_nick);
4202 let existed = crate::pending_inbound_pair::read_pending_inbound(nick)?;
4203 crate::pending_inbound_pair::consume_pending_inbound(nick)?;
4204
4205 if as_json {
4206 println!(
4207 "{}",
4208 serde_json::to_string(&json!({
4209 "peer": nick,
4210 "rejected": existed.is_some(),
4211 "had_pending": existed.is_some(),
4212 }))?
4213 );
4214 } else if existed.is_some() {
4215 println!("→ rejected pending pair from {nick}\n→ pending-inbound record deleted; no ack sent.");
4216 } else {
4217 println!("no pending pair from {nick} — nothing to reject");
4218 }
4219 Ok(())
4220}
4221
4222fn cmd_diag(action: DiagAction) -> Result<()> {
4225 let state = config::state_dir()?;
4226 let knob = state.join("diag.enabled");
4227 let log_path = state.join("diag.jsonl");
4228 match action {
4229 DiagAction::Tail { limit, json } => {
4230 let entries = crate::diag::tail(limit);
4231 if json {
4232 for e in entries {
4233 println!("{}", serde_json::to_string(&e)?);
4234 }
4235 } else if entries.is_empty() {
4236 println!("wire diag: no entries (diag may be disabled — `wire diag enable`)");
4237 } else {
4238 for e in entries {
4239 let ts = e["ts"].as_u64().unwrap_or(0);
4240 let ty = e["type"].as_str().unwrap_or("?");
4241 let pid = e["pid"].as_u64().unwrap_or(0);
4242 let payload = e["payload"].to_string();
4243 println!("[{ts}] pid={pid} {ty} {payload}");
4244 }
4245 }
4246 }
4247 DiagAction::Enable => {
4248 config::ensure_dirs()?;
4249 std::fs::write(&knob, "1")?;
4250 println!("wire diag: enabled at {knob:?}");
4251 }
4252 DiagAction::Disable => {
4253 if knob.exists() {
4254 std::fs::remove_file(&knob)?;
4255 }
4256 println!("wire diag: disabled (env WIRE_DIAG may still flip it on per-process)");
4257 }
4258 DiagAction::Status { json } => {
4259 let enabled = crate::diag::is_enabled();
4260 let size = std::fs::metadata(&log_path)
4261 .map(|m| m.len())
4262 .unwrap_or(0);
4263 if json {
4264 println!(
4265 "{}",
4266 serde_json::to_string(&serde_json::json!({
4267 "enabled": enabled,
4268 "log_path": log_path,
4269 "log_size_bytes": size,
4270 }))?
4271 );
4272 } else {
4273 println!("wire diag status");
4274 println!(" enabled: {enabled}");
4275 println!(" log: {log_path:?}");
4276 println!(" log size: {size} bytes");
4277 }
4278 }
4279 }
4280 Ok(())
4281}
4282
4283fn cmd_service(action: ServiceAction) -> Result<()> {
4286 let (report, as_json) = match action {
4287 ServiceAction::Install { json } => (crate::service::install()?, json),
4288 ServiceAction::Uninstall { json } => (crate::service::uninstall()?, json),
4289 ServiceAction::Status { json } => (crate::service::status()?, json),
4290 };
4291 if as_json {
4292 println!("{}", serde_json::to_string(&report)?);
4293 } else {
4294 println!("wire service {}", report.action);
4295 println!(" platform: {}", report.platform);
4296 println!(" unit: {}", report.unit_path);
4297 println!(" status: {}", report.status);
4298 println!(" detail: {}", report.detail);
4299 }
4300 Ok(())
4301}
4302
4303fn cmd_upgrade(check_only: bool, as_json: bool) -> Result<()> {
4318 let pgrep_out = std::process::Command::new("pgrep")
4320 .args(["-f", "wire daemon"])
4321 .output();
4322 let running_pids: Vec<u32> = match pgrep_out {
4323 Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
4324 .split_whitespace()
4325 .filter_map(|s| s.parse::<u32>().ok())
4326 .collect(),
4327 _ => Vec::new(),
4328 };
4329
4330 let record = crate::ensure_up::read_pid_record("daemon");
4332 let recorded_version: Option<String> = match &record {
4333 crate::ensure_up::PidRecord::Json(d) => Some(d.version.clone()),
4334 crate::ensure_up::PidRecord::LegacyInt(_) => Some("<pre-0.5.11>".to_string()),
4335 _ => None,
4336 };
4337 let cli_version = env!("CARGO_PKG_VERSION").to_string();
4338
4339 if check_only {
4340 let report = json!({
4341 "running_pids": running_pids,
4342 "pidfile_version": recorded_version,
4343 "cli_version": cli_version,
4344 "would_kill": running_pids,
4345 });
4346 if as_json {
4347 println!("{}", serde_json::to_string(&report)?);
4348 } else {
4349 println!("wire upgrade --check");
4350 println!(" cli version: {cli_version}");
4351 println!(" pidfile version: {}", recorded_version.as_deref().unwrap_or("(missing)"));
4352 if running_pids.is_empty() {
4353 println!(" running daemons: none");
4354 } else {
4355 let pids: Vec<String> = running_pids.iter().map(|p| p.to_string()).collect();
4356 println!(" running daemons: pids {}", pids.join(", "));
4357 println!(" would kill all + spawn fresh");
4358 }
4359 }
4360 return Ok(());
4361 }
4362
4363 let mut killed: Vec<u32> = Vec::new();
4366 for pid in &running_pids {
4367 let _ = std::process::Command::new("kill")
4369 .args(["-15", &pid.to_string()])
4370 .status();
4371 killed.push(*pid);
4372 }
4373 if !killed.is_empty() {
4375 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
4376 loop {
4377 let still_alive: Vec<u32> = killed
4378 .iter()
4379 .copied()
4380 .filter(|p| process_alive_pid(*p))
4381 .collect();
4382 if still_alive.is_empty() {
4383 break;
4384 }
4385 if std::time::Instant::now() >= deadline {
4386 for pid in still_alive {
4388 let _ = std::process::Command::new("kill")
4389 .args(["-9", &pid.to_string()])
4390 .status();
4391 }
4392 break;
4393 }
4394 std::thread::sleep(std::time::Duration::from_millis(50));
4395 }
4396 }
4397
4398 let pidfile = config::state_dir()?.join("daemon.pid");
4401 if pidfile.exists() {
4402 let _ = std::fs::remove_file(&pidfile);
4403 }
4404
4405 let spawned = crate::ensure_up::ensure_daemon_running()?;
4408
4409 let new_record = crate::ensure_up::read_pid_record("daemon");
4410 let new_pid = new_record.pid();
4411 let new_version: Option<String> = if let crate::ensure_up::PidRecord::Json(d) = &new_record {
4412 Some(d.version.clone())
4413 } else {
4414 None
4415 };
4416
4417 if as_json {
4418 println!(
4419 "{}",
4420 serde_json::to_string(&json!({
4421 "killed": killed,
4422 "spawned_fresh_daemon": spawned,
4423 "new_pid": new_pid,
4424 "new_version": new_version,
4425 "cli_version": cli_version,
4426 }))?
4427 );
4428 } else {
4429 if killed.is_empty() {
4430 println!("wire upgrade: no stale daemons running");
4431 } else {
4432 println!("wire upgrade: killed {} daemon(s) (pids {})",
4433 killed.len(),
4434 killed.iter().map(|p| p.to_string()).collect::<Vec<_>>().join(", "));
4435 }
4436 if spawned {
4437 println!(
4438 "wire upgrade: spawned fresh daemon (pid {} v{})",
4439 new_pid.map(|p| p.to_string()).unwrap_or_else(|| "?".to_string()),
4440 new_version.as_deref().unwrap_or(&cli_version),
4441 );
4442 } else {
4443 println!("wire upgrade: daemon was already running on current binary");
4444 }
4445 }
4446 Ok(())
4447}
4448
4449fn process_alive_pid(pid: u32) -> bool {
4450 #[cfg(target_os = "linux")]
4451 {
4452 std::path::Path::new(&format!("/proc/{pid}")).exists()
4453 }
4454 #[cfg(not(target_os = "linux"))]
4455 {
4456 std::process::Command::new("kill")
4457 .args(["-0", &pid.to_string()])
4458 .stdin(std::process::Stdio::null())
4459 .stdout(std::process::Stdio::null())
4460 .stderr(std::process::Stdio::null())
4461 .status()
4462 .map(|s| s.success())
4463 .unwrap_or(false)
4464 }
4465}
4466
4467#[derive(Clone, Debug, serde::Serialize)]
4471pub struct DoctorCheck {
4472 pub id: String,
4475 pub status: String,
4477 pub detail: String,
4479 #[serde(skip_serializing_if = "Option::is_none")]
4481 pub fix: Option<String>,
4482}
4483
4484impl DoctorCheck {
4485 fn pass(id: &str, detail: impl Into<String>) -> Self {
4486 Self {
4487 id: id.into(),
4488 status: "PASS".into(),
4489 detail: detail.into(),
4490 fix: None,
4491 }
4492 }
4493 fn warn(id: &str, detail: impl Into<String>, fix: impl Into<String>) -> Self {
4494 Self {
4495 id: id.into(),
4496 status: "WARN".into(),
4497 detail: detail.into(),
4498 fix: Some(fix.into()),
4499 }
4500 }
4501 fn fail(id: &str, detail: impl Into<String>, fix: impl Into<String>) -> Self {
4502 Self {
4503 id: id.into(),
4504 status: "FAIL".into(),
4505 detail: detail.into(),
4506 fix: Some(fix.into()),
4507 }
4508 }
4509}
4510
4511fn cmd_doctor(as_json: bool, recent_rejections: usize) -> Result<()> {
4516 let mut checks: Vec<DoctorCheck> = Vec::new();
4517
4518 checks.push(check_daemon_health());
4519 checks.push(check_daemon_pid_consistency());
4520 checks.push(check_relay_reachable());
4521 checks.push(check_pair_rejections(recent_rejections));
4522 checks.push(check_cursor_progress());
4523
4524 let fails = checks.iter().filter(|c| c.status == "FAIL").count();
4525 let warns = checks.iter().filter(|c| c.status == "WARN").count();
4526
4527 if as_json {
4528 println!(
4529 "{}",
4530 serde_json::to_string(&json!({
4531 "checks": checks,
4532 "fail_count": fails,
4533 "warn_count": warns,
4534 "ok": fails == 0,
4535 }))?
4536 );
4537 } else {
4538 println!("wire doctor — {} checks", checks.len());
4539 for c in &checks {
4540 let bullet = match c.status.as_str() {
4541 "PASS" => "✓",
4542 "WARN" => "!",
4543 "FAIL" => "✗",
4544 _ => "?",
4545 };
4546 println!(" {bullet} [{}] {}: {}", c.status, c.id, c.detail);
4547 if let Some(fix) = &c.fix {
4548 println!(" fix: {fix}");
4549 }
4550 }
4551 println!();
4552 if fails == 0 && warns == 0 {
4553 println!("ALL GREEN");
4554 } else {
4555 println!("{fails} FAIL, {warns} WARN");
4556 }
4557 }
4558
4559 if fails > 0 {
4560 std::process::exit(1);
4561 }
4562 Ok(())
4563}
4564
4565fn check_daemon_health() -> DoctorCheck {
4572 let output = std::process::Command::new("pgrep")
4577 .args(["-f", "wire daemon"])
4578 .output();
4579 let pgrep_pids: Vec<u32> = match output {
4580 Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
4581 .split_whitespace()
4582 .filter_map(|s| s.parse::<u32>().ok())
4583 .collect(),
4584 _ => Vec::new(),
4585 };
4586 let pidfile_pid = crate::ensure_up::read_pid_record("daemon").pid();
4587 let pidfile_alive = pidfile_pid
4589 .map(|pid| {
4590 #[cfg(target_os = "linux")]
4591 {
4592 std::path::Path::new(&format!("/proc/{pid}")).exists()
4593 }
4594 #[cfg(not(target_os = "linux"))]
4595 {
4596 std::process::Command::new("kill")
4597 .args(["-0", &pid.to_string()])
4598 .output()
4599 .map(|o| o.status.success())
4600 .unwrap_or(false)
4601 }
4602 })
4603 .unwrap_or(false);
4604 let orphan_pids: Vec<u32> = pgrep_pids
4605 .iter()
4606 .filter(|p| Some(**p) != pidfile_pid)
4607 .copied()
4608 .collect();
4609
4610 let fmt_pids = |xs: &[u32]| -> String {
4611 xs.iter()
4612 .map(|p| p.to_string())
4613 .collect::<Vec<_>>()
4614 .join(", ")
4615 };
4616
4617 match (pgrep_pids.len(), pidfile_alive, orphan_pids.is_empty()) {
4618 (0, _, _) => DoctorCheck::fail(
4619 "daemon",
4620 "no `wire daemon` process running — nothing pulling inbox or pushing outbox",
4621 "`wire daemon &` to start, or re-run `wire up <handle>@<relay>` to bootstrap",
4622 ),
4623 (1, true, true) => DoctorCheck::pass(
4625 "daemon",
4626 format!(
4627 "one daemon running (pid {}, matches pidfile)",
4628 pgrep_pids[0]
4629 ),
4630 ),
4631 (n, true, false) => DoctorCheck::fail(
4633 "daemon",
4634 format!(
4635 "{n} `wire daemon` processes running (pids: {}); pidfile claims pid {} but pgrep also sees orphan(s): {}. \
4636 The orphans race the relay cursor — they advance past events your current binary can't process. \
4637 (Issue #2 exact class.)",
4638 fmt_pids(&pgrep_pids),
4639 pidfile_pid.unwrap(),
4640 fmt_pids(&orphan_pids),
4641 ),
4642 "`wire upgrade` kills all orphans and spawns a fresh daemon with a clean pidfile",
4643 ),
4644 (n, false, _) => DoctorCheck::fail(
4646 "daemon",
4647 format!(
4648 "{n} `wire daemon` process(es) running (pids: {}) but pidfile {} — \
4649 every running daemon is an orphan, advancing the cursor without coordinating with the current CLI. \
4650 (Issue #2 exact class: doctor previously PASSed this state while `wire status` said DOWN.)",
4651 fmt_pids(&pgrep_pids),
4652 match pidfile_pid {
4653 Some(p) => format!("claims pid {p} which is dead"),
4654 None => "is missing".to_string(),
4655 },
4656 ),
4657 "`wire upgrade` to kill the orphan(s) and spawn a fresh daemon",
4658 ),
4659 (n, true, true) => DoctorCheck::warn(
4661 "daemon",
4662 format!(
4663 "{n} `wire daemon` processes running (pids: {}). Multiple daemons race the relay cursor.",
4664 fmt_pids(&pgrep_pids)
4665 ),
4666 "kill all-but-one: `pkill -f \"wire daemon\"; wire daemon &`",
4667 ),
4668 }
4669}
4670
4671fn check_daemon_pid_consistency() -> DoctorCheck {
4677 let record = crate::ensure_up::read_pid_record("daemon");
4678 match record {
4679 crate::ensure_up::PidRecord::Missing => DoctorCheck::pass(
4680 "daemon_pid_consistency",
4681 "no daemon.pid yet — fresh box or daemon never started",
4682 ),
4683 crate::ensure_up::PidRecord::Corrupt(reason) => DoctorCheck::warn(
4684 "daemon_pid_consistency",
4685 format!("daemon.pid is corrupt: {reason}"),
4686 "delete state/wire/daemon.pid; next `wire daemon &` will rewrite",
4687 ),
4688 crate::ensure_up::PidRecord::LegacyInt(pid) => DoctorCheck::warn(
4689 "daemon_pid_consistency",
4690 format!(
4691 "daemon.pid is legacy-int form (pid={pid}, no version/bin_path metadata). \
4692 Daemon was started by a pre-0.5.11 binary."
4693 ),
4694 "run `wire upgrade` to kill the old daemon and start a fresh one with the JSON pidfile",
4695 ),
4696 crate::ensure_up::PidRecord::Json(d) => {
4697 let mut issues: Vec<String> = Vec::new();
4698 if d.schema != crate::ensure_up::DAEMON_PID_SCHEMA {
4699 issues.push(format!(
4700 "schema={} (expected {})",
4701 d.schema,
4702 crate::ensure_up::DAEMON_PID_SCHEMA
4703 ));
4704 }
4705 let cli_version = env!("CARGO_PKG_VERSION");
4706 if d.version != cli_version {
4707 issues.push(format!(
4708 "version daemon={} cli={cli_version}",
4709 d.version
4710 ));
4711 }
4712 if !std::path::Path::new(&d.bin_path).exists() {
4713 issues.push(format!("bin_path {} missing on disk", d.bin_path));
4714 }
4715 if let Ok(card) = config::read_agent_card()
4717 && let Some(current_did) = card.get("did").and_then(Value::as_str)
4718 && let Some(recorded_did) = &d.did
4719 && recorded_did != current_did
4720 {
4721 issues.push(format!(
4722 "did daemon={recorded_did} config={current_did} — identity drift"
4723 ));
4724 }
4725 if let Ok(state) = config::read_relay_state()
4726 && let Some(current_relay) = state
4727 .get("self")
4728 .and_then(|s| s.get("relay_url"))
4729 .and_then(Value::as_str)
4730 && let Some(recorded_relay) = &d.relay_url
4731 && recorded_relay != current_relay
4732 {
4733 issues.push(format!(
4734 "relay_url daemon={recorded_relay} config={current_relay} — relay-migration drift"
4735 ));
4736 }
4737 if issues.is_empty() {
4738 DoctorCheck::pass(
4739 "daemon_pid_consistency",
4740 format!(
4741 "daemon v{} bound to {} as {}",
4742 d.version,
4743 d.relay_url.as_deref().unwrap_or("?"),
4744 d.did.as_deref().unwrap_or("?")
4745 ),
4746 )
4747 } else {
4748 DoctorCheck::warn(
4749 "daemon_pid_consistency",
4750 format!("daemon pidfile drift: {}", issues.join("; ")),
4751 "`wire upgrade` to atomically restart daemon with current config".to_string(),
4752 )
4753 }
4754 }
4755 }
4756}
4757
4758fn check_relay_reachable() -> DoctorCheck {
4760 let state = match config::read_relay_state() {
4761 Ok(s) => s,
4762 Err(e) => return DoctorCheck::fail(
4763 "relay",
4764 format!("could not read relay state: {e}"),
4765 "run `wire up <handle>@<relay>` to bootstrap",
4766 ),
4767 };
4768 let url = state
4769 .get("self")
4770 .and_then(|s| s.get("relay_url"))
4771 .and_then(Value::as_str)
4772 .unwrap_or("");
4773 if url.is_empty() {
4774 return DoctorCheck::warn(
4775 "relay",
4776 "no relay bound — wire send/pull will not work",
4777 "run `wire bind-relay <url>` or `wire up <handle>@<relay>`",
4778 );
4779 }
4780 let client = crate::relay_client::RelayClient::new(url);
4781 match client.check_healthz() {
4782 Ok(()) => DoctorCheck::pass("relay", format!("{url} healthz=200")),
4783 Err(e) => DoctorCheck::fail(
4784 "relay",
4785 format!("{url} unreachable: {e}"),
4786 format!("network reachable to {url}? relay running? check `curl {url}/healthz`"),
4787 ),
4788 }
4789}
4790
4791fn check_pair_rejections(recent_n: usize) -> DoctorCheck {
4795 let path = match config::state_dir() {
4796 Ok(d) => d.join("pair-rejected.jsonl"),
4797 Err(e) => return DoctorCheck::warn(
4798 "pair_rejections",
4799 format!("could not resolve state dir: {e}"),
4800 "set WIRE_HOME or fix XDG_STATE_HOME",
4801 ),
4802 };
4803 if !path.exists() {
4804 return DoctorCheck::pass(
4805 "pair_rejections",
4806 "no pair-rejected.jsonl — no recorded pair failures",
4807 );
4808 }
4809 let body = match std::fs::read_to_string(&path) {
4810 Ok(b) => b,
4811 Err(e) => return DoctorCheck::warn(
4812 "pair_rejections",
4813 format!("could not read {path:?}: {e}"),
4814 "check file permissions",
4815 ),
4816 };
4817 let lines: Vec<&str> = body.lines().filter(|l| !l.is_empty()).collect();
4818 if lines.is_empty() {
4819 return DoctorCheck::pass(
4820 "pair_rejections",
4821 "pair-rejected.jsonl present but empty",
4822 );
4823 }
4824 let total = lines.len();
4825 let recent: Vec<&str> = lines.iter().rev().take(recent_n).rev().copied().collect();
4826 let mut summary: Vec<String> = Vec::new();
4827 for line in &recent {
4828 if let Ok(rec) = serde_json::from_str::<Value>(line) {
4829 let peer = rec.get("peer").and_then(Value::as_str).unwrap_or("?");
4830 let code = rec.get("code").and_then(Value::as_str).unwrap_or("?");
4831 summary.push(format!("{peer}/{code}"));
4832 }
4833 }
4834 DoctorCheck::warn(
4835 "pair_rejections",
4836 format!(
4837 "{total} pair failures recorded. recent: [{}]",
4838 summary.join(", ")
4839 ),
4840 format!(
4841 "inspect {path:?} for full details. Each entry is a pair-flow error that previously silently dropped — re-run `wire pair <handle>@<relay>` to retry."
4842 ),
4843 )
4844}
4845
4846fn check_cursor_progress() -> DoctorCheck {
4851 let state = match config::read_relay_state() {
4852 Ok(s) => s,
4853 Err(e) => return DoctorCheck::warn(
4854 "cursor",
4855 format!("could not read relay state: {e}"),
4856 "check ~/Library/Application Support/wire/relay.json",
4857 ),
4858 };
4859 let cursor = state
4860 .get("self")
4861 .and_then(|s| s.get("last_pulled_event_id"))
4862 .and_then(Value::as_str)
4863 .map(|s| s.chars().take(16).collect::<String>())
4864 .unwrap_or_else(|| "<none>".to_string());
4865 DoctorCheck::pass(
4866 "cursor",
4867 format!(
4868 "current cursor: {cursor}. P0.1 cursor blocking is active — see `wire pull --json` for cursor_blocked / rejected[].blocks_cursor entries."
4869 ),
4870 )
4871}
4872
4873#[cfg(test)]
4874mod doctor_tests {
4875 use super::*;
4876
4877 #[test]
4878 fn doctor_check_constructors_set_status_correctly() {
4879 let p = DoctorCheck::pass("x", "ok");
4884 assert_eq!(p.status, "PASS");
4885 assert_eq!(p.fix, None);
4886
4887 let w = DoctorCheck::warn("x", "watch out", "do this");
4888 assert_eq!(w.status, "WARN");
4889 assert_eq!(w.fix, Some("do this".to_string()));
4890
4891 let f = DoctorCheck::fail("x", "broken", "fix it");
4892 assert_eq!(f.status, "FAIL");
4893 assert_eq!(f.fix, Some("fix it".to_string()));
4894 }
4895
4896 #[test]
4897 fn check_pair_rejections_no_file_is_pass() {
4898 config::test_support::with_temp_home(|| {
4901 config::ensure_dirs().unwrap();
4902 let c = check_pair_rejections(5);
4903 assert_eq!(c.status, "PASS", "no file should be PASS, got {c:?}");
4904 });
4905 }
4906
4907 #[test]
4908 fn check_pair_rejections_with_entries_warns() {
4909 config::test_support::with_temp_home(|| {
4913 config::ensure_dirs().unwrap();
4914 crate::pair_invite::record_pair_rejection(
4915 "willard",
4916 "pair_drop_ack_send_failed",
4917 "POST 502",
4918 );
4919 let c = check_pair_rejections(5);
4920 assert_eq!(c.status, "WARN");
4921 assert!(c.detail.contains("1 pair failures"));
4922 assert!(c.detail.contains("willard/pair_drop_ack_send_failed"));
4923 });
4924 }
4925}
4926
4927fn cmd_up(handle_arg: &str, name: Option<&str>, as_json: bool) -> Result<()> {
4939 let (nick, relay_url) = match handle_arg.split_once('@') {
4940 Some((n, host)) => {
4941 let url = if host.starts_with("http://") || host.starts_with("https://") {
4942 host.to_string()
4943 } else {
4944 format!("https://{host}")
4945 };
4946 (n.to_string(), url)
4947 }
4948 None => (handle_arg.to_string(), crate::pair_invite::DEFAULT_RELAY.to_string()),
4949 };
4950
4951 let mut report: Vec<(String, String)> = Vec::new();
4952 let mut step = |stage: &str, detail: String| {
4953 report.push((stage.to_string(), detail.clone()));
4954 if !as_json {
4955 eprintln!("wire up: {stage} — {detail}");
4956 }
4957 };
4958
4959 if config::is_initialized()? {
4961 let card = config::read_agent_card()?;
4962 let existing_did = card.get("did").and_then(Value::as_str).unwrap_or("");
4963 let existing_handle =
4964 crate::agent_card::display_handle_from_did(existing_did).to_string();
4965 if existing_handle != nick {
4966 bail!(
4967 "wire up: already initialized as {existing_handle:?} but you asked for {nick:?}. \
4968 Either run with the existing handle (`wire up {existing_handle}@<relay>`) or \
4969 delete `{:?}` to start fresh.",
4970 config::config_dir()?
4971 );
4972 }
4973 step("init", format!("already initialized as {existing_handle}"));
4974 } else {
4975 cmd_init(&nick, name, Some(&relay_url), false)?;
4976 step("init", format!("created identity {nick} bound to {relay_url}"));
4977 }
4978
4979 let relay_state = config::read_relay_state()?;
4983 let bound_relay = relay_state
4984 .get("self")
4985 .and_then(|s| s.get("relay_url"))
4986 .and_then(Value::as_str)
4987 .unwrap_or("")
4988 .to_string();
4989 if bound_relay.is_empty() {
4990 cmd_bind_relay(&relay_url, false)?;
4992 step("bind-relay", format!("bound to {relay_url}"));
4993 } else if bound_relay != relay_url {
4994 step(
4995 "bind-relay",
4996 format!(
4997 "WARNING: identity bound to {bound_relay} but you specified {relay_url}. \
4998 Keeping existing binding. Run `wire bind-relay {relay_url}` to switch."
4999 ),
5000 );
5001 } else {
5002 step("bind-relay", format!("already bound to {bound_relay}"));
5003 }
5004
5005 match cmd_claim(&nick, Some(&relay_url), None, false) {
5008 Ok(()) => step("claim", format!("{nick}@{} claimed", strip_proto(&relay_url))),
5009 Err(e) => step(
5010 "claim",
5011 format!("WARNING: claim failed: {e}. You can retry `wire claim {nick}`."),
5012 ),
5013 }
5014
5015 match crate::ensure_up::ensure_daemon_running() {
5017 Ok(true) => step("daemon", "started fresh background daemon".to_string()),
5018 Ok(false) => step("daemon", "already running".to_string()),
5019 Err(e) => step(
5020 "daemon",
5021 format!("WARNING: could not start daemon: {e}. Run `wire daemon &` manually."),
5022 ),
5023 }
5024
5025 let summary = format!(
5027 "ready. `wire pair <peer>@<relay>` to pair, `wire send <peer> \"<msg>\"` to send, \
5028 `wire monitor` to watch incoming events."
5029 );
5030 step("ready", summary.clone());
5031
5032 if as_json {
5033 let steps_json: Vec<_> = report
5034 .iter()
5035 .map(|(k, v)| json!({"stage": k, "detail": v}))
5036 .collect();
5037 println!(
5038 "{}",
5039 serde_json::to_string(&json!({
5040 "nick": nick,
5041 "relay": relay_url,
5042 "steps": steps_json,
5043 }))?
5044 );
5045 }
5046 Ok(())
5047}
5048
5049fn strip_proto(url: &str) -> String {
5051 url.trim_start_matches("https://")
5052 .trim_start_matches("http://")
5053 .to_string()
5054}
5055
5056fn cmd_pair_megacommand(
5070 handle_arg: &str,
5071 relay_override: Option<&str>,
5072 timeout_secs: u64,
5073 _as_json: bool,
5074) -> Result<()> {
5075 let parsed = crate::pair_profile::parse_handle(handle_arg)?;
5076 let peer_handle = parsed.nick.clone();
5077
5078 eprintln!("wire pair: resolving {handle_arg}...");
5079 cmd_add(handle_arg, relay_override, false)?;
5080
5081 eprintln!(
5082 "wire pair: intro delivered. waiting up to {timeout_secs}s for {peer_handle} \
5083 to ack (their daemon must be running + pulling)..."
5084 );
5085
5086 let _ = run_sync_pull();
5090
5091 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
5092 let poll_interval = std::time::Duration::from_millis(500);
5093
5094 loop {
5095 let _ = run_sync_pull();
5097 let relay_state = config::read_relay_state()?;
5098 let peer_entry = relay_state
5099 .get("peers")
5100 .and_then(|p| p.get(&peer_handle))
5101 .cloned();
5102 let token = peer_entry
5103 .as_ref()
5104 .and_then(|e| e.get("slot_token"))
5105 .and_then(Value::as_str)
5106 .unwrap_or("");
5107
5108 if !token.is_empty() {
5109 let trust = config::read_trust()?;
5111 let pinned_in_trust = trust
5112 .get("agents")
5113 .and_then(|a| a.get(&peer_handle))
5114 .is_some();
5115 println!(
5116 "wire pair: paired with {peer_handle}.\n trust: {} bilateral: yes (slot_token recorded)\n next: `wire send {peer_handle} \"<msg>\"`",
5117 if pinned_in_trust { "VERIFIED" } else { "MISSING (bug)" }
5118 );
5119 return Ok(());
5120 }
5121
5122 if std::time::Instant::now() >= deadline {
5123 bail!(
5130 "wire pair: timed out after {timeout_secs}s. \
5131 peer {peer_handle} never sent pair_drop_ack. \
5132 likely causes: (a) their daemon is down — ask them to run \
5133 `wire status` and `wire daemon &`; (b) their binary is older \
5134 than 0.5.x and doesn't understand pair_drop events — ask \
5135 them to `wire upgrade`; (c) network / relay blip — re-run \
5136 `wire pair {handle_arg}` to retry."
5137 );
5138 }
5139
5140 std::thread::sleep(poll_interval);
5141 }
5142}
5143
5144fn cmd_claim(
5145 nick: &str,
5146 relay_override: Option<&str>,
5147 public_url: Option<&str>,
5148 as_json: bool,
5149) -> Result<()> {
5150 if !crate::pair_profile::is_valid_nick(nick) {
5151 bail!(
5152 "phyllis: {nick:?} won't fit in the books — handles need 2-32 chars, lowercase [a-z0-9_-], not on the reserved list"
5153 );
5154 }
5155 let (_did, relay_url, slot_id, slot_token) =
5158 crate::pair_invite::ensure_self_with_relay(relay_override)?;
5159 let card = config::read_agent_card()?;
5160
5161 let client = crate::relay_client::RelayClient::new(&relay_url);
5162 let resp = client.handle_claim(nick, &slot_id, &slot_token, public_url, &card)?;
5163
5164 if as_json {
5165 println!(
5166 "{}",
5167 serde_json::to_string(&json!({
5168 "nick": nick,
5169 "relay": relay_url,
5170 "response": resp,
5171 }))?
5172 );
5173 } else {
5174 let domain = public_url
5178 .unwrap_or(&relay_url)
5179 .trim_start_matches("https://")
5180 .trim_start_matches("http://")
5181 .trim_end_matches('/')
5182 .split('/')
5183 .next()
5184 .unwrap_or("<this-relay-domain>")
5185 .to_string();
5186 println!("claimed {nick} on {relay_url} — others can reach you at: {nick}@{domain}");
5187 println!("verify with: wire whois {nick}@{domain}");
5188 }
5189 Ok(())
5190}
5191
5192fn cmd_profile(action: ProfileAction) -> Result<()> {
5193 match action {
5194 ProfileAction::Set { field, value, json } => {
5195 let parsed: Value =
5199 serde_json::from_str(&value).unwrap_or(Value::String(value.clone()));
5200 let new_profile = crate::pair_profile::write_profile_field(&field, parsed)?;
5201 if json {
5202 println!(
5203 "{}",
5204 serde_json::to_string(&json!({
5205 "field": field,
5206 "profile": new_profile,
5207 }))?
5208 );
5209 } else {
5210 println!("profile.{field} set");
5211 }
5212 }
5213 ProfileAction::Get { json } => return cmd_whois(None, json, None),
5214 ProfileAction::Clear { field, json } => {
5215 let new_profile = crate::pair_profile::write_profile_field(&field, Value::Null)?;
5216 if json {
5217 println!(
5218 "{}",
5219 serde_json::to_string(&json!({
5220 "field": field,
5221 "cleared": true,
5222 "profile": new_profile,
5223 }))?
5224 );
5225 } else {
5226 println!("profile.{field} cleared");
5227 }
5228 }
5229 }
5230 Ok(())
5231}
5232
5233fn cmd_setup(apply: bool) -> Result<()> {
5236 use std::path::PathBuf;
5237
5238 let entry = json!({"command": "wire", "args": ["mcp"]});
5239 let entry_pretty = serde_json::to_string_pretty(&json!({"wire": &entry}))?;
5240
5241 let mut targets: Vec<(&str, PathBuf)> = Vec::new();
5244 if let Some(home) = dirs::home_dir() {
5245 targets.push(("Claude Code", home.join(".claude.json")));
5248 targets.push(("Claude Code (alt)", home.join(".config/claude/mcp.json")));
5250 #[cfg(target_os = "macos")]
5252 targets.push((
5253 "Claude Desktop (macOS)",
5254 home.join("Library/Application Support/Claude/claude_desktop_config.json"),
5255 ));
5256 #[cfg(target_os = "windows")]
5258 if let Ok(appdata) = std::env::var("APPDATA") {
5259 targets.push((
5260 "Claude Desktop (Windows)",
5261 PathBuf::from(appdata).join("Claude/claude_desktop_config.json"),
5262 ));
5263 }
5264 targets.push(("Cursor", home.join(".cursor/mcp.json")));
5266 }
5267 targets.push(("project-local (.mcp.json)", PathBuf::from(".mcp.json")));
5269
5270 println!("wire setup\n");
5271 println!("MCP server snippet (add this to your client's mcpServers):");
5272 println!();
5273 println!("{entry_pretty}");
5274 println!();
5275
5276 if !apply {
5277 println!("Probable MCP host config locations on this machine:");
5278 for (name, path) in &targets {
5279 let marker = if path.exists() {
5280 "✓ found"
5281 } else {
5282 " (would create)"
5283 };
5284 println!(" {marker:14} {name}: {}", path.display());
5285 }
5286 println!();
5287 println!("Run `wire setup --apply` to merge wire into each config above.");
5288 println!(
5289 "Existing entries with a different command keep yours unchanged unless wire's exact entry is missing."
5290 );
5291 return Ok(());
5292 }
5293
5294 let mut modified: Vec<String> = Vec::new();
5295 let mut skipped: Vec<String> = Vec::new();
5296 for (name, path) in &targets {
5297 match upsert_mcp_entry(path, "wire", &entry) {
5298 Ok(true) => modified.push(format!("✓ {name} ({})", path.display())),
5299 Ok(false) => skipped.push(format!(" {name} ({}): already configured", path.display())),
5300 Err(e) => skipped.push(format!("✗ {name} ({}): {e}", path.display())),
5301 }
5302 }
5303 if !modified.is_empty() {
5304 println!("Modified:");
5305 for line in &modified {
5306 println!(" {line}");
5307 }
5308 println!();
5309 println!("Restart the app(s) above to load wire MCP.");
5310 }
5311 if !skipped.is_empty() {
5312 println!();
5313 println!("Skipped:");
5314 for line in &skipped {
5315 println!(" {line}");
5316 }
5317 }
5318 Ok(())
5319}
5320
5321fn upsert_mcp_entry(path: &std::path::Path, server_name: &str, entry: &Value) -> Result<bool> {
5324 let mut cfg: Value = if path.exists() {
5325 let body = std::fs::read_to_string(path).context("reading config")?;
5326 serde_json::from_str(&body).unwrap_or_else(|_| json!({}))
5327 } else {
5328 json!({})
5329 };
5330 if !cfg.is_object() {
5331 cfg = json!({});
5332 }
5333 let root = cfg.as_object_mut().unwrap();
5334 let servers = root
5335 .entry("mcpServers".to_string())
5336 .or_insert_with(|| json!({}));
5337 if !servers.is_object() {
5338 *servers = json!({});
5339 }
5340 let map = servers.as_object_mut().unwrap();
5341 if map.get(server_name) == Some(entry) {
5342 return Ok(false);
5343 }
5344 map.insert(server_name.to_string(), entry.clone());
5345 if let Some(parent) = path.parent()
5346 && !parent.as_os_str().is_empty()
5347 {
5348 std::fs::create_dir_all(parent).context("creating parent dir")?;
5349 }
5350 let out = serde_json::to_string_pretty(&cfg)? + "\n";
5351 std::fs::write(path, out).context("writing config")?;
5352 Ok(true)
5353}
5354
5355#[allow(clippy::too_many_arguments)]
5358fn cmd_reactor(
5359 on_event: &str,
5360 peer_filter: Option<&str>,
5361 kind_filter: Option<&str>,
5362 verified_only: bool,
5363 interval_secs: u64,
5364 once: bool,
5365 dry_run: bool,
5366 max_per_minute: u32,
5367 max_chain_depth: u32,
5368) -> Result<()> {
5369 use crate::inbox_watch::{InboxEvent, InboxWatcher};
5370 use std::collections::{HashMap, HashSet, VecDeque};
5371 use std::io::Write;
5372 use std::process::{Command, Stdio};
5373 use std::time::{Duration, Instant};
5374
5375 let cursor_path = config::state_dir()?.join("reactor.cursor");
5376 let emitted_path = config::state_dir()?.join("reactor-emitted.log");
5385 let mut emitted_ids: HashSet<String> = HashSet::new();
5386 if emitted_path.exists()
5387 && let Ok(body) = std::fs::read_to_string(&emitted_path)
5388 {
5389 for line in body.lines() {
5390 let t = line.trim();
5391 if !t.is_empty() {
5392 emitted_ids.insert(t.to_string());
5393 }
5394 }
5395 }
5396 let outbox_dir = config::outbox_dir()?;
5398 let mut outbox_cursors: HashMap<String, u64> = HashMap::new();
5401
5402 let mut watcher = InboxWatcher::from_cursor_file(&cursor_path)?;
5403
5404 let kind_num: Option<u32> = match kind_filter {
5405 Some(k) => Some(parse_kind(k)?),
5406 None => None,
5407 };
5408
5409 let mut peer_dispatch_log: HashMap<String, VecDeque<Instant>> = HashMap::new();
5411
5412 let dispatch = |ev: &InboxEvent,
5413 peer_dispatch_log: &mut HashMap<String, VecDeque<Instant>>,
5414 emitted_ids: &HashSet<String>|
5415 -> Result<bool> {
5416 if let Some(p) = peer_filter
5417 && ev.peer != p
5418 {
5419 return Ok(false);
5420 }
5421 if verified_only && !ev.verified {
5422 return Ok(false);
5423 }
5424 if let Some(want) = kind_num {
5425 let ev_kind = ev.raw.get("kind").and_then(Value::as_u64).map(|n| n as u32);
5426 if ev_kind != Some(want) {
5427 return Ok(false);
5428 }
5429 }
5430
5431 if max_chain_depth > 0 {
5435 let body_str = match &ev.raw["body"] {
5436 Value::String(s) => s.clone(),
5437 other => serde_json::to_string(other).unwrap_or_default(),
5438 };
5439 if let Some(referenced) = parse_re_marker(&body_str) {
5440 let matched = emitted_ids.contains(&referenced)
5443 || emitted_ids.iter().any(|full| full.starts_with(&referenced));
5444 if matched {
5445 eprintln!(
5446 "wire reactor: skip {} from {} — chain-depth (reply to our re:{})",
5447 ev.event_id, ev.peer, referenced
5448 );
5449 return Ok(false);
5450 }
5451 }
5452 }
5453
5454 if max_per_minute > 0 {
5456 let now = Instant::now();
5457 let win = peer_dispatch_log.entry(ev.peer.clone()).or_default();
5458 while let Some(&front) = win.front() {
5459 if now.duration_since(front) > Duration::from_secs(60) {
5460 win.pop_front();
5461 } else {
5462 break;
5463 }
5464 }
5465 if win.len() as u32 >= max_per_minute {
5466 eprintln!(
5467 "wire reactor: skip {} from {} — rate-limit ({}/min reached)",
5468 ev.event_id, ev.peer, max_per_minute
5469 );
5470 return Ok(false);
5471 }
5472 win.push_back(now);
5473 }
5474
5475 if dry_run {
5476 println!("{}", serde_json::to_string(&ev.raw)?);
5477 return Ok(true);
5478 }
5479
5480 let mut child = Command::new("sh")
5481 .arg("-c")
5482 .arg(on_event)
5483 .stdin(Stdio::piped())
5484 .stdout(Stdio::inherit())
5485 .stderr(Stdio::inherit())
5486 .env("WIRE_EVENT_PEER", &ev.peer)
5487 .env("WIRE_EVENT_ID", &ev.event_id)
5488 .env("WIRE_EVENT_KIND", &ev.kind)
5489 .spawn()
5490 .with_context(|| format!("spawning reactor handler: {on_event}"))?;
5491 if let Some(mut stdin) = child.stdin.take() {
5492 let body = serde_json::to_vec(&ev.raw)?;
5493 let _ = stdin.write_all(&body);
5494 let _ = stdin.write_all(b"\n");
5495 }
5496 std::mem::drop(child);
5497 Ok(true)
5498 };
5499
5500 let scan_outbox = |emitted_ids: &mut HashSet<String>,
5502 outbox_cursors: &mut HashMap<String, u64>|
5503 -> Result<usize> {
5504 if !outbox_dir.exists() {
5505 return Ok(0);
5506 }
5507 let mut added = 0;
5508 let mut new_ids: Vec<String> = Vec::new();
5509 for entry in std::fs::read_dir(&outbox_dir)?.flatten() {
5510 let path = entry.path();
5511 if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
5512 continue;
5513 }
5514 let peer = match path.file_stem().and_then(|s| s.to_str()) {
5515 Some(s) => s.to_string(),
5516 None => continue,
5517 };
5518 let cur_len = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
5519 let start = *outbox_cursors.get(&peer).unwrap_or(&0);
5520 if cur_len <= start {
5521 outbox_cursors.insert(peer, start);
5522 continue;
5523 }
5524 let body = std::fs::read_to_string(&path).unwrap_or_default();
5525 let tail = &body[start as usize..];
5526 for line in tail.lines() {
5527 if let Ok(v) = serde_json::from_str::<Value>(line)
5528 && let Some(eid) = v.get("event_id").and_then(Value::as_str)
5529 && emitted_ids.insert(eid.to_string())
5530 {
5531 new_ids.push(eid.to_string());
5532 added += 1;
5533 }
5534 }
5535 outbox_cursors.insert(peer, cur_len);
5536 }
5537 if !new_ids.is_empty() {
5538 let mut all: Vec<String> = emitted_ids.iter().cloned().collect();
5540 if all.len() > 500 {
5541 all.sort();
5542 let drop_n = all.len() - 500;
5543 let dropped: HashSet<String> = all.iter().take(drop_n).cloned().collect();
5544 emitted_ids.retain(|x| !dropped.contains(x));
5545 all = emitted_ids.iter().cloned().collect();
5546 }
5547 let _ = std::fs::write(&emitted_path, all.join("\n") + "\n");
5548 }
5549 Ok(added)
5550 };
5551
5552 let sweep = |watcher: &mut InboxWatcher,
5553 emitted_ids: &mut HashSet<String>,
5554 outbox_cursors: &mut HashMap<String, u64>,
5555 peer_dispatch_log: &mut HashMap<String, VecDeque<Instant>>|
5556 -> Result<usize> {
5557 let _ = scan_outbox(emitted_ids, outbox_cursors);
5559
5560 let events = watcher.poll()?;
5561 let mut fired = 0usize;
5562 for ev in &events {
5563 match dispatch(ev, peer_dispatch_log, emitted_ids) {
5564 Ok(true) => fired += 1,
5565 Ok(false) => {}
5566 Err(e) => eprintln!("wire reactor: handler error for {}: {e}", ev.event_id),
5567 }
5568 }
5569 watcher.save_cursors(&cursor_path)?;
5570 Ok(fired)
5571 };
5572
5573 if once {
5574 sweep(
5575 &mut watcher,
5576 &mut emitted_ids,
5577 &mut outbox_cursors,
5578 &mut peer_dispatch_log,
5579 )?;
5580 return Ok(());
5581 }
5582 let interval = std::time::Duration::from_secs(interval_secs.max(1));
5583 loop {
5584 if let Err(e) = sweep(
5585 &mut watcher,
5586 &mut emitted_ids,
5587 &mut outbox_cursors,
5588 &mut peer_dispatch_log,
5589 ) {
5590 eprintln!("wire reactor: sweep error: {e}");
5591 }
5592 std::thread::sleep(interval);
5593 }
5594}
5595
5596fn parse_re_marker(body: &str) -> Option<String> {
5599 let needle = "(re:";
5600 let i = body.find(needle)?;
5601 let rest = &body[i + needle.len()..];
5602 let end = rest.find(')')?;
5603 let id = rest[..end].trim().to_string();
5604 if id.is_empty() {
5605 return None;
5606 }
5607 Some(id)
5608}
5609
5610fn cmd_notify(
5613 interval_secs: u64,
5614 peer_filter: Option<&str>,
5615 once: bool,
5616 as_json: bool,
5617) -> Result<()> {
5618 use crate::inbox_watch::InboxWatcher;
5619 let cursor_path = config::state_dir()?.join("notify.cursor");
5620 let mut watcher = InboxWatcher::from_cursor_file(&cursor_path)?;
5621
5622 let sweep = |watcher: &mut InboxWatcher| -> Result<()> {
5623 let events = watcher.poll()?;
5624 for ev in events {
5625 if let Some(p) = peer_filter
5626 && ev.peer != p
5627 {
5628 continue;
5629 }
5630 if as_json {
5631 println!("{}", serde_json::to_string(&ev)?);
5632 } else {
5633 os_notify_inbox_event(&ev);
5634 }
5635 }
5636 watcher.save_cursors(&cursor_path)?;
5637 Ok(())
5638 };
5639
5640 if once {
5641 return sweep(&mut watcher);
5642 }
5643
5644 let interval = std::time::Duration::from_secs(interval_secs.max(1));
5645 loop {
5646 if let Err(e) = sweep(&mut watcher) {
5647 eprintln!("wire notify: sweep error: {e}");
5648 }
5649 std::thread::sleep(interval);
5650 }
5651}
5652
5653fn os_notify_inbox_event(ev: &crate::inbox_watch::InboxEvent) {
5654 let title = if ev.verified {
5655 format!("wire ← {}", ev.peer)
5656 } else {
5657 format!("wire ← {} (UNVERIFIED)", ev.peer)
5658 };
5659 let body = format!("{}: {}", ev.kind, ev.body_preview);
5660 crate::os_notify::toast(&title, &body);
5661}
5662
5663#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
5664fn os_toast(title: &str, body: &str) {
5665 eprintln!("[wire notify] {title}\n {body}");
5666}
5667
5668