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